diff --git a/packages/agent/docs/assistant-durability.md b/packages/agent/docs/assistant-durability.md index 127e5df6e..1c9edb2f4 100644 --- a/packages/agent/docs/assistant-durability.md +++ b/packages/agent/docs/assistant-durability.md @@ -95,7 +95,7 @@ encode the event against the per-stream frame encoder → consume the next provider event ``` -`appendList()` is called synchronously for each returned frame, so all frame mutations enter the lane line in provider-event order. The procedure does not await each write. Every promise is immediately observed for fault propagation before the latest reference replaces its predecessor. Covered queued events allocate no durable frame or write. Encoder state is proportional to open block count plus the unsynchronized prefix of an active tool-call JSON stream; it retains no second full assistant message. Model output limits bound queued frame bytes to bounded output plus frame/transaction overhead. +`appendList()` is called synchronously for each returned frame, so all frame mutations enter the Session line in provider-event order. The procedure does not await each write. Every promise is immediately observed for fault propagation before the latest reference replaces its predecessor. Covered queued events allocate no durable frame or write. Encoder state is proportional to open block count plus the unsynchronized prefix of an active tool-call JSON stream; it retains no second full assistant message. Model output limits bound queued frame bytes to bounded output plus frame/transaction overhead. The event side retains existing behavior: `AssistantStreamObserver.start/update` awaits `events.emit()` for every event. There is no separate latest event-delivery promise because no assistant event delivery remains outstanding when the provider loop advances. @@ -109,19 +109,19 @@ stop frame admission → classify and commit final response settlement ``` -The lane mutation line is FIFO, so completion of `latestFrameWrite` implies every earlier append completed. There is no array of promises, timer, batcher, active/waiting state, coalescer, or public/internal flush method. +The Session mutation line is FIFO, so completion of `latestFrameWrite` implies every earlier append completed. There is no array of promises, timer, batcher, active/waiting state, coalescer, or public/internal flush method. A failed frame append faults the harness before `after_response` starts. The complete final response remains process-local and does not commit after a storage fault. ## Normal settlement -Every assistant response settlement that used a frame list deletes that exact list in the same transaction as the immutable response entry, usage, leaf, and next operation state: +Every assistant response settlement that used a frame list deletes that exact list in the same transaction as the immutable response entry, usage, tip, and next operation state: ```text TX[ insert response entry R, insert usage U, - setValue(laneLeaf(lane), R), + setValue(branchTip(lane), R), deleteList(frames), setValue(operationState(operationId), classified next state) ] @@ -147,7 +147,7 @@ An orphaned assistant generation `effect_pending` has no surviving provider stre 3. constructs a harness-owned synthetic assistant response under the already-reserved response ID; 4. preserves reconstructed partial content and safe message identity metadata when frames exist; 5. sets `stopReason: "error"`, zero usage, and an explicit interruption/unknown-outcome `errorMessage`/diagnostic; -6. commits the synthetic response, zero-usage row, frame-list deletion, leaf, and ordinary retry/failure state atomically. +6. commits the synthetic response, zero-usage row, frame-list deletion, tip, and ordinary retry/failure state atomically. Required warning meaning: @@ -248,7 +248,7 @@ Close is a controlled crash: A frame-commit storage failure faults the harness. No later response settlement commits in that process. -Authorized external finalization deletes the operation-owned frame-list address in its terminal transaction. Every append mutation verifies current operation/response ownership on the lane line: +Authorized external finalization deletes the operation-owned frame-list address in its terminal transaction. Every append mutation verifies current operation/response ownership on the Session line: - append first → external terminal cleanup deletes the list; - finalization first → stale append rejects without recreating state. diff --git a/packages/agent/docs/harness.md b/packages/agent/docs/harness.md index 3f1342f55..ab3ea9a4a 100644 --- a/packages/agent/docs/harness.md +++ b/packages/agent/docs/harness.md @@ -45,7 +45,7 @@ - [Part 4 — Execution, recovery, abort, close](#part-4--execution-recovery-abort-close) - [4.1 The live operation task](#41-the-live-operation-task) - [4.2 Effect gate](#42-effect-gate) - - [4.3 The lane mutation line](#43-the-lane-mutation-line) + - [4.3 The Session mutation line](#43-the-session-mutation-line) - [4.4 Attachment and open-operation inventory](#44-attachment-and-open-operation-inventory) - [4.5 Driving and crash recovery](#45-driving-and-crash-recovery) - [4.6 Abort and cancellation reconciliation](#46-abort-and-cancellation-reconciliation) @@ -55,7 +55,7 @@ - [Part 5 — Public surface](#part-5--public-surface) - [5.1 The lane surface](#51-the-lane-surface) - [5.2 The harness](#52-the-harness) - - [5.3 SessionTree](#53-sessiontree) + - [5.3 Session and Branch](#53-session-and-branch) - [5.4 Snapshots and subscription](#54-snapshots-and-subscription) - [5.5 Events](#55-events) - [5.6 Hooks](#56-hooks) @@ -96,14 +96,14 @@ A session groups related work and has four parts: ``` - **Values and lists.** Mutable state at bound typed addresses. Built-ins include the session name and entry labels; applications define their own collision-resistant `value()` and `list()` addresses directly. -- **Lanes.** Named cursors into the tree. Every session has `main`. A lane owns its leaf, model configuration, queues, and at most one operation. Additional lanes support Slack threads, subagents, and other parallel work over shared history. +- **Branches and AgentLanes.** A Branch is one named data path with a movable tip. An AgentLane adds total model configuration, queues, and at most one operation. Sessions may start with zero Branches/Lanes; `main` is an ordinary explicit name. - **Usage ledger.** Append-only token and cost events for the session. ### Harness and operations -The session layer manages durable data and exposes typed tree views. The harness drives lanes through four general primitives: `accept` durably creates an operation, `drive` advances an expected operation, `requestAbort` durably requests cancellation, and `inspectExecution` atomically reports current and latest-terminal execution. Convenience methods such as `prompt`, `resume`, and `abort` compose those same primitives with process-local waiting policy. A serving layer may instead schedule `drive` calls through alarms, jobs, or another host runtime. The harness also owns harness-wide registries of available tools and prompt resources, hooks that intercept and transform execution, passive events that report activity and durable changes, and runtime configuration. +The Session layer owns global durable data and explicit Branch capabilities. The harness drives lanes through four general primitives: `accept` durably creates an operation, `drive` advances an expected operation, `requestAbort` durably requests cancellation, and `inspectExecution` atomically reports current and latest-terminal execution. Convenience methods such as `prompt`, `resume`, and `abort` compose those same primitives with process-local waiting policy. A serving layer may instead schedule `drive` calls through alarms, jobs, or another host runtime. The harness also owns harness-wide registries of available tools and prompt resources, hooks that intercept and transform execution, passive events that report activity and durable changes, and runtime configuration. -Every asynchronous public harness/lane operation and every Session, SessionTree, repository, and storage I/O method receives an explicit trailing `Context`. Synchronous capability/registration operations such as `Session.view()`, `events.on()`, and `hooks.on()` remain contextless; hooks and listeners receive Context when invoked. Context exists for two reasons: concurrent asynchronous calls need independent telemetry parentage, and an RPC adapter needs to carry one request's cancellation into the core as `context.abortSignal`. Shared receivers never retain a caller context or discover one through `AsyncLocalStorage`. At an RPC boundary, the client maps its signal to `cancel(requestId)` and injects trace metadata; the server allocates one request `AbortController`, extracts a local telemetry parent, derives a fresh local Context with both `withAbortSignal` and `withTelemetryContext` before invocation, and aborts that controller on matching cancellation or disconnect. Context is process-local invocation authority, never durable operation data: aborting it does not call `requestAbort()` or write `cancel_requested`. +Every asynchronous public harness/lane operation and every Session, Branch, repository, and storage I/O method receives an explicit trailing `Context`. Synchronous registration operations such as `events.on()` and `hooks.on()` remain contextless; hooks and listeners receive Context when invoked. Context exists for two reasons: concurrent asynchronous calls need independent telemetry parentage, and an RPC adapter needs to carry one request's cancellation into the core as `context.abortSignal`. Shared receivers never retain a caller context or discover one through `AsyncLocalStorage`. At an RPC boundary, the client maps its signal to `cancel(requestId)` and injects trace metadata; the server allocates one request `AbortController`, extracts a local telemetry parent, derives a fresh local Context with both `withAbortSignal` and `withTelemetryContext` before invocation, and aborts that controller on matching cancellation or disconnect. Context is process-local invocation authority, never durable operation data: aborting it does not call `requestAbort()` or write `cancel_requested`. An **operation** is one accepted unit of lane work: a run, compaction, or navigation. Its immutable metadata records its identity, intent, and starting point; its total current state records its phase, control, queues, and recovery data. Each durable transition replaces the current state. Acceptance and execution ownership are separate: an accepted operation may have no process-local driver. Completion removes the operation state and records the lane's result. @@ -143,10 +143,10 @@ Hooks follow their replay contract instead: a result becomes durable in the tran ## 0.4 Worked example — a Slack thread -A user posts in a channel that already has 400 entries of history. The application creates a lane for the thread, anchored at the channel's current leaf. Entry ids are UUIDv7s (§1.2); examples abbreviate them. +A user posts in a channel that already has 400 entries of history. The application creates a lane for the thread, anchored at the channel's current tip. Entry ids are UUIDv7s (§1.2); examples abbreviate them. ``` -harness.createLane("slack:1719432.0021", at: "0195c8d1-4a2e-7b31-…") +const lane = await harness.lane("slack:1719432.0021", { createAt: "0195c8d1-4a2e-7b31-…" }, context) lane.prompt("what changed in auth last week?") ``` @@ -162,25 +162,25 @@ What happens, in order: As a trace (ids abbreviated; every `TX[...]` is one atomic commit, in normative write order): ```text -TX[ insert entry n1 (user msg), upsert pi.lane.leaf = n1, +TX[ insert entry n1 (user msg), upsert pi.branch.tip = n1, upsert pi.op.meta/O, upsert pi.op.state/O = starting, upsert pi.lane.state = { currentOperationId: O } ] … first drive owns real work; before_drive then before_run … -TX[ insert injected messages if any, upsert pi.lane.leaf when needed, +TX[ insert injected messages if any, upsert pi.branch.tip when needed, upsert pi.op.state/O = checkpoint need_assistant ] TX[ upsert pi.op.state/O = assistant ready (config snapshot) ] TX[ upsert pi.op.state/O = effect_pending (reserves response n2, usage u1) ] … provider streams … ← the uncertain window TX[ append pi.pending.assistant_frame/O:n2 += frame ] ← zero or one per non-terminal event, enqueued without awaiting -TX[ insert entry n2, insert usage u1, upsert pi.lane.leaf = n2, +TX[ insert entry n2, insert usage u1, upsert pi.branch.tip = n2, delete list pi.pending.assistant_frame/O:n2, upsert pi.op.state/O = tools (result id n3 reserved) ] TX[ upsert pi.op.tool_args/O:s1:0, upsert pi.op.state/O = call 0 effect_pending ] … tool runs; selected bounded updates may replace pi.pending.tool_output/O:n3 … TX[ upsert pi.pending.entry/n3 = finalized tool result, delete pi.pending.tool_output/O:n3, upsert pi.op.state/O = call 0 outcome_ready ] -TX[ insert entry n3, delete pi.pending.entry/n3, upsert pi.lane.leaf = n3, +TX[ insert entry n3, delete pi.pending.entry/n3, upsert pi.branch.tip = n3, upsert pi.op.state/O = checkpoint ] … second turn: ready · intent · stream · settle (n4, u2) … TX[ delete pi.op.meta/O, pi.op.state/O, pi.op.tool_args/O:*, @@ -201,7 +201,7 @@ lane.prompt("delete the stale migrations and run the test suite") The model returns two tool calls. The harness commits the batch plan, then commits `call 0 is about to execute, with these exact arguments, and it declares itself unsafe to replay`. The tool starts deleting files. It emits bounded progress snapshots every 100 ms for live UI observation and requests a durable checkpoint every two seconds. The process is killed after one checkpoint commits. ```text -TX[ insert entry n2 (assistant, 2 calls), insert usage u1, upsert pi.lane.leaf = n2, +TX[ insert entry n2 (assistant, 2 calls), insert usage u1, upsert pi.branch.tip = n2, upsert pi.op.state/O = tools (result ids n3, n4 reserved) ] TX[ upsert pi.op.tool_args/O:s1:0, upsert pi.op.state/O = call 0 effect_pending, replay: "never" ] @@ -216,7 +216,7 @@ On restart the harness performs the bounded restore reads from §4.4; `pi.op.sta TX[ upsert pi.pending.entry/n3 = synthetic interrupted result containing u1, delete pi.pending.tool_output/O:n3, upsert pi.op.state/O = call 0 outcome_ready ] -TX[ insert entry n3, delete pi.pending.entry/n3, upsert pi.lane.leaf = n3, +TX[ insert entry n3, delete pi.pending.entry/n3, upsert pi.branch.tip = n3, upsert pi.op.state/O = call 0 completed ] ``` @@ -370,8 +370,8 @@ Dynamic locations use small owner-defined address constructors. Core and applica ```ts // packages/agent/src/harness/session/values.ts -export const laneLeaf = (lane: string) => - value("pi.lane.leaf", lane); +export const branchTip = (lane: string) => + value("pi.branch.tip", lane); export const laneConfig = (lane: string) => value("pi.lane.config", lane); export const laneState = (lane: string) => @@ -379,8 +379,8 @@ export const laneState = (lane: string) => export const laneLastResult = (lane: string) => value("pi.lane.lastResult", lane); /** Used only by scanValues() to enumerate configured lane names. */ -export const laneLeafInventoryPrefix = () => - value("pi.lane.leaf"); +export const branchTipInventoryPrefix = () => + value("pi.branch.tip"); export const operationMeta = (operationId: string) => value("pi.op.meta", operationId); @@ -471,32 +471,43 @@ interface DurableFileOperations { read: string[]; written: string[]; edited: string[]; } type DurableStructuralPreparation = - | { kind: "compaction"; messagesToSummarize: AgentMessage[]; - turnPrefixMessages: AgentMessage[]; retainedTail: AgentMessage[]; - isSplitTurn: boolean; tokensBefore: number; previousSummary?: string; - fileOps: DurableFileOperations; settings: CompactionSettings } - | { kind: "branch_summary"; messages: AgentMessage[]; - fileOps: DurableFileOperations; totalTokens: number }; + | { + kind: "compaction"; + messagesToSummarize: AgentMessage[]; + turnPrefixMessages: AgentMessage[]; + retainedTail: AgentMessage[]; + isSplitTurn: boolean; + tokensBefore: number; + previousSummary?: string; + fileOps: DurableFileOperations; + settings: CompactionSettings; + } + | { + kind: "branch_summary"; + messages: AgentMessage[]; + fileOps: DurableFileOperations; + totalTokens: number; + }; ``` -| Address constructor | Kind | Persisted namespace and key | Value | Meaning | -|---|---|---|---|---| -| `laneLeaf(lane)` | value | `pi.lane.leaf`, lane | entry id or `null` | where this lane appends next | -| `laneConfig(lane)` | value | `pi.lane.config`, lane | `LaneConfiguration` | total lane configuration | -| `laneState(lane)` | value | `pi.lane.state`, lane | `LaneState` (§3.3) | `currentOperationId`, `pendingNextRun` | -| `laneLastResult(lane)` | value | `pi.lane.lastResult`, lane | `LaneLastResult` (§3.13) | latest terminal outcome | -| `operationMeta(opId)` | value | `pi.op.meta`, operation id | `OperationMeta` (§3.1) | acceptance data; written once | -| `operationState(opId)` | value | `pi.op.state`, operation id | `OperationState` (§3.2) | total durable restart point | -| `operationToolArgs(opId, stepId, sourceIndex)` | value | `pi.op.tool_args`, `{opId}:{stepId}:{sourceIndex}` | effective arguments | written once at clearance | -| `operationToolMemo(opId, invocationId, name)` | value | `pi.op.tool_memo`, `{opId}:{invocationId}:{name}` | `JsonValue` | invocation-scoped durable memo | -| `operationPreparation(opId, taskId)` | value | `pi.op.preparation`, `{opId}:{taskId}` | `DurableStructuralPreparation` | structural preparation | -| `pendingEntry(entryId)` | value | `pi.pending.entry`, reserved entry id | `PendingEntry` | complete content awaiting placement | -| `pendingToolOutput(opId, invocationId)` | value | `pi.pending.tool_output`, `{opId}:{invocationId}` | `AgentToolResult` | latest bounded progress checkpoint | -| `pendingAssistantFrames(opId, responseEntryId)` | list | `pi.pending.assistant_frame`, `{opId}:{responseEntryId}` | `AssistantMessageFrame` elements | committed stream-frame prefix | -| `sessionName` | value | `pi.session.name`, empty key | string | session name | -| `entryLabel(entryId)` | value | `pi.entry.label`, entry id | string | entry label | +| Address constructor | Kind | Persisted namespace and key | Value | Meaning | +| ----------------------------------------------- | ----- | -------------------------------------------------------- | -------------------------------- | -------------------------------------- | +| `branchTip(lane)` | value | `pi.branch.tip`, lane | entry id or `null` | where this lane appends next | +| `laneConfig(lane)` | value | `pi.lane.config`, lane | `LaneConfiguration` | total lane configuration | +| `laneState(lane)` | value | `pi.lane.state`, lane | `LaneState` (§3.3) | `currentOperationId`, `pendingNextRun` | +| `laneLastResult(lane)` | value | `pi.lane.lastResult`, lane | `LaneLastResult` (§3.13) | latest terminal outcome | +| `operationMeta(opId)` | value | `pi.op.meta`, operation id | `OperationMeta` (§3.1) | acceptance data; written once | +| `operationState(opId)` | value | `pi.op.state`, operation id | `OperationState` (§3.2) | total durable restart point | +| `operationToolArgs(opId, stepId, sourceIndex)` | value | `pi.op.tool_args`, `{opId}:{stepId}:{sourceIndex}` | effective arguments | written once at clearance | +| `operationToolMemo(opId, invocationId, name)` | value | `pi.op.tool_memo`, `{opId}:{invocationId}:{name}` | `JsonValue` | invocation-scoped durable memo | +| `operationPreparation(opId, taskId)` | value | `pi.op.preparation`, `{opId}:{taskId}` | `DurableStructuralPreparation` | structural preparation | +| `pendingEntry(entryId)` | value | `pi.pending.entry`, reserved entry id | `PendingEntry` | complete content awaiting placement | +| `pendingToolOutput(opId, invocationId)` | value | `pi.pending.tool_output`, `{opId}:{invocationId}` | `AgentToolResult` | latest bounded progress checkpoint | +| `pendingAssistantFrames(opId, responseEntryId)` | list | `pi.pending.assistant_frame`, `{opId}:{responseEntryId}` | `AssistantMessageFrame` elements | committed stream-frame prefix | +| `sessionName` | value | `pi.session.name`, empty key | string | session name | +| `entryLabel(entryId)` | value | `pi.entry.label`, entry id | string | entry label | -The five exported scan-prefix constructors—`laneLeafInventoryPrefix`, `operationToolArgsPrefix`, `operationToolMemoPrefix`, `operationPreparationPrefix`, and `pendingToolOutputPrefix`—encapsulate lane inventory and operation-cleanup grammar. Their results are valid only as namespace-scoped `scanValues()` inputs, never exact get/set/delete addresses. +The five exported scan-prefix constructors—`branchTipInventoryPrefix`, `operationToolArgsPrefix`, `operationToolMemoPrefix`, `operationPreparationPrefix`, and `pendingToolOutputPrefix`—encapsulate lane inventory and operation-cleanup grammar. Their results are valid only as namespace-scoped `scanValues()` inputs, never exact get/set/delete addresses. These lifetimes are reflected in the address grammars: @@ -602,9 +613,9 @@ interface ListReadOptions { List read semantics: ascending reads return `seq > cursor.seq`, descending reads `seq < cursor.seq`; results are ordered before `limit` applies; absent and empty keys both return `[]`; callers continue with the last returned element's `seq`, and an empty page ends iteration. A cursor is a sequence filter, not a snapshot or key-incarnation token: concurrent later appends may appear on later ascending pages, and after a whole-key delete a read simply applies the comparison to surviving elements. There is deliberately no unbounded “read the whole list” helper. -`scanValues(prefixAddress)` is namespace-scoped, interprets the bound key as a prefix, and returns values in key-ascending order. Core inventory/cleanup uses only `laneLeafInventoryPrefix`, `operationToolArgsPrefix`, `operationToolMemoPrefix`, `operationPreparationPrefix`, and `pendingToolOutputPrefix`; core call sites do not repeat raw reserved namespace/key grammar. Ordinary reads use exact addresses. There is no cross-namespace value dump or durable write log. Entry inventory uses `scanEntries`; ledger reads use `scanUsage`; totals use the stats projection (§1.6); test-order assertions wrap `commit()` with the instrumented-storage decorator (Part 9). +`scanValues(prefixAddress)` is namespace-scoped, interprets the bound key as a prefix, and returns values in key-ascending order. Core inventory/cleanup uses only `branchTipInventoryPrefix`, `operationToolArgsPrefix`, `operationToolMemoPrefix`, `operationPreparationPrefix`, and `pendingToolOutputPrefix`; core call sites do not repeat raw reserved namespace/key grammar. Ordinary reads use exact addresses. There is no cross-namespace value dump or durable write log. Entry inventory uses `scanEntries`; ledger reads use `scanUsage`; totals use the stats projection (§1.6); test-order assertions wrap `commit()` with the instrumented-storage decorator (Part 9). -Recovery and execution reads must be index-driven and bounded. They may not infer state from an absent value, and there is no value history to fold. Exact dereference is allowed: current typed state may name a bounded set of entries and bound values. An exact list address derived from current state may be read in bounded sequence pages and reduced by its consumer—assistant frames use pi-ai's `reduceAssistantMessageFrames` (§3.7). Base restore never reads lists (§4.4). Public inventory and debugging APIs expose explicit limits/pagination at the `SessionTree` layer. +Recovery and execution reads must be index-driven and bounded. They may not infer state from an absent value, and there is no value history to fold. Exact dereference is allowed: current typed state may name a bounded set of entries and bound values. An exact list address derived from current state may be read in bounded sequence pages and reduced by its consumer—assistant frames use pi-ai's `reduceAssistantMessageFrames` (§3.7). Base restore never reads lists (§4.4). Public inventory and debugging APIs expose explicit limits/pagination through Session and Branch. `close()` is idempotent. It seals admission, rejects later reads/commits on that instance, drains commits admitted before the seal, then releases resources and the writer claim. Durable data is reopened through the repository. @@ -646,13 +657,13 @@ The file is not the state; it is the **replay recipe** for the Memory maps above [{"kind":"entry","seq":101,"timestamp":1700000000000,"id":"e_50","parentId":"e_41","type":"message","message":{"role":"user","content":[...]}}, {"kind":"value","op":"set","seq":102,"namespace":"pi.op.meta","key":"op_9","value":{...}}, {"kind":"value","op":"set","seq":103,"namespace":"pi.op.state","key":"op_9","value":{...}}, - {"kind":"value","op":"set","seq":104,"namespace":"pi.lane.leaf","key":"main","value":"e_50"}, + {"kind":"value","op":"set","seq":104,"namespace":"pi.branch.tip","key":"main","value":"e_50"}, {"kind":"value","op":"set","seq":105,"namespace":"pi.lane.state","key":"main","value":{...}}] {"kind":"value","op":"set","seq":106,"namespace":"pi.op.state","key":"op_9","value":{"phase":"assistant-ready",...}} {"kind":"value","op":"set","seq":107,"namespace":"pi.op.state","key":"op_9","value":{"phase":"assistant-effect-pending","responseEntryId":"e_51","usageId":"u_7",...}} {"kind":"list","op":"append","seq":108,"namespace":"pi.pending.assistant_frame","key":"op_9:e_51","value":{"type":"text_delta","contentIndex":0,"delta":"hi"}} [{"kind":"entry","seq":109,"timestamp":1700000000100,"id":"e_51","parentId":"e_50","type":"message","message":{"role":"assistant","content":[...]}}, - {"kind":"value","op":"set","seq":110,"namespace":"pi.lane.leaf","key":"main","value":"e_51"}, + {"kind":"value","op":"set","seq":110,"namespace":"pi.branch.tip","key":"main","value":"e_51"}, {"kind":"usage","seq":111,"id":"u_7","entryId":"e_51","adjustment":false,"usage":{...}}, {"kind":"list","op":"delete","seq":112,"namespace":"pi.pending.assistant_frame","key":"op_9:e_51"}, {"kind":"value","op":"set","seq":113,"namespace":"pi.op.state","key":"op_9","value":{...}}] @@ -783,7 +794,7 @@ Because the file is the session, the precise rewrite (§2.9) and forks are file ## 1.8 Why write-once plus values and lists -- **Attachment is bounded.** It performs fixed projection point reads per lane (§4.4). Detailed watch capture later performs one compaction-bounded transcript scan and exact state-directed reads while holding the lane line (§5.4). Drive procedures likewise dereference exact ids/addresses as they consume them. The only reducer on any durable path is pi-ai's frame reducer, applied to one exact bounded list a consumer explicitly names (§3.7). +- **Attachment is bounded.** It performs fixed projection point reads per lane (§4.4). Detailed watch capture later performs one compaction-bounded transcript scan and exact state-directed reads while holding the Session mutation line (§5.4). Drive procedures likewise dereference exact ids/addresses as they consume them. The only reducer on any durable path is pi-ai's frame reducer, applied to one exact bounded list a consumer explicitly names (§3.7). - **Crash states are enumerable.** Between transactions, never inside one. - **Cleanup is deletion, not collection.** A 30-turn run replaces one `operationState(operationId)` value ~30 times and then deletes it. What remains is exactly the conversation, the ledger, and a handful of lane and semantic session values — no dead state values, no history rows, nothing to garbage-collect. (JSONL defers *physical* reclamation to snapshot compaction; the logical state is identical.) - **No repair-by-rewrite.** Recovery appends entries and replaces only the values it owns, with the same transitions normal execution would commit; interrupt it and rerun it and you get the same result. @@ -799,17 +810,35 @@ Because the file is the session, the precise rewrite (§2.9) and forks are file An **entry** is the complete stored row (§1.1): placement fields and payload together. What `getEntries` and the scans return is exactly what was committed — there is no materialization step and no join. ```ts -interface MessageEntry extends EntryBase { type: "message"; message: AgentMessage; - terminate?: true } -interface CompactionEntry extends EntryBase { type: "compaction"; summary: string; - retainedTail: AgentMessage[]; tokensBefore: number; - details?: JsonValue; usage?: Usage; fromHook: boolean } -/** fromId is the summarized branch's pre-navigation leaf: the producing - operation's sourceLeafId (§3.10), or null when that source is the root. */ -interface BranchSummaryEntry extends EntryBase { type: "branch_summary"; fromId: string | null; - summary: string; details?: JsonValue; - usage?: Usage; fromHook: boolean } -interface CustomEntry extends EntryBase { type: "custom"; customType: string; data?: JsonValue } +interface MessageEntry extends EntryBase { + type: "message"; + message: AgentMessage; + terminate?: true; +} +interface CompactionEntry extends EntryBase { + type: "compaction"; + summary: string; + retainedTail: AgentMessage[]; + tokensBefore: number; + details?: JsonValue; + usage?: Usage; + fromHook: boolean; +} +/** fromId is the summarized branch's pre-navigation tip: the producing + operation's sourceTipId (§3.10), or null when that source is the root. */ +interface BranchSummaryEntry extends EntryBase { + type: "branch_summary"; + fromId: string | null; + summary: string; + details?: JsonValue; + usage?: Usage; + fromHook: boolean; +} +interface CustomEntry extends EntryBase { + type: "custom"; + customType: string; + data?: JsonValue; +} type Entry = MessageEntry | CompactionEntry | BranchSummaryEntry | CustomEntry; ``` @@ -836,7 +865,7 @@ The cases are mechanical: ``` TX[ insert e_a4 = { parent: e_q1, type: "message", message: }, - upsert pi.lane.leaf/main = "e_a4" ] + upsert pi.branch.tip/main = "e_a4" ] ``` **Content first, placement later — queued input.** `steer`, `followUp`, `nextRun`, and deferred tree writes mint the entry id at enqueue and construct `pendingEntry(id)`. Queue state references content by that id. Two transactions may be far apart: @@ -847,7 +876,7 @@ t0 TX[ upsert pi.pending.entry/e_q1 = { type: "message", payload: <200KB messag t1 TX[ insert e_q1 = { parent: e_a3, type: "message", message: }, delete pi.pending.entry/e_q1, - upsert pi.lane.leaf/main = "e_q1", + upsert pi.branch.tip/main = "e_q1", S(next){ ...inbox.steer -= "e_q1" } ] ``` @@ -875,14 +904,22 @@ Consequences to rely on: - A reserved tool-result id moves through `string only → pi.pending.entry → immutable entry`; no two representations coexist at a commit boundary. - Queued input still pays the deliberate content double-write (§1.8). Finalized tool outcomes also stage once before placement when source ordering requires it; this additional write is what prevents completed parallel effects from replaying after a crash. -## 2.3 Lanes +## 2.3 Branches and AgentLanes -A configured lane is three values — plus `pi.lane.lastResult` once its first operation has ended (§3.13). Fresh or normalized-v3 `main` may temporarily lack `pi.lane.config` until first harness attachment: +A `Branch` is data for one named path through the immutable entry tree. It exists exactly when its tip value exists: +```text +pi.branch.tip/{name} = entry id or null ``` -pi.lane.leaf/{name} = entry id or null -pi.lane.config/{name} = LaneConfiguration // absent only for unconfigured main -pi.lane.state/{name} = LaneState + +A Branch owns only its tip, branch-relative queries, and direct append. A raw Branch append always inserts the entry at the current tip and moves the tip in one Session mutation. It has no model, queues, operation state, hooks, or execution policy. + +A configured `AgentLane` is a Branch plus total agent state: + +```text +pi.lane.config/{name} = LaneConfiguration +pi.lane.state/{name} = LaneState +pi.lane.lastResult/{name} = LaneLastResult // optional until first terminal result ``` ```ts @@ -893,21 +930,9 @@ 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, 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: +`AgentHarness.lane(name, options?, context)` is atomic get-or-create. A missing Branch writes its tip, the immutable Harness seed configuration, and idle lane state together. A data-only Branch receives configuration and idle state without moving its existing tip. A complete AgentLane is returned unchanged. Partial combinations fault as corruption. Concurrent acquisitions publish and return one process-local AgentLane. A fresh Session and fresh Harness may have no Branches or AgentLanes; `main` is created only when explicitly acquired. -``` -TX[ upsert pi.lane.config/{name} = , - upsert pi.lane.leaf/{name} = anchorEntryId, - upsert pi.lane.state/{name} = { currentOperationId: null, pendingNextRun: [] } ] -``` - -- 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. +During an active run, AgentLane append methods preserve operation-aware deferred-write semantics. A raw Branch append remains direct; mutating that raw Branch while a Harness owns its corresponding lane is a trusted-programming defect. ## 2.4 Session metadata and application values @@ -918,7 +943,7 @@ sessionName // value("pi.session.name") entryLabel(entryId) // value("pi.entry.label", entryId) ``` -`getName`/`setName` and `getLabel`/`setLabel` are purpose-specific wrappers over those addresses. Passing `undefined` to a setter deletes the value; deleting an absent value is a no-op (§1.4). These writes commit immediately and never move a leaf. +`getName`/`setName` and `getLabel`/`setLabel` are purpose-specific wrappers over those addresses. Passing `undefined` to a setter deletes the value; deleting an absent value is a no-op (§1.4). These writes commit immediately and never move a tip. Applications define stable, collision-resistant addresses directly: @@ -927,15 +952,15 @@ const applicationState = value("my-app.state"); const applicationEvents = list("my-app.events"); ``` -There is no built-in application namespace or separate application-state API. Application values and lists use the same `SessionTree` address methods as harness storage and must define their own fork and migration policy. +There is no built-in application namespace or separate application-state API. Application values and lists use Session address methods and must define their own fork and migration policy. ## 2.5 Branch queries and context ```ts interface BranchScan { - start?: string; // required at the Storage layer; the Session - // tree view defaults it to the view's lane leaf - stopAtType?: EntryType; // scan ends after the first match, inclusive + start?: string; // required at Storage; Branch/AgentLane defaults + // it to the receiver's current tip + stopAtType?: EntryType; // scan ends after the first match, inclusive stopAtId?: string; type?: EntryType; customType?: string; @@ -951,7 +976,7 @@ Semantics: take the path from `start` toward the root, order it (default `newest **Context projection** — how a provider request is built: -1. `scanBranch({ start: leaf, order: "newestFirst", stopAtType: "compaction" })`. +1. `scanBranch({ start: tip, order: "newestFirst", stopAtType: "compaction" })`. 2. Reverse to oldest-first. If a compaction terminated the scan, the context is: its `summary`, then its `retainedTail`, then every entry after it. **Nothing earlier is read.** 3. Drop assistant responses whose stop reason is `error`, `aborted`, or `deferred`. Retain genuine output-limit `length`. 4. Run custom entries through `entryProjectors`. An unprojected custom entry never enters context. @@ -969,15 +994,15 @@ Memory and JSONL walk parent pointers in RAM. SQLite maintains a private segment Append: -1. If a branch tip equals the lane leaf, append one row and move that tip. -2. Otherwise resolve a branch that actually covers the leaf, find the newest compaction at or below the leaf through the complete segment chain, copy only rows after that compaction through the leaf, and set the older prefix as the new segment's base. +1. If a branch tip equals the lane tip, append one row and move that tip. +2. Otherwise resolve a branch that actually covers the tip, find the newest compaction at or below the tip through the complete segment chain, copy only rows after that compaction through the tip, and set the older prefix as the new segment's base. 3. Append the new entry and make it the new segment tip. Read newest segment first. If the requested range crosses `baseSeq`, continue through the base chain with the upper bound capped at that boundary. Merge segment results into the requested order before filtering/limiting. Two correctness rules are mandatory: -- The base branch must itself cover the leaf within its logical range; merely containing the leaf in an ancestor is insufficient. +- The base branch must itself cover the tip within its logical range; merely containing the tip in an ancestor is insufficient. - The newest compaction search must traverse the base chain; checking only the newest physical segment can miss it. The cache must preserve: @@ -992,9 +1017,7 @@ Tests assert these invariants and the required query plans. No wall-clock thresh ## 2.7 Forks -A fork is a repository operation over one coherent source-session snapshot. It copies selected entries, latest semantic values, lane leaves, and total configuration; it never copies operation/pending values, assistant frame lists, or ledger rows — destination lanes start with a fresh empty `LaneState`. Application-defined values and lists are not copied by the generic fork; a consuming feature must add an explicit address-specific policy before relying on copied application state; a precise rewrite (§2.9) that retains a list element preserves its sequence cursor unless it explicitly remaps the whole destination sequence space. - -Forking should not require all copied entries and values to be materialized in memory at once. Backends may use native copying, streaming, paging, or already-resident state while preserving one coherent source snapshot. +A fork is a repository operation over one coherent source-storage snapshot. It copies selected immutable entries, Branch tips, session name, and labels whose targets copy. It excludes usage rows, operation/pending values and lists, last results, and application-defined values/lists unless a later feature defines an explicit policy. ```ts type ForkOptions = @@ -1002,131 +1025,114 @@ type ForkOptions = | { scope: "tree"; id?: string }; ``` -- Memory and JSONL obtain the snapshot as one job on the source storage queue. SQLite uses one read transaction. -- Branch scope copies one path and creates only destination `main`. Tree scope copies the whole tree and every lane leaf/configuration. -- The destination is idle and its usage/cost ledger starts at zero. Entry-local display usage remains on copied entries. -- Semantic values follow their declared scope: the session name copies; labels copy only when their target copies unless tree scope copies all targets; application-defined values/lists are excluded unless a later consuming feature adds an explicit address-specific policy. -- Any message may be the fork point. Request construction heals orphaned tool calls. -- Copied entries keep their ids. -- `id` optionally supplies the destination session id. -- The destination metadata records `parentSessionId` as the source session id; callers cannot override it through fork options. +- Branch scope requires source `main`, copies one selected path, and creates destination Branch `main` at that tip. +- Tree scope copies the whole tree and every Branch tip; a branchless source produces a branchless destination. +- A complete configured source AgentLane copies its total configuration and receives fresh idle lane state **together iff** it is configured. Data-only Branches remain data-only. +- Operation state, pending content/progress/frames, last results, and usage rows never copy. Destination usage starts at zero. +- Session name copies; labels copy only with their target. Copied entries retain ids. Destination metadata records the source session id as `parentSessionId`. +- Memory/JSONL serialize snapshot capture with commits; SQLite uses one read transaction. -A source with only fresh/unconfigured `main`—new format 4 or read-only normalized v3—may have no configuration. Either fork scope then creates one unconfigured destination `main`, which first harness attachment seeds normally. Every configured format-4 lane copied by a fork keeps its current total configuration. +Code that must admit a commit before starting the independently queued fork snapshot uses keyless `beginMutation()`, invokes `commit()`, starts the repository fork after admission, and calls `end()` in `finally`. Storage queue ordering chooses one coherent boundary. ## 2.8 Session and repository boundary -`Storage` is deliberately one-session only. `Session` supplies lane-bound typed tree views and delegates typed storage values directly. `SessionRepo` owns discovery and storage-instance lifecycle: +`Storage` is deliberately one-session only. `Session` owns global metadata, values/lists, entry and usage queries, Branch discovery/creation, one mutation line, and one backend lifecycle. It does not implement Branch and has no implicit main. ```ts -interface SessionMetadata { - id: string; - createdAt: number; - /** Current storage schema version (Part 7). */ - storageVersion: number; // starts at 1 for new format-4 sessions - cwd?: string; // working directory, when the application records one - parentSessionId?: string; - /** Only when a v3 parent path cannot be resolved to an available header id. */ - legacyParentSessionPath?: string; -} - -interface SessionRepo { - create(options: C, context: Context): Promise>; - open(metadata: M, context: Context): Promise>; - list(options: L | undefined, context: Context): Promise; - delete(metadata: M, context: Context): Promise; - fork(source: M, options: ForkOptions, context: Context): Promise>; -} - interface SessionReader { getEntries(ids: string[], context: Context): Promise>; getValue(address: Value, context: Context): Promise | undefined>; scanValues(prefix: Value, context: Context): Promise[]>; - readList(address: ValueList, options: ListReadOptions | undefined, - context: Context): Promise[]>; - /** Internal branch read used by callback-scoped coherent snapshot capture. */ + readList( + address: ValueList, + options: ListReadOptions | undefined, + context: Context, + ): Promise[]>; scanBranch(query: StorageBranchScan, context: Context): Promise; } -/** Explicitly acquired mutation capability bound to one lane. */ interface SessionMutation extends SessionReader { - readonly lane: string; - - /** The mutation's sole commit. A second call rejects, including after a - failed first attempt. Commit does not release the lane line. */ commit(writes: Write[], context: Context): Promise; - - /** Wait for any admitted commit, invalidate this capability, and release - the lane line. Repeated calls are idempotent. */ end(context: Context): Promise; } - -/** A mutate callback receives no authority to release its own lane line. */ type SessionMutator = Omit; -interface Session - extends SessionTree, SessionReader { +interface Branch { + readonly name: string; + getTipId(context: Context): Promise; + findEntries( + query: BranchScan | undefined, + context: Context, + ): Promise; + findEntry( + query: BranchScan | undefined, + context: Context, + ): Promise; + appendMessage(message: AgentMessage, context: Context): Promise; + appendCustomEntry( + customType: string, + data: JsonValue | undefined, + context: Context, + ): Promise; +} + +interface Session< + M extends SessionMetadata = SessionMetadata, +> extends SessionReader { readonly metadata: M; - /** Mints UUIDv7 ids; a supplied timestamp mints a follower id (§1.2). */ readonly idGenerator: { next(timestampMs?: number): string }; - view(lane: string): SessionTree; - - /** Acquire the named lane's mutation line until SessionMutation.end(). */ - beginMutation(lane: string, context: Context): Promise; - - /** Serializes one complete read/decide/commit/publication job on the named lane. */ - mutate(lane: string, - mutation: (mutator: SessionMutator, context: Context) => T | Promise, - context: Context): Promise; - - createLane(name: string, at: string | null, configuration: LaneConfiguration, - onCommitted: ((context: Context) => void | Promise) | undefined, - context: Context): Promise; - + getEntry(id: string, context: Context): Promise; + getStats(context: Context): Promise; + findEntries( + query: EntryQuery | undefined, + context: Context, + ): Promise; + findEntry( + query: EntryQuery | undefined, + context: Context, + ): Promise; + getName(context: Context): Promise; + getLabel(targetId: string, context: Context): Promise; + branch(name: string, context: Context): Promise; + createBranch( + name: string, + at: string | null, + context: Context, + ): Promise; + beginMutation(context: Context): Promise; + mutate( + mutation: (mutator: SessionMutator, context: Context) => T | Promise, + context: Context, + ): Promise; + setValue( + address: Value, + next: NoInfer, + context: Context, + ): Promise; + deleteValue(address: Value, context: Context): Promise; + appendList( + address: ValueList, + element: NoInfer, + context: Context, + ): Promise; + deleteList(address: ValueList, context: Context): Promise; + setName(name: string | undefined, context: Context): Promise; + setLabel( + targetId: string, + label: string | undefined, + context: Context, + ): Promise; close(context: Context): Promise; } ``` -`Session.beginMutation` is the transportable raw mutation primitive. It resolves only after acquiring the named lane's mutation line and returns a `SessionMutation` that remains valid until `end()`. 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. `commit([])` is a valid empty transaction and returns its ordinary `CommitResult`; it still does not release the line. `end()` waits for an admitted commit to settle, invalidates the reader/commit capability, and releases the line. Ending without committing performs no storage commit. A direct caller must therefore call `end()` in `finally`; retaining or abandoning an acquired mutation indefinitely blocks later work on that lane and close. +All supported mutations serialize on one keyless Session line. `beginMutation()` is the transportable explicit scope: commit is zero-or-one attempt, commit does not release, and `end()` alone invalidates and releases after any admitted commit settles. `Session.mutate()` is the callback convenience and always ends in `finally`. Normal harness/plugin code uses `mutate`; direct begin/end callers must end in `finally`. -`Session.mutate` is the callback convenience built from that lifecycle: acquire with `beginMutation`, invoke the callback with the same object narrowed to `SessionMutator`, then call `end()` in `finally`. The callback cannot release its own line. It may return without committing, and an unawaited admitted commit still settles before `end()` releases the line. Most importantly, a successful `commit()` does **not** release the line: the callback may publish the exact process-local projection and synchronously bind event recipients before it returns. This preserves the existing read → decide → commit → publish boundary and the `onCommitted` semantics below. +Ordinary Session and Branch reads bypass the line. Each read observes the latest fully applied storage commit, but several reads are not a snapshot. Use `mutate()` for coherent read-decide-write. Providers, tools, hooks, timers, and asynchronous event delivery never run inside mutation callbacks. Process-local projection publication and synchronous event-recipient binding may follow commit before line release. -Every read, commit, and lifecycle call receives the invocation Context unless the caller deliberately derives a child. A mutation callback must not retain its 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. Callers normally await `mutate()` immediately. Code that must admit a commit before starting an independently queued repository operation, such as a fork snapshot, uses `beginMutation()`, invokes `commit()`, and only then starts that operation; it must not rely on a mutation callback's synchronous prefix. +The explicit begin/read/commit/end lifecycle is retained for RemoteSession transport. The worker runs its local callback and publication while the server holds the sole concrete Session line, then sends end. Disconnect or timeout terminates that server-side scope under hosting policy. No caller-selected lane key exists. -The split exists so a remote session can preserve the same lane-line scope without sending a JavaScript callback over RPC. The server owns the concrete Session, Storage, and mutation line; the worker's `RemoteSession.mutate()` performs `begin` RPC → local callback with remote reads/one remote commit → local post-commit publication → `end` RPC. The server does not release the lane after commit; it releases only after the worker ends the mutation. A disconnect or mutation timeout must terminate the server-side scope, reject/fault the in-flight harness according to the hosting policy, and leave durable recovery to resolve any uncertain commit response. Storage backends remain single-owner: no Memory maps, JSONL file, or SQLite writer are shared directly with workers. - -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 while the line remains held, retains any returned promise without awaiting it on the line, then releases the line and awaits that retained promise. A callback throw or retained-promise rejection means the lane committed but process-local publication failed. Reads remain available directly on `Session`, `SessionTree`, `SessionMutation`, and `SessionMutator`. - -The ordering is load-bearing and is the same for a local Session and `RemoteSession`: - -```ts -const mutation = await session.beginMutation(lane, context); -let completion: Promise | undefined; -try { - await mutation.commit(writes, context); - const result = onCommitted?.(context); // synchronous prefix runs on the line - completion = result === undefined ? undefined : Promise.resolve(result); -} finally { - await mutation.end(context); // release only after publication is bound -} -await completion; // asynchronous delivery runs off the line -``` - -Awaiting `onCommitted` before `end()` is incorrect because it holds the lane line across asynchronous event delivery. Calling it after `end()` is also incorrect because a later same-lane mutation could observe durable state before its matching process-local projection and recipients are published. - -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. - -`open()` compares the stored `storageVersion` with the binary's: equal proceeds; older runs chained migrations under the writer lease before returning (Part 7); newer refuses to open. Old coding-agent v3 JSONL sessions open through the same repository and normalize on load (Appendix B — "v3" there names the legacy JSONL session format, not this document). - -A repository must not open the same session twice concurrently. `create()` and `fork()` return their destination session already open; another `open()` for that session rejects until that returned `Session` finishes `close()`. Passing that open Session to `AgentHarness.create()` temporarily transfers orchestration ownership: until attachment rejects or the returned harness closes, callers do not mutate it directly or construct a second harness around it. Once close resolves, a later `open()` returns a new session instance over the same durable data. This is one open owner per session id, not a shared-handle or reference-counting contract. - -Repository implementations resolve `fork(source, ...)` to the source's serialized snapshot boundary: an active Memory/JSONL storage queues the snapshot with commits; an inactive JSONL file is read as one immutable prefix; SQLite uses one read snapshot of the session's file. Repositories may keep an active-storage registry by session id for this purpose. This is repository coordination, not part of the one-session `Storage` contract. - -All shipping backends receive explicit-mutation behavior from the same `StorageBackedSession`. JSONL returns that Session directly. Memory's open-handle facade forwards `beginMutation()` and keeps the handle admitted through `end()` so facade close waits for it. SQLite's open-session facade forwards the mutation to its storage-backed Session; closing the wrapped Session seals and drains the same lane line before releasing the writer lease. No backend implements a separate mutation protocol or becomes multi-process writable. - -How a repository organizes its sessions is its own choice, constrained only by the storage backend: JSONL and SQLite storage are one file per session, so their repositories are file-based; a Postgres storage could hold every session in one database. +A repository creates only metadata/header/catalog state: no Branch, configuration, or lane state. `createBranch` validates name, absence, and a non-null target atomically and writes only the tip. Repository open ownership, version checks, storage snapshot boundaries, and search integration otherwise remain as described below. ### Search @@ -1258,7 +1264,7 @@ Entries and usage rows are never deleted (§1.2). The sole sanctioned exception interface OperationMeta { operationId: string; lane: string; - sourceLeafId: string | null; + sourceTipId: string | null; startedAt: number; intent: | { kind: "run"; promptEntryIds: string[] } @@ -1268,7 +1274,7 @@ interface OperationMeta { } ``` -`OperationMeta` is immutable acceptance metadata. It lives in the `operationMeta(operationId)` value: written once at acceptance, never overwritten, and deleted by the terminal transaction (§3.13). The process-local `Operation` projection is `{ meta: OperationMeta, state: OperationState }`, assembled from the separate `pi.op.meta` and `pi.op.state` values; it is never persisted as one value. `sourceLeafId` is the lane's leaf *before* the operation; entries the operation itself appends come after it. `promptEntryIds` name the caller's normalized prompt entries, born placed in the acceptance transaction (§3.6). The id is either supplied to `accept` or minted before the acceptance command; it correlates a hosted submission with current operation and `pi.lane.lastResult`, but the harness retains no forever-idempotency index after a later terminal result replaces that value. +`OperationMeta` is immutable acceptance metadata. It lives in the `operationMeta(operationId)` value: written once at acceptance, never overwritten, and deleted by the terminal transaction (§3.13). The process-local `Operation` projection is `{ meta: OperationMeta, state: OperationState }`, assembled from the separate `pi.op.meta` and `pi.op.state` values; it is never persisted as one value. `sourceTipId` is the lane's tip _before_ the operation; entries the operation itself appends come after it. `promptEntryIds` name the caller's normalized prompt entries, born placed in the acceptance transaction (§3.6). The id is either supplied to `accept` or minted before the acceptance command; it correlates a hosted submission with current operation and `pi.lane.lastResult`, but the harness retains no forever-idempotency index after a later terminal result replaces that value. ## 3.2 Operation state — the durable restart point @@ -1427,20 +1433,20 @@ A deferred poll that returns an event stream persists frames exactly like assist The complete transition table — every row is one `commit()`; classification order (§3.7) applies to every poll settlement, cancellation first: -| From | Trigger | Transaction | To | -|---|---|---|---| -| assistant `effect_pending` | settlement classifies `deferred` with a valid handle | §3.7's deferred row | suspended, `poll: 0`, `sourceEntryId: R` | -| suspended, poll *k* | captured model is unavailable when a drive supplies `pollDeferred: true` | `TX[ S(failure_drain{error:{code:"model_unavailable", ...}, provenance:{kind:"configuration"}}) ]` — the handle is abandoned without fabricating a fetch response or usage | failure_drain | -| suspended, poll *k* | captured model resolves and a drive supplies `pollDeferred: true`: the poll's `before_request` settlement commits its intent, consuming the pass's single poll permit | mint fresh R′ and U′, then `TX[ S(deferred{effect_pending, poll k+1, responseEntryId R′, usageId U′}) ]` | effect_pending, poll *k*+1 | -| effect_pending, poll *k*+1 | fetch returns **pending** with a completely equal handle | `TX[ insert response entry R′, insert usage U′, upsert pi.lane.leaf = R′, S(latestAssistantEntryId=R′, deferred{suspended, sourceEntryId R′, poll k+1}) ]` — the pending response becomes the next source and the operation re-suspends; no second poll this invocation | suspended, poll *k*+1 | -| effect_pending | fetch returns **pending** with a mismatched handle | normalize to a durable `error` response explaining the mismatch: `TX[ insert normalized response R′, insert usage U′, upsert pi.lane.leaf = R′, S(latestAssistantEntryId=R′, failure_drain{error, provenance:response R′}) ]` | failure_drain | -| effect_pending | fetch returns **ready** with tool calls | `TX[ insert response R′, insert usage U′, upsert pi.lane.leaf = R′, S(latestAssistantEntryId=R′, tools{plan with reserved result ids}) ]` — result ids minted as followers of R′ (§1.2) | tools | -| effect_pending | fetch returns **ready** without tool calls | `TX[ insert response R′, insert usage U′, upsert pi.lane.leaf = R′, S(latestAssistantEntryId=R′, checkpoint{may_finish, includeFinalAssistant:true}) ]` | checkpoint | -| effect_pending | fetch settles as a provider `error` | `TX[ insert response R′, insert usage U′, upsert pi.lane.leaf = R′, S(latestAssistantEntryId=R′, failure_drain{error, provenance:response R′}) ]` — polls have no retry path | failure_drain | -| effect_pending, restored, running control | crash left the poll's outcome unknown; a drive with a poll permit finds the captured model unavailable | `TX[ delete list pi.pending.assistant_frame/O:R′, S(failure_drain{error:{code:"model_unavailable", ...}, provenance:{kind:"configuration"}}) ]` — abandon the old reserved response/usage strings without fabricating settlement | failure_drain | -| effect_pending, restored, running control | crash left the poll's outcome unknown; the next drive with a poll permit resolves the captured model and replaces it | mint fresh R″/U″ and commit a fresh intent at the **same** poll number — an unknown-outcome poll never completed, so `poll` does not increment; the old reserved id strings are abandoned, never materialized, and the intent transaction deletes the abandoned old frame list; the replacement poll starts a fresh list under its fresh response id | effect_pending, poll *k*+1 | -| effect_pending, cancelled control | reconciliation, live or restored (§4.5, §4.6) | synthetic settlement under the **existing** reserved ids, preserving partial content reduced from committed frames: `TX[ insert synthetic aborted response R′, insert zero usage U′, upsert pi.lane.leaf = R′, delete frame list O:R′, S(latestAssistantEntryId=R′, cancelled checkpoint{may_finish}) ]` | cancelled checkpoint → aborted finish | -| suspended, cancelled control | reconciliation | no fetch starts; best-effort `cancel_deferred` targets the newest source (§4.6), and the operation finishes through the aborted terminal transaction | terminal | +| From | Trigger | Transaction | To | +| ----------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------- | +| assistant `effect_pending` | settlement classifies `deferred` with a valid handle | §3.7's deferred row | suspended, `poll: 0`, `sourceEntryId: R` | +| suspended, poll _k_ | captured model is unavailable when a drive supplies `pollDeferred: true` | `TX[ S(failure_drain{error:{code:"model_unavailable", ...}, provenance:{kind:"configuration"}}) ]` — the handle is abandoned without fabricating a fetch response or usage | failure_drain | +| suspended, poll _k_ | captured model resolves and a drive supplies `pollDeferred: true`: the poll's `before_request` settlement commits its intent, consuming the pass's single poll permit | mint fresh R′ and U′, then `TX[ S(deferred{effect_pending, poll k+1, responseEntryId R′, usageId U′}) ]` | effect_pending, poll _k_+1 | +| effect_pending, poll _k_+1 | fetch returns **pending** with a completely equal handle | `TX[ insert response entry R′, insert usage U′, upsert pi.branch.tip = R′, S(latestAssistantEntryId=R′, deferred{suspended, sourceEntryId R′, poll k+1}) ]` — the pending response becomes the next source and the operation re-suspends; no second poll this invocation | suspended, poll _k_+1 | +| effect_pending | fetch returns **pending** with a mismatched handle | normalize to a durable `error` response explaining the mismatch: `TX[ insert normalized response R′, insert usage U′, upsert pi.branch.tip = R′, S(latestAssistantEntryId=R′, failure_drain{error, provenance:response R′}) ]` | failure_drain | +| effect_pending | fetch returns **ready** with tool calls | `TX[ insert response R′, insert usage U′, upsert pi.branch.tip = R′, S(latestAssistantEntryId=R′, tools{plan with reserved result ids}) ]` — result ids minted as followers of R′ (§1.2) | tools | +| effect_pending | fetch returns **ready** without tool calls | `TX[ insert response R′, insert usage U′, upsert pi.branch.tip = R′, S(latestAssistantEntryId=R′, checkpoint{may_finish, includeFinalAssistant:true}) ]` | checkpoint | +| effect_pending | fetch settles as a provider `error` | `TX[ insert response R′, insert usage U′, upsert pi.branch.tip = R′, S(latestAssistantEntryId=R′, failure_drain{error, provenance:response R′}) ]` — polls have no retry path | failure_drain | +| effect_pending, restored, running control | crash left the poll's outcome unknown; a drive with a poll permit finds the captured model unavailable | `TX[ delete list pi.pending.assistant_frame/O:R′, S(failure_drain{error:{code:"model_unavailable", ...}, provenance:{kind:"configuration"}}) ]` — abandon the old reserved response/usage strings without fabricating settlement | failure_drain | +| effect_pending, restored, running control | crash left the poll's outcome unknown; the next drive with a poll permit resolves the captured model and replaces it | mint fresh R″/U″ and commit a fresh intent at the **same** poll number — an unknown-outcome poll never completed, so `poll` does not increment; the old reserved id strings are abandoned, never materialized, and the intent transaction deletes the abandoned old frame list; the replacement poll starts a fresh list under its fresh response id | effect_pending, poll _k_+1 | +| effect_pending, cancelled control | reconciliation, live or restored (§4.5, §4.6) | synthetic settlement under the **existing** reserved ids, preserving partial content reduced from committed frames: `TX[ insert synthetic aborted response R′, insert zero usage U′, upsert pi.branch.tip = R′, delete frame list O:R′, S(latestAssistantEntryId=R′, cancelled checkpoint{may_finish}) ]` | cancelled checkpoint → aborted finish | +| suspended, cancelled control | reconciliation | no fetch starts; best-effort `cancel_deferred` targets the newest source (§4.6), and the operation finishes through the aborted terminal transaction | terminal | ### Structural work @@ -1484,7 +1490,7 @@ type NavigationState = phase: { kind: "summary"; structural: StructuralDecision } }; ``` -Structural preparation is built from an observed source leaf and settings snapshot, normalized (`Set` file-operation fields become sorted arrays), and written once to the `operationPreparation(operationId, taskId)` value before the decision hook, in the same transaction as the `deciding` state (§3.9). State carries only `taskId`; the deterministic constructor locates the value, and hooks/generators hydrate arrays back to the source preparation types. Reopen never rebuilds it from current settings, so the provider sees the same summary input the hook approved. +Structural preparation is built from an observed source tip and settings snapshot, normalized (`Set` file-operation fields become sorted arrays), and written once to the `operationPreparation(operationId, taskId)` value before the decision hook, in the same transaction as the `deciding` state (§3.9). State carries only `taskId`; the deterministic constructor locates the value, and hooks/generators hydrate arrays back to the source preparation types. Reopen never rebuilds it from current settings, so the provider sees the same summary input the hook approved. One structural attempt may make one or two sequential provider requests using the existing compaction implementation. Its request callback first commits `request:{index,usageId}`, invokes that request through `drive.gate.admit(...)`, then atomically writes usage and clears/advances the request field. Intermediate content remains process-local; any activated orphaned `effect_pending` attempt is treated as wholly uncertain and starts a later attempt under the captured policy rather than continuing request two. Never suspend between two requests of one live structural attempt: if its captured model identity disappears after request one, fail the attempt in-band so retry/failure follows the captured policy and no process-local intermediate text is parked waiting for registration. A durable `generating` decision prevents its decision hook from rerunning. @@ -1498,7 +1504,7 @@ interface LaneState { } ``` -Attachment restores only the small durable projection needed to construct each lane and inventory open operations. It reads the lane's `laneLeaf`, `laneConfig`, `laneState`, optional `laneLastResult`, and, when `currentOperationId` names O, `operationMeta(O)` plus `operationState(O)`. These values become the lane's owned process-local projection. While that harness remains alive and owns the session, this projection is the authoritative current lane and operation control state. Every supported control-state mutation commits through the owning `Lane` and publishes its exact next projection before the lane mutation line is released. `laneLastResult` supports inspection but is never a recovery input (§3.13). +Attachment restores only the small durable projection needed to construct each lane and inventory open operations. It reads the lane's `branchTip`, `laneConfig`, `laneState`, optional `laneLastResult`, and, when `currentOperationId` names O, `operationMeta(O)` plus `operationState(O)`. These values become the lane's owned process-local projection. While that harness remains alive and owns the session, this projection is the authoritative current lane and operation control state. Every supported control-state mutation commits through the owning `Lane` and publishes its exact next projection before the Session mutation line is released. `laneLastResult` supports inspection but is never a recovery input (§3.13). Projection restore validates the relationships required to construct that projection: required value existence, lane ownership, operation-id agreement, and intent/state kind compatibility. Missing or contradictory projection values fault attachment and no harness is returned. Process loss discards the projection; a replacement process rebuilds it from these durable values during attachment. @@ -1508,7 +1514,7 @@ Attachment does not dereference transcript, queue payloads, deferred sources, as > Compute the next total state in memory, then atomically commit every entry insert, usage insert, and value/list write that makes that state true. -A transition plans from the authoritative owned projection supplied by the lane mutation line, verifies the semantic state it extends, and changes only the fields it owns. In particular, settlement preserves newer inbox/control fields, and the terminal transaction clears `currentOperationId` while preserving concurrently accepted `pendingNextRun`. A procedure that awaited external work rechecks its expected phase and effect identity against that current projection before committing. Cancellation routes to reconciliation; a missing or different operation means ownership was lost; any other unexpected phase or identity is an invariant defect (§4.3, §4.9). The callback reader is used only for referenced payloads and operation-owned cleanup scans. Every edge below is exactly one `commit()`. +A transition plans from the authoritative owned projection supplied by the Session mutation line, verifies the semantic state it extends, and changes only the fields it owns. In particular, settlement preserves newer inbox/control fields, and the terminal transaction clears `currentOperationId` while preserving concurrently accepted `pendingNextRun`. A procedure that awaited external work rechecks its expected phase and effect identity against that current projection before committing. Cancellation routes to reconciliation; a missing or different operation means ownership was lost; any other unexpected phase or identity is an invariant defect (§4.3, §4.9). The callback reader is used only for referenced payloads and operation-owned cleanup scans. Every edge below is exactly one `commit()`. ## 3.5 The graph @@ -1564,44 +1570,44 @@ navigation: ready_to_commit ───────────────── ──→ generating ───→ terminal TX (completed|failed) ``` -A declined summarized navigation moves nothing: the leaf stays at the source, and the terminal transaction records outcome `declined`. Abort before any structural commit finishes `aborted`, likewise without a move (§4.6). +A declined summarized navigation moves nothing: the tip stays at the source, and the terminal transaction records outcome `declined`. Abort before any structural commit finishes `aborted`, likewise without a move (§4.6). ## 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 `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. +`accept(request, context)` is the only operation-acceptance primitive. It performs state-independent normalization outside the Session mutation 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 | -|---|---|---| -| idle lane | normalized prompt, skill, or prompt-template request | `TX[ insert entries for captured nextRun items (payloads from their pending values), insert request prompt entries, delete the captured pending values, upsert pi.lane.leaf = newest entry, upsert pi.op.meta/O, S(run{captured settings, starting, empty inbox}), L({currentOperationId: O, captured ids removed from pendingNextRun}) ]` | -| idle lane | compaction request with non-empty preparation | `TX[ upsert pi.op.preparation/O:{taskId} = P, upsert pi.op.meta/O, S(compaction{deciding, taskId}), L({currentOperationId: O}) ]` | -| idle lane | unsummarized navigation request after validation | `TX[ upsert pi.op.meta/O, S(navigation{ready_to_commit}), L ]` | -| idle lane | summarized navigation request with preparation | `TX[ upsert pi.op.preparation/O:{taskId} = P, upsert pi.op.meta/O, S(navigation{summary.deciding, taskId}), L ]` | +| From | Request | Transaction | +| --------- | ---------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| idle lane | normalized prompt, skill, or prompt-template request | `TX[ insert entries for captured nextRun items (payloads from their pending values), insert request prompt entries, delete the captured pending values, upsert pi.branch.tip = newest entry, upsert pi.op.meta/O, S(run{captured settings, starting, empty inbox}), L({currentOperationId: O, captured ids removed from pendingNextRun}) ]` | +| idle lane | compaction request with non-empty preparation | `TX[ upsert pi.op.preparation/O:{taskId} = P, upsert pi.op.meta/O, S(compaction{deciding, taskId}), L({currentOperationId: O}) ]` | +| idle lane | unsummarized navigation request after validation | `TX[ upsert pi.op.meta/O, S(navigation{ready_to_commit}), L ]` | +| idle lane | summarized navigation request with preparation | `TX[ upsert pi.op.preparation/O:{taskId} = P, upsert pi.op.meta/O, S(navigation{summary.deciding, taskId}), L ]` | Captured `nextRun` items already have their payloads in `pendingEntry(id)` values; the command inserts their entries before request prompt entries, deletes their values, and removes their ids from `pendingNextRun` — the placement half of queued-content staging (§1.8). A late item keeps its enqueue-minted id and remains queued for the next run. The run's immutable `promptEntryIds` name only the request's normalized prompt entries; hook-injected messages are not acceptance metadata. -A run is accepted in payload-free `starting`. An accepted-but-undriven operation is simply open; its immediate `before_run` consumption and checkpoint commit are identity-free durable progress. Its first real drive, after the cancellation check and `before_drive`, invokes `before_run` outside the lane line. The consuming command appends returned messages and atomically replaces `starting` with `checkpoint{need_assistant(false)}`, using the newest injected entry as trigger or otherwise the newest accepted entry. It preserves concurrent inbox and control changes. A crash before that commit may rerun the hook; a crash after it cannot. Under `cancel_requested`, reconciliation invokes neither prerequisite. +A run is accepted in payload-free `starting`. An accepted-but-undriven operation is simply open; its immediate `before_run` consumption and checkpoint commit are identity-free durable progress. Its first real drive, after the cancellation check and `before_drive`, invokes `before_run` outside the Session mutation line. The consuming command appends returned messages and atomically replaces `starting` with `checkpoint{need_assistant(false)}`, using the newest injected entry as trigger or otherwise the newest accepted entry. It preserves concurrent inbox and control changes. A crash before that commit may rerun the hook; a crash after it cannot. Under `cancel_requested`, reconciliation invokes neither prerequisite. -Structural preparation may be computed outside the lane line against an observed source leaf and settings snapshot, but it grants no ownership and blocks no lane work. Its final command must revalidate that the lane is idle and that every state-dependent source still matches; stale preparation is discarded and recomputed. A concurrent accepted operation returns `LaneBusy`. Unsummarized navigation performs validation in its one command. +Structural preparation may be computed outside the Session mutation line against an observed source tip and settings snapshot, but it grants no ownership and blocks no lane work. Its final command must revalidate that the lane is idle and that every state-dependent source still matches; stale preparation is discarded and recomputed. A concurrent accepted operation returns `LaneBusy`. Unsummarized navigation performs validation in its one command. -Pre-acceptance rejections write **nothing**: `LaneBusy`, `NothingToCompact`, `InvalidNavigation` (target is the current leaf, label on the root target, summarize from root, or a null target with summarize), `UnknownTarget` (non-null target missing), and `InvalidMessage` when acceptance would append zero entries. Acceptance does not consult process-local model/tool registries; unavailable captured implementations become in-band configuration failures at their actual execution boundary. An empty normalized caller request is valid only when the command captures at least one `nextRun` item; no later hook can rescue empty acceptance. +Pre-acceptance rejections write **nothing**: `LaneBusy`, `NothingToCompact`, `InvalidNavigation` (target is the current tip, label on the root target, summarize from root, or a null target with summarize), `UnknownTarget` (non-null target missing), and `InvalidMessage` when acceptance would append zero entries. Acceptance does not consult process-local model/tool registries; unavailable captured implementations become in-band configuration failures at their actual execution boundary. An empty normalized caller request is valid only when the command captures at least one `nextRun` item; no later hook can rescue empty acceptance. -**Acceptance must observe `currentOperationId === null`.** Because the check occurs on the sole lane mutation line, it is validation, not compare-and-swap. Concurrent accepts serialize naturally; the loser observes the committed operation and returns `LaneBusy`. Acceptance never installs a `Drive`. Convenience calls and hosted schedulers use the same accepted state: `prompt`/`skill`/`compact`/`navigateTree` call `accept`, then `drive`; a serving layer may return the admission, schedule a durable wake, and drive later. A crash after acceptance but before drive restores the initial committed phase and starts no work automatically. +**Acceptance must observe `currentOperationId === null`.** Because the check occurs on the sole Session mutation line, it is validation, not compare-and-swap. Concurrent accepts serialize naturally; the loser observes the committed operation and returns `LaneBusy`. Acceptance never installs a `Drive`. Convenience calls and hosted schedulers use the same accepted state: `prompt`/`skill`/`compact`/`navigateTree` call `accept`, then `drive`; a serving layer may return the admission, schedule a durable wake, and drive later. A crash after acceptance but before drive restores the initial committed phase and starts no work automatically. ## 3.7 Assistant generation -| From | Trigger | Transaction | To | -|---|---|---|---| -| checkpoint `need_assistant` | ordinary procedure | snapshot current lane config, stream options, and normalized retry policy inline into the context in `TX[ S(assistant{ready, nextAttempt:1}) ]` | ready | -| assistant `ready` | captured model or configured active-tool definition is unavailable before request admission | `TX[ S(failure_drain{error:{code:"model_unavailable" | "configured_tools_unavailable", ...}, provenance:{kind:"configuration"}}) ]` — no response/usage ids are reserved and no assistant entry or usage row is fabricated | failure_drain | -| assistant `ready` | identities resolve and `before_request` completes | mint R and U, then `TX[ S(assistant{effect_pending, attempt=nextAttempt, responseEntryId R, usageId U, intendedOutputLimit, contextWindow}) ]` | effect_pending | -| effect_pending | settles with tool calls | `TX[ insert response entry R, insert usage U, upsert pi.lane.leaf = R, delete frame list O:R, S(latestAssistantEntryId=R, tools{plan with reserved result ids}) ]` | tools | -| effect_pending | retryable error, attempts remain | `TX[ insert response entry R, insert usage U, upsert pi.lane.leaf = R, S(latestAssistantEntryId=R, assistant{retry_wait, nextAttempt k+1, notBefore}) ]` | retry_wait | -| effect_pending | first overflow, preparation non-empty | `TX[ insert response entry R **normalized to error**, insert usage U, upsert pi.lane.leaf = R, upsert pi.op.preparation/O:{taskId} = P, S(latestAssistantEntryId=R, compaction{reason:overflow, structural:{deciding, taskId}, resumeAfter:{checkpoint, prior trigger, need_assistant(true)}}) ]` | compaction | -| effect_pending | first overflow, preparation empty | `TX[ insert normalized response entry R, insert usage U, upsert pi.lane.leaf = R, S(latestAssistantEntryId=R, failure_drain{error, provenance:response R}) ]` | failure_drain | -| effect_pending | `stopReason: "deferred"` | `TX[ insert response entry R, insert usage U, upsert pi.lane.leaf = R, S(latestAssistantEntryId=R, deferred{suspended, sourceEntryId R, poll 0, configuration/options copied}) ]` | deferred | -| effect_pending | `stop` or genuine `length` | `TX[ insert response entry R, insert usage U, upsert pi.lane.leaf = R, S(latestAssistantEntryId=R, checkpoint{may_finish, includeFinalAssistant:true}) ]` | checkpoint | -| effect_pending | terminal error, retries exhausted, or 2nd overflow | `TX[ insert response entry R, insert usage U, upsert pi.lane.leaf = R, S(latestAssistantEntryId=R, failure_drain{error, provenance:response R}) ]` | failure_drain | -| retry_wait | gated timer reaches `notBefore` | `TX[ S(assistant{ready, nextAttempt:k+1}) ]` | ready | +| From | Trigger | Transaction | To | +| --------------------------- | ------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| checkpoint `need_assistant` | ordinary procedure | snapshot current lane config, stream options, and normalized retry policy inline into the context in `TX[ S(assistant{ready, nextAttempt:1}) ]` | ready | +| assistant `ready` | captured model or configured active-tool definition is unavailable before request admission | `TX[ S(failure_drain{error:{code:"model_unavailable" | "configured_tools_unavailable", ...}, provenance:{kind:"configuration"}}) ]` — no response/usage ids are reserved and no assistant entry or usage row is fabricated | failure_drain | +| assistant `ready` | identities resolve and `before_request` completes | mint R and U, then `TX[ S(assistant{effect_pending, attempt=nextAttempt, responseEntryId R, usageId U, intendedOutputLimit, contextWindow}) ]` | effect_pending | +| effect_pending | settles with tool calls | `TX[ insert response entry R, insert usage U, upsert pi.branch.tip = R, delete frame list O:R, S(latestAssistantEntryId=R, tools{plan with reserved result ids}) ]` | tools | +| effect_pending | retryable error, attempts remain | `TX[ insert response entry R, insert usage U, upsert pi.branch.tip = R, S(latestAssistantEntryId=R, assistant{retry_wait, nextAttempt k+1, notBefore}) ]` | retry_wait | +| effect_pending | first overflow, preparation non-empty | `TX[ insert response entry R **normalized to error**, insert usage U, upsert pi.branch.tip = R, upsert pi.op.preparation/O:{taskId} = P, S(latestAssistantEntryId=R, compaction{reason:overflow, structural:{deciding, taskId}, resumeAfter:{checkpoint, prior trigger, need_assistant(true)}}) ]` | compaction | +| effect_pending | first overflow, preparation empty | `TX[ insert normalized response entry R, insert usage U, upsert pi.branch.tip = R, S(latestAssistantEntryId=R, failure_drain{error, provenance:response R}) ]` | failure_drain | +| effect_pending | `stopReason: "deferred"` | `TX[ insert response entry R, insert usage U, upsert pi.branch.tip = R, S(latestAssistantEntryId=R, deferred{suspended, sourceEntryId R, poll 0, configuration/options copied}) ]` | deferred | +| effect_pending | `stop` or genuine `length` | `TX[ insert response entry R, insert usage U, upsert pi.branch.tip = R, S(latestAssistantEntryId=R, checkpoint{may_finish, includeFinalAssistant:true}) ]` | checkpoint | +| effect_pending | terminal error, retries exhausted, or 2nd overflow | `TX[ insert response entry R, insert usage U, upsert pi.branch.tip = R, S(latestAssistantEntryId=R, failure_drain{error, provenance:response R}) ]` | failure_drain | +| retry_wait | gated timer reaches `notBefore` | `TX[ S(assistant{ready, nextAttempt:k+1}) ]` | ready | Every settlement row above — tool plan, retry, overflow, deferred, checkpoint, and failure alike — additionally executes `deleteList(pendingAssistantFrames(O, R))`; the first row shows it explicitly and the rest abbreviate. Settlement and frame cleanup are one atomic commit. @@ -1623,7 +1629,7 @@ convert event to frame → consume the next provider event ``` -The provider loop never awaits storage per frame; existing assistant event delivery remains awaited per event. Because `appendList` is enqueued synchronously, lane-line FIFO preserves provider-event order, and the model's bounded output bounds queued frame memory — a response queues at most its bounded output plus constant frame/transaction overhead. Replacing the latest promise reference never leaves an earlier rejection unobserved: every promise carries the fault observer before it is superseded. +The provider loop never awaits storage per frame; existing assistant event delivery remains awaited per event. Because `appendList` is enqueued synchronously, Session mutation FIFO preserves provider-event order, and the model's bounded output bounds queued frame memory — a response queues at most its bounded output plus constant frame/transaction overhead. Replacing the latest promise reference never leaves an earlier rejection unobserved: every promise carries the fault observer before it is superseded. At stream settlement, the procedure stops frame admission and awaits the latest frame-write promise before `after_response`; lane FIFO means its completion implies completion of every earlier append. There is **no** timer, batcher, coalescer, active/waiting state, or flush API — public or internal. A failed frame append faults the harness before `after_response` starts; the complete final response stays process-local and never commits after a storage fault. @@ -1663,15 +1669,15 @@ An overflow classification never produces a tool plan. A *genuine* `length` that Tool execution separates effect completion from source-ordered tree placement: -| From | Trigger | Transaction | To | -|---|---|---|---| -| call *i* `planned` | clearance passed (`before_tool`, lookup, arg validation) | `TX[ upsert pi.op.tool_args/O:{stepId}:{i} = effective args, S(call i = effect_pending, replay) ]` | dispatch | -| call *i* `effect_pending` | tool calls `onUpdate(partial, { checkpoint:true })` | `TX[ upsert pi.pending.tool_output/O:{resultEntryId} = partial ]` after invocation fencing; state unchanged | `effect_pending` | -| call *i* `effect_pending` | effect settled; latest update delivery and latest checkpoint write awaited; `after_tool` applied; `tool_end` emitted | `TX[ upsert pi.pending.entry/{resultEntryId} = finalized result, delete pi.pending.tool_output/O:{resultEntryId}, delete pi.op.tool_memo/O:{resultEntryId}:*, S(call i = outcome_ready, terminate) ]` | `outcome_ready` | -| call *i* `planned` | unknown tool / invalid args / `before_tool` blocks or throws / control cancelled | stage the complete synthetic result in `pi.pending.entry` and write `outcome_ready` without effect intent | `outcome_ready` | -| source-ready prefix | first non-completed calls are `outcome_ready` | `TX[ insert result entries in source order, delete their pi.pending.entry values, insert reported usage, upsert pi.lane.leaf, S(calls = completed / next checkpoint) ]` | `completed` or checkpoint | +| From | Trigger | Transaction | To | +| ------------------------- | -------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------- | +| call _i_ `planned` | clearance passed (`before_tool`, lookup, arg validation) | `TX[ upsert pi.op.tool_args/O:{stepId}:{i} = effective args, S(call i = effect_pending, replay) ]` | dispatch | +| call _i_ `effect_pending` | tool calls `onUpdate(partial, { checkpoint:true })` | `TX[ upsert pi.pending.tool_output/O:{resultEntryId} = partial ]` after invocation fencing; state unchanged | `effect_pending` | +| call _i_ `effect_pending` | effect settled; latest update delivery and latest checkpoint write awaited; `after_tool` applied; `tool_end` emitted | `TX[ upsert pi.pending.entry/{resultEntryId} = finalized result, delete pi.pending.tool_output/O:{resultEntryId}, delete pi.op.tool_memo/O:{resultEntryId}:*, S(call i = outcome_ready, terminate) ]` | `outcome_ready` | +| call _i_ `planned` | unknown tool / invalid args / `before_tool` blocks or throws / control cancelled | stage the complete synthetic result in `pi.pending.entry` and write `outcome_ready` without effect intent | `outcome_ready` | +| source-ready prefix | first non-completed calls are `outcome_ready` | `TX[ insert result entries in source order, delete their pi.pending.entry values, insert reported usage, upsert pi.branch.tip, S(calls = completed / next checkpoint) ]` | `completed` or checkpoint | -Every `onUpdate` remains a process-local `tool_update` observation. The synchronous callback calls `events.emit(tool_update)` and retains the latest delivery promise internally; tools neither receive nor await that promise. `checkpoint:true` additionally requests replacement of the invocation's bounded durable progress snapshot: every such call synchronously enqueues one invocation-fenced scalar replacement on the lane mutation line, attaches the ordinary harness-fault observer to the returned promise, and replaces only the process-local latest checkpoint-write promise reference. No checkpoint write is dropped or coalesced; lane-line FIFO preserves request order, and each mutation verifies that the same call remains `effect_pending` when it executes. The tool alone controls cadence and duplicate suppression — a tool that requests checkpoints faster than storage can commit queues work in memory under the trusted-tool contract. Tools own bounding; this first API imposes no generic byte cap or truncation on trusted in-process values. When the tool promise settles, the harness stops accepting updates and closes checkpoint admission; a late request returns without committing. Before `after_tool`, the procedure awaits the latest update-delivery promise **and** the latest checkpoint-write promise — each implies completion of everything earlier in its queue. Checkpoint writes are ordered before outcome staging on the lane mutation line, and staging deletes the scalar; a failed checkpoint commit follows the ordinary storage-fault path and prevents staging. The built-in bash tool emits bounded live snapshots every 100 ms and requests a checkpoint at most every two seconds, only when the bounded snapshot differs from its last requested checkpoint. +Every `onUpdate` remains a process-local `tool_update` observation. The synchronous callback calls `events.emit(tool_update)` and retains the latest delivery promise internally; tools neither receive nor await that promise. `checkpoint:true` additionally requests replacement of the invocation's bounded durable progress snapshot: every such call synchronously enqueues one invocation-fenced scalar replacement on the Session mutation line, attaches the ordinary harness-fault observer to the returned promise, and replaces only the process-local latest checkpoint-write promise reference. No checkpoint write is dropped or coalesced; Session mutation FIFO preserves request order, and each mutation verifies that the same call remains `effect_pending` when it executes. The tool alone controls cadence and duplicate suppression — a tool that requests checkpoints faster than storage can commit queues work in memory under the trusted-tool contract. Tools own bounding; this first API imposes no generic byte cap or truncation on trusted in-process values. When the tool promise settles, the harness stops accepting updates and closes checkpoint admission; a late request returns without committing. Before `after_tool`, the procedure awaits the latest update-delivery promise **and** the latest checkpoint-write promise — each implies completion of everything earlier in its queue. Checkpoint writes are ordered before outcome staging on the Session mutation line, and staging deletes the scalar; a failed checkpoint commit follows the ordinary storage-fault path and prevents staging. The built-in bash tool emits bounded live snapshots every 100 ms and requests a checkpoint at most every two seconds, only when the bounded snapshot differs from its last requested checkpoint. Outcome staging is the point after which the tool can never replay. After `after_tool`, the procedure constructs the complete canonical final result—bounded independently of progress snapshots—emits and awaits `tool_end` for a real execution, then stages its `ToolResultMessage`; the state carries only `terminate` and the reserved id. Tool-reported usage remains inside that staged message until materialization, where its ledger row commits atomically with the immutable entry. Added tool names likewise become active from the materialized transcript point, never from invisible staging. @@ -1689,7 +1695,7 @@ Modes: Blocked and invalid calls skip intent/execution but still stage a synthetic outcome. A missing tool implementation is the ordinary unknown-tool case: stage an `isError:true` `ToolResultMessage` whose text says the named tool is unavailable, then continue the batch and later assistant turn. The harness constructs that message directly and omits `details`; it must not invent a value for the tool's application-defined typed details contract. A crash before staging reruns ordinary clearance, including `before_tool` under its replay contract. A crash after staging never reruns the hook or tool. -Calls are tracked internally by `sourceIndex`. Hooks and events see provider `toolCallId` and tool name. `AgentHarnessToolInvocation.invocationId` equals the reserved `resultEntryId`, remains stable across safe replay, and scopes durable memos under `operationToolMemo(O, invocationId, name)` addresses. Memo names must be non-empty and contain no `:`; `setMemo(name, undefined)` deletes. Memo operations synchronously enqueue on the lane mutation line before returning their promises, and tools must await writes. Each job verifies the same effect-pending invocation when it executes, protecting against authorized external finalization. A pre-return write is FIFO-ordered before staging and then deleted by staging; a post-return call rejects after capability expiry. No separate write drain exists. Flue-style `step.do(name, effect)` awaits these operations: a committed value returns on replay, while a crash before its memo commit may rerun the effect. There is no nested per-step replay state and no exactly-once external-effect promise. +Calls are tracked internally by `sourceIndex`. Hooks and events see provider `toolCallId` and tool name. `AgentHarnessToolInvocation.invocationId` equals the reserved `resultEntryId`, remains stable across safe replay, and scopes durable memos under `operationToolMemo(O, invocationId, name)` addresses. Memo names must be non-empty and contain no `:`; `setMemo(name, undefined)` deletes. Memo operations synchronously enqueue on the Session mutation line before returning their promises, and tools must await writes. Each job verifies the same effect-pending invocation when it executes, protecting against authorized external finalization. A pre-return write is FIFO-ordered before staging and then deleted by staging; a post-return call rejects after capability expiry. No separate write drain exists. Flue-style `step.do(name, effect)` awaits these operations: a committed value returns on replay, while a crash before its memo commit may rerun the effect. There is no nested per-step replay state and no exactly-once external-effect promise. ## 3.9 Summary generation — compaction and navigation summaries @@ -1708,18 +1714,18 @@ Both operations generate a summary through the same `deciding → generating → "Auto compaction" is the in-run row: `threshold` and `overflow`. Non-empty preparation and the transition into `deciding` commit together (`upsert pi.op.preparation/O:{taskId}` plus the structural state and, for threshold, marked `resumeAfter`). Preparation returning `undefined` never creates `StructuralDecision`: threshold atomically marks the checkpoint checked and continues; overflow atomically enters response-provenance `failure_drain` using the normalized overflow response. Neither path emits structural lifecycle. Empty standalone preparation is rejected before acceptance. -| From | Trigger | Transaction | -|---|---|---| -| deciding | hook declines | standalone: the terminal transaction (§3.13) with outcome `declined` · threshold: `TX[ S(restore marked resumeAfter) ]` · overflow: `TX[ S(failure_drain{error, provenance:structural taskId}) ]` | -| deciding | hook supplies compaction | standalone: `TX[ insert hook usage row?, insert compaction entry, upsert pi.lane.leaf, terminal writes (§3.13) ]`; in-run: same result-publication writes plus `S(resumeAfter)` | -| deciding | hook supplies navigation summary | use §3.10's final transaction with the hook usage/result | -| deciding | hook selects generation | conditionally snapshot current config/policy inline in `TX[ S(generating{ready}) ]` — **the decision hook will never run again** | -| generating ready / retry elapsed | captured model is unavailable before request admission | standalone: terminal `failed` with `OperationError.code = "model_unavailable"` · in-run: `TX[ S(failure_drain{error, provenance:{kind:"configuration"}}) ]`; no request/usage intent is written | -| generating ready / retry elapsed | captured model resolves | `TX[ S(effect_pending, attempt k) ]` | -| generating effect_pending | one nested request returns | `TX[ insert usage row under request.usageId, S(effect_pending, request cleared, usageIds += id) ]`; commit another request intent before request two | -| generating effect_pending | retryable attempt outcome | usage is already durable; `TX[ S(retry_wait) ]` | -| generating effect_pending | terminal or attempts exhausted | standalone: the terminal transaction (§3.13) with outcome `failed` · in-run: `TX[ S(failure_drain{provenance:structural taskId}) ]` | -| generating effect_pending | compaction succeeded | standalone: `TX[ insert result entry, upsert pi.lane.leaf, terminal writes (§3.13) ]`; in-run: result-publication writes plus `S(resumeAfter)` | +| From | Trigger | Transaction | +| -------------------------------- | ------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| deciding | hook declines | standalone: the terminal transaction (§3.13) with outcome `declined` · threshold: `TX[ S(restore marked resumeAfter) ]` · overflow: `TX[ S(failure_drain{error, provenance:structural taskId}) ]` | +| deciding | hook supplies compaction | standalone: `TX[ insert hook usage row?, insert compaction entry, upsert pi.branch.tip, terminal writes (§3.13) ]`; in-run: same result-publication writes plus `S(resumeAfter)` | +| deciding | hook supplies navigation summary | use §3.10's final transaction with the hook usage/result | +| deciding | hook selects generation | conditionally snapshot current config/policy inline in `TX[ S(generating{ready}) ]` — **the decision hook will never run again** | +| generating ready / retry elapsed | captured model is unavailable before request admission | standalone: terminal `failed` with `OperationError.code = "model_unavailable"` · in-run: `TX[ S(failure_drain{error, provenance:{kind:"configuration"}}) ]`; no request/usage intent is written | +| generating ready / retry elapsed | captured model resolves | `TX[ S(effect_pending, attempt k) ]` | +| generating effect_pending | one nested request returns | `TX[ insert usage row under request.usageId, S(effect_pending, request cleared, usageIds += id) ]`; commit another request intent before request two | +| generating effect_pending | retryable attempt outcome | usage is already durable; `TX[ S(retry_wait) ]` | +| generating effect_pending | terminal or attempts exhausted | standalone: the terminal transaction (§3.13) with outcome `failed` · in-run: `TX[ S(failure_drain{provenance:structural taskId}) ]` | +| generating effect_pending | compaction succeeded | standalone: `TX[ insert result entry, upsert pi.branch.tip, terminal writes (§3.13) ]`; in-run: result-publication writes plus `S(resumeAfter)` | Structural provider streams are internal: they emit **no** public assistant-message lifecycle and persist **no** `pendingAssistantFrames(...)` list — a future structural-diagnostics need would add a separate explicitly scoped consumer rather than silently reusing transcript-assistant semantics. The existing summary generator is retained, but its one/two request callback uses the nested request intent/effect/usage boundaries from §3.2 and §4.2. Intermediate content is not persisted; a crash before the final transaction makes the whole attempt unknown, and a later numbered attempt starts only under the captured retry policy. Failed-attempt usage stays in the ledger regardless — terminal cleanup deletes values/lists, never ledger rows (§1.6). @@ -1737,7 +1743,7 @@ Structural provider streams are internal: they emit **no** public assistant-mess ``` TX[ insert e_41 = { …assistant response, stopReason: "error", errorMessage: "context window exceeded: …" }, - insert usage u_41, upsert pi.lane.leaf/main = "e_41", + insert usage u_41, upsert pi.branch.tip/main = "e_41", upsert pi.op.preparation/op_9:t_1 = , S(compaction{ reason: overflow, structural: { deciding, taskId: "t_1" }, @@ -1771,43 +1777,43 @@ Unsummarized and summarized both finish in **one** transaction — navigation's ``` TX[ insert hook-reported usage row (only for a hook-supplied summary), - upsert pi.lane.leaf = target, + upsert pi.branch.tip = target, insert summary entry with its display usage snapshot (when summarize; - parent is the target; fromId = the operation's sourceLeafId — the - pre-navigation source leaf), - upsert pi.lane.leaf = summary entry (when summarize), + parent is the target; fromId = the operation's sourceTipId — the + pre-navigation source tip), + upsert pi.branch.tip = summary entry (when summarize), setValue(entryLabel(targetId), label) (when a label is present), delete the operation-owned values/lists, upsert pi.lane.lastResult = { kind: "navigation", outcome: "completed", - oldLeafId: sourceLeafId, leafId, + oldTipId: sourceTipId, tipId, summaryEntryId?: generatedSummaryId }, L({ currentOperationId: null }) ] ``` -Writes apply in order inside the transaction. Generated provider usage was already written per request in §3.9 and is not written again here; the summary payload only snapshots its producing attempt's usage. The summary entry explicitly names the target as parent, and the following value write makes that summary the completed lane leaf. A crash sees either an untouched navigation still at its source, or a fully completed one. **No prepared-summary state and no post-move recovery state exist.** Abort before this transaction ends in an aborted terminal transaction with no entry appended; abort after it means the operation completed. +Writes apply in order inside the transaction. Generated provider usage was already written per request in §3.9 and is not written again here; the summary payload only snapshots its producing attempt's usage. The summary entry explicitly names the target as parent, and the following value write makes that summary the completed lane tip. A crash sees either an untouched navigation still at its source, or a fully completed one. **No prepared-summary state and no post-move recovery state exist.** Abort before this transaction ends in an aborted terminal transaction with no entry appended; abort after it means the operation completed. ## 3.11 Inbox, queues, deferred writes Every queued admission mints the item's entry id (§1.2) and writes its payload once into `pendingEntry(id)`; queue lists carry only the id. -| Public input | Admitted when | Transaction | -|---|---|---| -| `nextRun(msg)` | any state, including idle | `TX[ upsert pi.pending.entry/{id} = payload, L(pendingNextRun += id) ]` — never starts a run | -| `steer(msg)` | open run with running control — including deferred suspension; under `cancel_requested` → `NoActiveRun` | `TX[ upsert pi.pending.entry/{id} = payload, S(inbox.steer += id) ]` | -| `followUp(msg)` | open run with running control — including deferred suspension; under `cancel_requested` → `NoActiveRun` | `TX[ upsert pi.pending.entry/{id} = payload, S(inbox.followUp += id) ]` | -| tree write, run active | including suspended and cancelling | `TX[ upsert pi.pending.entry/{id} = payload, S(inbox.writes += id) ]` — survives abort | -| tree write, lane idle | idle | `TX[ insert entry, upsert pi.lane.leaf ]` | -| tree write, structural op open | — | wait for the operation to end, then re-evaluate | -| `cancelQueued(id)` | item still pending | `TX[ S or L with the id removed, delete pi.pending.entry/{id} ]` | -| checkpoint consumes input | eligible | `TX[ insert entries from pending values, delete those values, upsert pi.lane.leaf, S(ids removed, continuation → need_assistant(false), triggerEntryId = newest entry, skipInboxOnce = true) ]` | -| first `abort(context)` | run active | `TX[ S(control = cancel_requested, requestedAt, drainedSteer, drainedFollowUp, steer/followUp emptied) ]` — drained pending values are **not** deleted | -| finish | inbox empty, no required continuation | the terminal transaction (§3.13) | +| Public input | Admitted when | Transaction | +| ------------------------------ | ------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `nextRun(msg)` | any state, including idle | `TX[ upsert pi.pending.entry/{id} = payload, L(pendingNextRun += id) ]` — never starts a run | +| `steer(msg)` | open run with running control — including deferred suspension; under `cancel_requested` → `NoActiveRun` | `TX[ upsert pi.pending.entry/{id} = payload, S(inbox.steer += id) ]` | +| `followUp(msg)` | open run with running control — including deferred suspension; under `cancel_requested` → `NoActiveRun` | `TX[ upsert pi.pending.entry/{id} = payload, S(inbox.followUp += id) ]` | +| tree write, run active | including suspended and cancelling | `TX[ upsert pi.pending.entry/{id} = payload, S(inbox.writes += id) ]` — survives abort | +| tree write, lane idle | idle | `TX[ insert entry, upsert pi.branch.tip ]` | +| tree write, structural op open | — | wait for the operation to end, then re-evaluate | +| `cancelQueued(id)` | item still pending | `TX[ S or L with the id removed, delete pi.pending.entry/{id} ]` | +| checkpoint consumes input | eligible | `TX[ insert entries from pending values, delete those values, upsert pi.branch.tip, S(ids removed, continuation → need_assistant(false), triggerEntryId = newest entry, skipInboxOnce = true) ]` | +| first `abort(context)` | run active | `TX[ S(control = cancel_requested, requestedAt, drainedSteer, drainedFollowUp, steer/followUp emptied) ]` — drained pending values are **not** deleted | +| finish | inbox empty, no required continuation | the terminal transaction (§3.13) | `cancelQueued` triage, in order: the id is still pending in a queue list → remove it and delete its `pendingEntry(id)` value in one transaction; the content is gone, never having touched the tree, and the call returns `cancelled`. An entry under that id exists → `already_consumed`. Neither → `not_found` — previously cancelled, cleared by abort, or never existed. A client retrying a lost cancel treats `not_found` as success. There are no disposition values, and nothing here is ever a recovery input. The first `abort(context)` moves steer/follow-up ids into `control.drainedSteer`/`control.drainedFollowUp` but deletes none of their `pendingEntry(id)` values: `AbortResult` and `LaneSnapshot.operation.drained` dereference the drained payloads from those values, including after attachment. They die in the terminal transaction (§3.13), never earlier. Deferred writes stay in `inbox.writes` and are applied during reconciliation. -Because acceptance, cancellation, consumption, abort, and finish all serialize on the lane mutation line, every race has exactly two possible histories, and **no item can be both pending and applied** in durable state: at every commit boundary a queued id has its pending value (pending or drained), its entry (consumed), or neither (cancelled) — never both. +Because acceptance, cancellation, consumption, abort, and finish all serialize on the Session mutation line, every race has exactly two possible histories, and **no item can be both pending and applied** in durable state: at every commit boundary a queued id has its pending value (pending or drained), its entry (consumed), or neither (cancelled) — never both. ## 3.12 The checkpoint procedure @@ -1843,33 +1849,60 @@ type StructuralLaneLastResultOutcome = | { outcome: "declined" | "completed"; error?: never; runCompletion?: never }; type LaneLastResult = - | ({ operationId: string; kind: "run"; leafId: string; - /** Newest settled assistant, when the outcome includes one. */ - finalAssistantEntryId?: string } & ( - | FailedLaneLastResult | AbortedLaneLastResult - | { outcome: "completed"; error?: never; - runCompletion: "assistant" | "terminated_tools" })) - | ({ operationId: string; kind: "compaction"; leafId: string; - finalAssistantEntryId?: never } & StructuralLaneLastResultOutcome) - | ({ operationId: string; kind: "navigation"; leafId: string | null; - /** The operation's pre-navigation source leaf. */ - oldLeafId: string | null; - finalAssistantEntryId?: never } & ( - | FailedLaneLastResult | AbortedLaneLastResult - | { outcome: "declined"; error?: never; runCompletion?: never; - summaryEntryId?: never } - | { outcome: "completed"; error?: never; runCompletion?: never; - /** Present when completed navigation published a summary. */ - summaryEntryId?: string })); + | ({ + operationId: string; + kind: "run"; + tipId: string; + /** Newest settled assistant, when the outcome includes one. */ + finalAssistantEntryId?: string; + } & ( + | FailedLaneLastResult + | AbortedLaneLastResult + | { + outcome: "completed"; + error?: never; + runCompletion: "assistant" | "terminated_tools"; + } + )) + | ({ + operationId: string; + kind: "compaction"; + tipId: string; + finalAssistantEntryId?: never; + } & StructuralLaneLastResultOutcome) + | ({ + operationId: string; + kind: "navigation"; + tipId: string | null; + /** The operation's pre-navigation source tip. */ + oldTipId: string | null; + finalAssistantEntryId?: never; + } & ( + | FailedLaneLastResult + | AbortedLaneLastResult + | { + outcome: "declined"; + error?: never; + runCompletion?: never; + summaryEntryId?: never; + } + | { + outcome: "completed"; + error?: never; + runCompletion?: never; + /** Present when completed navigation published a summary. */ + summaryEntryId?: string; + } + )); ``` -A normal run finish copies `RunState.latestAssistantEntryId` and records `runCompletion: "assistant"` when `may_finish.includeFinalAssistant` is true. An all-terminating tool batch records `runCompletion: "terminated_tools"` and omits the final assistant. Failed and aborted run outcomes include the newest settled assistant when non-null and omit the field otherwise. Structural operations omit `runCompletion` and the final assistant. Navigation records its source leaf and, when it publishes one, the summary entry id, so a later matching `drive` can hydrate the complete public result after `pi.op.meta` has been deleted. Only terminal transitions construct a `LaneLastResult`. +A normal run finish copies `RunState.latestAssistantEntryId` and records `runCompletion: "assistant"` when `may_finish.includeFinalAssistant` is true. An all-terminating tool batch records `runCompletion: "terminated_tools"` and omits the final assistant. Failed and aborted run outcomes include the newest settled assistant when non-null and omit the field otherwise. Structural operations omit `runCompletion` and the final assistant. Navigation records its source tip and, when it publishes one, the summary entry id, so a later matching `drive` can hydrate the complete public result after `pi.op.meta` has been deleted. Only terminal transitions construct a `LaneLastResult`. Every terminal transaction, for every operation kind and outcome, has one shape: ``` TX[ , delete pi.op.meta/{O}, delete pi.op.state/{O}, @@ -1891,7 +1924,7 @@ TX[ ; + getTipId(context: Context): Promise; + findEntries( + query: BranchScan | undefined, + context: Context, + ): Promise; + findEntry( + query: BranchScan | undefined, + context: Context, + ): Promise; + appendMessage(message: AgentMessage, context: Context): Promise; + appendCustomEntry( + customType: string, + data: JsonValue | undefined, + context: Context, + ): Promise; /** The lane's most recent terminal outcome (§3.13); undefined before the first terminal transaction. Never consulted by recovery. */ getLastResult(context: Context): Promise; @@ -2404,8 +2403,11 @@ interface AgentLane { // General execution primitives. Convenience methods below compose these. accept(request: OperationRequest, context: Context): Promise; drive(options: DriveOptions, context: Context): Promise; - requestAbort(operationId: string, context: Context): Promise; - /** No-write lane-line observation of projection, ownership, and identities. */ + requestAbort( + operationId: string, + context: Context, + ): Promise; + /** No-write Session mutation observation of projection, ownership, and identities. */ inspectExecution(context: Context): Promise; prompt(text: string, images: ImageContent[] | undefined, context: Context): Promise; @@ -2443,7 +2445,6 @@ interface AgentLane { getActiveTools(context: Context): Promise; setActiveTools(names: string[], context: Context): Promise; - readonly sessionTree: SessionTree; watch(context: Context): Promise>; } @@ -2458,7 +2459,7 @@ The convenience surface adds only invocation-lifetime policy: `prompt`/`skill`/` `getLastResult(context)` is the post-crash reconciliation path: an application that accepted an operation, lost its process, and reopened reads `laneLastResult(lane)` for the outcome its promise never delivered (§3.13). `inspectExecution(context)` enters a no-write lane job and reports the current small owned projection, local owner status, configured model identity, optional captured step identity, and latest result coherently. It does not resolve either identity through `Models` and performs no transcript/payload/frame reads. This lets a scheduler reconcile its own durable submission without an accept/settle race. Every state publication into that projection is serialized with the committing lane mutation. `pi.lane.lastResult` is still overwritten by the next terminal operation; permanent per-submission receipts remain a serving-layer concern. It is also how a caller learns the outcome of an operation finalized externally (§4.9). -`waitForIdle(context)` registers on the lane mutation line and resolves when all earlier admitted lane jobs have settled, `currentOperationId` is null, and no process-local idle callback owns the lane. Later operations may start immediately after it resolves. Multiple waiters resolve together; close/fault rejects pending waiters. +`waitForIdle(context)` registers on the Session mutation line and resolves when all earlier admitted lane jobs have settled, `currentOperationId` is null, and no process-local idle callback owns the lane. Later operations may start immediately after it resolves. Multiple waiters resolve together; close/fault rejects pending waiters. `runWhenIdle(callback, context)` waits by the same rule, then installs a process-local idle-callback owner. The owner is removed on return or throw; callback rejection propagates. The callback must not invoke a state-mutating method on the same lane, which would deadlock behind its own idle-callback ownership. Close rejects callbacks not yet started and waits for an already-running callback, which cannot be forcibly interrupted. @@ -2474,22 +2475,35 @@ type OptionalFinalAssistant = | { finalEntryId?: never; finalMessage?: never }; type RunOutcome = - | ({ kind: "completed"; leafId: string } & OptionalFinalAssistant) - | ({ kind: "aborted"; leafId: string } & OptionalFinalAssistant) - | ({ kind: "failed"; leafId: string; error: OperationError } & OptionalFinalAssistant) - | { kind: "suspended"; reason: "deferred"; leafId: string; - finalEntryId: string; deferred: DeferredHandle }; + | ({ kind: "completed"; tipId: string } & OptionalFinalAssistant) + | ({ kind: "aborted"; tipId: string } & OptionalFinalAssistant) + | ({ + kind: "failed"; + tipId: string; + error: OperationError; + } & OptionalFinalAssistant) + | { + kind: "suspended"; + reason: "deferred"; + tipId: string; + finalEntryId: string; + deferred: DeferredHandle; + }; type CompactionOutcome = - | { kind: "completed"; leafId: string; entry: CompactionEntry } - | { kind: "declined" | "aborted"; leafId: string } - | { kind: "failed"; leafId: string; error: OperationError }; + | { kind: "completed"; tipId: string; entry: CompactionEntry } + | { kind: "declined" | "aborted"; tipId: string } + | { kind: "failed"; tipId: string; error: OperationError }; type NavigationOutcome = - | { kind: "completed"; oldLeafId: string | null; newLeafId: string | null; - summaryEntry?: BranchSummaryEntry } - | { kind: "declined" | "aborted"; leafId: string | null } - | { kind: "failed"; leafId: string | null; error: OperationError }; + | { + kind: "completed"; + oldTipId: string | null; + newTipId: string | null; + summaryEntry?: BranchSummaryEntry; + } + | { kind: "declined" | "aborted"; tipId: string | null } + | { kind: "failed"; tipId: string | null; error: OperationError }; type RunOperationOutcome = { operation: "run"; runId: string } & RunOutcome; type CompactionOperationOutcome = @@ -2508,16 +2522,16 @@ A completed run may omit final assistant fields when every finalized tool result Expected errors use the existing `TaggedError` implementation in `harness/result.ts`: -| tag | fields beyond `message` | -|---|---| -| `LaneBusy` | `lane`, `operationId`, `operationKind` | -| `OperationMismatch` | `lane`, `expectedOperationId`, optional `currentOperationId` and `lastOperationId` | -| `NoActiveRun`, `NoActiveOperation`, `NothingToResume`, `NothingToCompact` | `lane` | -| `InvalidMessage`, `InvalidNavigation` | `lane`, `reason` | -| `UnknownSkill`, `UnknownTemplate` | `name` | -| `UnknownTarget` | `targetId` | -| `LaneExists`, `InvalidLane` | `lane` (`InvalidLane` also has `reason`) | -| `Closed` | none | +| tag | fields beyond `message` | +| ------------------------------------------------------------------------- | ---------------------------------------------------------------------------------- | +| `LaneBusy` | `lane`, `operationId`, `operationKind` | +| `OperationMismatch` | `lane`, `expectedOperationId`, optional `currentOperationId` and `lastOperationId` | +| `NoActiveRun`, `NoActiveOperation`, `NothingToResume`, `NothingToCompact` | `lane` | +| `InvalidMessage`, `InvalidNavigation` | `lane`, `reason` | +| `UnknownSkill`, `UnknownTemplate` | `name` | +| `UnknownTarget` | `targetId` | +| `InvalidLane` | `lane`, `reason` | +| `Closed` | none | ```ts type OperationAdmissionError = @@ -2574,13 +2588,24 @@ class HarnessClosed extends Error {} ## 5.2 The harness ```ts -interface AgentHarness - extends AgentLane { - lane(name: string, context: Context): Promise; - createLane(name: string, at: string | null, - context: Context): Promise>; - lanes(context: Context): Promise; // always includes main +interface AgentHarness< + TContext extends object | undefined = object | undefined, +> { + lane(name: string, context: Context): Promise; + lane( + name: string, + options: { createAt?: string | null }, + context: Context, + ): Promise; + lanes(context: Context): Promise; + getName(context: Context): Promise; + setName(name: string | undefined, context: Context): Promise; + getLabel(targetId: string, context: Context): Promise; + setLabel( + targetId: string, + label: string | undefined, + context: Context, + ): Promise; // Harness-global. Tool implementations are code and cannot persist; active // names live in each lane's configuration. setTools replaces only the registry. @@ -2625,16 +2650,16 @@ type OpenOperation = { interface LaneInfo { name: string; - leafId: string | null; + tipId: string | null; operation: CurrentOperationInfo | null; } // QueueMode, RetryPolicy, and CompactionSettings use the source types named in §0.7. ``` -Passing an open `Session` to `create(options, context)` transfers orchestration ownership to the attachment attempt and then the returned harness until `close(context)` resolves. During that interval every retained `Session` and pre-attachment `SessionTree` facade is read-only. Callers must not invoke `Session.mutate`, `Session.createLane`, `Session.appendMessage`, `Session.appendCustomEntry`, any mutating `session.view(lane)` method, or write reserved `pi.*` addresses through another facade. Application values and lists must be mutated through the harness-owned lane facade. Bypassing the owning `Lane` can stale its authoritative projection and is a programming defect, not a supported concurrency path. If create rejects, ownership returns to the caller. +Passing an open `Session` to `create(options, context)` transfers orchestration ownership to the attachment attempt and then the returned Harness until `close(context)` resolves. During ownership, raw Branch mutation for a configured AgentLane and direct writes to reserved `pi.*` control addresses can stale the authoritative Lane projection and are trusted-programming defects. Session-global application values remain available through the scoped Session-data capability. If create rejects, ownership returns to the caller. -`create(options, context)` initializes an unconfigured main when needed, then restores the small durable projection for every lane under that Context before returning. `open` contains exactly one item per lane with a durable current operation and omits idle lanes; `aborting:true` is copied only from durable cancellation control. The array is an inventory that may become stale after return, not a reservation, identity prediction, or drive claim. Detailed snapshot payloads are read only by `watch(context)`. +`create(options, context)` creates nothing and restores the small durable projection for every complete lane under that Context before returning. `open` contains exactly one item per lane with a durable current operation and omits idle lanes; `aborting:true` is copied only from durable cancellation control. The array is an inventory that may become stale after return, not a reservation, identity prediction, or drive claim. Detailed snapshot payloads are read only by `watch(context)`. ### Options @@ -2681,9 +2706,8 @@ interface AgentHarnessOptions There is no harness-level telemetry default. The same shared harness may serve concurrent callers, so each method and callback uses only its explicit invocation Context and `context.telemetryContext` is always the telemetry parent. Runtime configuration must not reintroduce a receiver-level fallback. -`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. +`create(options, context)` copies the three seed fields into one immutable `LaneConfiguration`, storing the model as `{ provider, modelId }`. Existing complete lanes use only their current config. `lane(name, options?, context)` uses the seed whenever it creates an AgentLane or attaches one to a data-only Branch. -`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. +`lane(name, options?, context)` atomically gets or creates/attaches one AgentLane on the Session mutation line. Missing lanes use `options.createAt ?? null`; existing lanes ignore it. Commit success publishes the one Lane object and synchronously binds `lane_created` recipients before line release, then awaits delivery outside. Invalid names and unknown non-null targets reject with `InvalidLane`/`UnknownTarget`; partial durable combinations fault. Lane configuration and Harness metadata setters likewise bind their events in the committing Session job. 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. `systemPrompt`, `toolContext`, `toProviderMessages`, and `entryProjectors` are deterministic/idempotent computation callbacks, receive the current invocation Context, and may repeat after a crash; effectful interception belongs in hooks. `systemPrompt` is evaluated per provider request, then `transform_context` receives and may request-locally transform both messages and that prompt. Durable run context belongs in `before_run` message injection, not request-local transformation. `toolContext` is resolved once per live batch; each bound tool call receives its stable `AgentHarnessToolInvocation` and a required synchronous update callback even when no live listener exists. A `replay:"safe"` tool may implement Flue-style durable memoization over `getMemo`/`setMemo`; committed values survive replay until the call reaches `outcome_ready`. Tools must await memo writes. These methods are invocation-scoped capabilities, not raw Session access. -## 5.3 SessionTree +## 5.3 Session and Branch + +Session-global metadata, values/lists, global entry queries, Branch discovery/creation, mutation, ID generation, and close live on `Session` (§2.8). Session has no tip or implicit-main methods. + +`Branch` is intentionally narrow: ```ts -interface SessionTree { - getLeafId(context: Context): Promise; - getEntry(id: string, context: Context): Promise; - getStats(context: Context): Promise; - - // Bound application values and lists. The address already contains its key. - getValue(address: Value, context: Context): Promise | undefined>; - scanValues(prefix: Value, context: Context): Promise[]>; - readList(address: ValueList, options: ListReadOptions | undefined, - context: Context): Promise[]>; - setValue(address: Value, next: NoInfer, context: Context): Promise; - deleteValue(address: Value, context: Context): Promise; - appendList(address: ValueList, element: NoInfer, context: Context): Promise; - deleteList(address: ValueList, context: Context): Promise; - - // Purpose-specific wrappers for session metadata. Latest wins and is not - // branch-scoped. undefined deletes the bound value. - getName(context: Context): Promise; - setName(name: string | undefined, context: Context): Promise; - getLabel(targetId: string, context: Context): Promise; - setLabel(targetId: string, label: string | undefined, context: Context): Promise; - /** Session-wide, all branches, sequence order. */ - findEntries(query: EntryQuery | undefined, context: Context): Promise; - findEntry(query: EntryQuery | undefined, context: Context): Promise; - - /** Branch-scoped: the path from start toward root (§2.5). */ - findEntriesOnBranch(query: BranchScan | undefined, context: Context): Promise; - findEntryOnBranch(query: BranchScan | undefined, context: Context): Promise; - - // Writes resolve on durable acceptance; the returned id is the entry id, - // reserved when the write defers. +interface Branch { + readonly name: string; + getTipId(context: Context): Promise; + findEntries( + query: BranchScan | undefined, + context: Context, + ): Promise; + findEntry( + query: BranchScan | undefined, + context: Context, + ): Promise; appendMessage(message: AgentMessage, context: Context): Promise; appendCustomEntry(customType: string, data: JsonValue | undefined, context: Context): Promise; } - -interface EntryQuery { type?: EntryType; customType?: string; - order?: "asc" | "desc"; limit?: number; cursor?: EntryCursor } -interface SessionStats { messageCount: number; usage: Usage } ``` -Global queries filter first, then apply the exclusive cursor, then `limit`; default order is `"desc"`. A descending cursor retains `seq < cursor.seq`, and an ascending cursor retains `seq > cursor.seq`. - -Useful patterns: effective extension state is `findEntryOnBranch({ type: "custom", customType })`; a collection is `findEntriesOnBranch(...)`; a global inventory is `findEntries(...)`. Note that extension-state lookups have **no** `stopAt` and therefore walk past compactions — which is exactly why §2.6 segments rather than truncates. - -`SessionTree` has no navigation; moving a lane is `navigateTree()` on the lane. Finders and `getEntry` return only committed entries: a deferred write is invisible here until applied, but appears in snapshots by its reserved id. +Because the receiver already names one Branch, its query methods are `findEntries` and `findEntry`. Direct appends always extend its current tip atomically. AgentLane exposes the same methods directly and adds operation-aware append behavior. There is no nested tree/store/view accessor. ## 5.4 Snapshots and subscription @@ -2778,18 +2778,18 @@ Useful patterns: effective extension state is `findEntryOnBranch({ type: "custom One no-write lane mutation job defines the snapshot boundary: ```text -enter lane mutation line after all earlier lane jobs +enter Session mutation line after all earlier lane jobs → synchronously register a buffering watcher → synchronously clone current live presentation fields → perform bounded durable snapshot reads while later lane mutations are excluded → assemble and install the snapshot -→ release the lane line +→ release the Session mutation line → return the handle; start(listener) flushes buffered events, then delivers live ``` The bounded reads are: -- one compaction-bounded branch scan from the current leaf; +- one compaction-bounded branch scan from the current tip; - exact `pendingEntry(id)` reads for next-run, steer, follow-up, pending-write, and abort-drained ids; - the exact deferred source entry when represented; - for a tools phase, the batch assistant entry and exact persisted arguments for represented `effect_pending` calls, using `batch.turnId` as the `operationToolArgs` step id; @@ -2812,7 +2812,7 @@ commit succeeds → after Session.mutate returns, await the retained delivery promise ``` -`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. +`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 Session mutation 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: @@ -2837,7 +2837,7 @@ type OperationStatus = "running" | "open" | "aborting"; interface LaneSnapshot { lane: string; transcript: Entry[]; - leafId: string | null; + tipId: string | null; lastResult?: LaneLastResult; operation: null | { id: string; @@ -2898,10 +2898,19 @@ type HarnessEventPayload = // Run lifecycle | { type: "run_start"; runId: string } | { type: "run_resume"; runId: string } - | { type: "run_suspend"; runId: string; reason: "deferred"; - deferred: DeferredHandle } - | { type: "run_abort"; runId: string; steer: AgentMessage[]; followUp: AgentMessage[] } - | ({ type: "run_end"; runId: string; leafId: string | null } & ( + | { + type: "run_suspend"; + runId: string; + reason: "deferred"; + deferred: DeferredHandle; + } + | { + type: "run_abort"; + runId: string; + steer: AgentMessage[]; + followUp: AgentMessage[]; + } + | ({ type: "run_end"; runId: string; tipId: string | null } & ( | ({ outcome: "completed" | "aborted" } & OptionalFinalAssistant) | ({ outcome: "failed"; error: OperationError } & OptionalFinalAssistant))) | { type: "fault"; code: string; message: string } @@ -2956,8 +2965,12 @@ type HarnessEventPayload = | { outcome: "declined" | "aborted" } | { outcome: "failed"; error: OperationError })) | { type: "navigation_start"; runId: string; targetId: string | null } - | ({ type: "navigation_end"; runId: string; - oldLeafId: string | null; newLeafId: string | null } & ( + | ({ + type: "navigation_end"; + runId: string; + oldTipId: string | null; + newTipId: string | null; + } & ( | { outcome: "completed"; summaryEntry?: BranchSummaryEntry } | { outcome: "declined" | "aborted"; summaryEntry?: never; error?: never } | { outcome: "failed"; error: OperationError; summaryEntry?: never })) @@ -3348,11 +3361,11 @@ planned call → materialize source-ready outcomes as entries + usage ``` -Unknown tools, `prepareArguments` failures, invalid initial/replacement arguments, and blocked calls produce `ImmediateToolOutcome` and skip intent/execution before staging a synthetic `outcome_ready` result. `AgentTool.prepareArguments` remains deterministic/idempotent computation and may repeat before intent; effectful policy belongs in `before_tool`. At `tool.execute` operation admission, `executeToolCall` derives `withAbortSignal(gate.signal, context)` and invokes the neutral `AgentTool` adapter through `gate.admit(...)`. The bound harness adapter passes that admitted Context to `AgentHarnessTool.execute` in its trailing position. The block converts expected tool throws to an error result, stops accepting updates when the tool promise settles, and emits raw tool-effect telemetry. Every update emits live observation; `checkpoint:true` additionally enqueues one invocation-fenced `pi.pending.tool_output` replacement and replaces only the latest checkpoint-write promise reference (§3.8). The procedure retains only the latest event-delivery promise because the event bus serializes delivery, and only the latest checkpoint-write promise because the lane line is FIFO; it closes checkpoint admission at tool-promise settlement and awaits both before `after_tool`. `finalizeToolCall` applies the documented field-by-field patch semantics before `tool_end` and outcome staging. +Unknown tools, `prepareArguments` failures, invalid initial/replacement arguments, and blocked calls produce `ImmediateToolOutcome` and skip intent/execution before staging a synthetic `outcome_ready` result. `AgentTool.prepareArguments` remains deterministic/idempotent computation and may repeat before intent; effectful policy belongs in `before_tool`. At `tool.execute` operation admission, `executeToolCall` derives `withAbortSignal(gate.signal, context)` and invokes the neutral `AgentTool` adapter through `gate.admit(...)`. The bound harness adapter passes that admitted Context to `AgentHarnessTool.execute` in its trailing position. The block converts expected tool throws to an error result, stops accepting updates when the tool promise settles, and emits raw tool-effect telemetry. Every update emits live observation; `checkpoint:true` additionally enqueues one invocation-fenced `pi.pending.tool_output` replacement and replaces only the latest checkpoint-write promise reference (§3.8). The procedure retains only the latest event-delivery promise because the event bus serializes delivery, and only the latest checkpoint-write promise because the Session mutation line is FIFO; it closes checkpoint admission at tool-promise settlement and awaits both before `after_tool`. `finalizeToolCall` applies the documented field-by-field patch semantics before `tool_end` and outcome staging. Before starting any call in a live batch, the tool-batch procedure resolves `toolContext` once and binds the complete captured active-tool set into ordinary `AgentTool` adapters retained in a procedure-local snapshot. Each adapter also binds `AgentHarnessToolInvocation { invocationId: resultEntryId, operationId, turnId, getMemo, setMemo }`, the harness-specific update callback, and the current invocation Context. Available captured tools are bound into the snapshot. A requested call whose implementation is absent becomes an ordinary synthetic unknown-tool result with no `details`; it does not suspend the batch. Provider calls to names outside the captured active names follow the same unknown-tool path. Every call in the batch observes the same application context and its own stable invocation identity. Safe replay creates a new code/context snapshot but passes the same invocation id and memos after deleting the stale progress checkpoint. `AgentTool.replay` defaults to `"never"` when omitted. -There is deliberately no harness `executeToolBatch`. In parallel mode the direct procedure makes one source-ordered start pass. Each position either starts a real promise or retains an immediate outcome until it can be staged. Effects/finalization then settle independently: each complete real or synthetic result commits `outcome_ready` immediately in completion order. A separate lane-line job materializes the contiguous ready prefix in source order. Durably, completed calls form a prefix while the remaining suffix may mix `planned`, `effect_pending`, and `outcome_ready`; for example `[effect_pending, outcome_ready, effect_pending]` is valid after the completed prefix. A crash discards only unstaged process-local outcomes. Recovery safely replays or interrupts orphaned effects, materializes already-ready outcomes without resolving tool code, and reruns ordinary clearance for planned positions. The same procedure owns cancellation and durable batch completion. Genuine-`length` calls bypass effects but stage their specified synthetic outcomes (§3.7). +There is deliberately no harness `executeToolBatch`. In parallel mode the direct procedure makes one source-ordered start pass. Each position either starts a real promise or retains an immediate outcome until it can be staged. Effects/finalization then settle independently: each complete real or synthetic result commits `outcome_ready` immediately in completion order. A separate Session mutation job materializes the contiguous ready prefix in source order. Durably, completed calls form a prefix while the remaining suffix may mix `planned`, `effect_pending`, and `outcome_ready`; for example `[effect_pending, outcome_ready, effect_pending]` is valid after the completed prefix. A crash discards only unstaged process-local outcomes. Recovery safely replays or interrupts orphaned effects, materializes already-ready outcomes without resolving tool code, and reruns ordinary clearance for planned positions. The same procedure owns cancellation and durable batch completion. Genuine-`length` calls bypass effects but stage their specified synthetic outcomes (§3.7). The legacy agent loop remains useful behavioral evidence for ordinary provider streaming and tool execution. Harness differences are deliberate: `before_tool` returns explicit replacement arguments that are revalidated, hooks have explicit gate boundaries, parallel outcomes stage in completion order, and their entries materialize in source order. @@ -3366,7 +3379,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 }`; `emitBatch` binds recipients synchronously so delayed local handlers and RPC event frames preserve source lineage. +Shared `AgentHarness`, `AgentLane`, `Session`, and `Branch` 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. @@ -3390,7 +3403,7 @@ Telemetry attributes may contain declared ids, names, counts, durations, statuse **This part is informative.** Nothing in it binds the shipping backends: Memory, JSONL, and SQLite never partition and never delete entries or usage rows (§1.2), and no core rule references this part for its correctness. It exists to show that the identity choices in §1.2 are sufficient for the one backend that would eventually retire old data — a possible Postgres deployment with TTL retention. It is a bridge we cross when we get there; this sketch is the current best guess, not a contract. - **The id is the partition key.** UUIDv7 sorts bytewise in time order, so the bulk tables — entries, usage ledger — use `PARTITION BY RANGE (id)` on the uuid id column, with period-boundary UUIDs (zeroed tails) as bounds. No partition column exists anywhere; §1.2's time prefix is the whole mechanism. Scalar values, `branch_meta`, stats, leases, and sessions stay in a hot unpartitioned catalog. `branch_entries` partitions by `entry_id` with the same bounds, so dropping a period cleans the branch index for free; `branch_meta` stays hot, and base pointers dangling into a dropped period are trimmed lazily on first access. -- **Pre-pass repair.** Before a period P is dropped, an online repairer makes live state stop referencing it: reparent edges crossing into P onto the nearest retained ancestor, found by an indexed uuid-range query; null any dormant `pi.lane.leaf` decoding into P via a value-seq CAS; force-expire open operations still referencing P through values only — the terminal transaction of §3.13 writing `pi.lane.lastResult`, no synthetic entries, with any live task stopping through external finalization (§4.9); delete `pi.entry.label` values whose keys decode into P with one uuid-range delete. +- **Pre-pass repair.** Before a period P is dropped, an online repairer makes live state stop referencing it: reparent edges crossing into P onto the nearest retained ancestor, found by an indexed uuid-range query; null any dormant `pi.branch.tip` decoding into P via a value-seq CAS; force-expire open operations still referencing P through values only — the terminal transaction of §3.13 writing `pi.lane.lastResult`, no synthetic entries, with any live task stopping through external finalization (§4.9); delete `pi.entry.label` values whose keys decode into P with one uuid-range delete. - **The commit barrier.** Repair races ordinary commits, so the final step is atomic against all of them: `BEGIN; LOCK entries, scalar_values IN ACCESS EXCLUSIVE MODE; ; ALTER TABLE … DETACH PARTITION p; COMMIT;` — plain `DETACH`, not `CONCURRENTLY`, precisely because it is transactional under the lock; the `DROP TABLE` happens later, unhurried. The barrier makes repair-plus-detach one linearization point: every commit sees either the fully attached period or a fully repaired store without it. - **The default partition.** A `DEFAULT` partition absorbs stray inserts whose ids predate every attached partition — an ancient `pendingNextRun` item consumed years after its mint still places under its reserved id and simply lands there. Nothing errors and nothing is lost; the default partition stays small and is never dropped. - **Value access under an external repairer.** A backend that admits an external repairer must perform value reads and CAS checks inside the commit transaction itself, so a repairer holding the barrier cannot interleave between a harness's read and its dependent write. The shipping backends need no such rule: single-writer sessions have no external repairer. @@ -3492,14 +3505,14 @@ Every package implements its named concern end to end and tests its normal path, ## Concrete packages -| ID | Outcome | Dependency | Handoff | -|---|---|---|---| -| WP00 (complete) | Reconciled acceptance/hooks, harvested runtime1 scenarios, switched the public factory, and deleted runtime1 without adding execution behavior. | Approved redesign and durability handoffs were present. | [Runtime1 removal](work-packages/00-runtime1-removal.md) | -| 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 implementing durable execution. | 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 (implementation) | Remove the withdrawn execution-step controls, then implement the complete durable execution graph as a direct phase switch over ordinary async procedures. | WP04 | [Direct durable drive](work-packages/05-direct-durable-drive.md) | +| ID | Outcome | Dependency | Handoff | +| --------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------- | -------------------------------------------------------------------------------------- | +| WP00 (complete) | Reconciled acceptance/hooks, harvested runtime1 scenarios, switched the public factory, and deleted runtime1 without adding execution behavior. | Approved redesign and durability handoffs were present. | [Runtime1 removal](work-packages/00-runtime1-removal.md) | +| 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, Session mutation 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 implementing durable execution. | 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 (implementation) | Remove the withdrawn execution-step controls, then implement the complete durable execution graph as a direct phase switch over ordinary async procedures. | WP04 | [Direct durable drive](work-packages/05-direct-durable-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. @@ -3509,22 +3522,22 @@ The former R1 row is likewise no longer a separate candidate. Its public types, These rows preserve requirements, not frozen package boundaries. Create their handoffs only when they become actionable. -| # | Slice | Implement | Required focused tests | -|---|---|---|---| -| S3 | **Search** | The standalone `SessionSearchService` plus repo catch-up utilities (§2.8): core entry search as `SessionSearch`, session-level ranked results, optional `searchEntries?: SessionSearch`, `remove()`, the `SessionSearchSyncTarget` cursor/index-batch contract, sync enumeration and catch-up outside the service contract, debounced notify as a utility, `(sessionId, storeGeneration)` cursor keys, and the reference SQLite FTS5 implementation working over any backend's repository through the sync utility. | Cursor catch-up from empty against existing sessions, idempotent re-index after crash mid-batch, notify-utility/sweep equivalence, sessions-vs-entries queries and ranking, removal and reconciliation, shared-index multi-process discipline. | -| S4 | **Dev TUI and Client** | A minimal `AgentClient` over one lane — `LaneSnapshot` plus `watch()` events, `prompt`/`steer`/`followUp`/`abort`/`resume`/`cancelQueued`, `pi.lane.lastResult` read — and a throwaway alt-screen TUI on `packages/tui`: transcript from snapshot and events, input box, status/queue display, abort key. Built first against a scripted fake client on the shared types; binds to the real harness as runtime packages land. Not final. | Compiles; fake-client smoke test. No durability obligations. | -| R2 | **Minimal no-tool drive** | Starting from WP02's durable `starting` operation, add the first real lane-owned `Drive` pass with expected-id fencing, same-id joining, latest-result lookup, a split `Gate`/`GateControl`, and direct durable transitions; run driver-owned `before_drive`/`before_run`, make `prompt`/`skill`/`promptFromTemplate` compose `accept`+`drive`, and execute one assistant generation through captured context/options, payload/response hooks, intent/effect/settlement, synchronous per-event frame appends with latest-promise fencing, atomic frame-list deletion, usage, terminal cleanup plus `pi.lane.lastResult`, results, events, and telemetry. Unavailable captured model or configured active-tool definitions fail in-band before intent with configuration provenance; no response/usage is fabricated. | Accepted `starting` close/reopen/drive; prompt convenience and explicit accept+drive produce byte-identical writes/events; one same-id pass installed/joined and stale ids isolated; deterministic transition coverage; successful run with final assistant fields; one frame append per convertible event with no per-frame storage await and only the latest promise retained; settlement awaits the latest frame write and deletes the list atomically; frame-append failure faults before `after_response`; caller rejection and adapter-reported failures; exact ordering; supplied operation id propagates through `run_end` and `pi.lane.lastResult`; terminal cleanup; close at every boundary; invalid references rejected at consumption. | -| R3 | **Generation recovery and retry** | Activation-only unknown-effect recovery that reduces committed frames into the synthetic zero-usage partial error under the reserved ids and then classifies ordinarily (retry or failure drain), non-retryable configuration failure when a later ready/retry attempt cannot resolve its captured model/tools, ordinary stop/error/deferred classification, provider-compliant `aborted`, failure-drain foundation, and both retry-wait policies: explicit drive returns `waiting/notBefore`, while convenience drive may own the abort-aware timer. Define deterministic timer and recovery-transition tests. Overflow classification remains explicitly unimplemented until R9. | Every generation state before/after reopen; accept/close/reopen/drive; recovery with no frames, partial frames, and authoritative end-frame content; synthetic settlement always commits under reserved ids with the interruption warning and deletes the frame list; interrupted partial tool calls never execute; `after_response` never runs for synthetic settlement; caps/backoff; no timer under `waitForRetry:false`; due and future `notBefore`; convenience/explicit-drive durable equivalence; timer abort-first/start-first; deterministic transition coverage; stop/error/aborted/deferred classification; non-retryable configuration unavailability. | -| R4 | **Tools** | Add the harness-specific tool-update checkpoint options/callback while leaving pi-ai's `AgentToolUpdateCallback` unchanged, then compose §5.7's neutral tool phases with bound context, invocation-scoped memos, `pi.op.tool_args`, scalar `pendingToolOutput(operationId, invocationId)` checkpoints, `outcome_ready` staging in `pi.pending.entry`, completion-order outcome durability, source-ordered materialization, orphan recovery/replay, sequential/parallel promises, hook-blocked/genuine-length outcomes, hooks/events/usage, and deterministic transition coverage. Missing requested tools stage direct `ToolResultMessage` errors with no `details`; missing safe-replay implementations synthesize interruption rather than suspending. The built-in bash tool emits live updates at 100 ms and requests distinct checkpoints at most every two seconds. | Stable invocation id/memos across safe replay; every planned/effect-pending/outcome-ready/completed state; parallel out-of-order staging without replay; synchronous update callback with the latest delivery and latest checkpoint write awaited before `after_tool`; `tool_end` before staging; every requested checkpoint enqueued synchronously with only the latest promise reference retained and no write dropped or coalesced; tool-selected cadence/duplicate suppression; late-write fencing after settlement; JSONL checkpoint growth/compaction; safe replay deletes old checkpoint; unsafe recovery with/without checkpoint; missing planned tools become source-ordered error results; absent or no-longer-safe replay implementations synthesize interruption without invented typed details; source-ordered materialization; termination and abort-ready states. | -| R5 | **Inbox, configuration, and writes** | `nextRun`/steer/follow-up via `pendingEntry(id)` values, pending-payload consumption-time dereference, `cancelQueued` triage (`not_found`), durable drain markers, checkpoint consumption with pending-value deletion, immediate total config setters, deferred tree writes, adjustments, and deterministic tests for the checkpoint boundaries introduced here. | Capture/cancel/consume races, repeated cancellation answering `not_found`, invalid queue payload kinds and missing pending values rejected at consumption, one-at-a-time crash after one drain, pending-value/entry exclusivity at every boundary, custom-write continuation, config-step race, writes surviving reopen. | -| R6 | **Abort, close, and failure drain** | Add expected-id `requestAbort` as commit/signalling only. Compose `abort()` from inspection, marker, and same-id drive. Implement orthogonal control, per-phase reconciliation including planned/effect-pending/outcome-ready tools, best-effort deferred cancellation, waiters/run-when-idle, controlled-crash close, complete terminal deletion of memos/checkpoints/staged outcomes, external-finalization stop, and deterministic transition coverage. | Cancellation with/without live pass; idempotency/fencing; abort at every tool state; restored interruption incorporates latest checkpoint without replay; restored cancellation synthesizes aborted responses preserving frame-reduced partial content and deleting the list; outcome-ready values survive and materialize; checkpoint/memo/frame cleanup; gate ordering; deferred cancellation; close races including a non-cooperative started tool that cannot block close and parallel retained promises that are all observed without later starts or unhandled rejection; external finalization fences late scalar/list writes; failure drain. | -| R7 | **Deferred provider redemption** | One poll per drive pass carrying `pollDeferred:true`; `resume()` composes inspection and that drive option. Add deferred consumption-time dereference and captured model resolution; unavailable models durably abandon redemption through configuration-provenance failure rather than waiting. Preserve copied configuration/options, per-poll hooks, exact source lineage/equality, poll frame persistence with fresh intent after unknown poll deleting the abandoned frame list, mismatch-to-error, ready tools, advancement of R6 cancellation, and deterministic poll-transition coverage. | `pollDeferred:false` returns waiting without work; one permit performs at most one poll; invalid/missing/mismatched durable handles; repeated pending, ready/error/aborted/mismatch, crash positions, poll frame cleanup on every settlement, replacement, and configuration abandonment with no fabricated response/usage, unknown-poll snapshot without permit, convenience/primitive equivalence, no cap/backoff/loop, newest-handle cancellation. | -| R8 | **Manual compaction** | Add compaction `OperationRequest` acceptance without task installation and compose `compact()` from accept+drive. Implement unowned preparation with final-command source revalidation, `pi.op.preparation/{opId}:{taskId}`, total structural state with consumption-time dereference, in-band structural model-unavailability failure, hook/generated sources, nested request intents/usage, retained tail, retry/recovery/abort, and deterministic structural-transition coverage. | Explicit accept+drive versus convenience equivalence; empty/stale-preparation race; accept/close/reopen/drive; invalid task/preparation/source relationships; hook decline/result; crash after request one of split-turn generation; every state/crash; no public summary-stream messages and no structural frame persistence. | -| R9 | **Threshold and overflow compaction** | In-run structural decision, durable once-per-trigger threshold marker, continuation preservation, all overflow predicates, atomic response/preparation publication, specified normalization/projection, one overflow recovery flag, bounded second failure, and deterministic tests for added boundaries. | Threshold decline/empty across reopen, all overflow classifier/preparation inputs, no overflow tool plan, genuine length, crash/reopen at every transition. | -| R10 | **Navigation** | Add navigation `OperationRequest` acceptance without task installation and compose `navigateTree()` from accept+drive. Implement navigation source/target/summary consumption-time dereference, validation, summarized decision/generation, one final transaction combining move/summary/leaf/label with terminal writes, summary-only navigation hook, and deterministic transition coverage. | Explicit accept+drive versus convenience equivalence; root/current/unknown rejection; invalid durable source/target/summary combinations; accept/close/reopen/drive; summarized/unsummarized paths; final leaf at summary; abort race; exact atomic publication including cleanup. | -| R11 | **Schema version and migrations** | Chained migrate-on-open under the writer lease, with total mappings for open `pi.op.state` including `outcome_ready`, staged `pi.pending.entry` results, invocation memos, optional progress checkpoints, and assistant frame lists (paged in sequence order, mapped preserving `seq` or deleted whole-key); address namespace/key/kind migrations; JSONL lenient old-shape replay, mandatory post-migration compaction, refuse-newer. | Version gate; chained crash-idempotent migration; planned/effect-pending/outcome-ready tool states mapped safely; staged result/checkpoint/memo mappings; every legacy frame mapped or the whole list explicitly discarded with no completion inference; resumed operation correctness; old bytes retired. | -| R12 | **Surface completion** | Complete full-session watch, remaining live execution snapshot updates and outcome-ready visibility, event catalog/order/filtering, telemetry, public exports, backend parity, hosted-control/convenience documentation, remove scaffold code including the S4 fake client, and update imports/tests. | Snapshot/event gap; attach during active effect with live/durable divergence for both frames and checkpoints; restored effect-pending exposing reduced `streamingMessage`; live partial precedence over durable reduction; outcome-ready, waiting/terminal states; no replay of old message or tool lifecycle; recovered turn brackets balanced by turn id across durable waits and reopened on the next pass; close before tool-effect admission creates no raw `pi.harness.tool` span; stale-wake fencing; sensitive-event/content-free telemetry; full race/crash matrix on all backends. | -| P1 | **Protocol schemas** | Future work after the internal harness is complete: define shared TypeBox schemas for serializable pi-ai and harness protocol data, derive the corresponding TypeScript types from those schemas, and reuse them across client/server protocol boundaries. Validation runs only on untrusted wire input and never inside Session, Storage, operation procedures, or in-process extensions. | Schema/type parity, accepted and rejected client/server payloads, protocol round trips, and no validators or schema construction on internal storage paths. | +| # | Slice | Implement | Required focused tests | +| --- | ------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| S3 | **Search** | The standalone `SessionSearchService` plus repo catch-up utilities (§2.8): core entry search as `SessionSearch`, session-level ranked results, optional `searchEntries?: SessionSearch`, `remove()`, the `SessionSearchSyncTarget` cursor/index-batch contract, sync enumeration and catch-up outside the service contract, debounced notify as a utility, `(sessionId, storeGeneration)` cursor keys, and the reference SQLite FTS5 implementation working over any backend's repository through the sync utility. | Cursor catch-up from empty against existing sessions, idempotent re-index after crash mid-batch, notify-utility/sweep equivalence, sessions-vs-entries queries and ranking, removal and reconciliation, shared-index multi-process discipline. | +| S4 | **Dev TUI and Client** | A minimal `AgentClient` over one lane — `LaneSnapshot` plus `watch()` events, `prompt`/`steer`/`followUp`/`abort`/`resume`/`cancelQueued`, `pi.lane.lastResult` read — and a throwaway alt-screen TUI on `packages/tui`: transcript from snapshot and events, input box, status/queue display, abort key. Built first against a scripted fake client on the shared types; binds to the real harness as runtime packages land. Not final. | Compiles; fake-client smoke test. No durability obligations. | +| R2 | **Minimal no-tool drive** | Starting from WP02's durable `starting` operation, add the first real lane-owned `Drive` pass with expected-id fencing, same-id joining, latest-result lookup, a split `Gate`/`GateControl`, and direct durable transitions; run driver-owned `before_drive`/`before_run`, make `prompt`/`skill`/`promptFromTemplate` compose `accept`+`drive`, and execute one assistant generation through captured context/options, payload/response hooks, intent/effect/settlement, synchronous per-event frame appends with latest-promise fencing, atomic frame-list deletion, usage, terminal cleanup plus `pi.lane.lastResult`, results, events, and telemetry. Unavailable captured model or configured active-tool definitions fail in-band before intent with configuration provenance; no response/usage is fabricated. | Accepted `starting` close/reopen/drive; prompt convenience and explicit accept+drive produce byte-identical writes/events; one same-id pass installed/joined and stale ids isolated; deterministic transition coverage; successful run with final assistant fields; one frame append per convertible event with no per-frame storage await and only the latest promise retained; settlement awaits the latest frame write and deletes the list atomically; frame-append failure faults before `after_response`; caller rejection and adapter-reported failures; exact ordering; supplied operation id propagates through `run_end` and `pi.lane.lastResult`; terminal cleanup; close at every boundary; invalid references rejected at consumption. | +| R3 | **Generation recovery and retry** | Activation-only unknown-effect recovery that reduces committed frames into the synthetic zero-usage partial error under the reserved ids and then classifies ordinarily (retry or failure drain), non-retryable configuration failure when a later ready/retry attempt cannot resolve its captured model/tools, ordinary stop/error/deferred classification, provider-compliant `aborted`, failure-drain foundation, and both retry-wait policies: explicit drive returns `waiting/notBefore`, while convenience drive may own the abort-aware timer. Define deterministic timer and recovery-transition tests. Overflow classification remains explicitly unimplemented until R9. | Every generation state before/after reopen; accept/close/reopen/drive; recovery with no frames, partial frames, and authoritative end-frame content; synthetic settlement always commits under reserved ids with the interruption warning and deletes the frame list; interrupted partial tool calls never execute; `after_response` never runs for synthetic settlement; caps/backoff; no timer under `waitForRetry:false`; due and future `notBefore`; convenience/explicit-drive durable equivalence; timer abort-first/start-first; deterministic transition coverage; stop/error/aborted/deferred classification; non-retryable configuration unavailability. | +| R4 | **Tools** | Add the harness-specific tool-update checkpoint options/callback while leaving pi-ai's `AgentToolUpdateCallback` unchanged, then compose §5.7's neutral tool phases with bound context, invocation-scoped memos, `pi.op.tool_args`, scalar `pendingToolOutput(operationId, invocationId)` checkpoints, `outcome_ready` staging in `pi.pending.entry`, completion-order outcome durability, source-ordered materialization, orphan recovery/replay, sequential/parallel promises, hook-blocked/genuine-length outcomes, hooks/events/usage, and deterministic transition coverage. Missing requested tools stage direct `ToolResultMessage` errors with no `details`; missing safe-replay implementations synthesize interruption rather than suspending. The built-in bash tool emits live updates at 100 ms and requests distinct checkpoints at most every two seconds. | Stable invocation id/memos across safe replay; every planned/effect-pending/outcome-ready/completed state; parallel out-of-order staging without replay; synchronous update callback with the latest delivery and latest checkpoint write awaited before `after_tool`; `tool_end` before staging; every requested checkpoint enqueued synchronously with only the latest promise reference retained and no write dropped or coalesced; tool-selected cadence/duplicate suppression; late-write fencing after settlement; JSONL checkpoint growth/compaction; safe replay deletes old checkpoint; unsafe recovery with/without checkpoint; missing planned tools become source-ordered error results; absent or no-longer-safe replay implementations synthesize interruption without invented typed details; source-ordered materialization; termination and abort-ready states. | +| R5 | **Inbox, configuration, and writes** | `nextRun`/steer/follow-up via `pendingEntry(id)` values, pending-payload consumption-time dereference, `cancelQueued` triage (`not_found`), durable drain markers, checkpoint consumption with pending-value deletion, immediate total config setters, deferred tree writes, adjustments, and deterministic tests for the checkpoint boundaries introduced here. | Capture/cancel/consume races, repeated cancellation answering `not_found`, invalid queue payload kinds and missing pending values rejected at consumption, one-at-a-time crash after one drain, pending-value/entry exclusivity at every boundary, custom-write continuation, config-step race, writes surviving reopen. | +| R6 | **Abort, close, and failure drain** | Add expected-id `requestAbort` as commit/signalling only. Compose `abort()` from inspection, marker, and same-id drive. Implement orthogonal control, per-phase reconciliation including planned/effect-pending/outcome-ready tools, best-effort deferred cancellation, waiters/run-when-idle, controlled-crash close, complete terminal deletion of memos/checkpoints/staged outcomes, external-finalization stop, and deterministic transition coverage. | Cancellation with/without live pass; idempotency/fencing; abort at every tool state; restored interruption incorporates latest checkpoint without replay; restored cancellation synthesizes aborted responses preserving frame-reduced partial content and deleting the list; outcome-ready values survive and materialize; checkpoint/memo/frame cleanup; gate ordering; deferred cancellation; close races including a non-cooperative started tool that cannot block close and parallel retained promises that are all observed without later starts or unhandled rejection; external finalization fences late scalar/list writes; failure drain. | +| R7 | **Deferred provider redemption** | One poll per drive pass carrying `pollDeferred:true`; `resume()` composes inspection and that drive option. Add deferred consumption-time dereference and captured model resolution; unavailable models durably abandon redemption through configuration-provenance failure rather than waiting. Preserve copied configuration/options, per-poll hooks, exact source lineage/equality, poll frame persistence with fresh intent after unknown poll deleting the abandoned frame list, mismatch-to-error, ready tools, advancement of R6 cancellation, and deterministic poll-transition coverage. | `pollDeferred:false` returns waiting without work; one permit performs at most one poll; invalid/missing/mismatched durable handles; repeated pending, ready/error/aborted/mismatch, crash positions, poll frame cleanup on every settlement, replacement, and configuration abandonment with no fabricated response/usage, unknown-poll snapshot without permit, convenience/primitive equivalence, no cap/backoff/loop, newest-handle cancellation. | +| R8 | **Manual compaction** | Add compaction `OperationRequest` acceptance without task installation and compose `compact()` from accept+drive. Implement unowned preparation with final-command source revalidation, `pi.op.preparation/{opId}:{taskId}`, total structural state with consumption-time dereference, in-band structural model-unavailability failure, hook/generated sources, nested request intents/usage, retained tail, retry/recovery/abort, and deterministic structural-transition coverage. | Explicit accept+drive versus convenience equivalence; empty/stale-preparation race; accept/close/reopen/drive; invalid task/preparation/source relationships; hook decline/result; crash after request one of split-turn generation; every state/crash; no public summary-stream messages and no structural frame persistence. | +| R9 | **Threshold and overflow compaction** | In-run structural decision, durable once-per-trigger threshold marker, continuation preservation, all overflow predicates, atomic response/preparation publication, specified normalization/projection, one overflow recovery flag, bounded second failure, and deterministic tests for added boundaries. | Threshold decline/empty across reopen, all overflow classifier/preparation inputs, no overflow tool plan, genuine length, crash/reopen at every transition. | +| R10 | **Navigation** | Add navigation `OperationRequest` acceptance without task installation and compose `navigateTree()` from accept+drive. Implement navigation source/target/summary consumption-time dereference, validation, summarized decision/generation, one final transaction combining move/summary/tip/label with terminal writes, summary-only navigation hook, and deterministic transition coverage. | Explicit accept+drive versus convenience equivalence; root/current/unknown rejection; invalid durable source/target/summary combinations; accept/close/reopen/drive; summarized/unsummarized paths; final tip at summary; abort race; exact atomic publication including cleanup. | +| R11 | **Schema version and migrations** | Chained migrate-on-open under the writer lease, with total mappings for open `pi.op.state` including `outcome_ready`, staged `pi.pending.entry` results, invocation memos, optional progress checkpoints, and assistant frame lists (paged in sequence order, mapped preserving `seq` or deleted whole-key); address namespace/key/kind migrations; JSONL lenient old-shape replay, mandatory post-migration compaction, refuse-newer. | Version gate; chained crash-idempotent migration; planned/effect-pending/outcome-ready tool states mapped safely; staged result/checkpoint/memo mappings; every legacy frame mapped or the whole list explicitly discarded with no completion inference; resumed operation correctness; old bytes retired. | +| R12 | **Surface completion** | Complete full-session watch, remaining live execution snapshot updates and outcome-ready visibility, event catalog/order/filtering, telemetry, public exports, backend parity, hosted-control/convenience documentation, remove scaffold code including the S4 fake client, and update imports/tests. | Snapshot/event gap; attach during active effect with live/durable divergence for both frames and checkpoints; restored effect-pending exposing reduced `streamingMessage`; live partial precedence over durable reduction; outcome-ready, waiting/terminal states; no replay of old message or tool lifecycle; recovered turn brackets balanced by turn id across durable waits and reopened on the next pass; close before tool-effect admission creates no raw `pi.harness.tool` span; stale-wake fencing; sensitive-event/content-free telemetry; full race/crash matrix on all backends. | +| P1 | **Protocol schemas** | Future work after the internal harness is complete: define shared TypeBox schemas for serializable pi-ai and harness protocol data, derive the corresponding TypeScript types from those schemas, and reuse them across client/server protocol boundaries. Validation runs only on untrusted wire input and never inside Session, Storage, operation procedures, or in-process extensions. | Schema/type parity, accepted and rejected client/server payloads, protocol round trips, and no validators or schema construction on internal storage paths. | # Part 9 — Invariants and tests @@ -3543,7 +3556,7 @@ Tree: 6. An entry's parent chain never changes. Branches share prefixes; nothing is copied. 7. Entries are trusted typed internal values. Only a custom entry may omit payload data; external shape corruption is unsupported rather than revalidated on internal reads. 8. Configuration and orchestration never enter the tree. Deleting every operation-owned value and list must leave a complete, valid conversation and ledger. -9. A lane's leaf moves only by append or navigation. +9. A lane's tip moves only by append or navigation. 10. A branch segment chain, followed to its end, yields the full root path (§2.6). 11. A missing parent is corruption — always (§1.2). @@ -3557,14 +3570,14 @@ Operations: 17. At most one operation is open per lane. Two is corruption. 18. `overflowRecoveryUsed` is `true` only after overflow compaction. A transition that adds projecting conversational input or tool results and requires an assistant writes `false`; an unprojected custom write preserves it. 19. **The settlement transaction that commits a response with `stopReason: "aborted"` must, in that same transaction, write an operation state with `control.status === "cancel_requested"`.** The invariant is scoped to the committing transaction — later terminal cleanup or forks may remove the state without violating it. Providers must comply with the harness-owned signal contract; violation is corruption. -20. Attachment restores and validates only the small lane/operation projection (§3.3, §4.4). That owned projection is authoritative until close, fault, or process loss. Detailed presentation references are validated by `watch(context)` under the lane line; drive payload references are validated by their consuming procedure. Missing or contradictory required data faults that consumer, while optional frame/checkpoint absence is legal. Live phase/effect-identity and exact-`Drive` checks remain concurrency fences. `pi.lane.lastResult` never determines an open operation's next procedure. +20. Attachment restores and validates only the small lane/operation projection (§3.3, §4.4). That owned projection is authoritative until close, fault, or process loss. Detailed presentation references are validated by `watch(context)` under the Session mutation line; drive payload references are validated by their consuming procedure. Missing or contradictory required data faults that consumer, while optional frame/checkpoint absence is legal. Live phase/effect-identity and exact-`Drive` checks remain concurrency fences. `pi.lane.lastResult` never determines an open operation's next procedure. 21. At most one terminal transaction ever commits per operation. In-process finalization closes the live Drive gate with `OperationEnded` and records `drive.finalizedOutcome` in committed materialization, then settles after terminal event delivery without waiting for detached work; once that outcome is recorded, the pass continuation may neither settle nor fail shared completion. The exact Drive is removed only after both finalizer-owned settlement and pass ending, so no caller hydrates before terminal event delivery. Queued/later planners return `lost_ownership` and commit nothing. With no local Drive, later matching callers hydrate from `pi.lane.lastResult` (§4.9). -22. At most one `Drive` exists per lane. Acceptance and taskless `requestAbort` never install one. A matching `drive` installs it before releasing the lane mutation line; another matching drive joins that pass, and a stale id starts nothing. Every drive-owned write rechecks exact Drive identity synchronously at commit admission. Normal `finally` removes only itself; abandonment and close/fault remove immediately; external finalization settles after terminal event delivery and retains ownership until both settlement and `finally` have occurred. Each newly installed pass invokes `before_drive` once after the cancellation check; joiners do not. `starting + cancel_requested` invokes neither `before_drive` nor `before_run`. +22. At most one `Drive` exists per lane. Acceptance and taskless `requestAbort` never install one. A matching `drive` installs it before releasing the Session mutation line; another matching drive joins that pass, and a stale id starts nothing. Every drive-owned write rechecks exact Drive identity synchronously at commit admission. Normal `finally` removes only itself; abandonment and close/fault remove immediately; external finalization settles after terminal event delivery and retains ownership until both settlement and `finally` have occurred. Each newly installed pass invokes `before_drive` once after the cancellation check; joiners do not. `starting + cancel_requested` invokes neither `before_drive` nor `before_run`. 23. The §4.2 `Gate.admit()` catalog is complete. Every listed hook/provider/tool/timer integration calls `admit(() => operation())` after preparation; no unlisted code calls it. Admitted asynchronous provider setup/delegation owns `drive.gate.signal`. 24. `drive` and `requestAbort` are fenced by expected operation id. They may affect only that current operation; `drive` may also return its matching latest terminal result. A stale wake for A cannot drive or cancel B. 25. No public drive option encodes a wall-clock budget or partial-progress return. An admitted effect settles normally or is recovered from durable state after task loss; host scheduling and process termination remain outside the harness contract. 26. Convenience operations and their explicit primitive compositions produce the same durable writes, events, results, and recovery behavior. Their only difference is process-local waiting/scheduling policy. -27. Each logical tool call's public `invocationId` is its reserved `resultEntryId`: unique within the session and unchanged across safe replay. Tools must await invocation-memo writes. Such writes synchronously enqueue, verify effect-pending ownership on the lane line, and are deleted with outcome staging. +27. Each logical tool call's public `invocationId` is its reserved `resultEntryId`: unique within the session and unchanged across safe replay. Tools must await invocation-memo writes. Such writes synchronously enqueue, verify effect-pending ownership on the Session mutation line, and are deleted with outcome staging. 28. Completed tool calls form a source-ordered prefix. A sequential suffix permits at most one effect-pending or outcome-ready call before planned calls; a parallel suffix may mix `planned`, `effect_pending`, and `outcome_ready`. Completion-order outcome staging never extends the prefix; source-ordered materialization does. 29. Every outcome-ready call has exactly one matching finalized `pi.pending.entry`, no immutable result entry, no invocation memos, and no tool-output checkpoint. Outcome-ready and completed calls never execute again. 30. A tool progress checkpoint is an optional bounded complete `AgentToolResult` snapshot, selected with `checkpoint:true`. It never proves completion. Every selected checkpoint synchronously enqueues one invocation-fenced scalar replacement; no write is dropped or coalesced, only the latest write promise reference is retained, and awaiting it implies completion of every earlier write. Staging or terminal cleanup deletes the value and fences late recreation. @@ -3572,45 +3585,45 @@ 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 fences the authoritative owned projection and exact `Drive` identity; storage reads only dereference payloads named by that projection. -35. Every event-producing committing harness lane job publishes its owned projection and calls `emitBatch` with its complete event batch in the exact continuation that observes commit, as the callback's final action; this includes 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. +35. Every event-producing committing harness lane job publishes its owned projection and calls `emitBatch` with its complete event batch in the exact continuation that observes commit, as the callback's final action; this includes AgentLane appends, lane and metadata setters, acceptance, and AgentLane acquisition/attachment. The mutation never awaits delivery, but the public operation does. A lane watch registers buffering and clones live presentation synchronously, then performs bounded durable reads while holding the line. Snapshot plus buffered events has no gap or duplicate and replays no pre-registration lifecycle. `emitBatch` binds recipients and the emitting Context immediately; a delayed watcher receives the object-identical source Context, never its start Context. +36. Shared Harness/AgentLane/Session/Branch receivers retain no invocation Context and expose no receiver-level telemetry default. Concurrent calls preserve independent telemetry and cancellation lineage. Context and its values are neither durable operation data nor serialized business arguments. RPC cancel/disconnect reaches only the matching invocation through `context.abortSignal` and never becomes durable cancellation. 37. Process-local model/tool registry absence never becomes durable waiting state or an acceptance error. Pre-intent request-configuration absence fails in-band without fabricating a response/usage; missing requested tools stage `isError` tool-result messages with no invented details; uncertain effects settle under their existing recovery rules first. -38. `beginMutation()` acquires exactly one lane line, `commit()` consumes at most one commit capability without releasing that line, and `end()` alone invalidates and releases it after any admitted commit settles. `Session.mutate()` always ends in `finally`; its callback cannot end early. Local and remote implementations preserve the same read → decide → commit → process-local publication → end order. +38. `beginMutation()` acquires exactly one Session mutation line, `commit()` consumes at most one commit capability without releasing that line, and `end()` alone invalidates and releases it after any admitted commit settles. `Session.mutate()` always ends in `finally`; its callback cannot end early. Local and remote implementations preserve the same read → decide → commit → process-local publication → end order. ## 9.2 Race catalog Each durable mutation race has exactly two durable histories. Process-local ownership races additionally have the one-driver/join or fence outcome shown below. Test every listed order with test-only commit gating and controlled hooks, providers, tools, and timers. -| Race | Orders | -|---|---| -| `prompt` vs `prompt` on one lane | both compose `accept`; one accepts, one gets `LaneBusy` | -| `accept(A)` vs process loss before `drive(A)` | acceptance absent → serving layer retries; acceptance present → restored `starting` drives normally, with no unknown effect | -| `drive(A)` vs `drive(A)` | one installs the pass; the other joins exactly that pass and may drive again after its outcome | -| stale `drive(A)`/`requestAbort(A)` vs current B | expected-id mismatch; B is untouched | -| `requestAbort` vs response settlement | marker first → normalized `aborted`; response first → stop reason preserved | -| `abort` vs started tool outcome staging | abort first → real result stages under cancelled control; outcome first → finalized result is preserved and later materializes | -| checkpoint vs tool settlement | every accepted checkpoint was enqueued before settlement closed admission; settlement awaits the latest write, then staging deletes the scalar; a late update is fenced without committing | -| assistant frame append vs response settlement | settlement awaits the latest frame write, then its transaction deletes the list; a crash between leaves the committed frame prefix under `effect_pending` | -| assistant frame append vs external finalization | append first → terminal cleanup deletes the list; finalization first → the fenced append declines without committing | -| live update event vs its queued frame/checkpoint commit | either finishes first; events are observation, and reconnect uses only committed frames/checkpoints | -| external finalization vs memo/checkpoint mutation | mutation first → terminal cleanup deletes it; finalization first → owned-projection/exact-Drive check declines without committing | -| later tool B settles vs earlier tool A | B stages outcome-ready immediately; tree placement waits for A | -| `abort` vs `before_run_end` follow-up | follow-up dropped; or committed and the run continues | -| `cancelQueued` vs checkpoint consumption | `cancelled`; or `already_consumed` | -| `setModel` vs generation step start | old snapshot used; or new snapshot used | -| `abort` vs structural commit | `aborted` with no entry; or `completed` | -| `nextRun` vs acceptance | captured by this run; or stays for the next | -| manual-compaction preparation vs idle tree write | write before the final command → stale preparation is discarded/recomputed; acceptance first → the write follows active-operation rules; preparation never blocks the lane | -| deferred write vs abort | write survives abort either way | -| `requestAbort` vs `before_drive`/`before_run` admission | admission first → the complete hook pipeline runs and its consuming command observes cancellation; cancellation first → reconciliation runs and neither hook starts | -| `requestAbort` vs ordinary operation admission | admission first → operation is invoked with the signal; cancellation first → gate refuses invocation | -| attachment vs concurrent resume | attachment owns the session before publication; after return, resume uses the authoritative owned projection and stale `open` remains harmless | -| 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 | -| concurrent invocation contexts | each call/event/session write retains its own telemetry parent and abort signal; joiner cancellation ends only that observer; installer cancellation abandons the shared pass for current joiners but writes no durable cancellation | -| `close` vs settlement | settlement abandoned, state stays `effect_pending`; or it committed before the flag was set | +| Race | Orders | +| ------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `prompt` vs `prompt` on one lane | both compose `accept`; one accepts, one gets `LaneBusy` | +| `accept(A)` vs process loss before `drive(A)` | acceptance absent → serving layer retries; acceptance present → restored `starting` drives normally, with no unknown effect | +| `drive(A)` vs `drive(A)` | one installs the pass; the other joins exactly that pass and may drive again after its outcome | +| stale `drive(A)`/`requestAbort(A)` vs current B | expected-id mismatch; B is untouched | +| `requestAbort` vs response settlement | marker first → normalized `aborted`; response first → stop reason preserved | +| `abort` vs started tool outcome staging | abort first → real result stages under cancelled control; outcome first → finalized result is preserved and later materializes | +| checkpoint vs tool settlement | every accepted checkpoint was enqueued before settlement closed admission; settlement awaits the latest write, then staging deletes the scalar; a late update is fenced without committing | +| assistant frame append vs response settlement | settlement awaits the latest frame write, then its transaction deletes the list; a crash between leaves the committed frame prefix under `effect_pending` | +| assistant frame append vs external finalization | append first → terminal cleanup deletes the list; finalization first → the fenced append declines without committing | +| live update event vs its queued frame/checkpoint commit | either finishes first; events are observation, and reconnect uses only committed frames/checkpoints | +| external finalization vs memo/checkpoint mutation | mutation first → terminal cleanup deletes it; finalization first → owned-projection/exact-Drive check declines without committing | +| later tool B settles vs earlier tool A | B stages outcome-ready immediately; tree placement waits for A | +| `abort` vs `before_run_end` follow-up | follow-up dropped; or committed and the run continues | +| `cancelQueued` vs checkpoint consumption | `cancelled`; or `already_consumed` | +| `setModel` vs generation step start | old snapshot used; or new snapshot used | +| `abort` vs structural commit | `aborted` with no entry; or `completed` | +| `nextRun` vs acceptance | captured by this run; or stays for the next | +| manual-compaction preparation vs idle tree write | write before the final command → stale preparation is discarded/recomputed; acceptance first → the write follows active-operation rules; preparation never blocks the lane | +| deferred write vs abort | write survives abort either way | +| `requestAbort` vs `before_drive`/`before_run` admission | admission first → the complete hook pipeline runs and its consuming command observes cancellation; cancellation first → reconciliation runs and neither hook starts | +| `requestAbort` vs ordinary operation admission | admission first → operation is invoked with the signal; cancellation first → gate refuses invocation | +| attachment vs concurrent resume | attachment owns the session before publication; after return, resume uses the authoritative owned projection and stale `open` remains harmless | +| watcher registration vs state publication | watcher first → old snapshot plus the complete buffered event batch; publication/`emitBatch` first → new snapshot without that old batch | +| close vs attachment | create completes and publishes a fully open harness; or close/fault rejects attachment without a partial harness | +| snapshot capture vs resume | capture first yields pre-resume snapshot plus events; resume publication first yields post-transition snapshot | +| external finalization vs attachment/inspection | finalization is visible as idle/latest result; or attachment sees the still-open operation and later Session mutation inspection observes publication | +| concurrent invocation contexts | each call/event/session write retains its own telemetry parent and abort signal; joiner cancellation ends only that observer; installer cancellation abandons the shared pass for current joiners but writes no durable cancellation | +| `close` vs settlement | settlement abandoned, state stays `effect_pending`; or it committed before the flag was set | ## 9.3 Test tiers @@ -3627,7 +3640,7 @@ One corruption assertion constructs an `aborted` response with running control d **Cross-cutting:** - **Backend conformance.** One suite, three backends, identical results — including explicit begin/commit/end lane exclusion, commit-without-release, end-without-commit, close waiting for end, 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 `emitBatch`, 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, Session mutation inspection, complete snapshots, live-over-durable partial precedence, no historical lifecycle replay, recipient binding at `emitBatch`, and both registration/publication orders without gaps or duplicates. - **Drive equivalence.** Convenience calls and explicit `accept`/`drive`/`requestAbort` compositions produce byte-identical durable state and equivalent events/results. - **Deterministic transition control.** Test-only storage gating parks commits without production annotations; controlled hooks, providers, tools, and timers expose effect windows. Each runtime slice tests every durable edge and both orders of each owned race. - **Effect-start gate.** Cover every item in §4.2's complete catalog and assert that no other path calls `Gate.admit()`. At each listed integration, force both orders of abort versus operation admission: abort-first invokes nothing, and admission-first gives the complete operation `drive.gate.signal`. Provider tests assert that request preparation precedes the check and that the same signal reaches Models auth/lazy/provider work. Hook tests treat each aggregate pipeline as one admitted unit. A cancelled drive must enter reconciliation without invoking `before_drive` or `before_run`. @@ -3642,42 +3655,42 @@ One corruption assertion constructs an `aborted` response with running control d # Appendix A — Glossary -| Term | Meaning | -|---|---| -| **Entry** | Write-once conversation record: placement and payload in one row. Its id is the public entry id. | -| **Value address** | Bound `Value` naming one replaceable current value by stable namespace and key. | -| **List address** | Bound `ValueList` naming one append-only list; each element has its own global write sequence and whole-list deletion is lifecycle cleanup. | -| **Usage row** | Append-only cost ledger row. Never modified, never deleted. | -| **Pending entry** | Complete unplaced content in `pi.pending.entry`, including queued input and finalized outcome-ready tool results, until placement/cancellation/cleanup. | -| **Session** | One conversation: tree, values/lists, ledger, lanes. | -| **Lane** | Named cursor into the tree with its own config, queues, and one operation. | -| **Operation** | One accepted unit of work: run, compaction, or navigation. | -| **Operation status** | Small process-relative observation: `running`, `open`, or `aborting`; idle is represented by no current operation. It never predicts registry availability. | -| **Open operation** | One attachment inventory item for a lane with durable current work. It is not a reservation or continuation policy. | -| **Attachment** | Minimal lane/operation projection restore plus open inventory; it starts no execution. | -| **Invocation Context** | Immutable explicit telemetry/cancellation lineage for one call. It is never durable state or a shared receiver default. | -| **Acceptance** | The atomic creation of one durable operation by `accept`; it starts no driver. | -| **Drive pass** | One process-local ownership span created or joined by `drive`, ending at settlement or a durable wait. | -| **Effect** | Anything not pure computation: commit, provider request, tool, hook, timer. | -| **Repeat-sensitive effect** | One whose repetition is observable outside the harness. | -| **Operation state** | The complete durable state of one open operation — the `operationState(operationId)` value and restart point after task loss. | -| **Drive** | The one installed process-local lane pass: exact ownership identity, shared completion, Gate, invocation Context, and wait policy. | -| **Effect gate** | Process-local synchronous arbitration of ordinary operation admission against a cancellation request, paired with the operation's cooperative signal. | -| **Reserved id** | An id minted before content exists. Tool-result ids progress from a string in state to staged pending content to an immutable entry. | -| **Follower id** | An id minted with its leader's 48-bit timestamp so a call/result group shares one time prefix (§1.2). | -| **Lane mutation line** | Per-lane serialization point where all state-dependent mutations queue. An explicit Session mutation holds it from `beginMutation()` until `end()`, including across its commit and process-local publication. | -| **Session mutation** | Explicit lane-bound read/one-commit capability. `commit()` does not release it; `end()` settles and releases it. `Session.mutate()` scopes the same capability around a callback without exposing `end()`. | -| **Control** | Orthogonal cancellation flag: `running` or `cancel_requested`. | -| **Checkpoint** | The state between turns where queues, writes, and finishing are decided. | -| **Continuation** | Durable answer to "does this run still owe an assistant turn?" | -| **Tool checkpoint** | Optional bounded complete live-update snapshot selected by a tool and stored as the current `pi.pending.tool_output` value; auxiliary, never completion authority. | -| **Assistant frame** | Compact replayable pi-ai stream frame appended to the `pi.pending.assistant_frame` list while its response is effect-pending; reduced by `reduceAssistantMessageFrames`; auxiliary, never completion authority. | -| **Outcome ready** | Tool call whose complete finalized result is durable in `pi.pending.entry` and will never execute again, but awaits source-ordered tree placement. | -| **Invocation memo** | Tool-invocation-scoped durable current value used for replay-safe memoization; valid only while the call is effect-pending. | -| **Terminal transaction** | The commit that deletes an operation's values/lists, writes `pi.lane.lastResult`, and clears `currentOperationId`. | -| **Segment** | A branch-index range that references an older branch instead of copying it. | -| **External finalization** | A terminal transaction committed outside the live task. An in-process finalizer closes the gate in committed materialization, publishes once, then settles the live Drive from `finalizedOutcome` after event delivery without waiting for detached work; the exact owner remains installed until both settlement and pass ending, preventing early hydration; with no local Drive, later callers hydrate from `pi.lane.lastResult` (§4.9). | -| **Precise rewrite** | The administrative copy-retained-and-swap rebuild of a session store — the sole sanctioned path that removes entries or usage rows (§2.9). | +| Term | Meaning | +| --------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **Entry** | Write-once conversation record: placement and payload in one row. Its id is the public entry id. | +| **Value address** | Bound `Value` naming one replaceable current value by stable namespace and key. | +| **List address** | Bound `ValueList` naming one append-only list; each element has its own global write sequence and whole-list deletion is lifecycle cleanup. | +| **Usage row** | Append-only cost ledger row. Never modified, never deleted. | +| **Pending entry** | Complete unplaced content in `pi.pending.entry`, including queued input and finalized outcome-ready tool results, until placement/cancellation/cleanup. | +| **Session** | One conversation: tree, values/lists, ledger, lanes. | +| **Lane** | Named cursor into the tree with its own config, queues, and one operation. | +| **Operation** | One accepted unit of work: run, compaction, or navigation. | +| **Operation status** | Small process-relative observation: `running`, `open`, or `aborting`; idle is represented by no current operation. It never predicts registry availability. | +| **Open operation** | One attachment inventory item for a lane with durable current work. It is not a reservation or continuation policy. | +| **Attachment** | Minimal lane/operation projection restore plus open inventory; it starts no execution. | +| **Invocation Context** | Immutable explicit telemetry/cancellation lineage for one call. It is never durable state or a shared receiver default. | +| **Acceptance** | The atomic creation of one durable operation by `accept`; it starts no driver. | +| **Drive pass** | One process-local ownership span created or joined by `drive`, ending at settlement or a durable wait. | +| **Effect** | Anything not pure computation: commit, provider request, tool, hook, timer. | +| **Repeat-sensitive effect** | One whose repetition is observable outside the harness. | +| **Operation state** | The complete durable state of one open operation — the `operationState(operationId)` value and restart point after task loss. | +| **Drive** | The one installed process-local lane pass: exact ownership identity, shared completion, Gate, invocation Context, and wait policy. | +| **Effect gate** | Process-local synchronous arbitration of ordinary operation admission against a cancellation request, paired with the operation's cooperative signal. | +| **Reserved id** | An id minted before content exists. Tool-result ids progress from a string in state to staged pending content to an immutable entry. | +| **Follower id** | An id minted with its leader's 48-bit timestamp so a call/result group shares one time prefix (§1.2). | +| **Session mutation line** | Session-wide serialization point where all state-dependent mutations queue. An explicit Session mutation holds it from `beginMutation()` until `end()`, including across its commit and process-local publication. | +| **Session mutation** | Explicit Session-wide read/one-commit capability. `commit()` does not release it; `end()` settles and releases it. `Session.mutate()` scopes the same capability around a callback without exposing `end()`. | +| **Control** | Orthogonal cancellation flag: `running` or `cancel_requested`. | +| **Checkpoint** | The state between turns where queues, writes, and finishing are decided. | +| **Continuation** | Durable answer to "does this run still owe an assistant turn?" | +| **Tool checkpoint** | Optional bounded complete live-update snapshot selected by a tool and stored as the current `pi.pending.tool_output` value; auxiliary, never completion authority. | +| **Assistant frame** | Compact replayable pi-ai stream frame appended to the `pi.pending.assistant_frame` list while its response is effect-pending; reduced by `reduceAssistantMessageFrames`; auxiliary, never completion authority. | +| **Outcome ready** | Tool call whose complete finalized result is durable in `pi.pending.entry` and will never execute again, but awaits source-ordered tree placement. | +| **Invocation memo** | Tool-invocation-scoped durable current value used for replay-safe memoization; valid only while the call is effect-pending. | +| **Terminal transaction** | The commit that deletes an operation's values/lists, writes `pi.lane.lastResult`, and clears `currentOperationId`. | +| **Segment** | A branch-index range that references an older branch instead of copying it. | +| **External finalization** | A terminal transaction committed outside the live task. An in-process finalizer closes the gate in committed materialization, publishes once, then settles the live Drive from `finalizedOutcome` after event delivery without waiting for detached work; the exact owner remains installed until both settlement and pass ending, preventing early hydration; with no local Drive, later callers hydrate from `pi.lane.lastResult` (§4.9). | +| **Precise rewrite** | The administrative copy-retained-and-swap rebuild of a session store — the sole sanctioned path that removes entries or usage rows (§2.9). | # Appendix B — Coding-agent v3-format compatibility @@ -3685,17 +3698,17 @@ One corruption assertion constructs an `aborted` response with running control d - `custom_message` becomes a custom agent message. - `label` and `session_info` become session-name/entry-label values (latest by file position wins) and leave the tree. A label target resolves through discarded nodes to its nearest retained ancestor; if no retained ancestor exists and resolution produces `null`, the label is skipped. -- Legacy `model_change`, `thinking_level_change`, and `active_tools_change` nodes disappear. They do **not** initialize or alter `LaneConfiguration`; a normalized `main` uses the immutable options seed. +- Legacy `model_change`, `thinking_level_change`, and `active_tools_change` nodes disappear from the entry tree. The importer uses the nearest change of each kind on the selected physical main path to write ordinary total main-lane configuration plus idle state before returning; an unsupported nearest value does not fall back to older history. Missing active-tools history normalizes to `[]`; missing or unsupported required model/thinking history leaves main data-only. - Each retained child of a discarded node is reparented to its nearest retained ancestor. -- `main`'s leaf is the final physical node resolved through discarded nodes to its nearest retained ancestor. +- `main`'s tip is the final physical node resolved through discarded nodes to its nearest retained ancestor. - An old compaction resolves its legacy `firstKeptEntryId` field against its own branch and materializes that range as `retainedTail`. Format 4 never exposes or persists that field. - Existing `details`, `usage`, and `fromHook` are preserved; an absent `fromHook` normalizes to `false`. - v3 ISO timestamps convert to Unix milliseconds. - A v3 `parentSession` path resolves to an available parent header id; otherwise metadata and first-write conversion preserve it as `legacyParentSessionPath`. - On first format-4 write, append one aggregate adjustment usage row with `details: { source: "v3-import" }`, summing v3 node usage so ledger-derived totals remain unchanged. -- Legacy v3 ids are re-minted at import: each entry gets a UUIDv7 whose prefix is the legacy entry's own timestamp (random tail for uniqueness), preserving time order and §1.2's every-id-is-time-prefixed property. All references the format knows are remapped — parent chains, `main`'s leaf, surviving label keys, non-null `fromId`, usage `entryId`. Ids embedded in opaque payloads (custom entry data, `details`, message text) are not rewritten; the opaque-payload contract (§1.2) already covers them. +- Legacy v3 ids are re-minted at import: each entry gets a UUIDv7 whose prefix is the legacy entry's own timestamp (random tail for uniqueness), preserving time order and §1.2's every-id-is-time-prefixed property. All references the format knows are remapped — parent chains, `main`'s tip, surviving label keys, non-null `fromId`, usage `entryId`. Ids embedded in opaque payloads (custom entry data, `details`, message text) are not rewritten; the opaque-payload contract (§1.2) already covers them. -Read-only open leaves the file unchanged and computes stats from normalized entry snapshots. The first format-4 write persists normalization through a temporary file and atomic rename over the original path, including the aggregate adjustment so subsequent stats are ledger-derived, and stamps the current `storageVersion` (§7.3). A fork from an unconfigured read-only v3 session follows §2.7 and leaves destination `main` for first harness attachment to seed. +Read-only open leaves the file unchanged and computes stats from normalized entry snapshots. The first format-4 write persists normalization through a temporary file and atomic rename over the original path, including the aggregate adjustment so subsequent stats are ledger-derived, and stamps the current `storageVersion` (§7.3). Forking uses the same ordinary configured-lane versus data-only-Branch rules as format 4. # Appendix C — Open questions diff --git a/packages/agent/docs/plugins.md b/packages/agent/docs/plugins.md index d4a285de9..9c1792e88 100644 --- a/packages/agent/docs/plugins.md +++ b/packages/agent/docs/plugins.md @@ -2,7 +2,7 @@ > **Status:** Tentative design input, not a normative contract or implementation handoff. The transport-neutral service token, provider, singleton `use()`, keyed `observe()`, and `ReplicatedState` substrate now have an experimental implementation, including `Models`, `Chat`, `SessionDirectory`, and `SessionManagement` vertical slices. `RemoteEvents` now has an experimental transport-neutral implementation with ordered non-replayed delivery, while the remaining documented built-in service contracts retain explicit `ServiceSliceNotImplemented` members. The application facet environments and attributes, plugin kernel, references, telemetry propagation, and most other example facets remain illustrative. Plugin reload semantics are specified separately in [`plugin-reloading.md`](plugin-reloading.md). Reconcile this design with `rpc.md`, `telemetry.md`, and the final harness contract before adding it to `harness.md` or creating a work package. -This document assumes you already understand `AgentHarness`, `AgentLane`, `Session`, `SessionTree`, `SessionRepo`, invocation `Context`, and telemetry. Read `rpc.md` for wire frames, remote references, subscriptions, and trace carriers, `telemetry.md` for context propagation and cancellation semantics, and `plugin-reloading.md` for manifest-generation replacement and durable reconstruction. +This document assumes you already understand `AgentHarness`, `AgentLane`, `Session`, `Branch`, `SessionRepo`, invocation `Context`, and telemetry. Read `rpc.md` for wire frames, remote references, subscriptions, and trace carriers, `telemetry.md` for context propagation and cancellation semantics, and `plugin-reloading.md` for manifest-generation replacement and durable reconstruction. ## Bird's-eye view @@ -365,14 +365,33 @@ Every facet uses the same unqualified `env.use()` and `env.observe()` operations This is the most important boundary in the design. -**Session facets run beside the real thing.** They execute in the process that owns the concrete `AgentHarness`, `AgentLane`, `Session`, and `SessionTree`, and receive direct, process-local, scoped capabilities backed by those instances — not RPC proxies. Calls preserve real method signatures, `Context` propagation, `Result` types, and object identity. A session facet never RPCs back into its own process. +**Session facets run beside the real thing.** They execute in the process that owns the concrete `AgentHarness`, `AgentLane`, `Session`, and Branches, and receive direct, process-local, scoped capabilities backed by those instances — not RPC proxies. Calls preserve real method signatures, `Context` propagation, `Result` types, and object identity. A session facet never RPCs back into its own process. ```ts +interface ScopedSessionData { + readonly metadata: SessionMetadata; + getEntry(id: string, context: Context): Promise; + getStats(context: Context): Promise; + getValue(address: Value, context: Context): Promise | undefined>; + scanValues(prefix: Value, context: Context): Promise[]>; + readList(address: ValueList, options: ListReadOptions | undefined, + context: Context): Promise[]>; + setValue(address: Value, next: NoInfer, context: Context): Promise; + deleteValue(address: Value, context: Context): Promise; + appendList(address: ValueList, element: NoInfer, context: Context): Promise; + deleteList(address: ValueList, context: Context): Promise; + getName(context: Context): Promise; + setName(name: string | undefined, context: Context): Promise; + getLabel(targetId: string, context: Context): Promise; + setLabel(targetId: string, label: string | undefined, context: Context): Promise; + findEntries(query: EntryQuery | undefined, context: Context): Promise; + findEntry(query: EntryQuery | undefined, context: Context): Promise; +} + interface AgentPluginScope { readonly identity: SessionIdentity; // sessionId + workspaceId - readonly main: AgentLanePluginView; - lane(name: string, context: Context): Promise; - readonly sessionTree: SessionTree; + lane(name: string, context: Context): Promise; + readonly session: ScopedSessionData; readonly hooks: ScopedHooks; readonly events: ScopedEvents; } @@ -386,7 +405,7 @@ interface SessionFacetAttributes { } ``` -"Local" and "unrestricted" are separate decisions. The environment narrows authority for lifecycle and composition — hooks and event subscriptions registered through it are automatically owned by the plugin and disposed with it — but `sessionTree` may be the actual local derived object. The host keeps the unrestricted concrete instances and reserves: `AgentHarness.close()` and `Session.close()`; raw `Session.mutate()` and `SessionMutator` (unless a narrowly trusted durability plugin explicitly owns them); `idGenerator` and backend/storage objects; whole-registry setters such as `setTools()`; unscoped hook/event registration; transport exposure and remote-reference registration. This is a composition and lifecycle boundary, not a security sandbox: session facets are trusted code in the authoritative process. The manifest may explicitly grant broader local capability, but built-ins receive no implicit bypass. +"Local" and "unrestricted" are separate decisions. The scope narrows authority for lifecycle and composition — hooks and event subscriptions registered through it are automatically owned by the plugin and disposed with it. `AgentLanePluginView` exposes Branch methods directly alongside agent operations. `ScopedSessionData` exposes purpose-bounded global value/list/name/label/query operations. The host keeps the unrestricted concrete instances and reserves: `AgentHarness.close()` and `Session.close()`; raw `Session.mutate()`, `beginMutation()`, and `SessionMutator` (unless a narrowly trusted durability plugin explicitly owns them); `idGenerator` and backend/storage objects; Branch creation; whole-registry setters such as `setTools()`; unscoped hook/event registration; transport exposure and remote-reference registration. This is a composition and lifecycle boundary, not a security sandbox: session facets are trusted code in the authoritative process. The manifest may explicitly grant broader local capability, but built-ins receive no implicit bypass. **Presentation facets hold none of this.** A TUI or web facet never receives the raw Harness, Session, tree, tool registry, hooks, or credentials — not even as proxies. It receives only what a session or server facet deliberately exposes: semantic service proxies and instances, replicated state, and semantic events. @@ -959,7 +978,7 @@ With no connected presentation, the added instance and unresolved tool remain Se ### Durability and worker replacement -The service instance is live process state; the invocation memo is the replay receipt. The Harness already persists a safe tool's effective arguments, stable invocation ID, `effect_pending` state, and memos. `memoOnce()` synchronously enters one atomic read-or-write on the invocation's lane mutation line and verifies that the same operation, turn, source position, and invocation still own the effect. It returns the existing value or commits and returns the candidate. +The service instance is live process state; the invocation memo is the replay receipt. The Harness already persists a safe tool's effective arguments, stable invocation ID, `effect_pending` state, and memos. `memoOnce()` synchronously enters one atomic read-or-write on the invocation's Session mutation line and verifies that the same operation, turn, source position, and invocation still own the effect. It returns the existing value or commits and returns the candidate. If the worker dies before the answer commit, the old instance and promise disappear. Safe replay reads no answer and adds the same logical key with a new generation. If it dies after the commit, replay reads the answer and returns without adding an instance. A client cannot answer while the worker is absent; calls through the old generation fail instead of locating an invocation by bare ID. @@ -1144,7 +1163,7 @@ const PromptQueue = defineLocalService("prompt-queue"); ### Why record mutations need a critical region -`DiffReviewRecords` builds on the session facet's `SessionTree` (`values.md`): typed durable values in a plugin-owned namespace. Each storage call is atomic, but an application read-modify-write cycle spans multiple calls and therefore multiple awaits. Concurrent service calls can interleave between them. +`DiffReviewRecords` builds on the facet's scoped Session data (`values.md`): typed durable values in a plugin-owned namespace. Each storage call is atomic, but an application read-modify-write cycle spans multiple calls and therefore multiple awaits. Concurrent service calls can interleave between them. Concrete failure without serialization — two users press submit at the same time: @@ -1163,7 +1182,7 @@ In the one-authoritative-worker model, the simplest fix is a per-review **critic ```ts async freezeForSubmission(reviewId, context) { return regionFor(reviewId).run(context.abortSignal, async () => { - const stored = await tree.getValue(reviewRecord(reviewId), context); + const stored = await session.getValue(reviewRecord(reviewId), context); if (stored === undefined) throw new RemoteServiceError("review_not_found", `Unknown review: ${reviewId}`); const current = stored.value; @@ -1179,7 +1198,7 @@ async freezeForSubmission(reviewId, context) { prompt: renderReviewPrompt(current.patch, current.comments), }, }; - await tree.setValue(reviewRecord(reviewId), frozen, context); + await session.setValue(reviewRecord(reviewId), frozen, context); return frozen; }); } diff --git a/packages/agent/docs/telemetry.md b/packages/agent/docs/telemetry.md index a29b5efd4..7c0fe62f5 100644 --- a/packages/agent/docs/telemetry.md +++ b/packages/agent/docs/telemetry.md @@ -4,7 +4,7 @@ ## Goal -`Session`, `SessionTree`, `AgentLane`, and `AgentHarness` receive invocation-scoped control data explicitly through a required trailing `Context` parameter. The same receiver may serve concurrent local callers or RPC clients, so it cannot retain a mutable or default caller context. +`Session`, `Branch`, `AgentLane`, and `AgentHarness` receive invocation-scoped control data explicitly through a required trailing `Context` parameter. The same receiver may serve concurrent local callers or RPC clients, so it cannot retain a mutable or default caller context. The invocation context must solve two related problems without `AsyncLocalStorage`: @@ -61,7 +61,7 @@ Shared receivers retain identity and durable/process state, not invocation conte AgentHarness receiver ── no caller context AgentLane receiver ── no caller context Session receiver ── no caller context -SessionTree receiver ── no caller context +Branch receiver ── no caller context ``` Every invocation supplies its own context. This prevents concurrent callers from overwriting each other's telemetry parent or cancellation signal. @@ -247,7 +247,7 @@ Current tests cover immutable typed-value layering and shadowing, distinct empty ## Resolved migration decisions - Receiver methods use one required trailing `Context`. -- Shared harness, lane, session, and tree receivers retain no default invocation context. +- Shared Harness, AgentLane, Session, and Branch receivers retain no default invocation context. - `Context`, `AbortSignal`, and `TelemetryContext` objects are never serialized across RPC boundaries. ## Open decisions before `harness.md` diff --git a/packages/agent/docs/tool-durability.md b/packages/agent/docs/tool-durability.md index cd1d1bfee..1b5dbc6fb 100644 --- a/packages/agent/docs/tool-durability.md +++ b/packages/agent/docs/tool-durability.md @@ -138,9 +138,9 @@ export type AgentHarnessToolUpdateCallback = ( Every callback invocation remains an immediate live update. The callback is synchronous and returns `void`; `checkpoint: true` additionally requests persistence of that complete snapshot. It is not a durability acknowledgement. Internally, the harness retains the latest `events.emit(tool_update)` promise so existing listener delivery completes before `after_tool`; tools do not await or receive that promise. -Every `checkpoint:true` call synchronously enqueues one scalar replacement on the lane mutation line, attaches the ordinary harness-fault observer to that promise, and replaces the process-local `latestCheckpointWrite` reference. Writes themselves are neither dropped nor coalesced, and replacing the reference never leaves an earlier rejection unobserved: +Every `checkpoint:true` call synchronously enqueues one scalar replacement on the Session mutation line, attaches the ordinary harness-fault observer to that promise, and replaces the process-local `latestCheckpointWrite` reference. Writes themselves are neither dropped nor coalesced, and replacing the reference never leaves an earlier rejection unobserved: -- lane-line FIFO preserves request order; +- Session mutation FIFO preserves request order; - each mutation verifies the same call remains `effect_pending`; - completion of the latest promise implies completion of every earlier checkpoint write; - tool-promise settlement stops accepting updates and awaits that latest promise before `after_tool`; @@ -317,7 +317,7 @@ TX[ deleteValue(pendingEntry(i+1)), insert tool usage row i+1 if reported, - setValue(laneLeaf(lane), newest result), + setValue(branchTip(lane), newest result), setValue(operationState(operationId), calls i..i+1 = completed and, when complete, next checkpoint) ] @@ -423,7 +423,7 @@ interface AgentHarnessToolInvocation { Each operation: 1. validates the memo name and checks process-local capability expiry; -2. synchronously enqueues work on the lane mutation line before returning its promise; +2. synchronously enqueues work on the Session mutation line before returning its promise; 3. verifies the operation, turn, source position, and invocation are still the same `effect_pending` call when that job executes; 4. constructs and reads or writes only `operationToolMemo(operationId, invocationId, name)`; 5. rejects after capability expiry or durable ownership loss. @@ -558,7 +558,7 @@ Materialization reads `pendingEntry(resultEntryId)`. Its absence or wrong truste Ordinary dispatch performs no restore-time entry audit. Context/tree reads later consume the immutable entry through their normal typed paths. -Every live mutation still verifies current operation, turn, source position, invocation, and status on the lane line. Those checks fence concurrent settlement, cancellation, and external finalization; they are not historical restore validation. Terminal prefix cleanup remains defensive and does not make orphan scans part of restore. +Every live mutation still verifies current operation, turn, source position, invocation, and status on the Session line. Those checks fence concurrent settlement, cancellation, and external finalization; they are not historical restore validation. Terminal prefix cleanup remains defensive and does not make orphan scans part of restore. ## Snapshots and reconnect @@ -593,7 +593,7 @@ Instrumented-storage tests assert this ordering. A crash after `tool_end` but be | crash after outcome staging | tool never replays; pending result later materializes | | crash during source-prefix placement | transaction exposes either none or all of that placement prefix | | safe replay vs old partial output | the old bound checkpoint value is deleted before replay emits new progress | -| cancellation vs real settlement | lane-line order chooses real cancelled-control result or synthetic reconciliation; at most one outcome stages | +| cancellation vs real settlement | Session mutation order chooses real cancelled-control result or synthetic reconciliation; at most one outcome stages | | terminal finalization vs late result | terminal ownership wins or outcome stages first; late task never recreates operation data | | external finalization vs memo/checkpoint mutation | mutation first is removed by terminal cleanup; finalization first makes the mutation's durable ownership check reject | @@ -672,7 +672,7 @@ Instrumented-storage tests assert this ordering. A crash after `tool_end` but be - exact intent, synchronous update acceptance, asynchronous update-delivery, `after_tool`, `tool_end`, outcome-ready, source-ordered message lifecycle, and materialization order; - outcome staging is atomic with memo/output cleanup; -- materialization is atomic with pending deletion, usage, leaf, and state; +- materialization is atomic with pending deletion, usage, tip, and state; - crash at every boundary; - no effect starts before intent; - no effect or hook starts from `outcome_ready`; @@ -687,7 +687,7 @@ Expected runtime areas: - tool-batch procedure and source-ordered materialization; - terminal cleanup and cancellation reconciliation; - harness-specific update options, snapshots, and events; -- invocation-scoped capability implementation on the lane mutation line; +- invocation-scoped capability implementation on the Session mutation line; - backend conformance through the bound value/list APIs; - instrumented-storage transaction assertions. diff --git a/packages/agent/docs/values.md b/packages/agent/docs/values.md index e27a8b7e0..af7c45846 100644 --- a/packages/agent/docs/values.md +++ b/packages/agent/docs/values.md @@ -13,17 +13,17 @@ The namespace/key pair is bound once when the address is constructed. Every late const state = value("my-app.state"); const events = list("my-app.events"); -await session.getValue(state); -await session.setValue(state, nextState); -await session.readList(events, { limit: 100 }); -await session.appendList(events, event); +await session.getValue(state, context); +await session.setValue(state, nextState, context); +await session.readList(events, { limit: 100 }, context); +await session.appendList(events, event, context); ``` It does **not** repeatedly pass a second unexplained key: ```ts // Not the API. -await session.readList(events, "another-key", { limit: 100 }); +await session.readList(events, "another-key", { limit: 100 }, context); ``` When an application genuinely has keyed instances, it constructs the address for that instance: @@ -32,7 +32,7 @@ When an application genuinely has keyed instances, it constructs the address for const workspaceEvents = (workspaceId: string) => list("my-app.events", workspaceId); -await session.readList(workspaceEvents("pi"), { limit: 100 }); +await session.readList(workspaceEvents("pi"), { limit: 100 }, context); ``` Storage may physically index the address as `(kind, namespace, key)`, but that representation does not leak into each read or write call. Storage, Session, harness code, and applications use the same address vocabulary. There is no global value-type map, dynamic registry, token catalog, or separate application-state storage mechanism. @@ -123,8 +123,8 @@ The two components remain separate rather than concatenated. Dynamic application An address names one value or one list. Internal code uses small constructors when it has dynamic keys: ```ts -export const laneLeaf = (lane: string) => - value("pi.lane.leaf", lane); +export const branchTip = (lane: string) => + value("pi.branch.tip", lane); export const operationState = (operationId: string) => value("pi.op.state", operationId); @@ -178,14 +178,18 @@ Applications should use a stable, collision-resistant namespace prefix. Namespac Built-in constructors live together in `packages/agent/src/harness/session/values.ts` and are imported directly by consumers. Representative definitions: ```ts -export const laneLeaf = (lane: string) => value("pi.lane.leaf", lane); -export const laneConfig = (lane: string) => value("pi.lane.config", lane); -export const laneState = (lane: string) => value("pi.lane.state", lane); +export const branchTip = (lane: string) => + value("pi.branch.tip", lane); +export const laneConfig = (lane: string) => + value("pi.lane.config", lane); +export const laneState = (lane: string) => + value("pi.lane.state", lane); export const laneLastResult = (lane: string) => value("pi.lane.lastResult", lane); -/** Used only by scanValues() to enumerate configured lane names. */ -export const laneLeafInventoryPrefix = () => value("pi.lane.leaf"); +/** Used only by scanValues() to enumerate Branch names. */ +export const branchTipInventoryPrefix = () => + value("pi.branch.tip"); export const operationMeta = (operationId: string) => value("pi.op.meta", operationId); @@ -239,7 +243,7 @@ export const entryLabel = (entryId: string) => value("pi.entry.label", e `OperationMeta` is immutable acceptance metadata stored at `pi.op.meta`. The process-local `Operation` projection is `{ meta: OperationMeta, state: OperationState }`, assembled from the separate metadata and state values; it is never stored at one address. -The five exported scan-prefix constructors are `laneLeafInventoryPrefix`, `operationToolArgsPrefix`, `operationToolMemoPrefix`, `operationPreparationPrefix`, and `pendingToolOutputPrefix`. Their addresses are consumed only by `scanValues()`. +The five exported scan-prefix constructors are `branchTipInventoryPrefix`, `operationToolArgsPrefix`, `operationToolMemoPrefix`, `operationPreparationPrefix`, and `pendingToolOutputPrefix`. Their addresses are consumed only by `scanValues()`. Applications define their own `value()` and `list()` addresses directly; there is no built-in custom application-state namespace or custom-state API. `AgentHarnessToolInvocation.getMemo()` and `setMemo()` are invocation-fenced capabilities over `operationToolMemo(...)`, not raw Session access. Invocation memos remain operation-owned and are deleted when their tool outcome becomes durable. @@ -290,14 +294,22 @@ interface ValueReader { `scanValues(prefixAddress)` scans scalar addresses with exactly that namespace and keys beginning with the bound key, returning them in key-ascending order. Core call sites use only the exported prefix constructors above so raw namespace/key grammar stays in `session/values.ts`. Prefix addresses are passed only to `scanValues()`, never to exact get/set/delete operations. There is no unrestricted cross-namespace dump. Ordinary application reads use exact addresses. -`SessionTree` exposes direct one-transition writes using the same addresses: +`Session` exposes direct one-transition writes using the same addresses: ```ts -interface SessionTree extends ValueReader { - setValue(address: Value, next: NoInfer): Promise; - deleteValue(address: Value): Promise; - appendList(address: ValueList, element: NoInfer): Promise; - deleteList(address: ValueList): Promise; +interface Session extends ValueReader { + setValue( + address: Value, + next: NoInfer, + context: Context, + ): Promise; + deleteValue(address: Value, context: Context): Promise; + appendList( + address: ValueList, + element: NoInfer, + context: Context, + ): Promise; + deleteList(address: ValueList, context: Context): Promise; } ``` @@ -441,18 +453,18 @@ For every convertible non-terminal provider event, the assistant procedure: ```text convert event to frame -→ synchronously enqueue appendList(frames, frame) on the lane mutation line +→ synchronously enqueue appendList(frames, frame) on the Session mutation line → attach the ordinary harness-fault observer to that returned promise → replace the process-local latestFrameWrite reference → emit and await the existing message event → consume the next provider event ``` -The provider loop does not await storage for every frame. Synchronous enqueue preserves provider-event order. Replacing the latest-promise reference never leaves an earlier rejection unobserved because every promise receives the fault observer. Bounded output bounds queued work. On stream settlement, the procedure stops frame admission and awaits the latest append promise before `after_response`; lane-line FIFO means that completion implies every earlier append completed. There is no timer, batcher, coalescer, or flush API. +The provider loop does not await storage for every frame. Synchronous enqueue preserves provider-event order. Replacing the latest-promise reference never leaves an earlier rejection unobserved because every promise receives the fault observer. Bounded output bounds queued work. On stream settlement, the procedure stops frame admission and awaits the latest append promise before `after_response`; Session mutation FIFO means that completion implies every earlier append completed. There is no timer, batcher, coalescer, or flush API. Scalar assistant `effect_pending` remains authoritative. Each append verifies that the same operation, attempt, and response ID still own the lane when its mutation executes. Frames never prove request admission, completion, success, or failure. -Final or synthetic assistant settlement deletes the exact list atomically with its immutable response, usage, leaf, and next scalar state: +Final or synthetic assistant settlement deletes the exact list atomically with its immutable response, usage, Branch tip, and next scalar state: ```text TX[ @@ -613,7 +625,7 @@ Append-path tests prove that no `readList` call occurs before append commit. Fra 1. One bound address has one stable namespace/key/kind and one trusted value type in a storage version. 2. Address object identity has no durable meaning. 3. Namespace `pi` and every `pi.*` are reserved by contract; every built-in namespace starts with `pi.`, and application use is a trusted-programming defect. -4. Exactly five built-in prefix constructors encapsulate lane inventory and operation cleanup grammar; their results are consumed only by namespace-scoped `scanValues()`. +4. Exactly five built-in prefix constructors encapsulate Branch inventory and operation cleanup grammar; their results are consumed only by namespace-scoped `scanValues()`. 5. Scalar and list addresses must not occupy the same physical location; this is a trusted-programming rule, not a runtime cross-kind collision check. 6. Typed reads and helper-constructed writes preserve `T`. 7. Scalar helpers reject list addresses; list helpers reject scalar addresses. @@ -643,9 +655,9 @@ Append-path tests prove that no `readList` call occurs before append commit. Fra - incompatible definitions of one physical address are documented/tested as a programming defect; - empty keys work, while empty namespaces and separator-containing components reject; - core and application code use the same `value()` and `list()` constructors, with no private constructor, privilege token, registry, or catalog; -- built-in address constructors produce exact `pi.lane.*`, `pi.op.*`, `pi.pending.*`, `pi.session.name`, and `pi.entry.label` namespace/key/kind triples; +- built-in address constructors produce exact `pi.branch.tip`, `pi.lane.*`, `pi.op.*`, `pi.pending.*`, `pi.session.name`, and `pi.entry.label` namespace/key/kind triples; - every built-in namespace starts with `pi.`, while application fixtures use non-reserved namespaces; -- `laneLeafInventoryPrefix()` binds the empty-key `pi.lane.leaf` inventory prefix and is used only to enumerate lanes through `scanValues`; +- `branchTipInventoryPrefix()` binds the empty-key `pi.branch.tip` inventory prefix and is used only to enumerate Branches through `scanValues`; - tool-args prefixes cover one operation and optionally one step, tool-memo prefixes cover one operation and optionally one invocation, preparation and tool-output prefixes cover exactly one operation; - each prefix constructor result is used only by `scanValues`, and no inventory or cleanup call constructs a raw reserved namespace; - application addresses work without declaration merging or core catalogs; @@ -713,8 +725,8 @@ Expected primary changes: - replace `session/registers.ts` with `packages/agent/src/harness/session/values.ts` containing addresses, constructors, typed write helpers, and built-in address constructors; - remove `RegisterValues`, namespace unions, register token types, and raw namespace/key read signatures from `session/types.ts`; -- expose `ValueReader` through Storage, SessionReader, SessionMutator, Session, and SessionTree; -- add direct application scalar/list methods to SessionTree using bound addresses; +- expose `ValueReader` through Storage, SessionReader, SessionMutator, and Session; +- expose direct application scalar/list methods on Session using bound addresses; - update Memory state, JSONL codec/storage, snapshots, fork/rewrite code, instrumentation, and conformance suites; - replace SQLite's unfinished initial schema in place with `scalar_values` and `list_values`; keep storage version 1 and add no migration runner; - update telemetry schema sources and regenerate `telemetry-schema.md`; do not edit that generated file manually. diff --git a/packages/agent/docs/work-packages/00-runtime1-removal.md b/packages/agent/docs/work-packages/00-runtime1-removal.md index a1f3819b7..49bb903e5 100644 --- a/packages/agent/docs/work-packages/00-runtime1-removal.md +++ b/packages/agent/docs/work-packages/00-runtime1-removal.md @@ -42,7 +42,7 @@ packages/agent/test/harness/scratch/r4.ts ## Retain - all `test/harness/runtime2/**` tests; -- Session, SessionTree, storage, repository, backend-conformance, and instrumentation tests; +- Session, Branch, storage, repository, backend-conformance, and instrumentation tests; - execution assistant/tool/primitives tests; - config, hooks, events, telemetry, compaction, and branch-summary code/tests; - `types.test.ts`, updated for the reduced public contract; diff --git a/packages/agent/docs/work-packages/01-bound-values-lists.md b/packages/agent/docs/work-packages/01-bound-values-lists.md index 5f0133bc3..d52f3c349 100644 --- a/packages/agent/docs/work-packages/01-bound-values-lists.md +++ b/packages/agent/docs/work-packages/01-bound-values-lists.md @@ -11,7 +11,7 @@ Replace the retained register/custom-state storage surface with bound `Value` ## Decisions fixed for this package 1. **Core and application namespaces.** Every core address uses the exact documented `pi.*` namespace. Applications use their own non-reserved namespaces through the same public `value()` / `list()` constructors. `fact.custom` and its API are deleted, not renamed to a built-in custom namespace. -2. **Forks.** Generic forks copy only explicitly handled core addresses: lane leaf/configuration plus fresh lane state, session name, and labels whose targets copy. They copy no `pi.op.*`, `pi.pending.*`, list, ledger, or arbitrary application address. A later application feature must add an address-specific fork policy before relying on copied application state. +2. **Forks.** Generic forks copy only explicitly handled core addresses: Branch tip/lane configuration plus fresh lane state, session name, and labels whose targets copy. They copy no `pi.op.*`, `pi.pending.*`, list, ledger, or arbitrary application address. A later application feature must add an address-specific fork policy before relying on copied application state. 3. **Trusted kind discipline.** Using one `(namespace, key)` as both a value and a list is a trusted-programming defect. Backends do not add cross-kind collision checks, triggers, registries, or catalogs. 4. **Operation names.** Keep source `OperationMeta` for immutable acceptance metadata and `Operation` for the process-local `{ meta: OperationMeta, state: OperationState }` projection. `operationMeta(id)` binds `Value`; the composite is never persisted as one value. 5. **Query ordering and bounds.** `scanValues()` returns key-ascending results. `readList()` limits only one query page, never total list length or bytes: reject non-positive or non-safe limits, default to 1,000, and clamp larger values to 10,000. @@ -37,7 +37,7 @@ listRegisters(namespace, prefix) -> scanValues(prefixAddress) register set/delete writes -> typed value helpers ``` -Storage, `SessionReader`, `SessionMutator`, `Session`, and `SessionTree` expose the same bound-address reads: +Storage and the historical Session reader, mutator, tree-view, and repository surfaces expose the same bound-address reads: ```ts getValue(address: Value): Promise | undefined>; @@ -45,7 +45,7 @@ scanValues(prefix: Value): Promise[]>; readList(address: ValueList, options?: ListReadOptions): Promise[]>; ``` -`SessionTree` and therefore `Session` additionally expose one-commit direct writes: +The historical tree-view and Session surfaces additionally expose one-commit direct writes: ```ts setValue(address: Value, next: NoInfer): Promise; @@ -144,7 +144,7 @@ If the final old-API grep identifies another retained source/test call site, it 2. **Cut the shared API once.** Replace register types and writes in `types.ts`/`commit.ts`; split `StorageState` into current scalar values and surviving list elements; implement Memory reads/writes, ordered prefix scans, paged list reads, transaction validation/application, snapshots, and direct Session methods. Remove custom-fact APIs and migrate name/label wrappers. 3. **Migrate JSONL and generic fork/snapshot code.** Encode only `kind:"value"` and `kind:"list"`; replay set/delete/append/delete; preserve transaction-line torn-tail atomicity; serialize surviving list elements with original `seq` merged in global sequence order; preserve the sequence high-water mark. This extends existing snapshot serialization only—do not add a new compaction trigger or precise-rewrite feature. Forks copy the fixed core set from Decisions item 2, re-sequence destination scalar values after copied entries as today, and copy no lists. 4. **Migrate instrumentation, conformance, and benchmarks.** The storage decorator exposes all three reads; instrumented storage records erased value/list writes in exact order without content telemetry. Extend shared conformance before backend-specific assertions. -5. **Migrate runtime2 shell call sites.** Replace lane/harness raw writes with built-in constructors/helpers. `restore.ts` uses `scanValues(laneLeafInventoryPrefix())` plus exact `getValue` lookups and performs no `readList()` call. Do not add acceptance, drive, hydration, or cleanup behavior. +5. **Migrate runtime2 shell call sites.** Replace lane/harness raw writes with built-in constructors/helpers. `restore.ts` uses `scanValues(branchTipInventoryPrefix())` plus exact `getValue` lookups and performs no `readList()` call. Do not add acceptance, drive, hydration, or cleanup behavior. 6. **Replace the SQLite WIP schema and adapter.** Edit `001_initial.sql` in place, implement scalar operations and indexed list append/delete/paging in `session/values.ts`, keep every write inside the existing `BEGIN IMMEDIATE` writer-lease transaction, update both fork snapshot paths, and retain all entry/usage/branch/lease behavior from current `dev`. 7. **Migrate public events, tests, and the coding-agent helper.** Remove old type assertions and raw namespaces. Change `fact_update` to `value_update`. Keep the two remote prompt tests skipped for the existing WP00 reason; WP01 must not alter runtime execution. 8. **Update telemetry and documentation.** Change `pi.session.write` item kinds from `register` to `value` and `list`, regenerate `telemetry-schema.md`, run the old-API sweeps, and record any branch-policy-deferred changelog requirement. Do not edit a changelog on `gramps` unless it becomes a pull-request branch or the user requests it. @@ -225,7 +225,7 @@ Do not add a value/list collision test: cross-kind misuse is intentionally an un - identical Memory/JSONL/SQLite pages and cursors; - JSONL single/multi-write replay, torn-tail behavior, and sequence-preserving snapshot output; - SQLite query plans and writer-lease transaction behavior; -- branch/tree forks copy session name, eligible labels, and lane configuration/leaf with fresh lane state; +- branch/tree forks copy session name, eligible labels, and lane configuration/Branch tip with fresh lane state; - forks exclude operation/pending values, all lists, application addresses, last results, queues, and ledger rows; - repository parent metadata, entry IDs, stats, branch indexes, v3 normalization, UUIDv7/follower IDs, and current SQLite lease/fork scenarios remain unchanged; - runtime2 restore enumerates lanes through the one prefix constructor and reads no list. @@ -254,7 +254,7 @@ rg -n 'kind: "register"|getCustomFact\(|setCustomFact\(|fact\.(name|label|custom packages/agent packages/session-backends/sqlite-node packages/coding-agent \ --glob '!**/dist/**' --glob '!**/CHANGELOG.md' --glob '!**/docs/**' -rg -n '"(lane\.(leaf|config|state|lastResult)|op\.(meta|state|tool_args|preparation)|pending\.entry)"' \ +rg -n '"(branch\.tip|lane\.(config|state|lastResult)|op\.(meta|state|tool_args|preparation)|pending\.entry)"' \ packages/agent packages/session-backends/sqlite-node packages/coding-agent \ --glob '!**/dist/**' --glob '!**/CHANGELOG.md' --glob '!**/docs/**' 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 eeeb91766..83b1e4161 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, 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. +Complete. Phase A established minimal attachment, open-operation inventory, Session-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. @@ -16,7 +16,7 @@ idle lane → durable open operation in payload-free starting open or running lane -→ lane-line watch capture +→ Session-line watch capture → complete snapshot plus gap-free subsequent events ``` @@ -25,7 +25,7 @@ After `AgentHarness.create(options, context)` succeeds: - every configured lane has a complete small process-local projection; - `open` inventories every durable current operation without predicting model/tool availability; - 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; +- `inspectExecution(context)` observes the small projection and local owner on the Session 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 binds its event batch in the commit continuation; - the mutation does not await listener delivery, while the public operation does. @@ -40,7 +40,7 @@ Passing the open Session to `AgentHarness.create()` transfers orchestration owne Attachment reads only: -- `laneLeaf`, `laneConfig`, `laneState`, optional `laneLastResult`; +- `branchTip`, `laneConfig`, `laneState`, optional `laneLastResult`; - `operationMeta` and `operationState` for a current operation. It validates required existence, operation id/lane ownership, and intent/state kind compatibility. Projection corruption faults `create()`. @@ -49,7 +49,7 @@ Attachment does not read transcript, queues, pending writes, drained payloads, d ### 2. Watch owns detailed snapshot reads -One no-write lane mutation job defines the watch boundary: +One no-write Session mutation job defines the watch boundary: ```text enter after all earlier lane jobs @@ -57,7 +57,7 @@ enter after all earlier lane jobs → synchronously clone live presentation state → perform bounded durable reads while later lane jobs are excluded → assemble snapshot -→ release lane line +→ release Session line → return handle ``` @@ -65,7 +65,7 @@ There is no special first-watch cache. The same path handles immediate post-atta The bounded read set is: -- one compaction-bounded branch scan from the current leaf; +- one compaction-bounded branch scan from the current tip; - exact `pendingEntry(id)` reads for next-run, steer, follow-up, writes, and abort drains; - exact deferred source entry when represented; - tools-phase assistant entry and exact args for represented `effect_pending` calls, using `batch.turnId` as the args step id; @@ -99,11 +99,11 @@ publication/`emitBatch` first 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 +### 4. Inspection is a no-write Session-line observation `inspectExecution(context)` observes: -- lane and leaf; +- lane and tip; - configured model identity as unresolved `{ provider, modelId }` strings; - current operation id/kind/start time; - process status `running`, `open`, or durable `aborting`; @@ -174,7 +174,7 @@ export interface CurrentOperationInfo { export interface LaneExecutionInfo { lane: string; - leafId: string | null; + tipId: string | null; configuredModel: ModelIdentity; current: CurrentOperationInfo | null; lastResult?: LaneLastResult; @@ -182,7 +182,7 @@ export interface LaneExecutionInfo { export interface LaneInfo { name: string; - leafId: string | null; + tipId: string | null; operation: CurrentOperationInfo | null; } @@ -221,7 +221,7 @@ WP05 removes the withdrawn action outcome before execution is enabled. Convenien export interface LaneSnapshot { lane: string; transcript: Entry[]; - leafId: string | null; + tipId: string | null; lastResult?: LaneLastResult; operation: null | { id: string; @@ -289,7 +289,7 @@ Exact writes: insert captured nextRun message entries insert request prompt entries delete captured pendingEntry values -set laneLeaf +set branchTip set operationMeta set operationState(run starting) set laneState(current operation, pendingNextRun=[]) @@ -315,7 +315,7 @@ Update `harness.md` before runtime source: - replace eager attachment hydration with minimal projection restore; - replace predictive status/classifier with open inventory; - specify configured/captured identity inspection without resolution; -- move detailed reads to ad-hoc lane-line watch capture; +- move detailed reads to ad-hoc Session-line watch capture; - 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; @@ -426,14 +426,14 @@ Provider/tool/configuration-failure transitions are specified now but implemente - durable cancellation sets only `aborting:true`; - no transcript/pending/frame/tool reads at create; - configured and captured model identities may differ and remain visible when unresolved; -- inspection runs on lane line and performs no payload reads; +- inspection runs on the Session line and performs no payload reads; - projection corruption faults create; - no option callback/effect starts. ### Event publication and watch - recipient set binds at `emitBatch`: a watcher registered after publication but before delivery receives nothing; -- state/event batch publication happens before lane-line release; +- state/event batch publication happens before Session-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 in their commit continuation, so later listeners receive neither historical event; - watcher-first gives snapshot-before plus complete events; @@ -530,8 +530,8 @@ Stop when: - acceptance commits exactly once into payload-free `starting` without registry preflight; - 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; +- inspection is a coherent Session-line no-write observation; +- watch captures detailed state ad hoc on the Session line; - 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; diff --git a/packages/agent/docs/work-packages/04-mutation-publication.md b/packages/agent/docs/work-packages/04-mutation-publication.md index 28375bd7e..6cfc6bfba 100644 --- a/packages/agent/docs/work-packages/04-mutation-publication.md +++ b/packages/agent/docs/work-packages/04-mutation-publication.md @@ -4,7 +4,9 @@ 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 direct durable-drive package follows as WP05. +> Historical note: WP06 later replaced the lane-creation API and keyed line described in this completed handoff with atomic `AgentHarness.lane()` acquisition on one Session line. The event-publication guarantees remain current. + +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 the historical `Session.createLane()` own lane creation end to end. The direct durable-drive package follows as WP05. ## Problem @@ -20,9 +22,9 @@ 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. +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. The historical `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()`. +Lane creation has a second one-off boundary. The historical `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. @@ -155,7 +157,7 @@ Expected no-commit rejections publish no events. Commit/materialization/publicat ## Session lane creation contract -`Session.createLane()` owns validation, commit, and the synchronous committed-publication callback. Context remains last: +The historical `Session.createLane()` owns validation, commit, and the synchronous committed-publication callback. Context remains last: ```ts createLane( @@ -223,7 +225,7 @@ Modify: - `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; +- local Session implementations and tests required by the historical `createLane` signature; - direct event primitive, acceptance, watch, lane, and harness tests. Remote/experimental runtime behavior is not a design constraint for WP04. Session mutation authority remains process-local; do not add remote Session callback transport, protocol machinery, compatibility abstractions, or boundary tests. @@ -235,7 +237,7 @@ 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)`; +- replace shared exported mutator-procedure lane creation with the historical `Session.createLane(onCommitted, context)`; - update invariants, races, tests, glossary, and Part 8; - link WP04 and move direct durable drive to WP05. diff --git a/packages/agent/docs/work-packages/05-direct-durable-drive.md b/packages/agent/docs/work-packages/05-direct-durable-drive.md index 20c2e4cde..3bd5f2a6f 100644 --- a/packages/agent/docs/work-packages/05-direct-durable-drive.md +++ b/packages/agent/docs/work-packages/05-direct-durable-drive.md @@ -1,6 +1,8 @@ # WP05 — Direct durable drive -**Status: M3 complete; M4 next.** Independent Opus, GPT-5.6-Sol, and Fable reviews approved the authoritative-`Lane.state` design and M3 generation implementation with no blockers. +**Status: M3 complete; WP06 foundation landed before M4.** Independent Opus, GPT-5.6-Sol, and Fable reviews approved the authoritative-`Lane.state` design and M3 generation implementation with no blockers. + +WP06 separates Session/Branch/AgentLane/AgentHarness, replaces keyed lane lines with one keyless Session mutation line, removes implicit main, and renames durable/public leaf fields to tip. Every M4–M8 instruction below is interpreted on that foundation; no compatibility aliases or Harness-as-main assumptions remain. Removes breakpoints/manual drive, then implements the complete durable execution graph. Public drive stays disabled until every reachable phase and reconciliation path exists. @@ -129,7 +131,7 @@ private removeDrive(drive: Drive): void { } ``` -Every late transition and progress write checks the current operation in the authoritative `Lane.state` supplied by the lane mutation line and exact process identity. Exact identity is checked again synchronously at commit admission, immediately before `mutator.commit()`, with no `await` between the check and invocation. A planner-side check alone is insufficient because abandonment may replace the owner after an asynchronous planner returns. +Every late transition and progress write checks the current operation in the authoritative `Lane.state` supplied by the Session mutation line and exact process identity. Exact identity is checked again synchronously at commit admission, immediately before `mutator.commit()`, with no `await` between the check and invocation. A planner-side check alone is insufficient because abandonment may replace the owner after an asynchronous planner returns. This prevents ABA writes: after invocation abandonment, a replacement `Drive` may own the same durable operation id; the old object still fails exact identity and cannot write or remove the replacement. The small package-internal `Lane.commandDriveOwned(drive, plan, context)` variant performs only this admission check; it otherwise has the same visible `LaneCommand` decision as `command` and introduces no planner or transition framework. @@ -456,7 +458,7 @@ The body is illustrative, not a required helper inventory. The unchanged-`contin `Lane.command` passes the authoritative current lane/operation projection. Every supported control-state mutation commits through that `Lane`, and a successful commit publishes `decision.next` before releasing the mutation line. A failed commit publishes nothing and faults the harness. A process crash destroys the projection; attachment restores and validates it from durable control values. -Procedures therefore do **not** reread `laneState`, `operationMeta`, `operationState`, `laneLeaf`, or `laneConfig` on each transition. They use the callback reader only to dereference content named by the projection: pending entries, assistant frames, tool arguments/checkpoints, preparations, memos, staged outcomes, tree entries, and cleanup-prefix scans. +Procedures therefore do **not** reread `laneState`, `operationMeta`, `operationState`, `branchTip`, or `laneConfig` on each transition. They use the callback reader only to dereference content named by the projection: pending entries, assistant frames, tool arguments/checkpoints, preparations, memos, staged outcomes, tree entries, and cleanup-prefix scans. Drive-owned commands use one small `Lane` variant so exact-object ownership is checked at commit admission rather than only in an asynchronous planner: @@ -475,7 +477,7 @@ async commandDriveOwned( } ``` -`commandDriveOwned` is not a scheduler, planner, transaction wrapper, or capability facade. It is `Lane.command` plus the exact-Drive admission fence. If its initial operation/id check or final exact-object check fails, it returns `lost_ownership` and commits nothing. Close/fault is checked first at final admission and propagates its lifecycle error rather than being mislabeled as ownership loss. This extra final open check is drive-specific: exact owner abandonment may occur outside the lane line while an asynchronous planner runs. An ordinary `command` is admitted when its mutation callback starts and may finish that active commit after close begins. Ordinary public lane commands continue to use `command`. +`commandDriveOwned` is not a scheduler, planner, transaction wrapper, or capability facade. It is `Lane.command` plus the exact-Drive admission fence. If its initial operation/id check or final exact-object check fails, it returns `lost_ownership` and commits nothing. Close/fault is checked first at final admission and propagates its lifecycle error rather than being mislabeled as ownership loss. This extra final open check is drive-specific: exact owner abandonment may occur outside the Session line while an asynchronous planner runs. An ordinary `command` is admitted when its mutation callback starts and may finish that active commit after close begins. Ordinary public lane commands continue to use `command`. A representative transition stays direct: @@ -512,7 +514,7 @@ if (intent.kind !== "committed") return intent.kind === "lost_ownership" The transition derives `next` from the state supplied to that planner, never from a snapshot captured before the lane job. Inbox and other concurrent fields that do not invalidate this transition are preserved in the committed next state. Cancellation returns to reconciliation without committing; a newly non-empty inbox invalidates only a terminal-finish boundary; relevant tool-sibling movement uses the tool-local `state_changed` result. Ownership loss is handled in band. A matching operation with any other impossible kind/phase relationship is a `SessionInvariantError`. -The single-owner contract is explicit: while a harness owns a `Session`, retained `Session` and pre-attachment `SessionTree` mutators are forbidden. In-process administrative finalization also commits through the owning `Lane`. External storage mutation is not a supported second authority, and the runtime does not silently heal it. Conformance tests compare each committed projection with a fresh restore at controlled boundaries. +The single-owner contract is explicit: while a Harness owns a `Session`, raw Branch mutation for a configured AgentLane and reserved control-address writes are forbidden. In-process administrative finalization also commits through the owning `Lane`. External storage mutation is not a supported second authority, and the runtime does not silently heal it. Conformance tests compare each committed projection with a fresh restore at controlled boundaries. ### 2.6 Generation procedure — approved shape, illustrative bodies @@ -551,7 +553,7 @@ async function runReadyGeneration( if (intent.kind === "lost_ownership") return intent; if (intent.kind === "state_changed") return { kind: "continue" }; - // ---- effect: admitted, off the lane line ---- + // ---- effect: admitted, off the Session line ---- const progress = openFrameProgress(lane, drive, responseEntryId); let response: SettledAssistantMessage; try { @@ -783,10 +785,10 @@ Inside that one command, the procedure: 1. verifies its exact terminal boundary against the current authoritative projection; 2. computes its bounded `LaneLastResult` directly from known state and reserved ids; -3. prepends any procedure-specific publication writes (summary entry, navigation leaf/label movement, usage); +3. prepends any procedure-specific publication writes (summary entry, navigation tip/label movement, usage); 4. appends the shared mechanical operation-cleanup writes; 5. writes `laneLastResult` and idle `laneState`; -6. publishes the exact idle process-local projection, including any leaf movement in its publication writes; +6. publishes the exact idle process-local projection, including any tip movement in its publication writes; 7. constructs the complete `{ kind: "settled", outcome }` and its one terminal event from `CommitResult`, which supplies storage-assigned entry sequence and timestamp when needed. A normal completed-run finish is representative. Its terminal check is visibly part of the checkpoint procedure: @@ -807,7 +809,7 @@ return lane.commandDriveOwned(drive, async (state, reader) => { if (run.inbox.steer.length !== 0 || run.inbox.followUp.length !== 0 || run.inbox.writes.length !== 0) { return { kind: "return", result: { kind: "continue" } as const }; } - if (state.leafId === null) throw new SessionInvariantError("completed run has no leaf"); + if (state.tipId === null) throw new SessionInvariantError("completed run has no tip"); const finalId = run.phase.continuation.includeFinalAssistant ? run.latestAssistantEntryId @@ -825,13 +827,13 @@ return lane.commandDriveOwned(drive, async (state, reader) => { : {}; const outcome: TerminalOperationOutcome = { operation: "run", runId: drive.operationId, kind: "completed", - leafId: state.leafId, ...final, + tipId: state.tipId, ...final, }; const lastResult: LaneLastResult = { operationId: drive.operationId, kind: "run", outcome: "completed", - leafId: state.leafId, + tipId: state.tipId, runCompletion: finalId === null ? "terminated_tools" : "assistant", ...(finalId === null ? {} : { finalAssistantEntryId: finalId }), }; @@ -852,7 +854,7 @@ return lane.commandDriveOwned(drive, async (state, reader) => { materialize: () => ({ kind: "settled", outcome } as const), events: () => [{ type: "run_end", lane: lane.name, runId: drive.operationId, - leafId: state.leafId, outcome: "completed", ...final, + tipId: state.tipId, outcome: "completed", ...final, }], }; }, drive.context); @@ -1092,7 +1094,7 @@ const usageRow: Omit = { id: usageId, usage: response.usage, en const writes: Write[] = [ insertEntry(responseEntry), // 0 insertUsage(usageRow), // 1 - setValue(laneLeaf(lane.name), responseEntryId), // 2 + setValue(branchTip(lane.name), responseEntryId), // 2 deleteList(pendingAssistantFrames(drive.operationId, responseEntryId)), setValue(operationStateValue(drive.operationId), nextState), ]; @@ -1113,7 +1115,7 @@ return { }; ``` -Cross-lane ordering is the storage commit queue's: both backends serialize commits, so two lanes committing usage concurrently observe two different snapshots in commit order, and the later one includes the earlier. A consumer keeping the greatest `row.seq` it applied therefore never regresses totals (harness.md §5.5). +Cross-lane ordering is first the sole Session mutation line and then the storage commit queue: two lanes committing usage observe two different snapshots in commit order, and the later one includes the earlier. A consumer keeping the greatest `row.seq` it applied therefore never regresses totals (harness.md §5.5). **Fabricated `CommitResult` literals must be inventoried**, because adding a required field breaks every hand-written one. Current sites: @@ -1224,7 +1226,7 @@ Only `packages/coding-agent/src/core/cache-stats.ts` (comment) and vendored `hig | `src/harness/result.ts`, `src/harness/agent-harness.ts` | keep tagged/runtime errors in the dependency-neutral result module and re-export them from `agent-harness.ts`; this lets `agent-harness.ts` directly wire `createAgentHarness` while concrete Lane imports remain order-independent | | `src/harness/runtime/types.ts` | add the `Drive` class (completion, gate control, stripped pass Context, installer signal, wait policy, mutable `deferredPermits`, mutable `finalizedOutcome`) and the initial procedure-result union, simplified in M2; move `LaneCommand` here from `lane.ts` | | `src/harness/runtime/lane.ts` | make the class `Lane`; expose package-internal `session`, `models`, `hooks`, `emitBatch`, `command`, `readConfig`, and `mismatch` for direct procedure use; add package-internal `activeDrive: Drive | undefined` and `isDriveActive(drive)`; import `LaneCommand` from `types.ts`; remove the obsolete `AcceptanceConfig` type | -| `src/harness/runtime/harness.ts` | `Harness extends Lane`; `buildLane` returns `Lane`; pass `HookRegistry` and full `Config` into `Lane` | +| `src/harness/runtime/harness.ts` | `Harness` manages ordinary `Lane` objects by composition; `buildLane` returns `Lane`; pass `HookRegistry` and full `Config` into each Lane | | `src/harness/runtime/restore.ts` | name `Lane` wherever it names `Lane` | | `src/harness/session/types.ts` | add `stats: SessionStats` to `CommitResult` (§2.12) | | `src/harness/session/commit.ts` | `PreparedCommit.result` becomes `Omit` | @@ -1287,7 +1289,7 @@ Only `packages/coding-agent/src/core/cache-stats.ts` (comment) and vendored `hig | `test/harness/runtime/lane.test.ts` | exact owner replaced after an async planner returns but before commit admission → no write; commit admitted first → write and projection publish complete before replacement | | `test/harness/runtime/progress.test.ts` | current projection permits matching frame/tool progress; terminal or phase movement first → queued write declines; no durable control reads | | `test/harness/runtime/drive-terminal.test.ts` | cleanup completeness for run, compaction, navigation, including leftovers and exact live frame list; `pendingNextRun` excluded; bounded hydration equality for directly constructed latest-result values per kind | -| `test/harness/runtime/restore.test.ts` | required durable projection absence/contradiction remains attachment corruption; a stored null leaf remains valid | +| `test/harness/runtime/restore.test.ts` | required durable projection absence/contradiction remains attachment corruption; a stored null tip remains valid | **Commands.** `npm run check` · targeted vitest for every modified test file @@ -1318,12 +1320,12 @@ Only `packages/coding-agent/src/core/cache-stats.ts` (comment) and vendored `hig |---|---| | `src/harness/execution/assistant.ts` | use provider-facing `Tool` declarations; observer callbacks receive trailing Context, and `start` receives the actual start event; pre-generation errors synthesize no start, while updates or successful completion before start are protocol defects; the runtime request adapter admits `Models.streamSimple` through `Gate` | -**Behavior/invariants landed.** Preparation precedes intent; ids reserved at intent and honoured at settlement; frames enqueued synchronously without per-frame awaits; settlement deletes the frame list; every transition plans from the current owned projection and publishes its exact next projection; checkpoint separates tree parent/leaf from `triggerEntryId` for unprojected custom writes. +**Behavior/invariants landed.** Preparation precedes intent; ids reserved at intent and honoured at settlement; frames enqueued synchronously without per-frame awaits; settlement deletes the frame list; every transition plans from the current owned projection and publishes its exact next projection; checkpoint separates tree parent/tip from `triggerEntryId` for unprojected custom writes. **Focused tests** | Path | Theme | |---|---| -| `test/harness/runtime/drive-generation.test.ts` | each procedure invoked directly with a concrete `Lane` and `Drive`, no switch; intent before provider; reserved-id settlement; frame order without storage awaits; orphan recovery without provider call; mixed projecting/unprojected drains (leaf vs trigger); queued inbox mutation before intent → intent commits while preserving it; cancellation before intent → procedure returns `continue` and commits nothing; fence taken by another operation → returns `lost_ownership` and commits nothing; published projection equals fresh restore | +| `test/harness/runtime/drive-generation.test.ts` | each procedure invoked directly with a concrete `Lane` and `Drive`, no switch; intent before provider; reserved-id settlement; frame order without storage awaits; orphan recovery without provider call; mixed projecting/unprojected drains (tip vs trigger); queued inbox mutation before intent → intent commits while preserving it; cancellation before intent → procedure returns `continue` and commits nothing; fence taken by another operation → returns `lost_ownership` and commits nothing; published projection equals fresh restore | **Commands.** `npm run check` · targeted vitest diff --git a/packages/agent/docs/work-packages/06-session-branch-lane-separation.md b/packages/agent/docs/work-packages/06-session-branch-lane-separation.md new file mode 100644 index 000000000..0114faf3e --- /dev/null +++ b/packages/agent/docs/work-packages/06-session-branch-lane-separation.md @@ -0,0 +1,734 @@ +# WP06 — Session, Branch, Lane separation + +**Status: implemented before WP05 M4.** WP05 M3 remains complete. Retry/deferred work resumes on the composition, mutation, and ownership boundaries landed here. + +This package replaces the mixed `SessionTree`/implicit-main inheritance design with four explicit concepts: + +```text +Session global durable data + one mutation line +Branch one path through the entry tree, with a movable tip +AgentLane Branch data surface + agent operations/configuration +AgentHarness manager of AgentLanes; never a lane itself +``` + +--- + +## 0. Mandatory reading + +Read completely before editing: + +1. `packages/agent/docs/harness.md`. +2. `packages/agent/docs/work-packages/05-direct-durable-drive.md`. +3. `packages/agent/src/harness/session/types.ts`. +4. `packages/agent/src/harness/session/session.ts`. +5. `packages/agent/src/harness/session/memory.ts`. +6. `packages/agent/src/harness/session/jsonl/repo.ts` and `jsonl/storage.ts`. +7. `packages/session-backends/sqlite-node/src/sqlite/session.ts`, `storage.ts`, and repo implementation. +8. `packages/agent/src/harness/runtime/lane.ts`, `harness.ts`, `restore.ts`, and `types.ts`. +9. `packages/agent/src/harness/agent-harness.ts`. +10. `packages/agent/src/harness/session/fork.ts` and repository conformance tests. +11. Every test named in §8 before modifying it. + +Do not inspect deleted runtime implementations or Git history. Current source, `harness.md`, WP05, and this package are the only sources of truth. + +--- + +## 1. Problem + +### 1.1 `SessionTree` mixes unrelated ownership + +`SessionTree` currently contains: + +```ts +interface SessionTree { + // Lane/path data. + getLeafId(...): Promise; + findEntriesOnBranch(...): Promise; + findEntryOnBranch(...): Promise; + appendMessage(...): Promise; + appendCustomEntry(...): Promise; + + // Session-global data that is not lane- or branch-owned. + getEntry(...): Promise; + getStats(...): Promise; + findEntries(...): Promise; + findEntry(...): Promise; + getValue(...): Promise | undefined>; + setValue(...): Promise; + readList(...): Promise[]>; + appendList(...): Promise; + getName(...): Promise; + setName(...): Promise; + getLabel(...): Promise; + setLabel(...): Promise; +} +``` + +A selected tree view changes only which mutation queue a global write enters. It does not change the durable address. Two views can therefore read the same global value under different lane lines and commit a lost update. + +### 1.2 `Session` silently means `main` + +`Session extends SessionTree`. Its inherited branch methods and high-level writes silently delegate to the `main` lane. `session.setValue(...)` is actually `setValueForLane("main", ...)`. A fresh repository session creates a partial implicit main lane before any harness exists. + +### 1.3 `AgentHarness` silently means `main` + +`AgentHarness extends AgentLane`, and the runtime `Harness extends Lane`. The manager object is inserted into its own lane map as `main`. Calls such as `harness.prompt(...)`, `harness.watch(...)`, or `harness.getModel(...)` silently target main. + +### 1.4 Per-lane mutation queues solve the wrong problem + +Storage already serializes atomic commits and assigns one session-wide `seq` per write. Per-lane mutation queues allow useful preparation overlap, but this runtime does not need that complexity now: all harness mutation callbacks perform bounded storage reads, prepare one write set, commit at most once, publish process-local state, and return. Providers, tools, hooks, timers, and asynchronous event delivery are outside mutation callbacks. + +The approved first implementation uses one Session mutation line. Keyed lines may be added later if profiling proves the global line is a bottleneck; that future change requires a mutable-ownership audit but no public API redesign. + +--- + +## 2. Approved terminology and ownership + +### Session + +Owns: + +- session metadata; +- global entry and usage queries; +- application values and lists; +- session name and entry labels; +- Branch discovery/creation; +- one process-local mutation line for every supported mutation; +- one open storage/backend lifecycle. + +A Session does not implement Branch and has no implicit branch. + +### Branch + +Data-only capability describing one named path through the immutable entry tree. + +Owns only: + +- its current tip; +- branch-relative entry queries; +- direct extension of its tip with message or custom entries. + +A Branch has no model configuration, queues, operation state, drive, hooks, or agent policy. + +### AgentLane + +One Branch plus agent configuration and operations. It exposes Branch methods directly rather than exposing a nested `Branch`, tree, store, view, or access object. + +When idle, `AgentLane.appendMessage` / `appendCustomEntry` extend the tip directly. During an active run, they retain the existing deferred-write semantics: reserve the entry id, persist `pendingEntry(id)`, and enqueue the id in the operation inbox for checkpoint placement. A raw Branch append is always a direct data append; raw Branch mutation while a Harness owns the corresponding lane is a trusted-programming defect. + +### AgentHarness + +Owns global registries/configuration, hooks, events, lifecycle, and a map of AgentLanes. It is not an AgentLane and exposes no implicit-main operation methods. + +--- + +## 3. Target public types + +### 3.1 Session reader and mutation + +```ts +export interface SessionReader { + getEntries(ids: string[], context: Context): Promise>; + getValue( + address: Value, + context: Context, + ): Promise | undefined>; + scanValues(prefix: Value, context: Context): Promise[]>; + readList( + address: ValueList, + options: ListReadOptions | undefined, + context: Context, + ): Promise[]>; + scanBranch(query: StorageBranchScan, context: Context): Promise; +} + +export interface SessionMutation extends SessionReader { + /** Exactly zero or one attempt. A second call rejects, including after failure. */ + commit(writes: Write[], context: Context): Promise; + /** Wait for any admitted commit, invalidate the capability, and release the Session line. */ + end(context: Context): Promise; +} + +export type SessionMutator = Omit; + +export type SessionMutationCallback = ( + mutator: SessionMutator, + context: Context, +) => TResult | Promise; +``` + +`beginMutation()`/`SessionMutation.end()` remain the transportable scope used by the current remote Session protocol. They are keyless and carry no lane field. Normal local/harness code uses `mutate()`; no caller selects a mutation key. + +### 3.2 Branch + +```ts +export interface Branch { + readonly name: string; + getTipId(context: Context): Promise; + findEntries( + query: BranchScan | undefined, + context: Context, + ): Promise; + findEntry( + query: BranchScan | undefined, + context: Context, + ): Promise; + appendMessage(message: AgentMessage, context: Context): Promise; + appendCustomEntry( + customType: string, + data: JsonValue | undefined, + context: Context, + ): Promise; +} +``` + +Because the receiver is already a Branch, the public names are `findEntries` and `findEntry`, not `findEntriesOnBranch` and `findEntryOnBranch`. + +### 3.3 Session + +```ts +export interface Session< + TMetadata extends SessionMetadata = SessionMetadata, +> extends SessionReader { + readonly metadata: TMetadata; + readonly idGenerator: IdGenerator; + + // Direct reads. No mutation-line acquisition. + getEntry(id: string, context: Context): Promise; + getStats(context: Context): Promise; + findEntries( + query: EntryQuery | undefined, + context: Context, + ): Promise; + findEntry( + query: EntryQuery | undefined, + context: Context, + ): Promise; + getName(context: Context): Promise; + getLabel(targetId: string, context: Context): Promise; + + // Existing Branch acquisition performs durable I/O and therefore receives Context. + branch(name: string, context: Context): Promise; + createBranch( + name: string, + at: string | null, + context: Context, + ): Promise; + + // Transportable explicit scope; RemoteSession maps begin/read/commit/end over RPC. + beginMutation(context: Context): Promise; + + // Trusted sharp edge. The callback holds the sole Session mutation line. + mutate( + mutation: SessionMutationCallback, + context: Context, + ): Promise; + + // One-write conveniences implemented through mutate(). + setValue( + address: Value, + next: NoInfer, + context: Context, + ): Promise; + deleteValue(address: Value, context: Context): Promise; + appendList( + address: ValueList, + element: NoInfer, + context: Context, + ): Promise; + deleteList(address: ValueList, context: Context): Promise; + setName(name: string | undefined, context: Context): Promise; + setLabel( + targetId: string, + label: string | undefined, + context: Context, + ): Promise; + + close(context: Context): Promise; +} +``` + +`mutate()` remains public. Plugins are trusted not to retain the mutator, invoke nested public writers, perform effects, or hold the line across unbounded work. Misuse may block every mutation in that Session and is a plugin defect. `beginMutation()` exists for transport/lifecycle integration rather than ordinary plugin work; every direct caller must call `end()` in `finally`. + +### 3.4 AgentLane + +`AgentLane` keeps its operation/configuration/observation methods and directly adds the Branch surface: + +```ts +export interface AgentLane { + readonly name: string; + + getTipId(context: Context): Promise; + findEntries( + query: BranchScan | undefined, + context: Context, + ): Promise; + findEntry( + query: BranchScan | undefined, + context: Context, + ): Promise; + appendMessage(message: AgentMessage, context: Context): Promise; + appendCustomEntry( + customType: string, + data: JsonValue | undefined, + context: Context, + ): Promise; + + getLastResult(context: Context): Promise; + accept( + request: OperationRequest, + context: Context, + ): Promise; + drive(options: DriveOptions, context: Context): Promise; + requestAbort( + operationId: string, + context: Context, + ): Promise; + inspectExecution(context: Context): Promise; + // Existing convenience, queue, configuration, idle, and watch methods remain. +} +``` + +Delete `AgentLane.sessionTree`. + +### 3.5 AgentHarness + +```ts +export interface AcquireLaneOptions { + /** Used only when the AgentLane does not exist. Defaults to null. */ + createAt?: string | null; +} + +export interface AgentHarness< + TContext extends object | undefined = object | undefined, +> { + lane(name: string, context: Context): Promise; + lane( + name: string, + options: AcquireLaneOptions, + context: Context, + ): Promise; + lanes(context: Context): Promise; + + // Session-global metadata wrappers preserve existing value_update events. + getName(context: Context): Promise; + setName(name: string | undefined, context: Context): Promise; + getLabel(targetId: string, context: Context): Promise; + setLabel( + targetId: string, + label: string | undefined, + context: Context, + ): Promise; + + // Existing global tools/resources/options/settings/hooks/events/watchSession/close surface. +} +``` + +`AgentHarness` does not extend `AgentLane`. Delete `createLane`; `lane()` is atomic get-or-create. Existing lanes ignore `createAt`. A missing lane uses `createAt ?? null`. Concurrent acquisitions return the same published AgentLane. Invalid names and unknown non-null targets reject with the existing tagged errors; close/fault reject with their existing lifecycle errors. + +A fresh Session and fresh Harness contain no implicit main Branch or AgentLane. `await harness.lane("main", context)` creates main completely. `lanes()` may return `[]`. + +--- + +## 4. Mutation and read semantics + +### 4.1 One Session mutation line + +Replace `LaneMutationLine` with a single `MutationLine`: + +```ts +export class MutationLine { + private tail: Promise = Promise.resolve(); + private sealedError: Error | undefined; + + run(operation: () => TResult | Promise): Promise; + seal(error: Error): Promise; +} +``` + +`StorageBackedSession.beginMutation()` acquires that line and returns one explicit keyless capability; only `end()` releases it. `mutate()` is the callback convenience built from begin/end and always ends in `finally`. The callback may read, prepare, commit once, publish process-local state, synchronously bind event recipients, and return. `close()` seals admission and waits for every acquired scope to end before closing Storage. + +High-level Session writes, Branch creation/appends, every `Lane.command`, progress writes, restore snapshots requiring coherence, and Harness lane acquisition all call the same `Session.mutate()`. + +### 4.2 Reads bypass the line + +Ordinary Session and Branch reads call Storage directly. They observe the latest fully applied atomic storage commit at the time each read executes: + +- a queued/planning/unapplied mutation is invisible; +- no partial commit is visible; +- once Storage commit resolves, direct reads may observe it even while the mutation callback is still publishing process-local state; +- multiple reads outside one `mutate()` are not a snapshot; +- coherent read-decide-write/CAS uses `Session.mutate()`. + +### 4.3 Effects stay outside + +A mutation callback must not perform or await: + +- providers or deferred fetch/cancel; +- tools; +- hooks; +- timers; +- asynchronous event delivery; +- idle callbacks or Drive completion; +- nested Session/Branch/AgentLane mutators. + +The callback may synchronously call `emitBatch` after publication to bind recipients and retain its delivery promise. The public operation awaits delivery after `mutate()` returns. + +### 4.4 Storage remains independently atomic + +Storage retains its one-session commit serializer and assigns one global `seq` per write. The Session mutation line protects read-decide-commit-publication procedures; the Storage queue protects atomic transaction application, sequence assignment, fork snapshots, and backend callers. Do not merge the two abstractions. + +--- + +## 5. Branch and lane durable shape + +### 5.1 No implicit main + +Repository `create()` writes only session metadata/header/catalog state. It writes no branch tip, lane configuration, or lane state. Remove main seeding from Memory and JSONL creation and from SQLite initialization. + +Legacy coding-agent v3 normalization may still produce a main Branch because the imported transcript has one selected path. + +### 5.2 Branch completeness + +A Branch exists exactly when its required tip value exists. `createBranch(name, at, context)` validates name, absence, and non-null target, then writes the tip in one mutation. It writes no model configuration or operation state. + +Use Branch terminology in source. This package renames the typed constructor and public concepts to `branchTip`/`tipId`. The persisted namespace and durable field spelling decision must be consistent across all backends and docs: + +- use `pi.branch.tip` and rename format-4 fields from leaf to tip rather than retain misleading new-code aliases; +- format 4 and the new harness are WIP, so replace their schema and field names in place: no storage-version bump, migration, compatibility decoder, or old-format rejection path; +- legacy coding-agent v3 import remains supported: it maps its selected main leaf to a main Branch tip and walks that selected physical ancestry to reconstruct the nearest `model_change`, `thinking_level_change`, and `active_tools_change` independently; unsupported nearest values do not fall back to older history; +- when the importer can reconstruct a total configuration, it writes ordinary `laneConfig("main")` plus fresh idle `laneState("main")` before returning the Session; missing active-tools history normalizes to `[]` because v3 did not persist the initial tool inventory; +- if required model or thinking configuration is missing or unsupported, the importer leaves a data-only main Branch rather than persisting partial compatibility state; +- update the legacy active-tools record type to read its encoded `activeToolNames` array; +- update protocol schemas and experimental adapters for public `tipId` fields in the same package. + +Inventory uses `branchTipInventoryPrefix()`. Legacy import emits only ordinary Branch/Lane values; there is no temporary compatibility address or attachment-time migration. + +### 5.3 AgentLane completeness + +An AgentLane adds total `laneConfig`, `laneState`, optional `laneLastResult`, and optional current operation values to an existing Branch. + +`harness.lane(name, options?, context)` executes one Session mutation and handles exactly these cases: + +| Durable state | Result | +| ----------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Branch absent; lane values absent | validate `createAt`, commit Branch tip + the immutable `AgentHarnessOptions` seed config + idle lane state, publish one AgentLane and `lane_created { at: createAt ?? null }` | +| Branch present; lane config/state absent and no last result | commit the immutable seed config + idle lane state at the existing tip, publish one AgentLane and `lane_created { at: existingTip }` | +| Branch and complete lane values present | return the restored/published AgentLane; no commit/event | +| Any partial or contradictory combination | fault as storage corruption | + +The committing callback publishes the new Branch/AgentLane into process-local maps and synchronously calls `emitBatch(lane_created, context)` before returning from `Session.mutate()`. Event delivery is awaited after line release. + +`AgentHarness.create()` restores every complete durable AgentLane and open operation, but creates nothing and requires no main. A data-only Branch remains a Branch until `harness.lane(name, ...)` attaches agent state. + +### 5.4 Append behavior + +`Branch.appendMessage` / `appendCustomEntry` always commit an immutable entry parented to the current tip and move the tip in the same transaction. + +`AgentLane.appendMessage` / `appendCustomEntry` retain harness semantics: + +- idle: append and move tip immediately; +- active run: stage `pendingEntry(id)` and enqueue `inbox.writes`; +- active structural operation: retain the existing wait/re-evaluate contract when that surface lands; +- pending assistant messages reject before commit. + +Both return the entry id reserved before their mutation. + +--- + +## 6. Runtime composition + +### 6.1 Harness + +Replace inheritance with composition: + +```ts +export class Harness< + TContext extends object | undefined, +> implements AgentHarness { + readonly session: Session; + readonly models: Models; + readonly hooks: HookRegistry; + readonly events: HarnessEventBus; + readonly lanesByName = new Map>(); + // global config/lifecycle fields +} +``` + +The constructor builds every restored Lane as an ordinary object. It never calls `super("main", ...)` and never inserts `this` into `lanesByName`. + +Fault and close iterate ordinary Lane objects. Global getters/setters live only on Harness. Coding-agent experimental services and workers must acquire/cache `main` explicitly before invoking lane operations. + +### 6.2 Lane + +`Lane.command` and `commandDriveOwned` call keyless `session.mutate(plan, context)`. The live `Lane.state` remains the authoritative process projection. Exact Drive fencing remains adjacent to commit admission. + +Lane directly implements Branch query/append names. It may hold a package-private Branch implementation for direct data reads, but no nested Branch is exposed publicly. + +### 6.3 Restore + +Restore no longer enters a named mutation line. `AgentHarness.create()` owns the Session attachment interval and performs one bounded keyless `Session.mutate()` callback to inventory/restore every complete AgentLane before publishing the Harness; it commits only when an intentional attachment normalization is required. Coherent live watch/inspection likewise uses `Session.mutate()` as a no-commit callback. + +Restore inventories complete AgentLanes separately from data-only Branches. Missing main is legal. + +--- + +## 7. Repository, backend, and fork requirements + +### Memory/JSONL/SQLite + +All Session facades: + +- remove lane arguments and lane fields from begin/mutate capabilities; +- retain explicit `beginMutation(context)` / `SessionMutation.end(context)` forwarding for local lifecycle and remote transport; +- keep an explicit scope admitted until `end()` and keep callback `mutate()` admitted through its implicit finally/end; +- expose Branch acquisition/creation; +- keep direct reads outside the line; +- keep Storage commit sequencing unchanged. + +SQLite and future SQL backends still serialize sequence-allocating commits in Storage. The Session mutation line intentionally serializes complete callbacks only within one open Session owner; separate sessions remain concurrent. + +### Forks + +Preserve one coherent source Storage snapshot. Update terms from lane leaf to Branch tip. + +- branch-scope fork creates destination Branch `main` at the copied path; +- if no explicit source entry is provided, source `main` must exist or fork rejects; +- branch scope copies source main's configuration and writes idle lane state **together iff** source main is a complete configured AgentLane; an unconfigured/data-only source main produces only a data-only destination main Branch; +- tree scope copies every Branch tip; each complete configured source AgentLane copies its configuration plus fresh idle lane state together, while each data-only Branch remains data-only; +- operation values, pending values/lists, last results, and usage rows remain excluded; +- destination sessions do not gain an unrelated implicit main; +- retain the explicit begin/commit/end fork-ordering seam: code that must admit a commit before starting a fork snapshot calls keyless `beginMutation()`, invokes `commit()`, starts the repository snapshot only after commit admission, and calls `end()` in `finally`; Storage queues the source snapshot against commits to choose one coherent boundary. + +--- + +## 8. Implementation phases and file manifest + +Public drive remains disabled. Complete this package before resuming WP05 M4. + +### Phase A — Session mutation and Branch + +**Rename** + +- `src/harness/session/lane-mutations.ts` → `mutation-line.ts`. + +**Modify** + +- `src/harness/session/types.ts` — `Branch`, keyless `Session.mutate` and keyless begin/end transport scope, no `SessionTree`, Session-global methods. +- `src/harness/session/session.ts` — one line, Branch implementation, no implicit-main delegates, direct Branch append; rename lane-creation validation errors to Branch terminology. +- Memory/JSONL/SQLite format-4 schema and codecs — replace WIP leaf/lane spellings in place; do not add a version gate or migration. +- `src/harness/session/memory.ts`. +- `src/harness/session/jsonl/repo.ts`, `jsonl/legacy-v3.ts`, and related open/create facade files. +- `packages/session-backends/sqlite-node/src/sqlite/session.ts` and repo creation. +- `src/harness/session/fork.ts`. +- `src/harness/session/index.ts` and package exports. +- storage/repository benchmarks and conformance call sites. + +**Rename tests** + +- `test/harness/session-tree.test.ts` → `branch.test.ts`. +- `test/harness/session-create-lane.test.ts` → `session-create-branch.test.ts`. + +**Modify tests** + +- `test/harness/storage-backed-session.test.ts`. +- `test/harness/memory-session-repo.test.ts`. +- `test/harness/jsonl-session-repo.test.ts`. +- `test/harness/memory-conformance.test.ts`. +- `src/harness/session/testing/conformance/session-repo.ts`. +- SQLite repo/storage tests. +- compaction/branch-summarization type fixtures. + +### Phase B — Harness/Lane composition + +**Modify** + +- `src/harness/agent-harness.ts`. +- `src/harness/runtime/harness.ts`. +- `src/harness/runtime/lane.ts`. +- `src/harness/runtime/restore.ts`. +- `src/harness/runtime/types.ts` where `leafId` becomes `tipId`. +- `src/harness/session/values.ts` and durable state types for Branch tip naming. +- all runtime drive modules already present (`checkpoint`, `generation`, `recovery`, `terminal`, `progress`) only where names/signatures change. +- `src/harness/compaction/branch-summarization.ts`. +- `packages/protocol/src/harness.ts`. +- `packages/coding-agent/src/experimental/services/chat-provider.ts`. +- `packages/coding-agent/src/experimental/services/models-provider.ts`. +- `packages/coding-agent/src/experimental/session-worker.ts`. +- experimental harness wire/session worker tests. + +**Modify focused tests** + +- every `test/harness/runtime/*.test.ts` helper/call site using keyed mutate, implicit Harness-as-main, `sessionTree`, `leafId`, or `createLane`; +- `test/harness/types.test.ts`; +- `test/harness/branch-summarization.test.ts`; +- protocol and coding-agent experimental tests. + +Phases A and B are one atomic landing. Removing `SessionTree`, keyed mutate, and Harness inheritance cannot compile as separately committed compatibility phases, and this package intentionally adds no temporary aliases. + +### Phase C — Normative documentation + +Update `packages/agent/docs/harness.md` completely and consistently: + +- orientation/system model and worked examples; +- bound Branch tip addresses; +- Branch, Session metadata, queries, forks, and repository boundary; +- operation metadata/result/snapshot `tipId` terminology; +- one Session mutation line in Parts 3–5; +- attachment with no required main; +- public Branch, AgentLane, AgentHarness, and Session surfaces; +- event/watcher ordering under the Session line; +- remove the old second harness-settings-line lock-order narrative: harness-global settings and every durable lane mutation now serialize through the sole Session line when a coherent snapshot is required; pure synchronous registry reads remain direct; +- work-package table, invariants, races, backend conformance, and glossary. + +Update WP05 before M4: + +- replace `SessionTree`/lane-line/inheritance assumptions; +- replace `leafId` source examples where the new type names require it; +- keep all M0–M3 historical behavior and M4–M8 durable requirements; +- state that WP06 is the foundation between M3 and M4. + +Update every current supporting document whose public names or ordering statements change: + +- `docs/assistant-durability.md` and `docs/tool-durability.md` — Session-line FIFO and `branchTip` terminology; +- `docs/values.md` — Session-global value/list surface, Branch tip addresses, and no `SessionTree`; +- `docs/telemetry.md` — receiver inventory and Session mutation spans; +- `docs/plugins.md` — remove `sessionTree`; the plugin's AgentLane directly supplies Branch methods, while a scoped Session-data facet supplies global value/list/name/label/query methods and excludes raw `mutate`, `idGenerator`, close, and backend authority; +- `docs/extensions/pi-extensions-v2.md` and `docs/extensions/pi-server-artifact/index.md` where examples/types use the changed surfaces; +- completed WP00–WP04 only where a forward-looking/current-state statement would otherwise claim the removed API still exists. + +Retain the current remote Session mutation contract and update it from a named lane line to the sole Session line: worker `RemoteSession.mutate()` performs keyless begin RPC → local callback with remote reads/one remote commit → local post-commit publication → end RPC. The server holds the Session line through commit and publication until end acknowledgment. Disconnect/timeout terminates the scope under the existing hosting policy. Update the current remote protocol/vertical-slice documentation and every implementation/test present on dev; do not delete or defer this behavior. + +Do not rewrite released changelog sections. Add no changelog entry on a non-main/non-PR development branch. + +--- + +## 9. Required tests + +### Session mutation line + +- concurrent `mutate()` callbacks serialize globally, including callbacks invoked from different AgentLanes; +- a second callback does not enter until the first callback returns after commit/publication; +- direct reads do not wait for a callback that has not committed; +- direct reads observe a fully landed commit even while that mutation callback remains open after commit; +- no direct read observes a partial multi-write commit; +- two read-modify-write counter mutations produce `1`, then `2`; +- separate direct `getValue` + `setValue` calls remain deliberately non-atomic; +- zero-commit callback is legal; +- keyless `beginMutation()` excludes every other mutation until `end()`, commit does not release it, end-without-commit is legal, repeated end is idempotent, and close waits for end; +- RemoteSession begin/read/commit/publication/end preserves that same scope; +- second commit attempt rejects, including after a failed first attempt; +- nested public write from a mutation callback is documented as invalid and deterministically blocks/rejects according to the chosen guard; +- close-first rejects mutation; mutation-first completes and close waits; a trusted callback that never returns can block close; +- Storage commit `seq` and stats behavior is unchanged. + +### Session and Branch + +- fresh Session has zero Branches and no main tip value; +- no storage-version bump, migration, compatibility decoder, or rejection path is added for the replaced WIP format-4 schema; +- legacy coding-agent v3 import reconstructs ordinary total main-lane configuration plus idle state before returning when valid model/thinking history exists; otherwise it returns a data-only main Branch; +- legacy config tests cover complete and incomplete histories, invalid legacy fields, and branched histories where only changes on the selected main path apply; +- `branch(name, context)` returns undefined for absence and receives the exact Context on reads; +- `createBranch` validates name, target, and duplicate atomically; +- two concurrent creates have one winner; +- Branch queries default to its tip and preserve scan/filter/cursor behavior; +- direct Branch message/custom append extends its tip atomically; +- custom entry with absent data remains valid; +- pending assistant append rejects; +- Session global values/lists/name/labels/global queries no longer depend on a branch; +- all pre-close Branch objects reject after close; +- branch-scope fork copies config + idle state together iff source main is configured; tree scope does the same independently per configured lane; data-only Branches remain data-only; +- explicit keyless begin/commit/end preserves commit-before-fork-snapshot ordering and captures one Storage-serialized snapshot boundary. + +### AgentHarness and AgentLane + +- Harness has no AgentLane methods at type or runtime; +- Session has no Branch methods at type or runtime; +- AgentLane has Branch methods directly and no `sessionTree`/nested Branch property; +- fresh Harness `lanes()` is empty; +- `lane("main")` creates complete main atomically; +- missing named lane defaults to null tip; `createAt` anchors creation; +- existing data-only Branch becomes one complete AgentLane without moving its tip; +- existing complete AgentLane acquisition commits nothing and emits no creation event; +- concurrent acquisitions return the same object and emit exactly one `lane_created`; +- creation commit publishes the Lane and binds recipients before releasing `Session.mutate`; +- invalid name/unknown anchor commit nothing; +- restored complete lanes/open operations are inventoried without creating main; +- partial Branch/config/lane-state combinations fault; +- Harness global metadata wrappers retain commit → publication → `value_update` delivery; +- AgentLane idle append moves the tip; active-run append stages one deferred write and preserves authoritative state; +- close/fault seal every ordinary Lane without relying on Harness inheritance. + +### Regression + +- M2 exact-Drive ABA fence still checks immediately before commit admission; +- M3 generation intent/frame/settlement writes are byte-identical apart from renamed durable/public fields; +- frame FIFO remains correct under the one Session line; +- watch has only snapshot-first or publication-first outcomes; +- usage totals remain commit-boundary exact across lanes; +- Context remains trailing and source-identical throughout; +- Memory, JSONL, and SQLite repository/storage conformance pass. + +--- + +## 10. Exclusions + +Do not add: + +- keyed mutation lines, arbitrary lock names, resource locks, multi-lock ordering, versions, or optimistic retries; +- a scheduler, transaction framework, action interpreter, or generic post-commit task system; +- a nested Branch/tree/store/access property on AgentLane; +- compatibility aliases for `SessionTree`, `view`, implicit Session main methods, Harness lane methods, or `createLane`; +- keyed/named begin/end RPC scopes or removal of the current keyless RemoteSession mutation transport; +- effects inside `Session.mutate()`; +- a second Storage commit/sequence mechanism; +- automatic work start from `AgentHarness.create()` or `harness.lane()`; +- public drive before WP05 M8. + +Do not modify provider/tool behavior, durable execution phases, retry/deferred policy, or assistant-frame semantics beyond signature/name propagation required by this package. + +--- + +## 11. Validation + +Run every modified focused test, then: + +```bash +git diff --check +npm run check +./test.sh + +rg -n "SessionTree|sessionTree|\.view\(|LaneMutationLine|extends AgentLane|extends Lane" \ + packages/agent/src packages/agent/test packages/agent/docs \ + packages/session-backends packages/protocol packages/coding-agent/src/experimental \ + packages/coding-agent/test/experimental* + +rg -n "beginMutation\([^)]*,|mutate\([^)]*,[^)]*," \ + packages/agent/src packages/agent/test packages/session-backends packages/coding-agent/src/experimental + +rg -n "session\.mutate\([^)]*\"|\.mutate\(\s*[a-zA-Z_$][a-zA-Z0-9_$]*\s*," \ + packages/agent/src packages/agent/test packages/session-backends +``` + +Expected first grep matches only this package's problem statement, explicitly historical work-package API descriptions, negative type assertions, and unrelated coding-agent tree-widget names such as `SessionTreeNode`; no current harness `SessionTree` concept remains. The keyed-mutate grep must be empty after manually checking false positives. + +Review the complete implementation and normative doc update with Fable before committing. Do not commit without explicit user approval. + +--- + +## 12. Stop condition + +WP06 is complete when: + +- Session has one keyless mutation line; callback `mutate()` and explicit begin/commit/end remote transport share it, and neither accepts a lane key; +- direct reads bypass that line and expose only fully applied Storage commits; +- `SessionTree` and implicit-main Session behavior are gone; +- Branch is the data-only path/tip abstraction; +- AgentLane exposes Branch methods directly and retains operation-aware append semantics; +- AgentHarness is composition-only, has no AgentLane methods, and atomically gets/creates lanes; +- fresh Session/Harness require no main; +- all process-local publication and event-binding boundaries remain correct; +- all three backends and focused race tests pass; +- `harness.md` and WP05 describe the new model consistently; +- final Fable review reports no findings; +- WP05 M4 may resume. diff --git a/packages/agent/src/harness/agent-harness.ts b/packages/agent/src/harness/agent-harness.ts index 0022d75c8..1bc5d55a2 100644 --- a/packages/agent/src/harness/agent-harness.ts +++ b/packages/agent/src/harness/agent-harness.ts @@ -18,11 +18,9 @@ import type { CompactionPreparation, CompactionSettings, CompactResult } from ". import type { Context } from "./context.ts"; import type { Closed, - InvalidLane, InvalidMessage, InvalidNavigation, LaneBusy, - LaneExists, NoActiveOperation, NoActiveRun, NothingToCompact, @@ -42,7 +40,6 @@ export { InvalidMessage, InvalidNavigation, LaneBusy, - LaneExists, NoActiveOperation, NoActiveRun, NothingToCompact, @@ -55,6 +52,7 @@ export { import { createAgentHarness } from "./runtime/harness.ts"; import type { + BranchScan, BranchSummaryEntry, CompactionEntry, Entry, @@ -64,7 +62,6 @@ import type { LaneLastResult, OperationError, Session, - SessionTree, SettledAssistantMessage, UsageRow, } from "./session/types.ts"; @@ -82,31 +79,31 @@ export type OptionalFinalAssistant = | { finalEntryId?: never; finalMessage?: never }; export type RunOutcome = - | ({ kind: "completed"; leafId: string } & OptionalFinalAssistant) - | ({ kind: "aborted"; leafId: string } & OptionalFinalAssistant) - | ({ kind: "failed"; leafId: string; error: OperationError } & OptionalFinalAssistant) + | ({ kind: "completed"; tipId: string } & OptionalFinalAssistant) + | ({ kind: "aborted"; tipId: string } & OptionalFinalAssistant) + | ({ kind: "failed"; tipId: string; error: OperationError } & OptionalFinalAssistant) | { kind: "suspended"; reason: "deferred"; - leafId: string; + tipId: string; finalEntryId: string; deferred: DeferredHandle; }; export type CompactionOutcome = - | { kind: "completed"; leafId: string; entry: CompactionEntry } - | { kind: "declined" | "aborted"; leafId: string } - | { kind: "failed"; leafId: string; error: OperationError }; + | { kind: "completed"; tipId: string; entry: CompactionEntry } + | { kind: "declined" | "aborted"; tipId: string } + | { kind: "failed"; tipId: string; error: OperationError }; export type NavigationOutcome = | { kind: "completed"; - oldLeafId: string | null; - newLeafId: string | null; + oldTipId: string | null; + newTipId: string | null; summaryEntry?: BranchSummaryEntry; } - | { kind: "declined" | "aborted"; leafId: string | null } - | { kind: "failed"; leafId: string | null; error: OperationError }; + | { kind: "declined" | "aborted"; tipId: string | null } + | { kind: "failed"; tipId: string | null; error: OperationError }; export type RunOperationOutcome = { operation: "run"; runId: string } & RunOutcome; export type CompactionOperationOutcome = { operation: "compaction"; runId: string } & CompactionOutcome; @@ -131,7 +128,6 @@ export type AbortResult = Result< NoActiveOperation | Closed >; export type RecordUsageResult = Result<{ usageId: string }, Closed>; -export type CreateLaneResult = Result; export interface NavigateOptions { summarize?: boolean; @@ -187,7 +183,7 @@ export interface CurrentOperationInfo { export interface LaneExecutionInfo { lane: string; - leafId: string | null; + tipId: string | null; configuredModel: ModelIdentity; current: CurrentOperationInfo | null; lastResult?: LaneLastResult; @@ -222,7 +218,7 @@ export interface WatchHandle { export interface LaneInfo { name: string; - leafId: string | null; + tipId: string | null; operation: CurrentOperationInfo | null; } @@ -242,7 +238,7 @@ export interface QueuedItem { export interface LaneSnapshot { lane: string; transcript: Entry[]; - leafId: string | null; + tipId: string | null; lastResult?: LaneLastResult; operation: null | { id: string; @@ -281,7 +277,7 @@ export type HarnessEventPayload = | { type: "run_resume"; runId: string } | { type: "run_suspend"; runId: string; reason: "deferred"; deferred: DeferredHandle } | { type: "run_abort"; runId: string; steer: AgentMessage[]; followUp: AgentMessage[] } - | ({ type: "run_end"; runId: string; leafId: string | null } & ( + | ({ type: "run_end"; runId: string; tipId: string | null } & ( | ({ outcome: "completed" | "aborted" } & OptionalFinalAssistant) | ({ outcome: "failed"; error: OperationError } & OptionalFinalAssistant) )) @@ -387,8 +383,8 @@ export type HarnessEventPayload = | ({ type: "navigation_end"; runId: string; - oldLeafId: string | null; - newLeafId: string | null; + oldTipId: string | null; + newTipId: string | null; } & ( | { outcome: "completed"; summaryEntry?: BranchSummaryEntry } | { outcome: "declined" | "aborted"; summaryEntry?: never; error?: never } @@ -548,7 +544,11 @@ export interface AgentHarnessOptions; + getTipId(context: Context): Promise; + findEntries(query: BranchScan | undefined, context: Context): Promise; + findEntry(query: BranchScan | undefined, context: Context): Promise; + appendMessage(message: AgentMessage, context: Context): Promise; + appendCustomEntry(customType: string, data: JsonValue | undefined, context: Context): Promise; getLastResult(context: Context): Promise; accept(request: OperationRequest, context: Context): Promise; drive(options: DriveOptions, context: Context): Promise; @@ -587,14 +587,21 @@ export interface AgentLane { setThinkingLevel(level: ThinkingLevel, context: Context): Promise; getActiveTools(context: Context): Promise; setActiveTools(names: string[], context: Context): Promise; - readonly sessionTree: SessionTree; watch(context: Context): Promise>; } -export interface AgentHarness extends AgentLane { - lane(name: string, context: Context): Promise; - createLane(name: string, at: string | null, context: Context): Promise; +export interface AcquireLaneOptions { + createAt?: string | null; +} + +export interface AgentHarness { + lane(name: string, context: Context): Promise; + lane(name: string, options: AcquireLaneOptions, context: Context): Promise; lanes(context: Context): Promise; + getName(context: Context): Promise; + setName(name: string | undefined, context: Context): Promise; + getLabel(targetId: string, context: Context): Promise; + setLabel(targetId: string, label: string | undefined, context: Context): Promise; getTools(context: Context): Promise[]>; setTools(tools: AgentHarnessTool[], context: Context): Promise; getResources(context: Context): Promise; diff --git a/packages/agent/src/harness/compaction/branch-summarization.ts b/packages/agent/src/harness/compaction/branch-summarization.ts index e7b7618cf..c0221ef61 100644 --- a/packages/agent/src/harness/compaction/branch-summarization.ts +++ b/packages/agent/src/harness/compaction/branch-summarization.ts @@ -11,7 +11,7 @@ import { import type { AgentMessage } from "../../types.ts"; import type { Context } from "../context.ts"; import { convertToLlm, createBranchSummaryMessage, createCompactionSummaryMessage } from "../messages.ts"; -import type { Entry, SessionTree } from "../session/index.ts"; +import type { Branch, Entry, Session } from "../session/index.ts"; import { BranchSummaryError, err, ok, type Result } from "../types.ts"; import { completeSimpleWithRetries, estimateTokens, SUMMARIZATION_SYSTEM_PROMPT } from "./compaction.ts"; import { @@ -55,7 +55,7 @@ export interface BranchPreparation { export interface CollectEntriesResult { /** Entries to summarize in chronological order. */ entries: Entry[]; - /** Deepest common ancestor between the previous leaf and target entry. */ + /** Deepest common ancestor between the previous tip and target entry. */ commonAncestorId: string | null; } @@ -79,16 +79,17 @@ export interface GenerateBranchSummaryOptions { /** Collect entries that should be summarized before navigating to a different session tree entry. */ export async function collectEntriesForBranchSummary( - session: Pick, - oldLeafId: string | null, + branch: Pick, + session: Pick, + oldTipId: string | null, targetId: string, context: Context, ): Promise { - if (!oldLeafId) { + if (!oldTipId) { return { entries: [], commonAncestorId: null }; } - const oldPath = new Set((await session.findEntriesOnBranch({ start: oldLeafId }, context)).map((entry) => entry.id)); - const targetPath = await session.findEntriesOnBranch({ start: targetId }, context); + const oldPath = new Set((await branch.findEntries({ start: oldTipId }, context)).map((entry) => entry.id)); + const targetPath = await branch.findEntries({ start: targetId }, context); let commonAncestorId: string | null = null; for (const entry of targetPath) { if (oldPath.has(entry.id)) { @@ -97,7 +98,7 @@ export async function collectEntriesForBranchSummary( } } const entries: Entry[] = []; - let current: string | null = oldLeafId; + let current: string | null = oldTipId; while (current && current !== commonAncestorId) { const entry = await session.getEntry(current, context); diff --git a/packages/agent/src/harness/result.ts b/packages/agent/src/harness/result.ts index 79120a25e..aac194ace 100644 --- a/packages/agent/src/harness/result.ts +++ b/packages/agent/src/harness/result.ts @@ -80,7 +80,6 @@ export class InvalidNavigation extends TaggedError("InvalidNavigation")<{ export class UnknownSkill extends TaggedError("UnknownSkill")<{ name: string; message: string }> {} export class UnknownTemplate extends TaggedError("UnknownTemplate")<{ name: string; message: string }> {} export class UnknownTarget extends TaggedError("UnknownTarget")<{ targetId: string; message: string }> {} -export class LaneExists extends TaggedError("LaneExists")<{ lane: string; message: string }> {} export class InvalidLane extends TaggedError("InvalidLane")<{ lane: string; reason: string; diff --git a/packages/agent/src/harness/runtime/drive/checkpoint.ts b/packages/agent/src/harness/runtime/drive/checkpoint.ts index 96406bd6a..965772ed7 100644 --- a/packages/agent/src/harness/runtime/drive/checkpoint.ts +++ b/packages/agent/src/harness/runtime/drive/checkpoint.ts @@ -13,9 +13,9 @@ import type { SettledAssistantMessage, } from "../../session/types.ts"; import { + branchTip, deleteValue, laneLastResult as laneLastResultValue, - laneLeaf, laneState as laneStateValue, operationState as operationStateValue, pendingEntry, @@ -74,10 +74,10 @@ async function readRunContext( if (operation.state.phase.kind !== "checkpoint" || !sameCheckpoint(operation.state.phase, phase)) { throw new SessionInvariantError("Checkpoint context lost its durable boundary"); } - if (state.leafId === null) throw new SessionInvariantError("Run checkpoint has no branch leaf"); + if (state.tipId === null) throw new SessionInvariantError("Run checkpoint has no Branch tip"); const path = ( await reader.scanBranch( - { start: state.leafId, stopAtType: "compaction", order: "newestFirst" }, + { start: state.tipId, stopAtType: "compaction", order: "newestFirst" }, drive.context, ) ).reverse(); @@ -158,7 +158,7 @@ export async function startRun( if (operation.state.phase.kind !== "starting") { throw new SessionInvariantError("before_run settlement lost the starting boundary"); } - let parentId = state.leafId; + let parentId = state.tipId; const entries = reserved.map(({ id, message }) => { const entry: NewEntry = { id, parentId, type: "message", message }; parentId = id; @@ -178,12 +178,12 @@ export async function startRun( kind: "commit", writes: [ ...entries.map((entry) => insertEntry(entry)), - ...(entries.length === 0 ? [] : [setValue(laneLeaf(lane.name), triggerEntryId)]), + ...(entries.length === 0 ? [] : [setValue(branchTip(lane.name), triggerEntryId)]), setValue(operationStateValue(drive.operationId), nextState), ], next: { ...state, - leafId: triggerEntryId, + tipId: triggerEntryId, operation: { meta: operation.meta, state: nextState }, }, materialize: () => ({ kind: "continue" }) as const, @@ -229,7 +229,7 @@ async function applyPendingWrites( return { id, pending: value.value }; }), ); - let parentId = state.leafId; + let parentId = state.tipId; let triggerEntryId: string | undefined; const entries: NewEntry[] = pending.map(({ id, pending: item }) => { const entry: NewEntry = @@ -267,10 +267,10 @@ async function applyPendingWrites( writes: [ ...entries.map((entry) => insertEntry(entry)), ...ids.map((id) => deleteValue(pendingEntry(id))), - setValue(laneLeaf(lane.name), parentId), + setValue(branchTip(lane.name), parentId), setValue(operationStateValue(drive.operationId), nextState), ], - next: { ...state, leafId: parentId, operation: { meta: operation.meta, state: nextState } }, + next: { ...state, tipId: parentId, operation: { meta: operation.meta, state: nextState } }, materialize: () => ({ kind: "continue" }) as const, events: (commit) => entries.flatMap((entry, index) => @@ -331,7 +331,7 @@ async function consumeQueuedMessages( readMessages(remainingFollowUpIds, "Follow-up entry"), readMessages(state.pendingNextRun, "Pending next-run entry"), ]); - let parentId = state.leafId; + let parentId = state.tipId; const entries = messages.map(({ id, message }) => { const entry: NewEntry = { id, parentId, type: "message", message }; parentId = id; @@ -354,12 +354,12 @@ async function consumeQueuedMessages( writes: [ ...entries.map((entry) => insertEntry(entry)), ...ids.map((id) => deleteValue(pendingEntry(id))), - setValue(laneLeaf(lane.name), triggerEntryId), + setValue(branchTip(lane.name), triggerEntryId), setValue(operationStateValue(drive.operationId), nextState), ], next: { ...state, - leafId: triggerEntryId, + tipId: triggerEntryId, operation: { meta: operation.meta, state: nextState }, }, materialize: () => ({ kind: "continue" }) as const, @@ -437,7 +437,7 @@ async function finishRun( }; const entry: NewEntry = { id: followUpId, - parentId: state.leafId, + parentId: state.tipId, type: "message", message: followUpMessage, }; @@ -445,12 +445,12 @@ async function finishRun( kind: "commit", writes: [ insertEntry(entry), - setValue(laneLeaf(lane.name), followUpId), + setValue(branchTip(lane.name), followUpId), setValue(operationStateValue(drive.operationId), nextState), ], next: { ...state, - leafId: followUpId, + tipId: followUpId, operation: { meta: operation.meta, state: nextState }, }, materialize: () => ({ kind: "continue" }) as const, @@ -463,7 +463,7 @@ async function finishRun( }; } - if (state.leafId === null) throw new SessionInvariantError("Completed run has no leaf"); + if (state.tipId === null) throw new SessionInvariantError("Completed run has no tip"); const finalId = current.phase.continuation.includeFinalAssistant ? current.latestAssistantEntryId : null; if (current.phase.continuation.includeFinalAssistant && finalId === null) { throw new SessionInvariantError("Completed run is missing its final assistant"); @@ -486,14 +486,14 @@ async function finishRun( operation: "run", runId: drive.operationId, kind: "completed", - leafId: state.leafId, + tipId: state.tipId, ...final, }; const lastResult: LaneLastResult = { operationId: drive.operationId, kind: "run", outcome: "completed", - leafId: state.leafId, + tipId: state.tipId, runCompletion: finalId === null ? "terminated_tools" : "assistant", ...(finalId === null ? {} : { finalAssistantEntryId: finalId }), }; @@ -515,7 +515,7 @@ async function finishRun( type: "run_end", lane: lane.name, runId: drive.operationId, - leafId: state.leafId, + tipId: state.tipId, outcome: "completed", ...final, }, diff --git a/packages/agent/src/harness/runtime/drive/generation.ts b/packages/agent/src/harness/runtime/drive/generation.ts index e6117b867..0fa541350 100644 --- a/packages/agent/src/harness/runtime/drive/generation.ts +++ b/packages/agent/src/harness/runtime/drive/generation.ts @@ -27,8 +27,8 @@ import type { UsageRow, } from "../../session/types.ts"; import { + branchTip, deleteList, - laneLeaf, operationState as operationStateValue, pendingAssistantFrames, setValue, @@ -155,10 +155,10 @@ async function readRequestMessages( ) { throw new SessionInvariantError("Generation context lost its ready boundary"); } - if (state.leafId === null) throw new SessionInvariantError("Assistant generation has no branch leaf"); + if (state.tipId === null) throw new SessionInvariantError("Assistant generation has no Branch tip"); const entries = ( await reader.scanBranch( - { start: state.leafId, stopAtType: "compaction", order: "newestFirst" }, + { start: state.tipId, stopAtType: "compaction", order: "newestFirst" }, drive.context, ) ).reverse(); @@ -388,7 +388,7 @@ export function settleAssistant( }; const responseEntry: NewEntry = { id: identity.responseEntryId, - parentId: state.leafId, + parentId: state.tipId, type: "message", message: committed, }; @@ -403,13 +403,13 @@ export function settleAssistant( writes: [ insertEntry(responseEntry), insertUsage(usageRow), - setValue(laneLeaf(lane.name), identity.responseEntryId), + setValue(branchTip(lane.name), identity.responseEntryId), deleteList(pendingAssistantFrames(drive.operationId, identity.responseEntryId)), setValue(operationStateValue(drive.operationId), nextState), ], next: { ...state, - leafId: identity.responseEntryId, + tipId: identity.responseEntryId, operation: { meta: operation.meta, state: nextState }, }, materialize: () => ({ kind: "continue" }) as const, diff --git a/packages/agent/src/harness/runtime/drive/terminal.ts b/packages/agent/src/harness/runtime/drive/terminal.ts index 0489ce156..363b506a9 100644 --- a/packages/agent/src/harness/runtime/drive/terminal.ts +++ b/packages/agent/src/harness/runtime/drive/terminal.ts @@ -140,7 +140,7 @@ export async function hydrateTerminalOutcome( operation: "run", runId: lastResult.operationId, kind: "completed", - leafId: lastResult.leafId, + tipId: lastResult.tipId, }; } if (lastResult.finalAssistantEntryId === undefined) { @@ -151,7 +151,7 @@ export async function hydrateTerminalOutcome( operation: "run", runId: lastResult.operationId, kind: "completed", - leafId: lastResult.leafId, + tipId: lastResult.tipId, finalEntryId: entry.id, finalMessage: entry.message, }; @@ -169,7 +169,7 @@ export async function hydrateTerminalOutcome( operation: "run", runId: lastResult.operationId, kind: "failed", - leafId: lastResult.leafId, + tipId: lastResult.tipId, error: lastResult.error, ...final, } @@ -177,19 +177,19 @@ export async function hydrateTerminalOutcome( operation: "run", runId: lastResult.operationId, kind: "aborted", - leafId: lastResult.leafId, + tipId: lastResult.tipId, ...final, }; } if (lastResult.kind === "compaction") { if (lastResult.outcome === "completed") { - const entry = await requireCompactionEntry(reader, lastResult.leafId, context); + const entry = await requireCompactionEntry(reader, lastResult.tipId, context); return { operation: "compaction", runId: lastResult.operationId, kind: "completed", - leafId: lastResult.leafId, + tipId: lastResult.tipId, entry, }; } @@ -198,7 +198,7 @@ export async function hydrateTerminalOutcome( operation: "compaction", runId: lastResult.operationId, kind: "failed", - leafId: lastResult.leafId, + tipId: lastResult.tipId, error: lastResult.error, }; } @@ -206,7 +206,7 @@ export async function hydrateTerminalOutcome( operation: "compaction", runId: lastResult.operationId, kind: lastResult.outcome, - leafId: lastResult.leafId, + tipId: lastResult.tipId, }; } @@ -217,7 +217,7 @@ export async function hydrateTerminalOutcome( : await requireSummaryEntry(reader, lastResult.summaryEntryId, context); if ( summaryEntry !== undefined && - (summaryEntry.id !== lastResult.leafId || summaryEntry.fromId !== lastResult.oldLeafId) + (summaryEntry.id !== lastResult.tipId || summaryEntry.fromId !== lastResult.oldTipId) ) { throw new SessionInvariantError("Completed navigation summary contradicts its terminal result"); } @@ -225,8 +225,8 @@ export async function hydrateTerminalOutcome( operation: "navigation", runId: lastResult.operationId, kind: "completed", - oldLeafId: lastResult.oldLeafId, - newLeafId: lastResult.leafId, + oldTipId: lastResult.oldTipId, + newTipId: lastResult.tipId, ...(summaryEntry === undefined ? {} : { summaryEntry }), }; } @@ -235,7 +235,7 @@ export async function hydrateTerminalOutcome( operation: "navigation", runId: lastResult.operationId, kind: "failed", - leafId: lastResult.leafId, + tipId: lastResult.tipId, error: lastResult.error, }; } @@ -243,6 +243,6 @@ export async function hydrateTerminalOutcome( operation: "navigation", runId: lastResult.operationId, kind: lastResult.outcome, - leafId: lastResult.leafId, + tipId: lastResult.tipId, }; } diff --git a/packages/agent/src/harness/runtime/harness.ts b/packages/agent/src/harness/runtime/harness.ts index 969df7249..d99180f4f 100644 --- a/packages/agent/src/harness/runtime/harness.ts +++ b/packages/agent/src/harness/runtime/harness.ts @@ -1,10 +1,10 @@ -import type { RetryPolicy } from "@earendil-works/pi-ai"; +import type { Models, RetryPolicy } from "@earendil-works/pi-ai"; import type { QueueMode } from "../../types.ts"; import type { + AcquireLaneOptions, AgentHarness, AgentHarnessOptions, AgentLane, - CreateLaneResult, GlobalConfigEventPayload, LaneInfo, OpenOperation, @@ -16,37 +16,46 @@ import type { Context } from "../context.ts"; import { HarnessEventBus } from "../events.ts"; import { HookRegistry } from "../hooks.ts"; import { convertToLlm } from "../messages.ts"; -import { Closed, HarnessClosed, HarnessFault, InvalidLane, LaneExists, Result, UnknownTarget } from "../result.ts"; -import { - SessionInvalidLaneError, - SessionInvariantError, - SessionLaneExistsError, - SessionUnknownTargetError, -} from "../session/session.ts"; +import { HarnessClosed, HarnessFault, InvalidLane, UnknownTarget } from "../result.ts"; +import { SessionInvariantError } from "../session/session.ts"; import type { LaneConfiguration, Session } from "../session/types.ts"; -import { laneConfig, laneLastResult, laneLeaf, laneState, setValue } from "../session/values.ts"; +import { + branchTip, + deleteValue, + entryLabel, + laneConfig, + laneLastResult, + laneState, + sessionName, + setValue, +} from "../session/values.ts"; import type { AgentHarnessStreamOptions, AgentHarnessTool } from "../types.ts"; import { Lane } from "./lane.ts"; -import { restoreSession } from "./restore.ts"; +import { restoreLaneState, restoreSession } from "./restore.ts"; import { type Config, type LaneState, SliceNotImplemented } from "./types.ts"; type GlobalConfigProperty = GlobalConfigEventPayload["property"]; -/** Runtime implementation of AgentHarness. The harness is the main lane. */ -export class Harness extends Lane implements AgentHarness { +/** Runtime implementation of AgentHarness. The harness manages lanes but is not itself a lane. */ +export class Harness implements AgentHarness { + readonly session: Session; + readonly models: Models; + readonly hooks: HookRegistry; readonly events: HarnessEventBus; - readonly seed: LaneConfiguration; readonly lanesByName = new Map>(); + private readonly seed: LaneConfiguration; private readonly configStore: { value: Config }; - closePromise: Promise | undefined; - faultError: HarnessFault | undefined; + private closePromise: Promise | undefined; + private closedError: Error | undefined; + private faultError: HarnessFault | undefined; constructor(options: AgentHarnessOptions, seed: LaneConfiguration, restored: Map) { - const main = restored.get("main"); - if (main === undefined) throw new SessionInvariantError("Session is missing main lane"); - const events = new HarnessEventBus(); - const hooks = new HookRegistry((error, hook, lane, context) => - events.emit( + this.session = options.session; + this.models = options.models; + this.seed = seed; + this.events = new HarnessEventBus(); + this.hooks = new HookRegistry((error, hook, lane, context) => + this.events.emit( { type: "handler_error", kind: "hook", @@ -58,7 +67,7 @@ export class Harness extends Lane context, ), ); - const configStore: { value: Config } = { + this.configStore = { value: { tools: options.tools ?? [], resources: options.resources ?? {}, @@ -74,29 +83,96 @@ export class Harness extends Lane entryProjectors: options.entryProjectors ?? {}, }, }; - super( - "main", - options.session, - options.models, - hooks, - main, - (cause, context) => this.fault(cause, context), - (events, context) => this.events.emitBatch(events, context), - (snapshot, filter, context) => this.events.watch(snapshot, filter, context), - () => configStore.value, - ); - this.seed = seed; - this.configStore = configStore; - this.events = events; - this.lanesByName.set("main", this); - for (const [name, state] of restored) { - if (name !== "main") this.lanesByName.set(name, this.buildLane(name, state)); - } + for (const [name, state] of restored) this.lanesByName.set(name, this.buildLane(name, state)); } - async lane(name: string, _context: Context): Promise { + lane(name: string, context: Context): Promise; + lane(name: string, options: AcquireLaneOptions, context: Context): Promise; + async lane( + name: string, + optionsOrContext: AcquireLaneOptions | Context, + maybeContext?: Context, + ): Promise { this.assertOpen(); - return this.lanesByName.get(name); + if (name.length === 0 || name.includes("\u0000")) { + const reason = name.length === 0 ? "lane name must not be empty" : "lane name must not contain \\u0000"; + throw new InvalidLane({ + lane: name, + reason, + message: `Invalid lane ${JSON.stringify(name)}: ${reason}`, + }); + } + const options = maybeContext === undefined ? {} : (optionsOrContext as AcquireLaneOptions); + const context = maybeContext ?? (optionsOrContext as Context); + let lane: Lane | undefined; + let delivery: Promise | undefined; + try { + await this.session.mutate(async (mutator) => { + this.assertOpen(); + lane = this.lanesByName.get(name); + if (lane !== undefined) return; + + const [tip, configuration, storedState, lastResult] = await Promise.all([ + mutator.getValue(branchTip(name), context), + mutator.getValue(laneConfig(name), context), + mutator.getValue(laneState(name), context), + mutator.getValue(laneLastResult(name), context), + ]); + + if (tip !== undefined && configuration !== undefined && storedState !== undefined) { + const restored = await restoreLaneState(mutator, name, context); + lane = this.buildLane(name, restored); + this.lanesByName.set(name, lane); + return; + } + + if ((configuration === undefined) !== (storedState === undefined) || lastResult !== undefined) { + throw new SessionInvariantError(`Lane ${JSON.stringify(name)} has incomplete durable state`); + } + if (configuration !== undefined || storedState !== undefined) { + throw new SessionInvariantError(`Lane ${JSON.stringify(name)} has durable state without a branch`); + } + + const tipId = tip?.value ?? options.createAt ?? null; + if (tip === undefined && tipId !== null && !(await mutator.getEntries([tipId], context)).has(tipId)) { + throw new UnknownTarget({ + targetId: tipId, + message: `Unknown target: ${tipId}`, + }); + } + const attachedConfiguration: LaneConfiguration = { + model: { ...this.seed.model }, + thinkingLevel: this.seed.thinkingLevel, + activeToolNames: [...this.seed.activeToolNames], + }; + const state: LaneState = { + tipId, + configuration: attachedConfiguration, + pendingNextRun: [], + operation: null, + }; + const writes = [ + ...(tip === undefined ? [setValue(branchTip(name), tipId)] : []), + setValue(laneConfig(name), attachedConfiguration), + setValue(laneState(name), { + currentOperationId: null, + pendingNextRun: [], + }), + ]; + await mutator.commit(writes, context); + lane = this.buildLane(name, state); + this.lanesByName.set(name, lane); + delivery = this.events.emitBatch([{ type: "lane_created", lane: name, at: tipId }], context); + }, context); + } catch (error) { + if (this.closedError !== undefined) throw this.closedError; + if (error instanceof InvalidLane || error instanceof UnknownTarget) throw error; + throw this.fault(error, context); + } + await delivery; + if (lane === undefined) + throw this.fault(new SessionInvariantError(`Lane ${JSON.stringify(name)} was not published`), context); + return lane; } async lanes(context: Context): Promise { @@ -106,50 +182,58 @@ export class Harness extends Lane ); return executions.map((execution) => ({ name: execution.lane, - leafId: execution.leafId, + tipId: execution.tipId, operation: execution.current, })); } - async createLane(name: string, at: string | null, context: Context): Promise { - // Public Result errors are expected caller/lifecycle outcomes. Harness faults reject every API call because the - // owned state can no longer advance safely; they are intentionally not members of CreateLaneResult. - if (this.faultError !== undefined) throw this.faultError; - if (this.closedError !== undefined) return Result.err(new Closed({ message: this.closedError.message })); - const state: LaneState = { - leafId: at, - configuration: this.seed, - pendingNextRun: [], - operation: null, - }; + getName(context: Context): Promise { + this.assertOpen(); + return this.session.getName(context); + } + + async setName(name: string | undefined, context: Context): Promise { + this.assertOpen(); + let delivery: Promise | undefined; try { - 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); + await this.session.mutate(async (mutator) => { + this.assertOpen(); + await mutator.commit( + [name === undefined ? deleteValue(sessionName) : setValue(sessionName, name)], + context, + ); + delivery = this.events.emitBatch([{ type: "value_update", value: "session_name", name }], context); + }, context); } catch (error) { - // Session has no Result layer, so its expected validation failures are thrown. Translate only those failures - // to the public tagged Result contract; storage and invariant failures fault the harness below. - if (error instanceof SessionLaneExistsError) { - return Result.err(new LaneExists({ lane: error.lane, message: error.message })); - } - if (error instanceof SessionInvalidLaneError) { - return Result.err(new InvalidLane({ lane: error.lane, reason: error.reason, message: error.message })); - } - if (error instanceof SessionUnknownTargetError) { - return Result.err(new UnknownTarget({ targetId: error.targetId, message: error.message })); - } + if (this.closedError !== undefined) throw this.closedError; throw this.fault(error, context); } + await delivery; + } + + getLabel(targetId: string, context: Context): Promise { + this.assertOpen(); + return this.session.getLabel(targetId, context); + } + + async setLabel(targetId: string, label: string | undefined, context: Context): Promise { + this.assertOpen(); + let delivery: Promise | undefined; + try { + await this.session.mutate(async (mutator) => { + this.assertOpen(); + const address = entryLabel(targetId); + await mutator.commit([label === undefined ? deleteValue(address) : setValue(address, label)], context); + delivery = this.events.emitBatch( + [{ type: "value_update", value: "entry_label", targetId, label }], + context, + ); + }, context); + } catch (error) { + if (this.closedError !== undefined) throw this.closedError; + throw this.fault(error, context); + } + await delivery; } getTools(context: Context): Promise[]> { @@ -215,6 +299,30 @@ export class Harness extends Lane throw new SliceNotImplemented("watchSession"); } + fault(cause: unknown, context: Context): Error { + if (this.faultError !== undefined) return this.faultError; + if (this.closedError !== undefined) return this.closedError; + const normalized = cause instanceof Error ? cause : new Error(String(cause)); + const fault = new HarnessFault("AgentHarness storage or invariant fault", normalized); + this.faultError = fault; + for (const lane of this.lanesByName.values()) lane.seal(fault); + this.hooks.close(fault); + void this.events.emit({ type: "fault", code: "harness_fault", message: fault.message }, context); + this.events.close(fault); + return fault; + } + + close(context: Context): Promise { + if (this.closePromise !== undefined) return this.closePromise; + const error = new HarnessClosed(); + this.closedError = error; + for (const lane of this.lanesByName.values()) lane.seal(error); + this.hooks.close(error); + this.events.close(error); + this.closePromise = this.session.close(context); + return this.closePromise; + } + private buildLane(name: string, state: LaneState): Lane { return new Lane( name, @@ -248,46 +356,9 @@ export class Harness extends Lane await this.events.emit({ type: "config_update", property }, context); } - fault(cause: unknown, context: Context): Error { - if (this.faultError !== undefined) return this.faultError; - if (this.closedError !== undefined) return this.closedError; - const normalized = cause instanceof Error ? cause : new Error(String(cause)); - const fault = new HarnessFault("AgentHarness storage or invariant fault", normalized); - this.faultError = fault; - for (const lane of this.lanesByName.values()) lane.seal(fault); - this.hooks.close(fault); - void this.events.emit({ type: "fault", code: "harness_fault", message: fault.message }, context); - this.events.close(fault); - return fault; - } - - close(context: Context): Promise { - if (this.closePromise !== undefined) return this.closePromise; - /* - * Close is a process-local admission boundary, not an abort. Public lane work checks Lane.assertOpen() at - * entry. Durable work then enters Session.mutate(), whose mutation line is the final write-admission gate. - * External effects use Gate.admit() immediately around invocation. Closing seals all applicable gates - * before awaiting anything. - * - * If close wins, new work and queued mutation callbacks that have not started reject. An active mutation may - * finish, and close waits for it. A successful commit must always publish its in-memory candidate and resolve - * without another open check; otherwise close racing the commit would make durable and in-memory state - * diverge. An admitted effect is signalled and allowed to unwind, but its settlement is a new durable unit and - * cannot commit after close. Every later command, settlement, or effect must pass admission again. - * - * Session.close() is the final mutation barrier: it rejects later session jobs, drains active callbacks and - * commits, then closes storage. Close itself writes no cancellation or terminal state. - * - * HarnessClosed is a process-local control signal. Lower procedures let it propagate, using only finally blocks - * for cleanup. The outer drive owner catches it to remove live task state and reject the caller; it must not turn - * close into a durable failure, abort settlement, or harness fault. Calls with no drive owner reject directly. - */ - const error = new HarnessClosed(); - for (const lane of this.lanesByName.values()) lane.seal(error); - this.hooks.close(error); - this.events.close(error); - this.closePromise = this.session.close(context); - return this.closePromise; + private assertOpen(): void { + if (this.faultError !== undefined) throw this.faultError; + if (this.closedError !== undefined) throw this.closedError; } } @@ -303,10 +374,9 @@ export async function createAgentHarness tool.name), + activeToolNames: [...(options.activeToolNames ?? tools.map((tool) => tool.name))], }; try { - await seedMain(options.session, seed, context); const restored = await restoreSession(options.session, context); const open = [...restored].flatMap(([lane, state]): OpenOperation[] => { const operation = state.operation; @@ -327,26 +397,3 @@ export async function createAgentHarness { - await session.mutate( - "main", - async (mutator) => { - const [leaf, state, configuration, lastResult] = await Promise.all([ - mutator.getValue(laneLeaf("main"), context), - mutator.getValue(laneState("main"), context), - mutator.getValue(laneConfig("main"), context), - mutator.getValue(laneLastResult("main"), context), - ]); - if (leaf === undefined || state === undefined) { - throw new SessionInvariantError("Session main lane has incomplete durable state"); - } - if (configuration !== undefined) return; - if (state.value.currentOperationId !== null || lastResult !== undefined) { - throw new SessionInvariantError("Configured or active main lane is missing lane.config"); - } - await mutator.commit([setValue(laneConfig("main"), seed)], context); - }, - context, - ); -} diff --git a/packages/agent/src/harness/runtime/lane.ts b/packages/agent/src/harness/runtime/lane.ts index bd987e5a5..5f5bdf21b 100644 --- a/packages/agent/src/harness/runtime/lane.ts +++ b/packages/agent/src/harness/runtime/lane.ts @@ -36,19 +36,18 @@ import { SessionInvariantError, SessionPendingAssistantMessageError } from "../s import type { BranchScan, Entry, + JsonValue, LaneLastResult, Operation, PendingEntry, Session, SessionReader, - SessionTree, StructuralDecision, } from "../session/types.ts"; import { + branchTip, deleteValue, - entryLabel, laneConfig, - laneLeaf, laneState as laneStateValue, operationMeta as operationMetaValue, operationState as operationStateValue, @@ -56,7 +55,6 @@ import { pendingAssistantFrames, pendingEntry, pendingToolOutput, - sessionName, setValue, } from "../session/values.ts"; import { formatSkillInvocation } from "../skills.ts"; @@ -111,12 +109,10 @@ function capturedModel(operation: Operation): ModelIdentity | undefined { /** Runtime implementation of one configured lane. */ export class Lane implements AgentLane { readonly name: string; - readonly sessionTree: SessionTree; readonly session: Session; readonly models: Models; readonly hooks: HookRegistry; readonly emitBatch: EmitBatch; - private readonly sessionView: SessionTree; private readonly onFault: FaultHandler; private readonly installWatch: WatchHandler; private readonly config: () => Config; @@ -141,18 +137,6 @@ export class Lane implements AgentLane { this.models = models; this.hooks = hooks; this.name = name; - this.sessionView = session.view(name); - this.sessionTree = { - ...this.sessionView, - getLeafId: (context) => this.getLeafId(context), - setName: (name, context) => this.setName(name, context), - setLabel: (targetId, label, context) => this.setLabel(targetId, label, context), - findEntriesOnBranch: (query, context) => this.findEntriesOnBranch(query, context), - findEntryOnBranch: (query, context) => this.findEntryOnBranch(query, context), - appendMessage: (message, context) => this.append({ type: "message", payload: message }, context), - appendCustomEntry: (customType, data, context) => - this.append({ type: "custom", customType, ...(data === undefined ? {} : { payload: data }) }, context), - }; this.state = state; this.onFault = onFault; this.emitBatch = emitBatch; @@ -160,9 +144,9 @@ export class Lane implements AgentLane { this.config = readConfig; } - async getLeafId(_context: Context): Promise { + async getTipId(_context: Context): Promise { this.assertOpen(); - return this.state.leafId; + return this.state.tipId; } async getLastResult(_context: Context): Promise { @@ -197,13 +181,13 @@ export class Lane implements AgentLane { * A planner may choose exactly one outcome: * - `commit` commits once, publishes `next`, then synchronously materializes the caller result from * storage-assigned `CommitResult` metadata; - * - `return` returns without a commit, boxed so a promise value is not awaited while holding the lane line; + * - `return` returns without a commit, boxed so a promise value is not awaited while holding the Session line; * - `reject` rejects outside the mutation/fault boundary as an expected caller error without a commit. * - * Planner, commit, and materialization errors fault the harness before releasing the lane line. Close/fault gates + * Planner, commit, and materialization errors fault the harness before releasing the Session line. Close/fault gates * are checked both before queueing and when the callback starts: close-first rejects, while a callback admitted * before close may finish its commit, publish memory, and resolve without another open check. Drive-owned commands - * additionally need a final fence because their exact owner may be removed outside the lane line. Never invoke + * additionally need a final fence because their exact owner may be removed outside the Session line. Never invoke * providers, tools, hooks, * timers, event handlers, or wait for task completion here; perform those after `command()` returns. */ @@ -214,36 +198,32 @@ export class Lane implements AgentLane { this.assertOpen(); let outcome: LaneCommandOutcome; try { - outcome = await this.session.mutate( - this.name, - async (mutator) => { - this.assertOpen(); - try { - const decision = await plan(this.state, mutator); - switch (decision.kind) { - case "return": - return { kind: "return", result: decision.result }; - case "reject": - return { kind: "reject", error: decision.error }; - case "commit": { - const commit = await mutator.commit(decision.writes, context); - this.state = decision.next; - const result = decision.materialize(commit); - if (isPromiseLike(result)) { - throw new TypeError("Lane command materialize() must be synchronous"); - } - const events = decision.events?.(commit) ?? []; - const delivery = events.length === 0 ? undefined : this.emitBatch(events, context); - return { kind: "return", result, ...(delivery === undefined ? {} : { delivery }) }; + outcome = await this.session.mutate(async (mutator) => { + this.assertOpen(); + try { + const decision = await plan(this.state, mutator); + switch (decision.kind) { + case "return": + return { kind: "return", result: decision.result }; + case "reject": + return { kind: "reject", error: decision.error }; + case "commit": { + const commit = await mutator.commit(decision.writes, context); + this.state = decision.next; + const result = decision.materialize(commit); + if (isPromiseLike(result)) { + throw new TypeError("Lane command materialize() must be synchronous"); } + const events = decision.events?.(commit) ?? []; + const delivery = events.length === 0 ? undefined : this.emitBatch(events, context); + return { kind: "return", result, ...(delivery === undefined ? {} : { delivery }) }; } - } catch (error) { - if (this.closedError !== undefined) throw this.closedError; - throw this.onFault(error, context); } - }, - context, - ); + } catch (error) { + if (this.closedError !== undefined) throw this.closedError; + throw this.onFault(error, context); + } + }, context); } catch (error) { if (this.closedError !== undefined) throw this.closedError; throw error; @@ -256,7 +236,7 @@ export class Lane implements AgentLane { /** * Run one drive-owned command with an exact-object fence at commit admission. * Durable control state comes from the authoritative projection; storage reads are only for referenced payloads. - * Unlike an ordinary command, owner abandonment can occur outside the lane line while the planner awaits. The final + * Unlike an ordinary command, owner abandonment can occur outside the Session line while the planner awaits. The final * open check preserves close/fault as lifecycle errors before the exact-owner check can report lost ownership. */ async commandDriveOwned( @@ -267,47 +247,43 @@ export class Lane implements AgentLane { this.assertOpen(); let outcome: LaneCommandOutcome; try { - outcome = await this.session.mutate( - this.name, - async (mutator) => { - this.assertOpen(); - const operation = this.state.operation; - if (operation?.meta.operationId !== drive.operationId || this.activeDrive !== drive) { - return { kind: "return", result: { kind: "lost_ownership" } as const }; - } - try { - const decision = await plan(this.state, mutator); - switch (decision.kind) { - case "return": - return { kind: "return", result: decision.result }; - case "reject": - return { kind: "reject", error: decision.error }; - case "commit": { - // Keep these checks adjacent to commit admission. Owner abandonment may occur - // outside the line; close/fault must still win classification over ownership loss. - this.assertOpen(); - if (this.activeDrive !== drive) { - return { kind: "return", result: { kind: "lost_ownership" } as const }; - } - const commitPromise = mutator.commit(decision.writes, context); - const commit = await commitPromise; - this.state = decision.next; - const result = decision.materialize(commit); - if (isPromiseLike(result)) { - throw new TypeError("Lane command materialize() must be synchronous"); - } - const events = decision.events?.(commit) ?? []; - const delivery = events.length === 0 ? undefined : this.emitBatch(events, context); - return { kind: "return", result, ...(delivery === undefined ? {} : { delivery }) }; + outcome = await this.session.mutate(async (mutator) => { + this.assertOpen(); + const operation = this.state.operation; + if (operation?.meta.operationId !== drive.operationId || this.activeDrive !== drive) { + return { kind: "return", result: { kind: "lost_ownership" } as const }; + } + try { + const decision = await plan(this.state, mutator); + switch (decision.kind) { + case "return": + return { kind: "return", result: decision.result }; + case "reject": + return { kind: "reject", error: decision.error }; + case "commit": { + // Keep these checks adjacent to commit admission. Owner abandonment may occur + // outside the line; close/fault must still win classification over ownership loss. + this.assertOpen(); + if (this.activeDrive !== drive) { + return { kind: "return", result: { kind: "lost_ownership" } as const }; } + const commitPromise = mutator.commit(decision.writes, context); + const commit = await commitPromise; + this.state = decision.next; + const result = decision.materialize(commit); + if (isPromiseLike(result)) { + throw new TypeError("Lane command materialize() must be synchronous"); + } + const events = decision.events?.(commit) ?? []; + const delivery = events.length === 0 ? undefined : this.emitBatch(events, context); + return { kind: "return", result, ...(delivery === undefined ? {} : { delivery }) }; } - } catch (error) { - if (this.closedError !== undefined) throw this.closedError; - throw this.onFault(error, context); } - }, - context, - ); + } catch (error) { + if (this.closedError !== undefined) throw this.closedError; + throw this.onFault(error, context); + } + }, context); } catch (error) { if (this.closedError !== undefined) throw this.closedError; throw error; @@ -439,7 +415,7 @@ export class Lane implements AgentLane { }; } - let parentId = state.leafId; + let parentId = state.tipId; const entryWrites = placed.map(({ id, message }) => { const write = insertEntry({ id, parentId, type: "message", message }); parentId = id; @@ -448,7 +424,7 @@ export class Lane implements AgentLane { const meta = { operationId, lane: this.name, - sourceLeafId: state.leafId, + sourceTipId: state.tipId, startedAt, intent: { kind: "run" as const, promptEntryIds: prompt.map(({ id }) => id) }, }; @@ -467,7 +443,7 @@ export class Lane implements AgentLane { }; const next: LaneState = { ...state, - leafId: parentId, + tipId: parentId, pendingNextRun: [], operation: { meta, state: operationState }, }; @@ -476,7 +452,7 @@ export class Lane implements AgentLane { writes: [ ...entryWrites, ...capturedIds.map((id) => deleteValue(pendingEntry(id))), - setValue(laneLeaf(this.name), parentId), + setValue(branchTip(this.name), parentId), setValue(operationMetaValue(operationId), meta), setValue(operationStateValue(operationId), operationState), setValue(laneStateValue(this.name), { currentOperationId: operationId, pendingNextRun: [] }), @@ -486,7 +462,7 @@ export class Lane implements AgentLane { events: (commit) => { const events: HarnessEvent[] = [{ type: "run_start", runId: operationId, lane: this.name }]; for (const [index, item] of placed.entries()) { - const parent = index === 0 ? state.leafId : placed[index - 1]!.id; + const parent = index === 0 ? state.tipId : placed[index - 1]!.id; const entry: Entry = { id: item.id, parentId: parent, @@ -551,7 +527,7 @@ export class Lane implements AgentLane { kind: "return", result: { lane: this.name, - leafId: state.leafId, + tipId: state.tipId, configuredModel: state.configuration.model, current, ...(state.lastResult === undefined ? {} : { lastResult: state.lastResult }), @@ -687,11 +663,11 @@ export class Lane implements AgentLane { const captured = structuredClone(state); try { const transcript = - captured.leafId === null + captured.tipId === null ? [] : ( await reader.scanBranch( - { start: captured.leafId, stopAtType: "compaction", order: "newestFirst" }, + { start: captured.tipId, stopAtType: "compaction", order: "newestFirst" }, context, ) ).reverse(); @@ -883,7 +859,7 @@ export class Lane implements AgentLane { watcher.snapshot = structuredClone({ lane: this.name, transcript, - leafId: captured.leafId, + tipId: captured.tipId, ...(captured.lastResult === undefined ? {} : { lastResult: captured.lastResult }), operation: operationSnapshot, queues: { steer, followUp, nextRun }, @@ -915,41 +891,28 @@ export class Lane implements AgentLane { }, context); } - private async setName(name: string | undefined, context: Context): Promise { - await this.command( - (state) => ({ - kind: "commit", - writes: [name === undefined ? deleteValue(sessionName) : setValue(sessionName, name)], - next: state, - materialize: () => undefined, - events: () => [{ type: "value_update", value: "session_name", name }], - }), - context, - ); - } - - private async setLabel(targetId: string, label: string | undefined, context: Context): Promise { - await this.command( - (state) => ({ - kind: "commit", - writes: [label === undefined ? deleteValue(entryLabel(targetId)) : setValue(entryLabel(targetId), label)], - next: state, - materialize: () => undefined, - events: () => [{ type: "value_update", value: "entry_label", targetId, label }], - }), - context, - ); - } - - private async findEntriesOnBranch(query: BranchScan | undefined, context: Context): Promise { + async findEntries(query: BranchScan | undefined, context: Context): Promise { query ??= {}; this.assertOpen(); - const start = query.start ?? this.state.leafId; - return start === null ? [] : this.sessionView.findEntriesOnBranch({ ...query, start }, context); + const start = query.start ?? this.state.tipId; + return start === null + ? [] + : this.session.scanBranch({ ...query, start, order: query.order ?? "newestFirst" }, context); } - private async findEntryOnBranch(query: BranchScan | undefined, context: Context): Promise { - return (await this.findEntriesOnBranch({ ...query, limit: 1 }, context))[0]; + async findEntry(query: BranchScan | undefined, context: Context): Promise { + query ??= {}; + return ( + await this.findEntries({ ...query, limit: query.limit === undefined ? 1 : Math.min(query.limit, 1) }, context) + )[0]; + } + + appendMessage(message: AgentMessage, context: Context): Promise { + return this.append({ type: "message", payload: message }, context); + } + + appendCustomEntry(customType: string, data: JsonValue | undefined, context: Context): Promise { + return this.append({ type: "custom", customType, ...(data === undefined ? {} : { payload: data }) }, context); } private append(pending: PendingEntry, context: Context): Promise { @@ -969,25 +932,25 @@ export class Lane implements AgentLane { writes: [ insertEntry( pending.type === "message" - ? { id, parentId: state.leafId, type: "message", message: pending.payload } + ? { id, parentId: state.tipId, type: "message", message: pending.payload } : { id, - parentId: state.leafId, + parentId: state.tipId, type: "custom", customType: pending.customType, ...(pending.payload === undefined ? {} : { data: pending.payload }), }, ), - setValue(laneLeaf(this.name), id), + setValue(branchTip(this.name), id), ], - next: { ...state, leafId: id }, + next: { ...state, tipId: id }, materialize: () => id, events: (commit) => { const entry: Entry = pending.type === "message" ? { id, - parentId: state.leafId, + parentId: state.tipId, type: "message", message: pending.payload, seq: commit.seqs[0]!, @@ -995,7 +958,7 @@ export class Lane implements AgentLane { } : { id, - parentId: state.leafId, + parentId: state.tipId, type: "custom", customType: pending.customType, ...(pending.payload === undefined ? {} : { data: pending.payload }), diff --git a/packages/agent/src/harness/runtime/restore.ts b/packages/agent/src/harness/runtime/restore.ts index ef5e6ce89..988b2701c 100644 --- a/packages/agent/src/harness/runtime/restore.ts +++ b/packages/agent/src/harness/runtime/restore.ts @@ -2,41 +2,59 @@ import type { Context } from "../context.ts"; import { SessionInvariantError } from "../session/session.ts"; import type { Session, SessionReader } from "../session/types.ts"; import { + branchTip, + branchTipInventoryPrefix, laneConfig, laneLastResult, - laneLeaf, - laneLeafInventoryPrefix, laneState as laneStateValue, operationMeta, operationState, } from "../session/values.ts"; import type { LaneState } from "./types.ts"; -/** Restore every configured lane in one session without starting work. */ -export async function restoreSession(session: Session, context: Context): Promise> { - const lanes = await session.scanValues(laneLeafInventoryPrefix(), context); - if (!lanes.some((stored) => stored.address.key === "main")) { - throw new SessionInvariantError("Session is missing main lane"); - } - const restored = await Promise.all( - lanes.map(async ({ address }) => ({ key: address.key, state: await restoreLane(session, address.key, context) })), - ); - return new Map(restored.map(({ key, state }) => [key, state])); +/** Restore every complete configured AgentLane in one coherent Session read. */ +export function restoreSession(session: Session, context: Context): Promise> { + return session.mutate(async (reader) => { + const inventories = await Promise.all([ + reader.scanValues(branchTipInventoryPrefix(), context), + reader.scanValues(laneConfig(""), context), + reader.scanValues(laneStateValue(""), context), + reader.scanValues(laneLastResult(""), context), + ]); + const names = new Set(inventories.flatMap((values) => values.map(({ address }) => address.key))); + const restored = new Map(); + for (const lane of names) { + const [configuration, state, lastResult] = await Promise.all([ + reader.getValue(laneConfig(lane), context), + reader.getValue(laneStateValue(lane), context), + reader.getValue(laneLastResult(lane), context), + ]); + const tip = await reader.getValue(branchTip(lane), context); + if (tip !== undefined && configuration === undefined && state === undefined && lastResult === undefined) { + continue; + } + if (tip === undefined || configuration === undefined || state === undefined) { + throw new SessionInvariantError(`Lane ${JSON.stringify(lane)} has incomplete durable state`); + } + restored.set(lane, await restoreLaneState(reader, lane, context)); + } + return restored; + }, context); } /** Restore one configured lane without starting work or interpreting its state. */ export function restoreLane(session: Session, lane: string, context: Context): Promise { - return session.mutate(lane, (reader) => restoreLaneState(reader, lane, context), context); + return session.mutate((reader) => restoreLaneState(reader, lane, context), context); } -async function restoreLaneState(reader: SessionReader, lane: string, context: Context): Promise { - const [leaf, configuration, laneState, lastResult] = await Promise.all([ - reader.getValue(laneLeaf(lane), context), +export async function restoreLaneState(reader: SessionReader, lane: string, context: Context): Promise { + const [tip, configuration, laneState, lastResult] = await Promise.all([ + reader.getValue(branchTip(lane), context), reader.getValue(laneConfig(lane), context), reader.getValue(laneStateValue(lane), context), reader.getValue(laneLastResult(lane), context), ]); - if (leaf === undefined) throw new SessionInvariantError(`Lane ${JSON.stringify(lane)} is missing lane.leaf`); + if (tip === undefined) throw new SessionInvariantError(`Lane ${JSON.stringify(lane)} is missing branch.tip`); if (configuration === undefined) throw new SessionInvariantError(`Lane ${JSON.stringify(lane)} is missing lane.config`); if (laneState === undefined) throw new SessionInvariantError(`Lane ${JSON.stringify(lane)} is missing lane.state`); @@ -69,7 +87,7 @@ async function restoreLaneState(reader: SessionReader, lane: string, context: Co } return { - leafId: leaf.value, + tipId: tip.value, configuration: configuration.value, pendingNextRun: laneState.value.pendingNextRun, ...(lastResult === undefined ? {} : { lastResult: lastResult.value }), diff --git a/packages/agent/src/harness/runtime/types.ts b/packages/agent/src/harness/runtime/types.ts index eb779b891..11a5784d6 100644 --- a/packages/agent/src/harness/runtime/types.ts +++ b/packages/agent/src/harness/runtime/types.ts @@ -39,7 +39,7 @@ export interface Config { /** The current durable state owned by one lane. */ export interface LaneState { - readonly leafId: string | null; + readonly tipId: string | null; readonly configuration: LaneConfiguration; readonly pendingNextRun: string[]; readonly lastResult?: LaneLastResult; diff --git a/packages/agent/src/harness/session/fork.ts b/packages/agent/src/harness/session/fork.ts index 42d57d4cc..259c9c9d9 100644 --- a/packages/agent/src/harness/session/fork.ts +++ b/packages/agent/src/harness/session/fork.ts @@ -1,10 +1,10 @@ import type { CommittedWrite } from "./commit.ts"; import type { Entry, ForkOptions } from "./types.ts"; import { + branchTip, entryLabel, laneConfig, laneLastResult, - laneLeaf, laneState, type StoredValue, sessionName, @@ -38,10 +38,10 @@ function findStoredValue(values: readonly StoredValue[], address: Va /** Build the complete logical state for a forked destination session. */ export function createForkSnapshot(source: ForkSourceSnapshot, options: ForkOptions): ForkDestinationSnapshot { const sourceEntries = new Map(source.entries.map((entry) => [entry.id, entry])); - const sourceLeaves = storedValuesInNamespace(source.scalarValues, laneLeaf("")); - validateForkSourceSnapshot(source, sourceEntries, sourceLeaves, options); + const sourceTips = storedValuesInNamespace(source.scalarValues, branchTip("")); + validateForkSourceSnapshot(source, sourceEntries, sourceTips, options); - const { entryIds, laneToLeafId } = selectForkContents(sourceEntries, sourceLeaves, options); + const { entryIds, sourceToDestinationTip } = selectForkContents(sourceEntries, sourceTips, options); const entries = new Map(); for (const id of entryIds) entries.set(id, sourceEntries.get(id)!); @@ -54,11 +54,16 @@ export function createForkSnapshot(source: ForkSourceSnapshot, options: ForkOpti seq: nextSeq++, }); }; - for (const [lane, leaf] of laneToLeafId) { - const configuration = findStoredValue(source.scalarValues, laneConfig(lane)); - if (configuration !== undefined) store(laneConfig(lane), configuration.value); - store(laneLeaf(lane), leaf); - store(laneState(lane), { currentOperationId: null, pendingNextRun: [] }); + for (const [sourceName, destination] of sourceToDestinationTip) { + const configuration = findStoredValue(source.scalarValues, laneConfig(sourceName)); + store(branchTip(destination.name), destination.tipId); + if (configuration !== undefined) { + store(laneConfig(destination.name), configuration.value); + store(laneState(destination.name), { + currentOperationId: null, + pendingNextRun: [], + }); + } } const name = findStoredValue(source.scalarValues, sessionName); if (name !== undefined) store(sessionName, name.value); @@ -88,72 +93,82 @@ export function forkSnapshotWrites(snapshot: ForkDestinationSnapshot): Committed function selectForkContents( sourceEntries: Map, - sourceLeaves: StoredValue[], + sourceTips: StoredValue[], options: ForkOptions, -): { entryIds: Set; laneToLeafId: Map } { +): { + entryIds: Set; + sourceToDestinationTip: Map; +} { const entryIds = new Set(); - const laneToLeafId = new Map(); + const sourceToDestinationTip = new Map(); if (options.scope === "tree") { for (const id of sourceEntries.keys()) entryIds.add(id); - for (const stored of sourceLeaves) laneToLeafId.set(stored.address.key, stored.value); + for (const stored of sourceTips) { + sourceToDestinationTip.set(stored.address.key, { + name: stored.address.key, + tipId: stored.value, + }); + } } else { - const mainLeaf = sourceLeaves.find((stored) => stored.address.key === "main"); - if (mainLeaf === undefined) throw new Error("Source session is missing main lane"); - const requested = options.entryId ?? mainLeaf.value; - let leaf = requested; + const mainTip = sourceTips.find((stored) => stored.address.key === "main"); + if (mainTip === undefined) throw new Error("Source session is missing main branch"); + const requested = options.entryId ?? mainTip.value; + let tipId = requested; if (requested !== null) { const target = sourceEntries.get(requested); if (target === undefined) throw new Error(`Unknown fork entry: ${requested}`); - if (options.position === "before") leaf = target.parentId; + if (options.position === "before") tipId = target.parentId; } - let entryId = leaf; + let entryId = tipId; while (entryId !== null) { const entry = sourceEntries.get(entryId); if (entry === undefined) throw new Error(`Corrupt source branch: missing parent ${entryId}`); entryIds.add(entryId); entryId = entry.parentId; } - laneToLeafId.set("main", leaf); + sourceToDestinationTip.set("main", { name: "main", tipId }); } - return { entryIds, laneToLeafId }; + return { entryIds, sourceToDestinationTip }; } function validateForkSourceSnapshot( source: ForkSourceSnapshot, sourceEntries: Map, - sourceLeaves: StoredValue[], + sourceTips: StoredValue[], options: ForkOptions, ): void { - const sourceLeafKeys = new Set(sourceLeaves.map((stored) => stored.address.key)); + const sourceTipKeys = new Set(sourceTips.map((stored) => stored.address.key)); - if (!sourceLeafKeys.has("main")) throw new Error("Source session is missing main lane"); + if (options.scope !== "tree" && !sourceTipKeys.has("main")) { + throw new Error("Source session is missing main branch"); + } for (const stored of source.scalarValues) { if ( (stored.address.namespace === laneConfig("").namespace || stored.address.namespace === laneState("").namespace || stored.address.namespace === laneLastResult("").namespace) && - !sourceLeafKeys.has(stored.address.key) + !sourceTipKeys.has(stored.address.key) ) { - throw new Error(`Source session lane ${JSON.stringify(stored.address.key)} is missing lane.leaf`); + throw new Error(`Source session branch ${JSON.stringify(stored.address.key)} is missing branch.tip`); } } - for (const leaf of sourceLeaves) { - if (findStoredValue(source.scalarValues, laneState(leaf.address.key)) === undefined) { - throw new Error(`Source session lane ${JSON.stringify(leaf.address.key)} is missing lane.state`); + for (const tip of sourceTips) { + const configuration = findStoredValue(source.scalarValues, laneConfig(tip.address.key)); + const state = findStoredValue(source.scalarValues, laneState(tip.address.key)); + const lastResult = findStoredValue(source.scalarValues, laneLastResult(tip.address.key)); + if ((configuration === undefined) !== (state === undefined)) { + throw new Error(`Source session branch ${JSON.stringify(tip.address.key)} has incomplete lane state`); } - if ( - leaf.address.key !== "main" && - findStoredValue(source.scalarValues, laneConfig(leaf.address.key)) === undefined - ) { - throw new Error(`Source session lane ${JSON.stringify(leaf.address.key)} is missing lane.config`); + if (configuration === undefined && lastResult !== undefined) { + throw new Error(`Source session branch ${JSON.stringify(tip.address.key)} has a result without lane state`); } if ( (source.entriesComplete !== false || options.scope === "tree") && - leaf.value !== null && - !sourceEntries.has(leaf.value) + tip.value !== null && + !sourceEntries.has(tip.value) ) { - throw new Error(`Source session lane ${JSON.stringify(leaf.address.key)} has an unknown leaf`); + throw new Error(`Source session branch ${JSON.stringify(tip.address.key)} has an unknown tip`); } } } diff --git a/packages/agent/src/harness/session/index.ts b/packages/agent/src/harness/session/index.ts index e883222a4..3efeee8f9 100644 --- a/packages/agent/src/harness/session/index.ts +++ b/packages/agent/src/harness/session/index.ts @@ -22,9 +22,9 @@ export { export type { MemorySessionRepoOptions } from "./memory.ts"; export { MemorySessionRepo } from "./memory.ts"; export { - SessionInvalidLaneError, + SessionBranchExistsError, + SessionInvalidBranchError, SessionInvariantError, - SessionLaneExistsError, SessionPendingAssistantMessageError, SessionUnknownTargetError, StorageBackedSession, diff --git a/packages/agent/src/harness/session/jsonl/legacy-v3.ts b/packages/agent/src/harness/session/jsonl/legacy-v3.ts index d4f1a1e62..29ee4d8d6 100644 --- a/packages/agent/src/harness/session/jsonl/legacy-v3.ts +++ b/packages/agent/src/harness/session/jsonl/legacy-v3.ts @@ -1,12 +1,12 @@ import { type ImageContent, type TextContent, type Usage, uuidv7 } from "@earendil-works/pi-ai"; -import type { AgentMessage } from "../../../types.ts"; +import type { AgentMessage, ThinkingLevel } from "../../../types.ts"; import type { Context } from "../../context.ts"; import { createBranchSummaryMessage, createCompactionSummaryMessage } from "../../messages.ts"; import type { FileSystem } from "../../types.ts"; import { addUsage, emptyUsage } from "../../utils/usage.ts"; import type { CommittedEntryWrite, CommittedValueSetWrite, CommittedWrite } from "../commit.ts"; -import type { JsonValue } from "../types.ts"; -import { entryLabel, laneLeaf, laneState, sessionName } from "../values.ts"; +import type { JsonValue, LaneConfiguration } from "../types.ts"; +import { branchTip, entryLabel, laneConfig, laneState, sessionName } from "../values.ts"; import { type LegacyV3SessionHeader, parseJsonlSessionHeader } from "./codec.ts"; import { JSONL_FORMAT_VERSION, @@ -73,6 +73,7 @@ interface LegacyV3ThinkingLevelChangeEntry extends LegacyV3EntryBase { interface LegacyV3ActiveToolsChangeEntry extends LegacyV3EntryBase { type: "active_tools_change"; + activeToolNames: string[]; } interface LegacyV3SessionInfoEntry extends LegacyV3EntryBase { @@ -337,7 +338,11 @@ function normalizeRetainedEntry( return { ...committedBase, type: "message", message: entry.message }; } if (entry.type === "custom_message") { - return { ...committedBase, type: "message", message: importedCustomMessage(entry) }; + return { + ...committedBase, + type: "message", + message: importedCustomMessage(entry), + }; } if (entry.type === "branch_summary") { return { @@ -370,8 +375,55 @@ function normalizeRetainedEntry( }; } +const THINKING_LEVELS = new Set(["off", "minimal", "low", "medium", "high", "xhigh", "max"]); + +function selectedConfiguration( + entriesById: ReadonlyMap, + selectedId: string | null, +): LaneConfiguration | undefined { + let model: LaneConfiguration["model"] | undefined; + let thinkingLevel: ThinkingLevel | undefined; + let activeToolNames: string[] | undefined; + let sawModel = false; + let sawThinkingLevel = false; + let sawActiveToolNames = false; + const visited = new Set(); + let currentId = selectedId; + while (currentId !== null && (!sawModel || !sawThinkingLevel || !sawActiveToolNames)) { + if (visited.has(currentId)) throw new Error(`Cycle in legacy v3 parent chain at entry: ${currentId}`); + visited.add(currentId); + const entry = entriesById.get(currentId); + if (entry === undefined) throw new Error(`Missing legacy v3 entry reference: ${currentId}`); + if (entry.type === "model_change" && !sawModel) { + sawModel = true; + if ( + typeof entry.provider === "string" && + entry.provider.length !== 0 && + typeof entry.modelId === "string" && + entry.modelId.length !== 0 + ) { + model = { provider: entry.provider, modelId: entry.modelId }; + } + } else if (entry.type === "thinking_level_change" && !sawThinkingLevel) { + sawThinkingLevel = true; + if (typeof entry.thinkingLevel === "string" && THINKING_LEVELS.has(entry.thinkingLevel as ThinkingLevel)) { + thinkingLevel = entry.thinkingLevel as ThinkingLevel; + } + } else if (entry.type === "active_tools_change" && !sawActiveToolNames) { + sawActiveToolNames = true; + if (Array.isArray(entry.activeToolNames) && entry.activeToolNames.every((name) => typeof name === "string")) { + activeToolNames = [...entry.activeToolNames]; + } + } + currentId = entry.parentId; + } + if (model === undefined || thinkingLevel === undefined) return undefined; + return { model, thinkingLevel, activeToolNames: activeToolNames ?? [] }; +} + function normalizeLegacyV3Values( entries: readonly LegacyV3Entry[], + entriesById: ReadonlyMap, resolver: RetainedIdResolver, firstSeq: number, ): CommittedValueSetWrite[] { @@ -414,24 +466,36 @@ function normalizeLegacyV3Values( } const finalEntry = entries.at(-1); - const leafAddress = laneLeaf("main"); + const tipAddress = branchTip("main"); writes.push({ kind: "value", op: "set", seq: firstSeq + writes.length, - namespace: leafAddress.namespace, - key: leafAddress.key, + namespace: tipAddress.namespace, + key: tipAddress.key, value: finalEntry === undefined ? null : resolver.resolve(finalEntry.id), }); - const stateAddress = laneState("main"); - writes.push({ - kind: "value", - op: "set", - seq: firstSeq + writes.length, - namespace: stateAddress.namespace, - key: stateAddress.key, - value: { currentOperationId: null, pendingNextRun: [] }, - }); + const configuration = selectedConfiguration(entriesById, finalEntry?.id ?? null); + if (configuration !== undefined) { + const configAddress = laneConfig("main"); + writes.push({ + kind: "value", + op: "set", + seq: firstSeq + writes.length, + namespace: configAddress.namespace, + key: configAddress.key, + value: configuration, + }); + const stateAddress = laneState("main"); + writes.push({ + kind: "value", + op: "set", + seq: firstSeq + writes.length, + namespace: stateAddress.namespace, + key: stateAddress.key, + value: { currentOperationId: null, pendingNextRun: [] }, + }); + } return writes; } @@ -464,7 +528,7 @@ export function normalizeLegacyV3Records(recordLines: readonly string[]): Normal const entryWrites = retainedEntries.map((entry, index) => normalizeRetainedEntry(entry, index + 1, entriesById, resolver), ); - const valueWrites = normalizeLegacyV3Values(entries, resolver, entryWrites.length + 1); + const valueWrites = normalizeLegacyV3Values(entries, entriesById, resolver, entryWrites.length + 1); const writes: CommittedWrite[] = [...entryWrites, ...valueWrites]; return { writes, diff --git a/packages/agent/src/harness/session/jsonl/repo.ts b/packages/agent/src/harness/session/jsonl/repo.ts index 017efd1bc..f32ae4f99 100644 --- a/packages/agent/src/harness/session/jsonl/repo.ts +++ b/packages/agent/src/harness/session/jsonl/repo.ts @@ -4,7 +4,6 @@ import type { FileError, FileInfo, FileSystem, Result } from "../../types.ts"; import { createForkSnapshot, type ForkSourceSnapshot } from "../fork.ts"; import { StorageBackedSession } from "../session.ts"; import type { ForkOptions, Session, SessionRepo } from "../types.ts"; -import { laneLeaf, laneState, setValue } from "../values.ts"; import { parseJsonlSessionHeader } from "./codec.ts"; import { metadataFromLegacyV3Header } from "./legacy-v3.ts"; import { JsonlStorage } from "./storage.ts"; @@ -85,15 +84,7 @@ export class JsonlSessionRepo cwd, ...(options.parentSessionId === undefined ? {} : { parentSessionId: options.parentSessionId }), }; - storage = await JsonlStorage.create( - { fileSystem: this.fileSystem, path, now: this.now }, - header, - [ - setValue(laneLeaf("main"), null), - setValue(laneState("main"), { currentOperationId: null, pendingNextRun: [] }), - ], - context, - ); + storage = await JsonlStorage.create({ fileSystem: this.fileSystem, path, now: this.now }, header, [], context); const info = fileValue(await this.fileSystem.fileInfo(path, context), `Failed to read session ${path}`); return this.publishOpenSession(metadataFromHeader(header, path, info.mtimeMs), storage, key); } catch (error) { diff --git a/packages/agent/src/harness/session/lane-mutations.ts b/packages/agent/src/harness/session/lane-mutations.ts deleted file mode 100644 index 03ea725d1..000000000 --- a/packages/agent/src/harness/session/lane-mutations.ts +++ /dev/null @@ -1,38 +0,0 @@ -/** Serializes complete read-modify-write jobs independently per lane. */ -export class LaneMutationLine { - private readonly tails = new Map>(); - private sealedError: Error | undefined; - - run(lane: string, operation: () => T | Promise): Promise { - if (this.sealedError !== undefined) return Promise.reject(this.sealedError); - const previous = this.tails.get(lane); - let result: Promise; - if (previous === undefined) { - try { - result = Promise.resolve(operation()); - } catch (error) { - result = Promise.reject(error); - } - } else { - result = previous.then(() => { - if (this.sealedError !== undefined) throw this.sealedError; - return operation(); - }); - } - - const tail = result.then( - () => undefined, - () => undefined, - ); - this.tails.set(lane, tail); - void tail.then(() => { - if (this.tails.get(lane) === tail) this.tails.delete(lane); - }); - return result; - } - - seal(error: Error): Promise { - this.sealedError ??= error; - return Promise.all(this.tails.values()).then(() => undefined); - } -} diff --git a/packages/agent/src/harness/session/memory.ts b/packages/agent/src/harness/session/memory.ts index 98be3f487..7b45fade0 100644 --- a/packages/agent/src/harness/session/memory.ts +++ b/packages/agent/src/harness/session/memory.ts @@ -1,5 +1,4 @@ import { uuidv7 } from "@earendil-works/pi-ai"; -import type { AgentMessage } from "../../types.ts"; import type { Context } from "../context.ts"; import { createForkSnapshot, @@ -10,7 +9,7 @@ import { import { InMemoryStorageState } from "./in-memory-storage-state.ts"; import { StorageBackedSession } from "./session.ts"; import type { - BranchScan, + Branch, CommitResult, Entry, EntryQuery, @@ -18,32 +17,20 @@ import type { EntryStructure, ForkOptions, IdGenerator, - JsonValue, - LaneConfiguration, Session, SessionCreateOptions, SessionMetadata, SessionMutation, - SessionMutator, + SessionMutationCallback, SessionRepo, SessionStats, - SessionTree, Storage, StorageBranchScan, UsageRow, UsageScan, Write, } from "./types.ts"; -import { - type ListElement, - type ListReadOptions, - laneLeaf, - laneState, - type StoredValue, - setValue as setValueWrite, - type Value, - type ValueList, -} from "./values.ts"; +import type { ListElement, ListReadOptions, StoredValue, Value, ValueList } from "./values.ts"; export interface MemoryStorageOptions { now?: () => number; @@ -182,22 +169,38 @@ class MemorySessionFacade implements Session { this.onClose = onClose; } - async beginMutation(lane: string, context: Context): Promise { - const source = await this.admit(() => this.session.beginMutation(lane, context)); + async beginMutation(context: Context): Promise { let resolveFinished!: () => void; const finished = new Promise((resolve) => { resolveFinished = resolve; }); this.admitted.add(finished); + let source: SessionMutation; + try { + source = await this.admit(() => this.session.beginMutation(context)); + } catch (error) { + this.admitted.delete(finished); + resolveFinished(); + throw error; + } + if (this.state !== "open") { + await source.end(context); + this.admitted.delete(finished); + resolveFinished(); + throw this.closedError; + } + let ended = false; return { - lane: source.lane, commit: (writes, commitContext) => source.commit(writes, commitContext), end: async (endContext) => { try { await source.end(endContext); } finally { - this.admitted.delete(finished); - resolveFinished(); + if (!ended) { + ended = true; + this.admitted.delete(finished); + resolveFinished(); + } } }, getEntries: (ids, readContext) => source.getEntries(ids, readContext), @@ -208,18 +211,23 @@ class MemorySessionFacade implements Session { }; } - async mutate( - lane: string, - mutation: (mutator: SessionMutator, context: Context) => T | Promise, - context: Context, - ): Promise { - return this.admit(() => this.session.mutate(lane, mutation, context)); + mutate(mutation: SessionMutationCallback, context: Context): Promise { + return this.admit(() => + this.session.mutate((mutator, mutationContext) => { + if (this.state !== "open") throw this.closedError; + return mutation(mutator, mutationContext); + }, context), + ); } getEntries(ids: string[], context: Context): Promise> { return this.admit(() => this.session.getEntries(ids, context)); } + getEntry(id: string, context: Context): Promise { + return this.admit(() => this.session.getEntry(id, context)); + } + getValue(address: Value, context: Context): Promise | undefined> { return this.admit(() => this.session.getValue(address, context)); } @@ -240,58 +248,35 @@ class MemorySessionFacade implements Session { return this.admit(() => this.session.scanBranch(query, context)); } - view(lane: string): SessionTree { - const view = this.session.view(lane); - return { - getLeafId: (context) => this.admit(() => view.getLeafId(context)), - getEntry: (id, context) => this.admit(() => view.getEntry(id, context)), - getStats: (context) => this.admit(() => view.getStats(context)), - getValue: (address, context) => this.admit(() => view.getValue(address, context)), - scanValues: (prefix, context) => this.admit(() => view.scanValues(prefix, context)), - readList: (address, options, context) => this.admit(() => view.readList(address, options, context)), - setValue: (address, next, context) => this.admit(() => view.setValue(address, next, context)), - deleteValue: (address, context) => this.admit(() => view.deleteValue(address, context)), - appendList: (address, element, context) => this.admit(() => view.appendList(address, element, context)), - deleteList: (address, context) => this.admit(() => view.deleteList(address, context)), - getName: (context) => this.admit(() => view.getName(context)), - setName: (name, context) => this.admit(() => view.setName(name, context)), - getLabel: (targetId, context) => this.admit(() => view.getLabel(targetId, context)), - setLabel: (targetId, label, context) => this.admit(() => view.setLabel(targetId, label, context)), - findEntries: (query, context) => this.admit(() => view.findEntries(query, context)), - findEntry: (query, context) => this.admit(() => view.findEntry(query, context)), - findEntriesOnBranch: (query, context) => this.admit(() => view.findEntriesOnBranch(query, context)), - findEntryOnBranch: (query, context) => this.admit(() => view.findEntryOnBranch(query, context)), - appendMessage: (message, context) => this.admit(() => view.appendMessage(message, context)), - appendCustomEntry: (customType, data, context) => - this.admit(() => view.appendCustomEntry(customType, data, context)), - }; - } - - createLane( - 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, onCommitted, context); - return this.view(name); - }); - } - - getLeafId(context: Context): Promise { - return this.admit(() => this.session.getLeafId(context)); - } - - getEntry(id: string, context: Context): Promise { - return this.admit(() => this.session.getEntry(id, context)); - } - getStats(context: Context): Promise { return this.admit(() => this.session.getStats(context)); } + getName(context: Context): Promise { + return this.admit(() => this.session.getName(context)); + } + + getLabel(targetId: string, context: Context): Promise { + return this.admit(() => this.session.getLabel(targetId, context)); + } + + findEntries(query: EntryQuery | undefined, context: Context): Promise { + return this.admit(() => this.session.findEntries(query, context)); + } + + findEntry(query: EntryQuery | undefined, context: Context): Promise { + return this.admit(() => this.session.findEntry(query, context)); + } + + async branch(name: string, context: Context): Promise { + const branch = await this.admit(() => this.session.branch(name, context)); + return branch === undefined ? undefined : this.wrapBranch(branch); + } + + async createBranch(name: string, at: string | null, context: Context): Promise { + return this.wrapBranch(await this.admit(() => this.session.createBranch(name, at, context))); + } + setValue(address: Value, next: NoInfer, context: Context): Promise { return this.admit(() => this.session.setValue(address, next, context)); } @@ -308,46 +293,14 @@ class MemorySessionFacade implements Session { return this.admit(() => this.session.deleteList(address, context)); } - getName(context: Context): Promise { - return this.admit(() => this.session.getName(context)); - } - setName(name: string | undefined, context: Context): Promise { return this.admit(() => this.session.setName(name, context)); } - getLabel(targetId: string, context: Context): Promise { - return this.admit(() => this.session.getLabel(targetId, context)); - } - setLabel(targetId: string, label: string | undefined, context: Context): Promise { return this.admit(() => this.session.setLabel(targetId, label, context)); } - findEntries(query: EntryQuery | undefined, context: Context): Promise { - return this.admit(() => this.session.findEntries(query, context)); - } - - findEntry(query: EntryQuery | undefined, context: Context): Promise { - return this.admit(() => this.session.findEntry(query, context)); - } - - findEntriesOnBranch(query: BranchScan | undefined, context: Context): Promise { - return this.admit(() => this.session.findEntriesOnBranch(query, context)); - } - - findEntryOnBranch(query: BranchScan | undefined, context: Context): Promise { - return this.admit(() => this.session.findEntryOnBranch(query, context)); - } - - appendMessage(message: AgentMessage, context: Context): Promise { - return this.admit(() => this.session.appendMessage(message, context)); - } - - appendCustomEntry(customType: string, data: JsonValue | undefined, context: Context): Promise { - return this.admit(() => this.session.appendCustomEntry(customType, data, context)); - } - close(_context: Context): Promise { if (this.closePromise !== undefined) return this.closePromise; this.state = "closing"; @@ -358,6 +311,18 @@ class MemorySessionFacade implements Session { return this.closePromise; } + private wrapBranch(branch: Branch): Branch { + return { + name: branch.name, + getTipId: (context) => this.admit(() => branch.getTipId(context)), + findEntries: (query, context) => this.admit(() => branch.findEntries(query, context)), + findEntry: (query, context) => this.admit(() => branch.findEntry(query, context)), + appendMessage: (message, context) => this.admit(() => branch.appendMessage(message, context)), + appendCustomEntry: (customType, data, context) => + this.admit(() => branch.appendCustomEntry(customType, data, context)), + }; + } + private admit(operation: () => Promise): Promise { if (this.state !== "open") return Promise.reject(this.closedError); let result: Promise; @@ -400,19 +365,12 @@ export class MemorySessionRepo implements SessionRepo { const storage = new MemoryStorage({ now: this.now }); const session = new StorageBackedSession(metadata, storage); try { - await session.mutate( - "main", - (mutator) => - mutator.commit( - [ - setValueWrite(laneLeaf("main"), null), - setValueWrite(laneState("main"), { currentOperationId: null, pendingNextRun: [] }), - ], - context, - ), - context, - ); - const record: MemorySessionRecord = { metadata, storage, session, open: true }; + const record: MemorySessionRecord = { + metadata, + storage, + session, + open: true, + }; this.sessions.set(id, record); return this.openRecord(record); } catch (error) { @@ -466,7 +424,12 @@ export class MemorySessionRepo implements SessionRepo { parentSessionId: sourceRecord.metadata.id, }; const session = new StorageBackedSession(metadata, storage); - const record: MemorySessionRecord = { metadata, storage, session, open: true }; + const record: MemorySessionRecord = { + metadata, + storage, + session, + open: true, + }; this.sessions.set(id, record); return this.openRecord(record); } finally { diff --git a/packages/agent/src/harness/session/mutation-line.ts b/packages/agent/src/harness/session/mutation-line.ts new file mode 100644 index 000000000..01ccc61ed --- /dev/null +++ b/packages/agent/src/harness/session/mutation-line.ts @@ -0,0 +1,23 @@ +/** Serializes complete read-modify-write jobs for one Session. */ +export class MutationLine { + private tail: Promise = Promise.resolve(); + private sealedError: Error | undefined; + + run(operation: () => T | Promise): Promise { + if (this.sealedError !== undefined) return Promise.reject(this.sealedError); + const result = this.tail.then(() => { + if (this.sealedError !== undefined) throw this.sealedError; + return operation(); + }); + this.tail = result.then( + () => undefined, + () => undefined, + ); + return result; + } + + seal(error: Error): Promise { + this.sealedError ??= error; + return this.tail; + } +} diff --git a/packages/agent/src/harness/session/session.ts b/packages/agent/src/harness/session/session.ts index 34254eb52..440a9afe3 100644 --- a/packages/agent/src/harness/session/session.ts +++ b/packages/agent/src/harness/session/session.ts @@ -2,42 +2,32 @@ import { uuidv7 } from "@earendil-works/pi-ai"; import type { AgentMessage } from "../../types.ts"; import type { Context } from "../context.ts"; import { insertEntry } from "./commit.ts"; -import { LaneMutationLine } from "./lane-mutations.ts"; +import { MutationLine } from "./mutation-line.ts"; import type { + Branch, BranchScan, CommitResult, Entry, EntryQuery, IdGenerator, JsonValue, - LaneConfiguration, - OperationMeta, - OperationState, - PendingEntry, Session, SessionMetadata, SessionMutation, - SessionMutator, + SessionMutationCallback, SessionStats, - SessionTree, Storage, StorageBranchScan, Write, } from "./types.ts"; import { appendList as appendListWrite, + branchTip, deleteList as deleteListWrite, deleteValue as deleteValueWrite, entryLabel, type ListElement, type ListReadOptions, - laneConfig, - laneLastResult, - laneLeaf, - laneState, - operationMeta, - operationState, - pendingEntry, type StoredValue, sessionName, setValue as setValueWrite, @@ -46,7 +36,7 @@ import { } from "./values.ts"; export interface StorageBackedSessionOptions { - laneMutationLine?: LaneMutationLine; + mutationLine?: MutationLine; idGenerator?: IdGenerator; onClose?: () => void; } @@ -59,27 +49,27 @@ export class SessionInvariantError extends Error { } } -/** A requested session lane name is invalid. */ -export class SessionInvalidLaneError extends Error { - readonly lane: string; +/** A requested Branch name is invalid. */ +export class SessionInvalidBranchError extends Error { + readonly branch: string; readonly reason: string; - constructor(lane: string, reason: string) { - super(`Invalid lane ${JSON.stringify(lane)}: ${reason}`); - this.name = "SessionInvalidLaneError"; - this.lane = lane; + constructor(branch: string, reason: string) { + super(`Invalid branch ${JSON.stringify(branch)}: ${reason}`); + this.name = "SessionInvalidBranchError"; + this.branch = branch; this.reason = reason; } } -/** A requested session lane already exists. */ -export class SessionLaneExistsError extends Error { - readonly lane: string; +/** A requested branch already exists. */ +export class SessionBranchExistsError extends Error { + readonly branch: string; - constructor(lane: string) { - super(`Lane already exists: ${lane}`); - this.name = "SessionLaneExistsError"; - this.lane = lane; + constructor(branch: string) { + super(`Branch already exists: ${branch}`); + this.name = "SessionBranchExistsError"; + this.branch = branch; } } @@ -103,15 +93,13 @@ export class SessionUnknownTargetError extends Error { } class StorageBackedSessionMutation implements SessionMutation { - readonly lane: string; private readonly storage: Storage; private readonly release: () => void; private active = true; private commitResult: Promise | undefined; private endPromise: Promise | undefined; - constructor(lane: string, storage: Storage, release: () => void) { - this.lane = lane; + constructor(storage: Storage, release: () => void) { this.storage = storage; this.release = release; } @@ -138,10 +126,9 @@ class StorageBackedSessionMutation implements SessionMutation { } end(_context: Context): Promise { - this.endPromise ??= this.settle().finally(() => { - this.active = false; - this.release(); - }); + if (this.endPromise !== undefined) return this.endPromise; + this.active = false; + this.endPromise = this.settle().finally(this.release); return this.endPromise; } @@ -188,18 +175,54 @@ class StorageBackedSessionMutation implements SessionMutation { } } -/** - * Package-internal typed boundary shared by concrete session repositories. - * After this Session is passed to AgentHarness.create(), retained Session and SessionTree - * facades are read-only until harness close; mutating through them would bypass the owning - * Lane's authoritative projection and is a trusted-programming defect. - */ +class StorageBackedBranch implements Branch { + readonly name: string; + private readonly session: StorageBackedSession; + + constructor(name: string, session: StorageBackedSession) { + this.name = name; + this.session = session; + } + + getTipId(context: Context): Promise { + return this.session.getBranchTip(this.name, context); + } + + async findEntries(query: BranchScan | undefined, context: Context): Promise { + query ??= {}; + const start = query.start ?? (await this.getTipId(context)); + if (start === null) return []; + return this.session.scanBranch({ ...query, start, order: query.order ?? "newestFirst" }, context); + } + + async findEntry(query: BranchScan | undefined, context: Context): Promise { + query ??= {}; + return ( + await this.findEntries({ ...query, limit: query.limit === undefined ? 1 : Math.min(query.limit, 1) }, context) + )[0]; + } + + appendMessage(message: AgentMessage, context: Context): Promise { + return this.session.appendToBranch(this.name, { type: "message", message }, context); + } + + appendCustomEntry(customType: string, data: JsonValue | undefined, context: Context): Promise { + return this.session.appendToBranch( + this.name, + { type: "custom", customType, ...(data === undefined ? {} : { data }) }, + context, + ); + } +} + +/** Package-internal typed boundary shared by concrete session repositories. */ export class StorageBackedSession implements Session { readonly metadata: TMetadata; readonly idGenerator: IdGenerator; private readonly storage: Storage; - private readonly laneMutationLine: LaneMutationLine; + private readonly mutationLine: MutationLine; private readonly onClose: (() => void) | undefined; + private readonly branches = new Map(); private readonly closedError = new Error("Session is closed"); private state: "open" | "closing" | "closed" = "open"; private closePromise: Promise | undefined; @@ -208,11 +231,11 @@ export class StorageBackedSession { + async beginMutation(_context: Context): Promise { this.assertOpen(); let grant!: (mutation: SessionMutation) => void; let rejectGrant!: (error: unknown) => void; @@ -224,20 +247,16 @@ export class StorageBackedSession((resolve) => { release = resolve; }); - const line = this.laneMutationLine.run(lane, async () => { - grant(new StorageBackedSessionMutation(lane, this.storage, release)); + const line = this.mutationLine.run(async () => { + grant(new StorageBackedSessionMutation(this.storage, release)); await finished; }); void line.catch(rejectGrant); return granted; } - async mutate( - lane: string, - mutation: (mutator: SessionMutator, context: Context) => T | Promise, - context: Context, - ): Promise { - const mutator = await this.beginMutation(lane, context); + async mutate(mutation: SessionMutationCallback, context: Context): Promise { + const mutator = await this.beginMutation(context); try { return await mutation(mutator, context); } finally { @@ -250,6 +269,10 @@ export class StorageBackedSession { + return (await this.getEntries([id], context)).get(id); + } + async getValue(address: Value, context: Context): Promise | undefined> { this.assertOpen(); return this.storage.getValue(address, context); @@ -274,136 +297,19 @@ export class StorageBackedSession this.getLeafIdForLane(lane, context), - getEntry: (id, context) => this.getEntry(id, context), - getStats: (context) => this.getStats(context), - getValue: (address, context) => this.getValue(address, context), - scanValues: (prefix, context) => this.scanValues(prefix, context), - readList: (address, options, context) => this.readList(address, options, context), - setValue: (address, next, context) => this.setValueForLane(lane, address, next, context), - deleteValue: (address, context) => this.deleteValueForLane(lane, address, context), - appendList: (address, element, context) => this.appendListForLane(lane, address, element, context), - deleteList: (address, context) => this.deleteListForLane(lane, address, context), - getName: (context) => this.getName(context), - setName: (name, context) => this.setNameForLane(lane, name, context), - getLabel: (targetId, context) => this.getLabel(targetId, context), - setLabel: (targetId, label, context) => this.setLabelForLane(lane, targetId, label, context), - findEntries: (query, context) => this.findEntries(query, context), - findEntry: (query, context) => this.findEntry(query, context), - findEntriesOnBranch: (query, context) => this.findEntriesOnBranchForLane(lane, query, context), - findEntryOnBranch: (query, context) => this.findEntryOnBranchForLane(lane, query, context), - appendMessage: (message, context) => this.appendMessageForLane(lane, message, context), - appendCustomEntry: (customType, data, context) => - this.appendCustomEntryForLane(lane, customType, data, context), - }; - } - - async createLane( - name: string, - at: string | null, - configuration: LaneConfiguration, - onCommitted: ((context: Context) => void | Promise) | undefined, - context: Context, - ): Promise { - this.assertOpen(); - 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); - } - - getLeafId(context: Context): Promise { - return this.getLeafIdForLane("main", context); - } - - async getEntry(id: string, context: Context): Promise { - return (await this.getEntries([id], context)).get(id); - } - async getStats(context: Context): Promise { this.assertOpen(); return this.storage.getStats(context); } - setValue(address: Value, next: NoInfer, context: Context): Promise { - return this.setValueForLane("main", address, next, context); - } - - deleteValue(address: Value, context: Context): Promise { - return this.deleteValueForLane("main", address, context); - } - - appendList(address: ValueList, element: NoInfer, context: Context): Promise { - return this.appendListForLane("main", address, element, context); - } - - deleteList(address: ValueList, context: Context): Promise { - return this.deleteListForLane("main", address, context); - } - async getName(context: Context): Promise { return (await this.getValue(sessionName, context))?.value; } - setName(name: string | undefined, context: Context): Promise { - return this.setNameForLane("main", name, context); - } - async getLabel(targetId: string, context: Context): Promise { return (await this.getValue(entryLabel(targetId), context))?.value; } - setLabel(targetId: string, label: string | undefined, context: Context): Promise { - return this.setLabelForLane("main", targetId, label, context); - } - async findEntries(query: EntryQuery | undefined, context: Context): Promise { query ??= {}; this.assertOpen(); @@ -430,44 +336,69 @@ export class StorageBackedSession { query ??= {}; - const entries = await this.findEntries( - { - ...query, - limit: query.limit === undefined ? 1 : Math.min(query.limit, 1), - }, - context, - ); - return entries[0]; + return ( + await this.findEntries({ ...query, limit: query.limit === undefined ? 1 : Math.min(query.limit, 1) }, context) + )[0]; } - findEntriesOnBranch(query: BranchScan | undefined, context: Context): Promise { - return this.findEntriesOnBranchForLane("main", query, context); + async branch(name: string, context: Context): Promise { + this.assertValidBranchName(name); + if ((await this.getValue(branchTip(name), context)) === undefined) return undefined; + return this.getOrCreateBranchObject(name); } - findEntryOnBranch(query: BranchScan | undefined, context: Context): Promise { - return this.findEntryOnBranchForLane("main", query, context); + async createBranch(name: string, at: string | null, context: Context): Promise { + this.assertOpen(); + this.assertValidBranchName(name); + await this.mutate(async (mutator) => { + if ((await mutator.getValue(branchTip(name), context)) !== undefined) { + throw new SessionBranchExistsError(name); + } + if (at !== null && !(await mutator.getEntries([at], context)).has(at)) { + throw new SessionUnknownTargetError(at); + } + await mutator.commit([setValueWrite(branchTip(name), at)], context); + }, context); + return this.getOrCreateBranchObject(name); } - appendMessage(message: AgentMessage, context: Context): Promise { - return this.captureAppend("main", { type: "message", payload: message }, context); + setValue(address: Value, next: NoInfer, context: Context): Promise { + return this.mutate(async (mutator) => { + await mutator.commit([setValueWrite(address, next)], context); + }, context); } - appendCustomEntry(customType: string, data: JsonValue | undefined, context: Context): Promise { - return this.captureAppend( - "main", - { - type: "custom", - customType, - ...(data === undefined ? {} : { payload: data }), - }, - context, - ); + deleteValue(address: Value, context: Context): Promise { + return this.mutate(async (mutator) => { + await mutator.commit([deleteValueWrite(address)], context); + }, context); + } + + appendList(address: ValueList, element: NoInfer, context: Context): Promise { + return this.mutate(async (mutator) => { + await mutator.commit([appendListWrite(address, element)], context); + }, context); + } + + deleteList(address: ValueList, context: Context): Promise { + return this.mutate(async (mutator) => { + await mutator.commit([deleteListWrite(address)], context); + }, context); + } + + setName(name: string | undefined, context: Context): Promise { + return name === undefined ? this.deleteValue(sessionName, context) : this.setValue(sessionName, name, context); + } + + setLabel(targetId: string, label: string | undefined, context: Context): Promise { + const address = entryLabel(targetId); + return label === undefined ? this.deleteValue(address, context) : this.setValue(address, label, context); } close(context: Context): Promise { if (this.closePromise !== undefined) return this.closePromise; this.state = "closing"; - this.closePromise = this.laneMutationLine + this.closePromise = this.mutationLine .seal(this.closedError) .then(() => this.storage.close(context)) .finally(() => { @@ -477,215 +408,59 @@ export class StorageBackedSession { - const leaf = await this.getValue(laneLeaf(lane), context); - if (leaf === undefined) throw new Error(`Unknown lane: ${lane}`); - return leaf.value; + async getBranchTip(name: string, context: Context): Promise { + const stored = await this.getValue(branchTip(name), context); + if (stored === undefined) throw new SessionInvariantError(`Unknown branch: ${name}`); + return stored.value; } - private async findEntriesOnBranchForLane( - lane: string, - query: BranchScan | undefined, - context: Context, - ): Promise { - query ??= {}; - this.assertOpen(); - const start = query.start ?? (await this.getLeafIdForLane(lane, context)); - if (start === null) return []; - return this.storage.scanBranch({ ...query, start, order: query.order ?? "newestFirst" }, context); - } - - private async findEntryOnBranchForLane( - lane: string, - query: BranchScan | undefined, - context: Context, - ): Promise { - query ??= {}; - const entries = await this.findEntriesOnBranchForLane( - lane, - { - ...query, - limit: query.limit === undefined ? 1 : Math.min(query.limit, 1), - }, - context, - ); - return entries[0]; - } - - private setValueForLane(lane: string, address: Value, next: NoInfer, context: Context): Promise { - return this.mutate( - lane, - async (mutator) => { - await mutator.commit([setValueWrite(address, next)], context); - }, - context, - ); - } - - private deleteValueForLane(lane: string, address: Value, context: Context): Promise { - return this.mutate( - lane, - async (mutator) => { - await mutator.commit([deleteValueWrite(address)], context); - }, - context, - ); - } - - private appendListForLane( - lane: string, - address: ValueList, - element: NoInfer, - context: Context, - ): Promise { - return this.mutate( - lane, - async (mutator) => { - await mutator.commit([appendListWrite(address, element)], context); - }, - context, - ); - } - - private deleteListForLane(lane: string, address: ValueList, context: Context): Promise { - return this.mutate( - lane, - async (mutator) => { - await mutator.commit([deleteListWrite(address)], context); - }, - context, - ); - } - - private setNameForLane(lane: string, name: string | undefined, context: Context): Promise { - return name === undefined - ? this.deleteValueForLane(lane, sessionName, context) - : this.setValueForLane(lane, sessionName, name, context); - } - - private setLabelForLane(lane: string, targetId: string, label: string | undefined, context: Context): Promise { - const address = entryLabel(targetId); - return label === undefined - ? this.deleteValueForLane(lane, address, context) - : this.setValueForLane(lane, address, label, context); - } - - private appendMessageForLane(lane: string, message: AgentMessage, context: Context): Promise { - return this.captureAppend(lane, { type: "message", payload: message }, context); - } - - private appendCustomEntryForLane( - lane: string, - customType: string, - data: JsonValue | undefined, + async appendToBranch( + name: string, + entry: { type: "message"; message: AgentMessage } | { type: "custom"; customType: string; data?: JsonValue }, context: Context, ): Promise { - return this.captureAppend( - lane, - { - type: "custom", - customType, - ...(data === undefined ? {} : { payload: data }), - }, - context, - ); - } - - private async captureAppend(lane: string, pending: PendingEntry, context: Context): Promise { this.assertOpen(); - if ( - pending.type === "message" && - pending.payload.role === "assistant" && - pending.payload.stopReason === "pending" - ) { + if (entry.type === "message" && entry.message.role === "assistant" && entry.message.stopReason === "pending") { throw new SessionPendingAssistantMessageError(); } - return this.appendCaptured(lane, this.idGenerator.next(), pending, context); - } - - private async appendCaptured(lane: string, id: string, pending: PendingEntry, context: Context): Promise { - await this.mutate(lane, (mutator) => this.appendCapturedIfReady(mutator, id, pending, context), context); - return id; - } - - private async appendCapturedIfReady( - mutator: SessionMutator, - id: string, - pending: PendingEntry, - context: Context, - ): Promise { - const { lane } = mutator; - const [leaf, storedLaneState] = await Promise.all([ - mutator.getValue(laneLeaf(lane), context), - mutator.getValue(laneState(lane), context), - ]); - if (leaf === undefined) throw new SessionInvariantError(`Unknown lane: ${lane}`); - if (storedLaneState === undefined) - throw new SessionInvariantError(`Lane ${JSON.stringify(lane)} is missing lane.state`); - const operationId = storedLaneState.value.currentOperationId; - if (operationId === null) { + const id = this.idGenerator.next(); + await this.mutate(async (mutator) => { + const tip = await mutator.getValue(branchTip(name), context); + if (tip === undefined) throw new SessionInvariantError(`Unknown branch: ${name}`); await mutator.commit( [ insertEntry( - pending.type === "message" - ? { id, parentId: leaf.value, type: "message", message: pending.payload } + entry.type === "message" + ? { id, parentId: tip.value, type: "message", message: entry.message } : { id, - parentId: leaf.value, + parentId: tip.value, type: "custom", - customType: pending.customType, - ...(pending.payload === undefined ? {} : { data: pending.payload }), + customType: entry.customType, + ...(entry.data === undefined ? {} : { data: entry.data }), }, ), - setValueWrite(laneLeaf(lane), id), + setValueWrite(branchTip(name), id), ], context, ); - return; - } - - const [operation, storedOperationState] = await Promise.all([ - mutator.getValue(operationMeta(operationId), context), - mutator.getValue(operationState(operationId), context), - ]); - if (operation === undefined) { - throw new SessionInvariantError(`Active operation ${operationId} is missing op.meta`); - } - if (storedOperationState === undefined) { - throw new SessionInvariantError(`Active operation ${operationId} is missing op.state`); - } - this.validateCurrentOperation(lane, operation.value, storedOperationState.value); - if (storedOperationState.value.kind !== "run") { - // TODO: Tree writes during structural operations must wait for the operation to finish, - // then re-evaluate the lane state. That coordination is not yet implemented. - throw new Error(`Cannot append while structural operation ${operationId} is active`); - } - - await mutator.commit( - [ - setValueWrite(pendingEntry(id), pending), - setValueWrite(operationState(operationId), { - ...storedOperationState.value, - inbox: { - ...storedOperationState.value.inbox, - writes: [...storedOperationState.value.inbox.writes, id], - }, - }), - ], - context, - ); + }, context); + return id; } - private validateCurrentOperation(lane: string, operation: OperationMeta, state: OperationState): void { - if (operation.lane !== lane) { - throw new SessionInvariantError( - `Active operation ${operation.operationId} belongs to lane ${JSON.stringify(operation.lane)}, not ${JSON.stringify(lane)}`, - ); + private getOrCreateBranchObject(name: string): Branch { + let branch = this.branches.get(name); + if (branch === undefined) { + branch = new StorageBackedBranch(name, this); + this.branches.set(name, branch); } - if (operation.intent.kind !== state.kind) { - throw new SessionInvariantError( - `Active operation ${operation.operationId} intent ${operation.intent.kind} does not match state ${state.kind}`, - ); + return branch; + } + + private assertValidBranchName(name: string): void { + if (name.length === 0) throw new SessionInvalidBranchError(name, "branch name must not be empty"); + if (name.includes("\u0000")) { + throw new SessionInvalidBranchError(name, "branch name must not contain \\u0000"); } } diff --git a/packages/agent/src/harness/session/testing/benchmark/datasets.ts b/packages/agent/src/harness/session/testing/benchmark/datasets.ts index 2d9b1f85c..95f9f70bd 100644 --- a/packages/agent/src/harness/session/testing/benchmark/datasets.ts +++ b/packages/agent/src/harness/session/testing/benchmark/datasets.ts @@ -3,7 +3,7 @@ export interface StorageBenchmarkDataset { readonly entryCount: number; readonly payloadBytes: number; readonly lookupIds: readonly string[]; - readonly leafId: string; + readonly tipId: string; } /** Package-internal deterministic id shared by dataset and transaction generation. */ @@ -21,7 +21,7 @@ function createDataset(scale: string, entryCount: number): StorageBenchmarkDatas const entryIndex = Math.floor((index * (entryCount - 1)) / Math.max(1, lookupCount - 1)); return storageBenchmarkEntryId(entryIndex); }), - leafId: storageBenchmarkEntryId(entryCount - 1), + tipId: storageBenchmarkEntryId(entryCount - 1), }; } diff --git a/packages/agent/src/harness/session/testing/benchmark/session-repo.ts b/packages/agent/src/harness/session/testing/benchmark/session-repo.ts index ac0ec15c2..562082033 100644 --- a/packages/agent/src/harness/session/testing/benchmark/session-repo.ts +++ b/packages/agent/src/harness/session/testing/benchmark/session-repo.ts @@ -1,6 +1,6 @@ import { BACKGROUND_CONTEXT } from "../../../context.ts"; import type { SessionMetadata, SessionRepo } from "../../types.ts"; -import { laneLeaf, setValue } from "../../values.ts"; +import { branchTip, setValue } from "../../values.ts"; import { STORAGE_BENCHMARK_DATASETS, type StorageBenchmarkDataset } from "./datasets.ts"; import { generateStorageBenchmarkSeedTransactions } from "./storage.ts"; @@ -139,11 +139,10 @@ export async function seedSessionRepoForkBenchmark( ): Promise { const session = await repo.create({ id: BENCHMARK_SESSION_ID }, BACKGROUND_CONTEXT); for (const transaction of generateStorageBenchmarkSeedTransactions(dataset)) { - await session.mutate("main", (mutator) => mutator.commit(transaction, BACKGROUND_CONTEXT), BACKGROUND_CONTEXT); + await session.mutate((mutator) => mutator.commit(transaction, BACKGROUND_CONTEXT), BACKGROUND_CONTEXT); } await session.mutate( - "main", - (mutator) => mutator.commit([setValue(laneLeaf("main"), dataset.leafId)], BACKGROUND_CONTEXT), + (mutator) => mutator.commit([setValue(branchTip("main"), dataset.tipId)], BACKGROUND_CONTEXT), BACKGROUND_CONTEXT, ); return session.metadata; diff --git a/packages/agent/src/harness/session/testing/benchmark/storage.ts b/packages/agent/src/harness/session/testing/benchmark/storage.ts index 5c66decbd..9f1c49579 100644 --- a/packages/agent/src/harness/session/testing/benchmark/storage.ts +++ b/packages/agent/src/harness/session/testing/benchmark/storage.ts @@ -1,7 +1,7 @@ import { BACKGROUND_CONTEXT } from "../../../context.ts"; import { insertEntry, insertUsage } from "../../commit.ts"; import type { MessageEntry, NewEntry, Storage, Write } from "../../types.ts"; -import { laneLeaf, setValue } from "../../values.ts"; +import { branchTip, setValue } from "../../values.ts"; import { STORAGE_BENCHMARK_DATASETS, type StorageBenchmarkDataset, storageBenchmarkEntryId } from "./datasets.ts"; const MESSAGE_TIMESTAMP = 1_650_000_000_000; @@ -66,7 +66,7 @@ const hundredEntryTransaction = createStorageBenchmarkTransaction(0, 100, 256); const appendedEntryId = storageBenchmarkEntryId(WRITE_BASELINE_DATASET.entryCount); const mixedAppendTransaction: Write[] = [ ...createStorageBenchmarkTransaction(WRITE_BASELINE_DATASET.entryCount, 1, WRITE_BASELINE_DATASET.payloadBytes), - setValue(laneLeaf("main"), appendedEntryId), + setValue(branchTip("main"), appendedEntryId), insertUsage({ id: "benchmark-usage", entryId: appendedEntryId, @@ -108,7 +108,7 @@ export const STORAGE_READ_BENCHMARK_SCENARIOS: readonly StorageReadBenchmarkScen return dataset.entryCount; }, async run(storage, dataset) { - return (await storage.scanBranchStructure({ start: dataset.leafId, order: "newestFirst" }, BACKGROUND_CONTEXT)) + return (await storage.scanBranchStructure({ start: dataset.tipId, order: "newestFirst" }, BACKGROUND_CONTEXT)) .length; }, }, diff --git a/packages/agent/src/harness/session/testing/conformance/session-repo.ts b/packages/agent/src/harness/session/testing/conformance/session-repo.ts index 1505e31db..159293bbd 100644 --- a/packages/agent/src/harness/session/testing/conformance/session-repo.ts +++ b/packages/agent/src/harness/session/testing/conformance/session-repo.ts @@ -2,13 +2,21 @@ import { deepStrictEqual, rejects, strictEqual } from "node:assert/strict"; import type { AssistantMessage, StopReason } from "@earendil-works/pi-ai"; import { BACKGROUND_CONTEXT } from "../../../context.ts"; import { insertEntry, insertUsage } from "../../commit.ts"; -import type { JsonValue, LaneConfiguration, LaneState, SessionMetadata, SessionRepo, UsageRow } from "../../types.ts"; +import type { + JsonValue, + LaneConfiguration, + LaneState, + Session, + SessionMetadata, + SessionRepo, + UsageRow, +} from "../../types.ts"; import { appendList, + branchTip, entryLabel, laneConfig, laneLastResult, - laneLeaf, laneState, list, operationMeta, @@ -28,7 +36,10 @@ const SIBLING_ID = "00000000-0000-7000-8000-000000000003"; const USAGE_ID = "00000000-0000-7000-8000-000000000004"; const OPERATION_ID = "00000000-0000-7000-8000-000000000005"; const PENDING_ID = "00000000-0000-7000-8000-000000000006"; -const idleLaneState = { currentOperationId: null, pendingNextRun: [] } satisfies LaneState; +const idleLaneState = { + currentOperationId: null, + pendingNextRun: [], +} satisfies LaneState; const applicationValue = value("test.application.value"); const applicationList = list("test.application.list"); const configuration = { @@ -37,6 +48,10 @@ const configuration = { activeToolNames: ["read"], } satisfies LaneConfiguration; +async function getBranchTip(session: Session, name = "main"): Promise { + return (await session.branch(name, BACKGROUND_CONTEXT))?.getTipId(BACKGROUND_CONTEXT); +} + function assistantMessage(stopReason: StopReason): AssistantMessage { return { role: "assistant", @@ -57,7 +72,14 @@ function assistantMessage(stopReason: StopReason): AssistantMessage { }, stopReason, ...(stopReason === "deferred" - ? { deferred: { provider: "anthropic", modelId: "claude-sonnet-4-5", api: "anthropic-messages", id: "job" } } + ? { + deferred: { + provider: "anthropic", + modelId: "claude-sonnet-4-5", + api: "anthropic-messages", + id: "job", + }, + } : {}), timestamp: 1, }; @@ -87,7 +109,10 @@ function prepareRepoCaseFactory( factory: () => Promise, onClose?: () => void | Promise, ): () => Promise> { - return async () => ({ repo: await factory(), ...(onClose === undefined ? {} : { close: onClose }) }); + return async () => ({ + repo: await factory(), + ...(onClose === undefined ? {} : { close: onClose }), + }); } function createCase( @@ -121,23 +146,40 @@ export function createSessionRepoLifecycleConformance { const session = await repo.create({ id: "session" }, BACKGROUND_CONTEXT); strictEqual(session.metadata.id, "session"); strictEqual(Number.isSafeInteger(session.metadata.createdAt), true); strictEqual(session.metadata.storageVersion, 1); - strictEqual(await session.getLeafId(BACKGROUND_CONTEXT), null); - deepStrictEqual((await session.getValue(laneState("main"), BACKGROUND_CONTEXT))?.value, { - currentOperationId: null, - pendingNextRun: [], - }); + strictEqual(await session.branch("main", BACKGROUND_CONTEXT), undefined); + strictEqual(await session.getValue(laneState("main"), BACKGROUND_CONTEXT), undefined); strictEqual(await session.getValue(laneConfig("main"), BACKGROUND_CONTEXT), undefined); await rejects(repo.create({ id: "session" }, BACKGROUND_CONTEXT)); await session.close(BACKGROUND_CONTEXT); }, ), + createCase( + factory, + "lifecycle", + "close drains an acquired scope and rejects a queued mutation callback", + async ({ repo }) => { + const session = await repo.create({ id: "session" }, BACKGROUND_CONTEXT); + const active = await session.beginMutation(BACKGROUND_CONTEXT); + let queuedStarted = false; + const queued = session.mutate(() => { + queuedStarted = true; + }, BACKGROUND_CONTEXT); + void queued.catch(() => {}); + const closing = session.close(BACKGROUND_CONTEXT); + + await active.end(BACKGROUND_CONTEXT); + await rejects(queued); + await closing; + strictEqual(queuedStarted, false); + }, + ), createCase( factory, "lifecycle", @@ -214,10 +256,11 @@ export function createSessionRepoMessageConformance { const session = await repo.create({ id: "session" }, BACKGROUND_CONTEXT); + const branch = await session.createBranch("main", null, BACKGROUND_CONTEXT); - await rejects(session.appendMessage(assistantMessage("pending"), BACKGROUND_CONTEXT)); + await rejects(branch.appendMessage(assistantMessage("pending"), BACKGROUND_CONTEXT)); - strictEqual(await session.getLeafId(BACKGROUND_CONTEXT), null); + strictEqual(await getBranchTip(session), null); deepStrictEqual(await session.findEntries(undefined, BACKGROUND_CONTEXT), []); await session.close(BACKGROUND_CONTEXT); }, @@ -232,10 +275,11 @@ export function createSessionRepoMessageConformance, AssistantMessage>; + const branch = await session.createBranch("main", null, BACKGROUND_CONTEXT); const messages = Object.values(messagesByStopReason); const ids: string[] = []; - for (const message of messages) ids.push(await session.appendMessage(message, BACKGROUND_CONTEXT)); + for (const message of messages) ids.push(await branch.appendMessage(message, BACKGROUND_CONTEXT)); const entries = await session.findEntries({ order: "asc", type: "message" }, BACKGROUND_CONTEXT); deepStrictEqual( @@ -246,7 +290,7 @@ export function createSessionRepoMessageConformance { + createCase(factory, "forks", "tree-forks a fresh session before first attachment", async ({ repo }) => { const source = await repo.create({ id: "source" }, BACKGROUND_CONTEXT); - const fork = await repo.fork(source.metadata, { id: "fork" }, BACKGROUND_CONTEXT); + const fork = await repo.fork(source.metadata, { id: "fork", scope: "tree" }, BACKGROUND_CONTEXT); strictEqual(fork.metadata.id, "fork"); strictEqual(fork.metadata.parentSessionId, "source"); - strictEqual(await fork.getLeafId(BACKGROUND_CONTEXT), null); + strictEqual(await fork.branch("main", BACKGROUND_CONTEXT), undefined); strictEqual(await fork.getValue(laneConfig("main"), BACKGROUND_CONTEXT), undefined); - deepStrictEqual((await fork.getValue(laneState("main"), BACKGROUND_CONTEXT))?.value, { - currentOperationId: null, - pendingNextRun: [], - }); + strictEqual(await fork.getValue(laneState("main"), BACKGROUND_CONTEXT), undefined); deepStrictEqual(await fork.findEntries(undefined, BACKGROUND_CONTEXT), []); deepStrictEqual(await fork.getStats(BACKGROUND_CONTEXT), { messageCount: 0, @@ -280,7 +321,13 @@ export function createSessionRepoForkBehaviorConformance { const source = await repo.create({ id: "source" }, BACKGROUND_CONTEXT); await source.mutate( - "main", (mutator) => mutator.commit( [ - insertEntry({ id: ROOT_ID, parentId: null, type: "custom", customType: "root" }), + insertEntry({ + id: ROOT_ID, + parentId: null, + type: "custom", + customType: "root", + }), insertEntry({ id: CHILD_ID, parentId: ROOT_ID, @@ -309,7 +360,7 @@ export function createSessionRepoForkBehaviorConformance id), [ROOT_ID, CHILD_ID], ); - strictEqual(await fork.getLeafId(BACKGROUND_CONTEXT), CHILD_ID); + strictEqual(await getBranchTip(fork), CHILD_ID); deepStrictEqual((await fork.getValue(laneConfig("main"), BACKGROUND_CONTEXT))?.value, configuration); deepStrictEqual((await fork.getValue(laneState("main"), BACKGROUND_CONTEXT))?.value, idleLaneState); strictEqual(await fork.getName(BACKGROUND_CONTEXT), "source name"); @@ -408,18 +468,22 @@ export function createSessionRepoForkBehaviorConformance { const source = await repo.create({ id: "source" }, BACKGROUND_CONTEXT); await source.mutate( - "main", (mutator) => mutator.commit( [ - insertEntry({ id: ROOT_ID, parentId: null, type: "custom", customType: "root" }), + insertEntry({ + id: ROOT_ID, + parentId: null, + type: "custom", + customType: "root", + }), insertEntry({ id: CHILD_ID, parentId: ROOT_ID, type: "custom", customType: "child", }), - setValue(laneLeaf("main"), CHILD_ID), + setValue(branchTip("main"), CHILD_ID), ], BACKGROUND_CONTEXT, ), @@ -431,7 +495,7 @@ export function createSessionRepoForkBehaviorConformance id), [ROOT_ID], @@ -447,12 +511,16 @@ export function createSessionRepoForkBehaviorConformance { const source = await repo.create({ id: "source" }, BACKGROUND_CONTEXT); await source.mutate( - "main", (mutator) => mutator.commit( [ - insertEntry({ id: ROOT_ID, parentId: null, type: "custom", customType: "root" }), - setValue(laneLeaf("main"), ROOT_ID), + insertEntry({ + id: ROOT_ID, + parentId: null, + type: "custom", + customType: "root", + }), + setValue(branchTip("main"), ROOT_ID), setValue(applicationValue, "excluded"), appendList(applicationList, "excluded"), ], @@ -463,7 +531,9 @@ export function createSessionRepoForkBehaviorConformance { const source = await repo.create({ id: "source" }, BACKGROUND_CONTEXT); await source.mutate( - "main", (mutator) => mutator.commit( [ - insertEntry({ id: ROOT_ID, parentId: null, type: "custom", customType: "root" }), - insertEntry({ id: CHILD_ID, parentId: ROOT_ID, type: "custom", customType: "child" }), + insertEntry({ + id: ROOT_ID, + parentId: null, + type: "custom", + customType: "root", + }), + insertEntry({ + id: CHILD_ID, + parentId: ROOT_ID, + type: "custom", + customType: "child", + }), insertEntry({ id: SIBLING_ID, parentId: ROOT_ID, type: "custom", customType: "sibling", }), - setValue(laneLeaf("main"), CHILD_ID), + setValue(branchTip("main"), CHILD_ID), setValue(laneConfig("main"), configuration), - setValue(laneLeaf("review"), SIBLING_ID), + setValue(laneState("main"), idleLaneState), + setValue(branchTip("review"), SIBLING_ID), setValue(laneConfig("review"), configuration), setValue(laneState("review"), idleLaneState), + setValue(branchTip("notes"), ROOT_ID), ], BACKGROUND_CONTEXT, ), @@ -501,8 +582,11 @@ export function createSessionRepoForkBehaviorConformance id), [ROOT_ID, CHILD_ID, SIBLING_ID], ); - strictEqual(await fork.getLeafId(BACKGROUND_CONTEXT), CHILD_ID); - strictEqual(await fork.view("review").getLeafId(BACKGROUND_CONTEXT), SIBLING_ID); + strictEqual(await getBranchTip(fork), CHILD_ID); + strictEqual(await getBranchTip(fork, "review"), SIBLING_ID); + strictEqual(await getBranchTip(fork, "notes"), ROOT_ID); + strictEqual(await fork.getValue(laneConfig("notes"), BACKGROUND_CONTEXT), undefined); + strictEqual(await fork.getValue(laneState("notes"), BACKGROUND_CONTEXT), undefined); deepStrictEqual((await fork.getValue(laneConfig("review"), BACKGROUND_CONTEXT))?.value, configuration); deepStrictEqual((await fork.getValue(laneState("review"), BACKGROUND_CONTEXT))?.value, idleLaneState); await Promise.all([source.close(BACKGROUND_CONTEXT), fork.close(BACKGROUND_CONTEXT)]); @@ -525,7 +609,7 @@ export function createSessionRepoForkDestinationReservationConformance { const source = await repo.create({ id: "source" }, BACKGROUND_CONTEXT); const results = await Promise.allSettled([ - repo.fork(source.metadata, { id: "destination" }, BACKGROUND_CONTEXT), + repo.fork(source.metadata, { id: "destination", scope: "tree" }, BACKGROUND_CONTEXT), repo.create({ id: "destination" }, BACKGROUND_CONTEXT), ]); strictEqual(results[0].status, "fulfilled"); @@ -565,11 +649,16 @@ export function createSessionRepoForkSourceSnapshotConformance { const source = await repo.create({ id: "source" }, BACKGROUND_CONTEXT); - const firstMutation = await source.beginMutation("main", BACKGROUND_CONTEXT); + const firstMutation = await source.beginMutation(BACKGROUND_CONTEXT); const firstCommit = firstMutation.commit( [ - insertEntry({ id: ROOT_ID, parentId: null, type: "custom", customType: "first" }), - setValue(laneLeaf("main"), ROOT_ID), + insertEntry({ + id: ROOT_ID, + parentId: null, + type: "custom", + customType: "first", + }), + setValue(branchTip("main"), ROOT_ID), setValue(sessionName, "first name"), setValue(entryLabel(ROOT_ID), "first label"), ], @@ -577,7 +666,6 @@ export function createSessionRepoForkSourceSnapshotConformance mutator.commit( [ @@ -587,7 +675,7 @@ export function createSessionRepoForkSourceSnapshotConformance id), [ROOT_ID], diff --git a/packages/agent/src/harness/session/testing/conformance/storage.ts b/packages/agent/src/harness/session/testing/conformance/storage.ts index efec33b25..e1aaf6d23 100644 --- a/packages/agent/src/harness/session/testing/conformance/storage.ts +++ b/packages/agent/src/harness/session/testing/conformance/storage.ts @@ -16,10 +16,10 @@ import type { } from "../../types.ts"; import { appendList, + branchTip, deleteList, deleteValue, entryLabel, - laneLeaf, list, pendingEntry, setValue, @@ -318,7 +318,7 @@ export function createStorageConformance(factory: () => Promise) await storage.commit( [ setValue(pendingEntry(entry.id), { type: "message", payload: entry.message }), - setValue(laneLeaf("main"), null), + setValue(branchTip("main"), null), ], BACKGROUND_CONTEXT, ); @@ -328,10 +328,10 @@ export function createStorageConformance(factory: () => Promise) type: "message", payload: entry.message, }); - strictEqual((await storage.getValue(laneLeaf("main"), BACKGROUND_CONTEXT))?.value, null); + strictEqual((await storage.getValue(branchTip("main"), BACKGROUND_CONTEXT))?.value, null); const placement = await storage.commit( - [insertEntry(entry), deleteValue(pendingEntry(entry.id)), setValue(laneLeaf("main"), entry.id)], + [insertEntry(entry), deleteValue(pendingEntry(entry.id)), setValue(branchTip("main"), entry.id)], BACKGROUND_CONTEXT, ); @@ -340,8 +340,8 @@ export function createStorageConformance(factory: () => Promise) new Map([[entry.id, { ...entry, seq: placement.seqs[0], timestamp: placement.timestamp }]]), ); strictEqual(await storage.getValue(pendingEntry(entry.id), BACKGROUND_CONTEXT), undefined); - deepStrictEqual(await storage.getValue(laneLeaf("main"), BACKGROUND_CONTEXT), { - address: laneLeaf("main"), + deepStrictEqual(await storage.getValue(branchTip("main"), BACKGROUND_CONTEXT), { + address: branchTip("main"), value: entry.id, seq: placement.seqs[2], }); diff --git a/packages/agent/src/harness/session/types.ts b/packages/agent/src/harness/session/types.ts index 2e39ab23f..145ae4ce1 100644 --- a/packages/agent/src/harness/session/types.ts +++ b/packages/agent/src/harness/session/types.ts @@ -74,7 +74,7 @@ export interface LaneConfiguration { export interface OperationMeta { operationId: string; lane: string; - sourceLeafId: string | null; + sourceTipId: string | null; startedAt: number; intent: | { kind: "run"; promptEntryIds: string[] } @@ -296,8 +296,16 @@ export interface LaneState { pendingNextRun: string[]; } -type FailedLaneLastResult = { outcome: "failed"; error: OperationError; runCompletion?: never }; -type AbortedLaneLastResult = { outcome: "aborted"; error?: never; runCompletion?: never }; +type FailedLaneLastResult = { + outcome: "failed"; + error: OperationError; + runCompletion?: never; +}; +type AbortedLaneLastResult = { + outcome: "aborted"; + error?: never; + runCompletion?: never; +}; type StructuralLaneLastResultOutcome = | FailedLaneLastResult | AbortedLaneLastResult @@ -308,7 +316,7 @@ export type LaneLastResult = | ({ operationId: string; kind: "run"; - leafId: string; + tipId: string; finalAssistantEntryId?: string; } & ( | FailedLaneLastResult @@ -322,20 +330,30 @@ export type LaneLastResult = | ({ operationId: string; kind: "compaction"; - leafId: string; + tipId: string; finalAssistantEntryId?: never; } & StructuralLaneLastResultOutcome) | ({ operationId: string; kind: "navigation"; - leafId: string | null; - oldLeafId: string | null; + tipId: string | null; + oldTipId: string | null; finalAssistantEntryId?: never; } & ( | FailedLaneLastResult | AbortedLaneLastResult - | { outcome: "declined"; error?: never; runCompletion?: never; summaryEntryId?: never } - | { outcome: "completed"; error?: never; runCompletion?: never; summaryEntryId?: string } + | { + outcome: "declined"; + error?: never; + runCompletion?: never; + summaryEntryId?: never; + } + | { + outcome: "completed"; + error?: never; + runCompletion?: never; + summaryEntryId?: string; + } )); export type PendingEntry = @@ -495,62 +513,52 @@ export interface SessionReader { scanBranch(query: StorageBranchScan, context: Context): Promise; } -/** Exclusive mutation barrier bound to one lane. */ +/** Exclusive keyless mutation barrier for one Session. */ export interface SessionMutation extends SessionReader { - readonly lane: string; - /** The mutation's sole commit. A second attempt rejects. */ + /** Exactly zero or one commit attempt. A second attempt rejects. */ commit(writes: Write[], context: Context): Promise; /** Wait for any commit attempt, invalidate the capability, and release the barrier. */ end(context: Context): Promise; } -/** Callback-scoped mutation capability without authority to release its lane barrier. */ +/** Callback-scoped mutation capability without authority to release its Session barrier. */ export type SessionMutator = Omit; -export interface SessionTree { - getLeafId(context: Context): Promise; - getEntry(id: string, context: Context): Promise; - getStats(context: Context): Promise; - getValue(address: Value, context: Context): Promise | undefined>; - scanValues(prefix: Value, context: Context): Promise[]>; - readList( - address: ValueList, - options: ListReadOptions | undefined, - context: Context, - ): Promise[]>; - setValue(address: Value, next: NoInfer, context: Context): Promise; - deleteValue(address: Value, context: Context): Promise; - appendList(address: ValueList, element: NoInfer, context: Context): Promise; - deleteList(address: ValueList, context: Context): Promise; - getName(context: Context): Promise; - setName(name: string | undefined, context: Context): Promise; - getLabel(targetId: string, context: Context): Promise; - setLabel(targetId: string, label: string | undefined, context: Context): Promise; - findEntries(query: EntryQuery | undefined, context: Context): Promise; - findEntry(query: EntryQuery | undefined, context: Context): Promise; - findEntriesOnBranch(query: BranchScan | undefined, context: Context): Promise; - findEntryOnBranch(query: BranchScan | undefined, context: Context): Promise; +export type SessionMutationCallback = (mutator: SessionMutator, context: Context) => T | Promise; + +export interface Branch { + readonly name: string; + getTipId(context: Context): Promise; + findEntries(query: BranchScan | undefined, context: Context): Promise; + findEntry(query: BranchScan | undefined, context: Context): Promise; appendMessage(message: AgentMessage, context: Context): Promise; appendCustomEntry(customType: string, data: JsonValue | undefined, context: Context): Promise; } -export interface Session extends SessionTree, SessionReader { +export interface Session extends SessionReader { readonly metadata: TMetadata; readonly idGenerator: IdGenerator; - view(lane: string): SessionTree; - beginMutation(lane: string, context: Context): Promise; - mutate( - lane: string, - mutation: (mutator: SessionMutator, context: Context) => T | Promise, - context: Context, - ): Promise; - createLane( - name: string, - at: string | null, - configuration: LaneConfiguration, - onCommitted: ((context: Context) => void | Promise) | undefined, - context: Context, - ): Promise; + getEntry(id: string, context: Context): Promise; + getStats(context: Context): Promise; + getName(context: Context): Promise; + getLabel(targetId: string, context: Context): Promise; + findEntries(query: EntryQuery | undefined, context: Context): Promise; + findEntry(query: EntryQuery | undefined, context: Context): Promise; + branch(name: string, context: Context): Promise; + createBranch(name: string, at: string | null, context: Context): Promise; + beginMutation(context: Context): Promise; + /** + * Trusted exclusive callback over the Session mutation line. Calling a public Session writer from + * this callback queues it behind the callback; awaiting that nested writer therefore deadlocks. + * Use the supplied mutator for the callback's sole commit. + */ + mutate(mutation: SessionMutationCallback, context: Context): Promise; + setValue(address: Value, next: NoInfer, context: Context): Promise; + deleteValue(address: Value, context: Context): Promise; + appendList(address: ValueList, element: NoInfer, context: Context): Promise; + deleteList(address: ValueList, context: Context): Promise; + setName(name: string | undefined, context: Context): Promise; + setLabel(targetId: string, label: string | undefined, context: Context): Promise; close(context: Context): Promise; } @@ -562,13 +570,12 @@ export interface SessionCreateOptions { export type ForkOptions = | { /** - * Copy one branch path into the destination session's main lane. This is - * the default scope. The destination starts idle with a fresh lane state, - * no operation values, no pending entries, no last result, and an empty - * usage ledger. + * Copy one path into destination Branch main. A configured source AgentLane + * copies its configuration plus fresh idle state; a data-only Branch remains + * data-only. Operation/pending/result/usage state is excluded. */ scope?: "branch"; - /** Entry to fork from. Defaults to the source main lane's current leaf. */ + /** Entry to fork from. Defaults to the source main Branch's current tip. */ entryId?: string; /** * Whether the fork includes the selected entry or stops at its parent. @@ -580,9 +587,9 @@ export type ForkOptions = } | { /** - * Copy the whole conversation tree and every lane leaf/configuration. The - * destination starts idle with fresh lane states, no operation values, - * no pending entries, no last results, and an empty usage ledger. + * Copy the whole conversation tree and every Branch tip. Each configured + * AgentLane copies configuration plus fresh idle state; data-only Branches + * remain data-only. Operation/pending/result/usage state is excluded. */ scope: "tree"; /** Optional destination session id. */ diff --git a/packages/agent/src/harness/session/values.ts b/packages/agent/src/harness/session/values.ts index 889c4a80b..e9471262e 100644 --- a/packages/agent/src/harness/session/values.ts +++ b/packages/agent/src/harness/session/values.ts @@ -106,19 +106,41 @@ export function list(namespace: string, key = ""): ValueList { } export function setValue(address: Value, next: NoInfer): ValueSetWrite { - return { kind: "value", op: "set", namespace: address.namespace, key: address.key, value: next }; + return { + kind: "value", + op: "set", + namespace: address.namespace, + key: address.key, + value: next, + }; } export function deleteValue(address: Value): ValueDeleteWrite { - return { kind: "value", op: "delete", namespace: address.namespace, key: address.key }; + return { + kind: "value", + op: "delete", + namespace: address.namespace, + key: address.key, + }; } export function appendList(address: ValueList, element: NoInfer): ListAppendWrite { - return { kind: "list", op: "append", namespace: address.namespace, key: address.key, value: element }; + return { + kind: "list", + op: "append", + namespace: address.namespace, + key: address.key, + value: element, + }; } export function deleteList(address: ValueList): ListDeleteWrite { - return { kind: "list", op: "delete", namespace: address.namespace, key: address.key }; + return { + kind: "list", + op: "delete", + namespace: address.namespace, + key: address.key, + }; } export function resolveListReadOptions(options: ListReadOptions = {}): ResolvedListReadOptions { @@ -133,11 +155,11 @@ export function resolveListReadOptions(options: ListReadOptions = {}): ResolvedL }; } -export const laneLeaf = (lane: string) => value("pi.lane.leaf", lane); +export const branchTip = (branch: string) => value("pi.branch.tip", branch); +export const branchTipInventoryPrefix = () => value("pi.branch.tip"); export const laneConfig = (lane: string) => value("pi.lane.config", lane); export const laneState = (lane: string) => value("pi.lane.state", lane); export const laneLastResult = (lane: string) => value("pi.lane.lastResult", lane); -export const laneLeafInventoryPrefix = () => value("pi.lane.leaf"); export const operationMeta = (operationId: string) => value("pi.op.meta", operationId); export const operationState = (operationId: string) => value("pi.op.state", operationId); diff --git a/packages/agent/test/harness/branch-summarization.test.ts b/packages/agent/test/harness/branch-summarization.test.ts index 09bbe74bf..6e8a6a5ca 100644 --- a/packages/agent/test/harness/branch-summarization.test.ts +++ b/packages/agent/test/harness/branch-summarization.test.ts @@ -2,7 +2,7 @@ import type { AgentMessage } from "@earendil-works/pi-agent-core"; import { describe, expect, it } from "vitest"; import { collectEntriesForBranchSummary } from "../../src/harness/compaction/branch-summarization.ts"; import { BACKGROUND_CONTEXT } from "../../src/harness/context.ts"; -import type { Entry, MessageEntry, SessionTree } from "../../src/harness/session/index.ts"; +import type { Branch, Entry, MessageEntry, Session } from "../../src/harness/session/index.ts"; function message(text: string): AgentMessage { return { role: "user", content: [{ type: "text", text }], timestamp: 1 }; @@ -12,22 +12,29 @@ function messageEntry(id: string, parentId: string | null, text: string, seq: nu return { type: "message", id, parentId, message: message(text), seq, timestamp: seq }; } -function branchReader(entries: Entry[]): Pick { +function branchReader(entries: Entry[]): { + branch: Pick; + session: Pick; +} { const byId = new Map(entries.map((entry) => [entry.id, entry])); return { - async getEntry(id) { - return byId.get(id); + session: { + async getEntry(id) { + return byId.get(id); + }, }, - async findEntriesOnBranch(query = {}) { - const path: Entry[] = []; - let currentId = query.start ?? null; - while (currentId !== null) { - const entry = byId.get(currentId); - if (!entry) throw new Error(`Unknown entry ${currentId}`); - path.push(entry); - currentId = entry.parentId; - } - return path; + branch: { + async findEntries(query = {}) { + const path: Entry[] = []; + let currentId = query.start ?? null; + while (currentId !== null) { + const entry = byId.get(currentId); + if (!entry) throw new Error(`Unknown entry ${currentId}`); + path.push(entry); + currentId = entry.parentId; + } + return path; + }, }, }; } @@ -39,9 +46,15 @@ describe("v4 branch summarization", () => { const abandoned1 = messageEntry("abandoned-1", common.id, "abandoned 1", 3); const abandoned2 = messageEntry("abandoned-2", abandoned1.id, "abandoned 2", 4); const target = messageEntry("target", common.id, "target", 5); - const session = branchReader([root, common, abandoned1, abandoned2, target]); + const { branch, session } = branchReader([root, common, abandoned1, abandoned2, target]); - const result = await collectEntriesForBranchSummary(session, abandoned2.id, target.id, BACKGROUND_CONTEXT); + const result = await collectEntriesForBranchSummary( + branch, + session, + abandoned2.id, + target.id, + BACKGROUND_CONTEXT, + ); expect(result.commonAncestorId).toBe(common.id); expect(result.entries.map((entry) => entry.id)).toEqual([abandoned1.id, abandoned2.id]); expect(result.entries.some((entry) => entry.id === root.id)).toBe(false); @@ -49,11 +62,10 @@ describe("v4 branch summarization", () => { it("returns no entries when there was no previous leaf", async () => { const target = messageEntry("target", null, "target", 1); - expect(await collectEntriesForBranchSummary(branchReader([target]), null, target.id, BACKGROUND_CONTEXT)).toEqual( - { - entries: [], - commonAncestorId: null, - }, - ); + const { branch, session } = branchReader([target]); + expect(await collectEntriesForBranchSummary(branch, session, null, target.id, BACKGROUND_CONTEXT)).toEqual({ + entries: [], + commonAncestorId: null, + }); }); }); diff --git a/packages/agent/test/harness/branch.test.ts b/packages/agent/test/harness/branch.test.ts new file mode 100644 index 000000000..dbe7f922c --- /dev/null +++ b/packages/agent/test/harness/branch.test.ts @@ -0,0 +1,110 @@ +import { describe, expect, it } from "vitest"; +import { BACKGROUND_CONTEXT } from "../../src/harness/context.ts"; +import { insertEntry } from "../../src/harness/session/commit.ts"; +import { MemoryStorage } from "../../src/harness/session/memory.ts"; +import { SessionPendingAssistantMessageError, StorageBackedSession } from "../../src/harness/session/session.ts"; +import { branchTip, setValue } from "../../src/harness/session/values.ts"; + +function createSession(): StorageBackedSession { + return new StorageBackedSession( + { id: "session", createdAt: 1, storageVersion: 1 }, + new MemoryStorage({ now: () => 10 }), + { idGenerator: { next: () => `entry-${nextId++}` } }, + ); +} + +let nextId = 1; + +describe("Branch", () => { + it("is absent until explicitly created and Session has no implicit Branch surface", async () => { + const session = createSession(); + expect(await session.branch("main", BACKGROUND_CONTEXT)).toBeUndefined(); + expect("getTipId" in session).toBe(false); + expect("appendMessage" in session).toBe(false); + expect("findEntriesOnBranch" in session).toBe(false); + + const branch = await session.createBranch("main", null, BACKGROUND_CONTEXT); + expect(branch.name).toBe("main"); + expect(await branch.getTipId(BACKGROUND_CONTEXT)).toBeNull(); + await session.close(BACKGROUND_CONTEXT); + }); + + it("directly appends immutable entries and advances only its own tip", async () => { + nextId = 1; + const session = createSession(); + const main = await session.createBranch("main", null, BACKGROUND_CONTEXT); + const review = await session.createBranch("review", null, BACKGROUND_CONTEXT); + const message = { role: "user" as const, content: "hello", timestamp: 1 }; + + const messageId = await main.appendMessage(message, BACKGROUND_CONTEXT); + const customId = await main.appendCustomEntry("note", { ok: true }, BACKGROUND_CONTEXT); + const reviewId = await review.appendCustomEntry("review", undefined, BACKGROUND_CONTEXT); + + expect(await main.getTipId(BACKGROUND_CONTEXT)).toBe(customId); + expect(await review.getTipId(BACKGROUND_CONTEXT)).toBe(reviewId); + expect((await main.findEntries({ order: "oldestFirst" }, BACKGROUND_CONTEXT)).map(({ id }) => id)).toEqual([ + messageId, + customId, + ]); + expect(await main.findEntry({ type: "message" }, BACKGROUND_CONTEXT)).toMatchObject({ + id: messageId, + type: "message", + message, + }); + await session.close(BACKGROUND_CONTEXT); + }); + + it("supports explicit starts without moving the Branch tip", async () => { + const session = createSession(); + await session.mutate( + (mutator) => + mutator.commit( + [ + insertEntry({ id: "root", parentId: null, type: "custom", customType: "root" }), + insertEntry({ id: "left", parentId: "root", type: "custom", customType: "left" }), + insertEntry({ id: "right", parentId: "root", type: "custom", customType: "right" }), + setValue(branchTip("main"), "left"), + ], + BACKGROUND_CONTEXT, + ), + BACKGROUND_CONTEXT, + ); + const branch = await session.branch("main", BACKGROUND_CONTEXT); + if (branch === undefined) throw new Error("Expected main Branch"); + + expect( + (await branch.findEntries({ start: "right", order: "oldestFirst" }, BACKGROUND_CONTEXT)).map(({ id }) => id), + ).toEqual(["root", "right"]); + expect(await branch.getTipId(BACKGROUND_CONTEXT)).toBe("left"); + await session.close(BACKGROUND_CONTEXT); + }); + + it("rejects pending assistant messages before committing", async () => { + const session = createSession(); + const branch = await session.createBranch("main", null, BACKGROUND_CONTEXT); + await expect( + branch.appendMessage( + { + role: "assistant", + content: [], + api: "test", + provider: "test", + model: "test", + usage: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 0, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + }, + stopReason: "pending", + timestamp: 1, + }, + BACKGROUND_CONTEXT, + ), + ).rejects.toBeInstanceOf(SessionPendingAssistantMessageError); + expect(await branch.getTipId(BACKGROUND_CONTEXT)).toBeNull(); + await session.close(BACKGROUND_CONTEXT); + }); +}); diff --git a/packages/agent/test/harness/execution-primitives.test.ts b/packages/agent/test/harness/execution-primitives.test.ts index 0f94281c0..998f61063 100644 --- a/packages/agent/test/harness/execution-primitives.test.ts +++ b/packages/agent/test/harness/execution-primitives.test.ts @@ -373,7 +373,7 @@ describe("HookRegistry", () => { describe("HarnessEventBus", () => { it("buffers between snapshot and start, then delivers each event once in order", async () => { const bus = new HarnessEventBus(); - const watcher = bus.watch({ leafId: null }, () => true, BACKGROUND_CONTEXT); + const watcher = bus.watch({ tipId: null }, () => true, BACKGROUND_CONTEXT); await bus.emit({ type: "run_start", runId: "one", lane: "main" }, BACKGROUND_CONTEXT); const seen: string[] = []; watcher.start((event) => { @@ -381,7 +381,7 @@ describe("HarnessEventBus", () => { }); await bus.emit({ type: "run_start", runId: "two", lane: "main" }, BACKGROUND_CONTEXT); await new Promise((resolve) => setTimeout(resolve, 0)); - expect(watcher.snapshot).toEqual({ leafId: null }); + expect(watcher.snapshot).toEqual({ tipId: null }); expect(seen).toEqual(["run_start:one", "run_start:two"]); watcher.unsubscribe(); await bus.emit({ type: "run_start", runId: "three", lane: "main" }, BACKGROUND_CONTEXT); diff --git a/packages/agent/test/harness/jsonl-session-repo.test.ts b/packages/agent/test/harness/jsonl-session-repo.test.ts index 9757faaf8..33cf05887 100644 --- a/packages/agent/test/harness/jsonl-session-repo.test.ts +++ b/packages/agent/test/harness/jsonl-session-repo.test.ts @@ -67,7 +67,7 @@ describe("JsonlSessionRepo cwd-scoped lifecycle", () => { await repo.close(BACKGROUND_CONTEXT); }); - it("atomically publishes the header and initialized main lane", async () => { + it("atomically publishes a branchless session header", async () => { const fileSystem = new AtomicPublicationNodeExecutionEnv({ cwd: createTempDir() }); const repo = new JsonlSessionRepo({ fileSystem, sessionsRoot: "sessions", now: () => NOW }); const session = await repo.create({ id: "session", cwd: "/workspace" }, BACKGROUND_CONTEXT); @@ -77,26 +77,8 @@ describe("JsonlSessionRepo cwd-scoped lifecycle", () => { expect(publication.destinationPath).toBe(session.metadata.path); expect(publication.destinationExisted).toBe(false); const lines = publication.stagedContent.trimEnd().split("\n"); - expect(lines).toHaveLength(2); + expect(lines).toHaveLength(1); expect(JSON.parse(lines[0]!)).toMatchObject({ kind: "header", id: "session" }); - expect(JSON.parse(lines[1]!)).toEqual([ - { - kind: "value", - op: "set", - seq: 1, - namespace: "pi.lane.leaf", - key: "main", - value: null, - }, - { - kind: "value", - op: "set", - seq: 2, - namespace: "pi.lane.state", - key: "main", - value: { currentOperationId: null, pendingNextRun: [] }, - }, - ]); expect(getOrThrow(await fileSystem.readTextFile(session.metadata.path, BACKGROUND_CONTEXT))).toBe( publication.stagedContent, ); @@ -106,19 +88,15 @@ describe("JsonlSessionRepo cwd-scoped lifecycle", () => { await repo.close(BACKGROUND_CONTEXT); }); - it("keeps an explicit mutation on the lane through commit until end", async () => { + it("keeps an explicit Session mutation through commit until end", async () => { const fileSystem = new NodeExecutionEnv({ cwd: createTempDir() }); const repo = new JsonlSessionRepo({ fileSystem, sessionsRoot: "sessions", now: () => NOW }); const session = await repo.create({ id: "session", cwd: "/workspace" }, BACKGROUND_CONTEXT); - const mutation = await session.beginMutation("main", BACKGROUND_CONTEXT); + const mutation = await session.beginMutation(BACKGROUND_CONTEXT); let queuedStarted = false; - const queued = session.mutate( - "main", - () => { - queuedStarted = true; - }, - BACKGROUND_CONTEXT, - ); + const queued = session.mutate(() => { + queuedStarted = true; + }, BACKGROUND_CONTEXT); const result = await mutation.commit([setValue(sessionName, "explicit")], BACKGROUND_CONTEXT); expect(result.seqs).toHaveLength(1); @@ -162,7 +140,7 @@ describe("JsonlSessionRepo cwd-scoped lifecycle", () => { const fileSystem = new NodeExecutionEnv({ cwd: createTempDir() }); const repo = new JsonlSessionRepo({ fileSystem, sessionsRoot: "sessions", now: () => NOW }); const source = await repo.create({ id: "source", cwd: "/workspace" }, BACKGROUND_CONTEXT); - const fork = await repo.fork(source.metadata, { id: "fork" }, BACKGROUND_CONTEXT); + const fork = await repo.fork(source.metadata, { id: "fork", scope: "tree" }, BACKGROUND_CONTEXT); await expect(repo.open(fork.metadata, BACKGROUND_CONTEXT)).rejects.toThrow("already open"); await expect(repo.delete(fork.metadata, BACKGROUND_CONTEXT)).rejects.toThrow("open"); diff --git a/packages/agent/test/harness/jsonl-storage.test.ts b/packages/agent/test/harness/jsonl-storage.test.ts index e7ae26fe5..22a033b85 100644 --- a/packages/agent/test/harness/jsonl-storage.test.ts +++ b/packages/agent/test/harness/jsonl-storage.test.ts @@ -45,7 +45,7 @@ function preparedSnapshot(): ForkDestinationSnapshot { ], ]), scalarValues: [ - stored(storedValues.laneLeaf("main"), "root", 2), + stored(storedValues.branchTip("main"), "root", 2), stored(storedValues.laneState("main"), { currentOperationId: null, pendingNextRun: [] }, 3), stored(storedValues.sessionName, "forked", 4), ], @@ -69,7 +69,7 @@ describe("JsonlStorage snapshot creation", () => { .split("\n"); expect(lines.slice(1).every((line) => !Array.isArray(JSON.parse(line)))).toBe(true); expect((await storage.getEntries(["root"], BACKGROUND_CONTEXT)).get("root")?.timestamp).toBe(NOW); - expect((await storage.getValue(storedValues.laneLeaf("main"), BACKGROUND_CONTEXT))?.value).toBe("root"); + expect((await storage.getValue(storedValues.branchTip("main"), BACKGROUND_CONTEXT))?.value).toBe("root"); expect((await storage.getValue(storedValues.laneState("main"), BACKGROUND_CONTEXT))?.value).toEqual({ currentOperationId: null, pendingNextRun: [], @@ -153,7 +153,7 @@ describe("JsonlStorage persistence", () => { type: "message", message: { role: "user", content: "hello", timestamp: 1 }, }), - storedValues.setValue(storedValues.laneLeaf("main"), "root"), + storedValues.setValue(storedValues.branchTip("main"), "root"), sessionWrites.insertUsage({ id: "usage", entryId: "root", @@ -189,8 +189,8 @@ describe("JsonlStorage persistence", () => { seq: committed.seqs[0], timestamp: committed.timestamp, }); - expect(await reopened.getValue(storedValues.laneLeaf("main"), BACKGROUND_CONTEXT)).toEqual({ - address: storedValues.laneLeaf("main"), + expect(await reopened.getValue(storedValues.branchTip("main"), BACKGROUND_CONTEXT)).toEqual({ + address: storedValues.branchTip("main"), value: "root", seq: committed.seqs[1], }); @@ -225,7 +225,7 @@ describe("JsonlStorage snapshots", () => { const firstCommit = storage.commit( [ sessionWrites.insertEntry({ id: "root", parentId: null, type: "custom", customType: "root" }), - storedValues.setValue(storedValues.laneLeaf("main"), "root"), + storedValues.setValue(storedValues.branchTip("main"), "root"), ], BACKGROUND_CONTEXT, ); @@ -233,7 +233,7 @@ describe("JsonlStorage snapshots", () => { const secondCommit = storage.commit( [ sessionWrites.insertEntry({ id: "child", parentId: "root", type: "custom", customType: "child" }), - storedValues.setValue(storedValues.laneLeaf("main"), "child"), + storedValues.setValue(storedValues.branchTip("main"), "child"), ], BACKGROUND_CONTEXT, ); @@ -245,11 +245,11 @@ describe("JsonlStorage snapshots", () => { expect(captured.entries.map(({ id }) => id)).toEqual(["root"]); expect( captured.scalarValues.find( - ({ address }) => address.namespace === storedValues.laneLeaf("main").namespace && address.key === "main", + ({ address }) => address.namespace === storedValues.branchTip("main").namespace && address.key === "main", )?.value, ).toBe("root"); expect((await storage.getEntries(["child"], BACKGROUND_CONTEXT)).has("child")).toBe(true); - expect((await storage.getValue(storedValues.laneLeaf("main"), BACKGROUND_CONTEXT))?.value).toBe("child"); + expect((await storage.getValue(storedValues.branchTip("main"), BACKGROUND_CONTEXT))?.value).toBe("child"); await storage.close(BACKGROUND_CONTEXT); }); }); diff --git a/packages/agent/test/harness/jsonl-v3-migration.test.ts b/packages/agent/test/harness/jsonl-v3-migration.test.ts index d1b220041..bd6c8d33b 100644 --- a/packages/agent/test/harness/jsonl-v3-migration.test.ts +++ b/packages/agent/test/harness/jsonl-v3-migration.test.ts @@ -36,13 +36,27 @@ function uuidTimestamp(id: string): number { return Number.parseInt(id.replaceAll("-", "").slice(0, 12), 16); } +async function mainBranch(session: Session) { + const branch = await session.branch("main", BACKGROUND_CONTEXT); + if (branch === undefined) throw new Error("Expected imported main Branch"); + return branch; +} + +async function mainTip(session: Session): Promise { + return (await mainBranch(session)).getTipId(BACKGROUND_CONTEXT); +} + describe("JSONL v3 migration", () => { let fileSystem: FailableRenameNodeExecutionEnv; let repo: JsonlSessionRepo; beforeEach(() => { fileSystem = new FailableRenameNodeExecutionEnv({ cwd: createTempDir() }); - repo = new JsonlSessionRepo({ fileSystem, sessionsRoot: "sessions", now: () => NOW }); + repo = new JsonlSessionRepo({ + fileSystem, + sessionsRoot: "sessions", + now: () => NOW, + }); }); afterEach(async () => { @@ -75,7 +89,9 @@ describe("JSONL v3 migration", () => { } it("discovers legacy v3 session files without rewriting them", async () => { - const { path, content } = await writeLegacyV3Fixture([], { parentSession: "/old-session.jsonl" }); + const { path, content } = await writeLegacyV3Fixture([], { + parentSession: "/old-session.jsonl", + }); const [metadata] = await repo.list({ cwd: "/workspace" }, BACKGROUND_CONTEXT); const after = getOrThrow(await fileSystem.readTextFile(path, BACKGROUND_CONTEXT)); @@ -124,7 +140,10 @@ describe("JSONL v3 migration", () => { const [metadata] = await repo.list({ cwd: "/workspace" }, BACKGROUND_CONTEXT); if (metadata === undefined) throw new Error("Legacy fixture was not discovered"); - expect(metadata).toMatchObject({ id: "legacy", parentSessionId: parentId }); + expect(metadata).toMatchObject({ + id: "legacy", + parentSessionId: parentId, + }); expect(metadata).not.toHaveProperty("legacyParentSessionPath"); const session = await repo.open(metadata, BACKGROUND_CONTEXT); expect(session.metadata).toMatchObject({ parentSessionId: parentId }); @@ -158,7 +177,13 @@ describe("JSONL v3 migration", () => { cacheRead: 2, cacheWrite: 1, totalTokens: 18, - cost: { input: 0.1, output: 0.05, cacheRead: 0.02, cacheWrite: 0.01, total: 0.18 }, + cost: { + input: 0.1, + output: 0.05, + cacheRead: 0.02, + cacheWrite: 0.01, + total: 0.18, + }, } satisfies Usage; const secondMessage = { role: "assistant", @@ -217,17 +242,22 @@ describe("JSONL v3 migration", () => { expect(entries).toHaveLength(2); const [first, second] = entries; if (first === undefined || second === undefined) throw new Error("Forked entries were not written"); - expect(first).toMatchObject({ type: "message", parentId: null, message: firstMessage }); - expect(second).toMatchObject({ type: "message", parentId: first.id, message: secondMessage }); + expect(first).toMatchObject({ + type: "message", + parentId: null, + message: firstMessage, + }); + expect(second).toMatchObject({ + type: "message", + parentId: first.id, + message: secondMessage, + }); expect(first.id).not.toBe("message-1"); expect(second.id).not.toBe("message-2"); - expect(await fork.getLeafId(BACKGROUND_CONTEXT)).toBe(second.id); + expect(await mainTip(fork)).toBe(second.id); expect(await fork.getName(BACKGROUND_CONTEXT)).toBe("Imported fork"); expect(await fork.getLabel(first.id, BACKGROUND_CONTEXT)).toBe("Fork point"); - expect((await fork.getValue(storedValues.laneState("main"), BACKGROUND_CONTEXT))?.value).toEqual({ - currentOperationId: null, - pendingNextRun: [], - }); + expect(await fork.getValue(storedValues.laneState("main"), BACKGROUND_CONTEXT)).toBeUndefined(); expect(await fork.getValue(storedValues.laneConfig("main"), BACKGROUND_CONTEXT)).toBeUndefined(); expect(await fork.getStats(BACKGROUND_CONTEXT)).toEqual({ messageCount: 2, @@ -292,18 +322,15 @@ describe("JSONL v3 migration", () => { }); }); - it("opens an empty legacy session with an idle unconfigured main lane", async () => { + it("opens an empty legacy session with a data-only main Branch", async () => { await writeLegacyV3Fixture([]); const [metadata] = await repo.list({ cwd: "/workspace" }, BACKGROUND_CONTEXT); if (metadata === undefined) throw new Error("Legacy fixture was not discovered"); const session = await repo.open(metadata, BACKGROUND_CONTEXT); expect(await session.findEntries(undefined, BACKGROUND_CONTEXT)).toEqual([]); - expect(await session.getLeafId(BACKGROUND_CONTEXT)).toBeNull(); - expect((await session.getValue(storedValues.laneState("main"), BACKGROUND_CONTEXT))?.value).toEqual({ - currentOperationId: null, - pendingNextRun: [], - }); + expect(await mainTip(session)).toBeNull(); + expect(await session.getValue(storedValues.laneState("main"), BACKGROUND_CONTEXT)).toBeUndefined(); expect(await session.getValue(storedValues.laneConfig("main"), BACKGROUND_CONTEXT)).toBeUndefined(); await session.close(BACKGROUND_CONTEXT); }); @@ -400,7 +427,13 @@ describe("JSONL v3 migration", () => { cacheRead: 2, cacheWrite: 1, totalTokens: 18, - cost: { input: 0.1, output: 0.05, cacheRead: 0.02, cacheWrite: 0.01, total: 0.18 }, + cost: { + input: 0.1, + output: 0.05, + cacheRead: 0.02, + cacheWrite: 0.01, + total: 0.18, + }, } satisfies Usage; const importedMessage = { role: "assistant", @@ -518,13 +551,10 @@ describe("JSONL v3 migration", () => { expect(await reopened.scanEntries({ order: "asc" }, BACKGROUND_CONTEXT)).toEqual(importedEntries); const [importedEntry] = importedEntries; if (importedEntry === undefined) throw new Error("Legacy message was not imported"); - expect((await reopened.getValue(storedValues.laneLeaf("main"), BACKGROUND_CONTEXT))?.value).toBe( + expect((await reopened.getValue(storedValues.branchTip("main"), BACKGROUND_CONTEXT))?.value).toBe( importedEntry.id, ); - expect((await reopened.getValue(storedValues.laneState("main"), BACKGROUND_CONTEXT))?.value).toEqual({ - currentOperationId: null, - pendingNextRun: [], - }); + expect(await reopened.getValue(storedValues.laneState("main"), BACKGROUND_CONTEXT)).toBeUndefined(); expect((await reopened.getValue(storedValues.sessionName, BACKGROUND_CONTEXT))?.value).toBe( "Converted session", ); @@ -538,12 +568,13 @@ describe("JSONL v3 migration", () => { const options = { fileSystem, path, now: () => NOW }; const storage = await JsonlStorage.open(options, BACKGROUND_CONTEXT); const entriesBefore = await storage.scanEntries({ order: "asc" }, BACKGROUND_CONTEXT); - const leafBefore = await storage.getValue(storedValues.laneLeaf("main"), BACKGROUND_CONTEXT); + const leafBefore = await storage.getValue(storedValues.branchTip("main"), BACKGROUND_CONTEXT); const laneStateBefore = await storage.getValue(storedValues.laneState("main"), BACKGROUND_CONTEXT); const nameBefore = await storage.getValue(storedValues.sessionName, BACKGROUND_CONTEXT); const statsBefore = await storage.getStats(BACKGROUND_CONTEXT); const usageBefore = await storage.scanUsage({ order: "asc" }, BACKGROUND_CONTEXT); - if (laneStateBefore === undefined) throw new Error("Normalized main lane state is missing"); + if (leafBefore === undefined) throw new Error("Normalized main Branch tip is missing"); + expect(laneStateBefore).toBeUndefined(); fileSystem.failRename = true; await expect( @@ -553,7 +584,7 @@ describe("JSONL v3 migration", () => { expect(getOrThrow(await fileSystem.readTextFile(path, BACKGROUND_CONTEXT))).toBe(content); expect(await storage.scanEntries({ order: "asc" }, BACKGROUND_CONTEXT)).toEqual(entriesBefore); - expect(await storage.getValue(storedValues.laneLeaf("main"), BACKGROUND_CONTEXT)).toEqual(leafBefore); + expect(await storage.getValue(storedValues.branchTip("main"), BACKGROUND_CONTEXT)).toEqual(leafBefore); expect(await storage.getValue(storedValues.laneState("main"), BACKGROUND_CONTEXT)).toEqual(laneStateBefore); expect(await storage.getValue(storedValues.sessionName, BACKGROUND_CONTEXT)).toEqual(nameBefore); expect(await storage.getStats(BACKGROUND_CONTEXT)).toEqual(statsBefore); @@ -564,7 +595,7 @@ describe("JSONL v3 migration", () => { BACKGROUND_CONTEXT, ); - expect(committed.firstSeq).toBe(laneStateBefore.seq + 2); + expect(committed.firstSeq).toBe(leafBefore.seq + 2); expect(committed.seqs).toEqual([committed.firstSeq]); expect(await storage.getValue(storedValues.sessionName, BACKGROUND_CONTEXT)).toMatchObject({ seq: committed.firstSeq, @@ -582,7 +613,7 @@ describe("JSONL v3 migration", () => { }); }); - describe("discarding legacy configuration changes", () => { + describe("reconstructing legacy lane configuration", () => { const firstTimestamp = NOW + 1_000; const modelChangeTimestamp = NOW + 2_000; const thinkingChangeTimestamp = NOW + 3_000; @@ -651,15 +682,243 @@ describe("JSONL v3 migration", () => { if (first === undefined || second === undefined) { throw new Error("Legacy configuration-change chain was not imported"); } - expect(first).toMatchObject({ type: "message", parentId: null, message: firstMessage }); - expect(second).toMatchObject({ type: "message", parentId: first.id, message: secondMessage }); + expect(first).toMatchObject({ + type: "message", + parentId: null, + message: firstMessage, + }); + expect(second).toMatchObject({ + type: "message", + parentId: first.id, + message: secondMessage, + }); expect(second.seq).toBeGreaterThan(first.seq); - expect(await session.getLeafId(BACKGROUND_CONTEXT)).toBe(second.id); - expect(await session.getValue(storedValues.laneConfig("main"), BACKGROUND_CONTEXT)).toBeUndefined(); + expect(await mainTip(session)).toBe(second.id); + expect((await session.getValue(storedValues.laneConfig("main"), BACKGROUND_CONTEXT))?.value).toEqual({ + model: { provider: "anthropic", modelId: "claude-sonnet-4-5" }, + thinkingLevel: "high", + activeToolNames: ["read", "bash"], + }); + expect((await session.getValue(storedValues.laneState("main"), BACKGROUND_CONTEXT))?.value).toEqual({ + currentOperationId: null, + pendingNextRun: [], + }); await session.close(BACKGROUND_CONTEXT); }); - it("resolves the main leaf through trailing configuration changes", async () => { + it("retains only configuration changes on the selected physical branch", async () => { + await writeLegacyV3Fixture([ + { + type: "message", + id: "root", + parentId: null, + timestamp: new Date(firstTimestamp).toISOString(), + message: firstMessage, + }, + { + type: "model_change", + id: "selected-model", + parentId: "root", + timestamp: new Date(modelChangeTimestamp).toISOString(), + provider: "anthropic", + modelId: "selected", + }, + { + type: "thinking_level_change", + id: "selected-thinking", + parentId: "selected-model", + timestamp: new Date(thinkingChangeTimestamp).toISOString(), + thinkingLevel: "high", + }, + { + type: "model_change", + id: "abandoned-model", + parentId: "root", + timestamp: new Date(activeToolsChangeTimestamp).toISOString(), + provider: "openai", + modelId: "abandoned", + }, + { + type: "message", + id: "selected-tip", + parentId: "selected-thinking", + timestamp: new Date(secondTimestamp).toISOString(), + message: secondMessage, + }, + ]); + const [metadata] = await repo.list({ cwd: "/workspace" }, BACKGROUND_CONTEXT); + if (metadata === undefined) throw new Error("Legacy fixture was not discovered"); + + const session = await repo.open(metadata, BACKGROUND_CONTEXT); + expect((await session.getValue(storedValues.laneConfig("main"), BACKGROUND_CONTEXT))?.value).toEqual({ + model: { provider: "anthropic", modelId: "selected" }, + thinkingLevel: "high", + activeToolNames: [], + }); + expect((await session.getValue(storedValues.laneState("main"), BACKGROUND_CONTEXT))?.value).toEqual({ + currentOperationId: null, + pendingNextRun: [], + }); + await session.close(BACKGROUND_CONTEXT); + }); + + it("omits an unsupported nearest value instead of falling back to an older change", async () => { + await writeLegacyV3Fixture([ + { + type: "message", + id: "root", + parentId: null, + timestamp: new Date(firstTimestamp).toISOString(), + message: firstMessage, + }, + { + type: "model_change", + id: "older-model", + parentId: "root", + timestamp: new Date(modelChangeTimestamp).toISOString(), + provider: "anthropic", + modelId: "older", + }, + { + type: "model_change", + id: "invalid-model", + parentId: "older-model", + timestamp: new Date(thinkingChangeTimestamp).toISOString(), + provider: "", + modelId: "", + }, + { + type: "message", + id: "tip", + parentId: "invalid-model", + timestamp: new Date(secondTimestamp).toISOString(), + message: secondMessage, + }, + ]); + const [metadata] = await repo.list({ cwd: "/workspace" }, BACKGROUND_CONTEXT); + if (metadata === undefined) throw new Error("Legacy fixture was not discovered"); + + const session = await repo.open(metadata, BACKGROUND_CONTEXT); + expect(await session.getValue(storedValues.laneConfig("main"), BACKGROUND_CONTEXT)).toBeUndefined(); + expect(await session.getValue(storedValues.laneState("main"), BACKGROUND_CONTEXT)).toBeUndefined(); + await session.close(BACKGROUND_CONTEXT); + }); + + it.each([ + { + name: "missing model", + changes: [ + { + type: "thinking_level_change", + id: "thinking", + parentId: "root", + timestamp: new Date(thinkingChangeTimestamp).toISOString(), + thinkingLevel: "high", + }, + ], + }, + { + name: "missing thinking level", + changes: [ + { + type: "model_change", + id: "model", + parentId: "root", + timestamp: new Date(modelChangeTimestamp).toISOString(), + provider: "anthropic", + modelId: "selected", + }, + ], + }, + { + name: "invalid thinking level", + changes: [ + { + type: "model_change", + id: "model", + parentId: "root", + timestamp: new Date(modelChangeTimestamp).toISOString(), + provider: "anthropic", + modelId: "selected", + }, + { + type: "thinking_level_change", + id: "thinking", + parentId: "model", + timestamp: new Date(thinkingChangeTimestamp).toISOString(), + thinkingLevel: "unsupported", + }, + ], + }, + ])("leaves main data-only for $name", async ({ changes }) => { + await writeLegacyV3Fixture([ + { + type: "message", + id: "root", + parentId: null, + timestamp: new Date(firstTimestamp).toISOString(), + message: firstMessage, + }, + ...changes, + { + type: "message", + id: "tip", + parentId: changes.at(-1)?.id ?? "root", + timestamp: new Date(secondTimestamp).toISOString(), + message: secondMessage, + }, + ]); + const [metadata] = await repo.list({ cwd: "/workspace" }, BACKGROUND_CONTEXT); + if (metadata === undefined) throw new Error("Legacy fixture was not discovered"); + + const session = await repo.open(metadata, BACKGROUND_CONTEXT); + expect(await session.getValue(storedValues.laneConfig("main"), BACKGROUND_CONTEXT)).toBeUndefined(); + expect(await session.getValue(storedValues.laneState("main"), BACKGROUND_CONTEXT)).toBeUndefined(); + await session.close(BACKGROUND_CONTEXT); + }); + + it("normalizes malformed active-tool history without compatibility state", async () => { + await writeLegacyV3Fixture([ + { + type: "model_change", + id: "model", + parentId: null, + timestamp: new Date(modelChangeTimestamp).toISOString(), + provider: "anthropic", + modelId: "selected", + }, + { + type: "thinking_level_change", + id: "thinking", + parentId: "model", + timestamp: new Date(thinkingChangeTimestamp).toISOString(), + thinkingLevel: "high", + }, + { + type: "active_tools_change", + id: "tools", + parentId: "thinking", + timestamp: new Date(activeToolsChangeTimestamp).toISOString(), + activeToolNames: ["read", 42], + }, + ]); + const [metadata] = await repo.list({ cwd: "/workspace" }, BACKGROUND_CONTEXT); + if (metadata === undefined) throw new Error("Legacy fixture was not discovered"); + + const session = await repo.open(metadata, BACKGROUND_CONTEXT); + expect((await session.getValue(storedValues.laneConfig("main"), BACKGROUND_CONTEXT))?.value).toEqual({ + model: { provider: "anthropic", modelId: "selected" }, + thinkingLevel: "high", + activeToolNames: [], + }); + expect((await session.getValue(storedValues.laneState("main"), BACKGROUND_CONTEXT))?.value).toEqual({ + currentOperationId: null, + pendingNextRun: [], + }); + await session.close(BACKGROUND_CONTEXT); + }); + + it("resolves the main tip through trailing configuration changes", async () => { await writeLegacyV3Fixture([ { type: "message", @@ -678,12 +937,16 @@ describe("JSONL v3 migration", () => { expect(entries).toHaveLength(1); const [message] = entries; if (message === undefined) throw new Error("Legacy message was not imported"); - expect(await session.getLeafId(BACKGROUND_CONTEXT)).toBe(message.id); + expect(await mainTip(session)).toBe(message.id); + expect((await session.getValue(storedValues.laneConfig("main"), BACKGROUND_CONTEXT))?.value).toEqual({ + model: { provider: "anthropic", modelId: "claude-sonnet-4-5" }, + thinkingLevel: "high", + activeToolNames: ["read", "bash"], + }); expect((await session.getValue(storedValues.laneState("main"), BACKGROUND_CONTEXT))?.value).toEqual({ currentOperationId: null, pendingNextRun: [], }); - expect(await session.getValue(storedValues.laneConfig("main"), BACKGROUND_CONTEXT)).toBeUndefined(); await session.close(BACKGROUND_CONTEXT); }); }); @@ -704,7 +967,7 @@ describe("JSONL v3 migration", () => { const session = await repo.open(metadata, BACKGROUND_CONTEXT); expect(await session.getName(BACKGROUND_CONTEXT)).toBe("Imported session"); expect(await session.findEntries(undefined, BACKGROUND_CONTEXT)).toEqual([]); - expect(await session.getLeafId(BACKGROUND_CONTEXT)).toBeNull(); + expect(await mainTip(session)).toBeNull(); await session.close(BACKGROUND_CONTEXT); }); @@ -759,7 +1022,7 @@ describe("JSONL v3 migration", () => { if (first === undefined || second === undefined) throw new Error("Legacy message chain was not imported"); expect(second.parentId).toBe(first.id); expect(await session.getName(BACKGROUND_CONTEXT)).toBe("Latest name"); - expect(await session.getLeafId(BACKGROUND_CONTEXT)).toBe(second.id); + expect(await mainTip(session)).toBe(second.id); await session.close(BACKGROUND_CONTEXT); }); @@ -820,7 +1083,7 @@ describe("JSONL v3 migration", () => { const [entry] = entries; if (entry === undefined) throw new Error("Legacy message was not imported"); expect((await session.getValue(storedValues.entryLabel(entry.id), BACKGROUND_CONTEXT))?.value).toBe("Important"); - expect(await session.getLeafId(BACKGROUND_CONTEXT)).toBe(entry.id); + expect(await mainTip(session)).toBe(entry.id); await session.close(BACKGROUND_CONTEXT); }); @@ -863,7 +1126,7 @@ describe("JSONL v3 migration", () => { if (entry === undefined) throw new Error("Legacy message was not imported"); expect(entry.parentId).toBeNull(); expect(await session.getLabel(entry.id, BACKGROUND_CONTEXT)).toBeUndefined(); - expect(await session.getLeafId(BACKGROUND_CONTEXT)).toBe(entry.id); + expect(await mainTip(session)).toBe(entry.id); await session.close(BACKGROUND_CONTEXT); }); @@ -926,7 +1189,7 @@ describe("JSONL v3 migration", () => { if (first === undefined || second === undefined) throw new Error("Legacy message chain was not imported"); expect(second.parentId).toBe(first.id); expect(await session.getLabel(first.id, BACKGROUND_CONTEXT)).toBe("Latest label"); - expect(await session.getLeafId(BACKGROUND_CONTEXT)).toBe(second.id); + expect(await mainTip(session)).toBe(second.id); await session.close(BACKGROUND_CONTEXT); }); @@ -969,7 +1232,7 @@ describe("JSONL v3 migration", () => { const [entry] = entries; if (entry === undefined) throw new Error("Legacy message was not imported"); expect(await session.getLabel(entry.id, BACKGROUND_CONTEXT)).toBeUndefined(); - expect(await session.getLeafId(BACKGROUND_CONTEXT)).toBe(entry.id); + expect(await mainTip(session)).toBe(entry.id); await session.close(BACKGROUND_CONTEXT); }); @@ -1023,7 +1286,7 @@ describe("JSONL v3 migration", () => { }); expect(customEntry.id).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/); expect(uuidTimestamp(customEntry.id)).toBe(customTimestamp); - expect(await session.getLeafId(BACKGROUND_CONTEXT)).toBe(customEntry.id); + expect(await mainTip(session)).toBe(customEntry.id); await session.close(BACKGROUND_CONTEXT); }); @@ -1083,7 +1346,7 @@ describe("JSONL v3 migration", () => { }); expect(customMessageEntry.id).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/); expect(uuidTimestamp(customMessageEntry.id)).toBe(customMessageTimestamp); - expect(await session.getLeafId(BACKGROUND_CONTEXT)).toBe(customMessageEntry.id); + expect(await mainTip(session)).toBe(customMessageEntry.id); expect((await session.getStats(BACKGROUND_CONTEXT)).messageCount).toBe(2); await session.close(BACKGROUND_CONTEXT); }); @@ -1099,7 +1362,13 @@ describe("JSONL v3 migration", () => { cacheRead: 3, cacheWrite: 2, totalTokens: 23, - cost: { input: 0.11, output: 0.07, cacheRead: 0.03, cacheWrite: 0.02, total: 0.23 }, + cost: { + input: 0.11, + output: 0.07, + cacheRead: 0.03, + cacheWrite: 0.02, + total: 0.23, + }, } satisfies Usage; const branchPointMessage = { role: "user", @@ -1118,7 +1387,13 @@ describe("JSONL v3 migration", () => { cacheRead: 0, cacheWrite: 0, totalTokens: 30, - cost: { input: 0.2, output: 0.1, cacheRead: 0, cacheWrite: 0, total: 0.3 }, + cost: { + input: 0.2, + output: 0.1, + cacheRead: 0, + cacheWrite: 0, + total: 0.3, + }, }, stopReason: "stop", timestamp: abandonedResponseTimestamp, @@ -1182,14 +1457,17 @@ describe("JSONL v3 migration", () => { }); expect(branchSummary.id).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/); expect(uuidTimestamp(branchSummary.id)).toBe(summaryTimestamp); - expect(await session.getLeafId(BACKGROUND_CONTEXT)).toBe(branchSummary.id); + expect(await mainTip(session)).toBe(branchSummary.id); await session.close(BACKGROUND_CONTEXT); }); it("preserves an explicit fromHook flag", async () => { const { session, branchSummary } = await openFixture(true); - expect(branchSummary).toMatchObject({ type: "branch_summary", fromHook: true }); + expect(branchSummary).toMatchObject({ + type: "branch_summary", + fromHook: true, + }); await session.close(BACKGROUND_CONTEXT); }); @@ -1210,7 +1488,10 @@ describe("JSONL v3 migration", () => { const session = await repo.open(metadata, BACKGROUND_CONTEXT); const entries = await session.findEntries({ order: "asc" }, BACKGROUND_CONTEXT); expect(entries).toHaveLength(1); - expect(entries[0]).toMatchObject({ type: "branch_summary", fromId: null }); + expect(entries[0]).toMatchObject({ + type: "branch_summary", + fromId: null, + }); await session.close(BACKGROUND_CONTEXT); }); @@ -1258,7 +1539,11 @@ describe("JSONL v3 migration", () => { const session = await repo.open(metadata, BACKGROUND_CONTEXT); const entries = await session.findEntries({ order: "asc" }, BACKGROUND_CONTEXT); expect(entries).toHaveLength(1); - expect(entries[0]).toMatchObject({ type: "branch_summary", parentId: null, fromId: null }); + expect(entries[0]).toMatchObject({ + type: "branch_summary", + parentId: null, + fromId: null, + }); await session.close(BACKGROUND_CONTEXT); }); }); @@ -1284,7 +1569,13 @@ describe("JSONL v3 migration", () => { cacheRead: 10, cacheWrite: 5, totalTokens: 165, - cost: { input: 1.2, output: 0.3, cacheRead: 0.1, cacheWrite: 0.05, total: 1.65 }, + cost: { + input: 1.2, + output: 0.3, + cacheRead: 0.1, + cacheWrite: 0.05, + total: 1.65, + }, } satisfies Usage; async function openFixture(fromHook?: true) { @@ -1348,7 +1639,7 @@ describe("JSONL v3 migration", () => { expect(compaction).not.toHaveProperty("firstKeptEntryId"); expect(compaction.id).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/); expect(uuidTimestamp(compaction.id)).toBe(compactionTimestamp); - expect(await session.getLeafId(BACKGROUND_CONTEXT)).toBe(compaction.id); + expect(await mainTip(session)).toBe(compaction.id); await session.close(BACKGROUND_CONTEXT); }); @@ -1621,11 +1912,23 @@ describe("JSONL v3 migration", () => { expect(entries).toHaveLength(2); const [first, second] = entries; if (first === undefined || second === undefined) throw new Error("Legacy message chain was not imported"); - expect(first).toMatchObject({ parentId: null, seq: 1, timestamp: firstTimestamp, message: firstMessage }); - expect(second).toMatchObject({ parentId: first.id, seq: 2, timestamp: secondTimestamp, message: secondMessage }); - expect(await session.getLeafId(BACKGROUND_CONTEXT)).toBe(second.id); + expect(first).toMatchObject({ + parentId: null, + seq: 1, + timestamp: firstTimestamp, + message: firstMessage, + }); + expect(second).toMatchObject({ + parentId: first.id, + seq: 2, + timestamp: secondTimestamp, + message: secondMessage, + }); + expect(await mainTip(session)).toBe(second.id); expect( - (await session.findEntriesOnBranch({ order: "oldestFirst" }, BACKGROUND_CONTEXT)).map((entry) => entry.id), + (await (await mainBranch(session)).findEntries({ order: "oldestFirst" }, BACKGROUND_CONTEXT)).map( + (entry) => entry.id, + ), ).toEqual([first.id, second.id]); expect((await session.getStats(BACKGROUND_CONTEXT)).messageCount).toBe(2); await session.close(BACKGROUND_CONTEXT); @@ -1688,14 +1991,11 @@ describe("JSONL v3 migration", () => { expect(uuidTimestamp(entry.id)).toBe(entryTimestamp); }); - it("initializes an idle unconfigured main lane at the imported entry", async () => { + it("initializes a data-only main Branch at the imported entry", async () => { const entry = await importedEntry(); - expect(await session.getLeafId(BACKGROUND_CONTEXT)).toBe(entry.id); - expect((await session.getValue(storedValues.laneState("main"), BACKGROUND_CONTEXT))?.value).toEqual({ - currentOperationId: null, - pendingNextRun: [], - }); + expect(await mainTip(session)).toBe(entry.id); + expect(await session.getValue(storedValues.laneState("main"), BACKGROUND_CONTEXT)).toBeUndefined(); expect(await session.getValue(storedValues.laneConfig("main"), BACKGROUND_CONTEXT)).toBeUndefined(); }); diff --git a/packages/agent/test/harness/lane-mutations.test.ts b/packages/agent/test/harness/lane-mutations.test.ts deleted file mode 100644 index 0486c5baa..000000000 --- a/packages/agent/test/harness/lane-mutations.test.ts +++ /dev/null @@ -1,71 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { LaneMutationLine } from "../../src/harness/session/lane-mutations.ts"; - -function deferred(): { promise: Promise; resolve: () => void } { - let resolve: (() => void) | undefined; - const promise = new Promise((settle) => { - resolve = settle; - }); - return { - promise, - resolve: () => { - if (resolve === undefined) throw new Error("Deferred promise was not initialized"); - resolve(); - }, - }; -} - -describe("LaneMutationLine", () => { - it("serializes each lane", async () => { - const line = new LaneMutationLine(); - const gate = deferred(); - const order: string[] = []; - const first = line.run("main", async () => { - order.push("first:start"); - await gate.promise; - order.push("first:end"); - return "first"; - }); - const second = line.run("main", () => { - order.push("second"); - return "second"; - }); - - expect(order).toEqual(["first:start"]); - gate.resolve(); - await expect(first).resolves.toBe("first"); - await expect(second).resolves.toBe("second"); - expect(order).toEqual(["first:start", "first:end", "second"]); - }); - - it("continues the lane after a failed job while preserving the original failure", async () => { - const line = new LaneMutationLine(); - const rejection = new Error("mutation failed"); - - await expect( - line.run("main", () => { - throw rejection; - }), - ).rejects.toBe(rejection); - await expect(line.run("main", () => "next")).resolves.toBe("next"); - }); - - it("seals queued and future jobs while draining the running job", async () => { - const line = new LaneMutationLine(); - const gate = deferred(); - const running = line.run("main", async () => { - await gate.promise; - return "running"; - }); - await Promise.resolve(); - const queued = line.run("main", () => "queued"); - const closed = new Error("closed"); - - const drained = line.seal(closed); - await expect(line.run("other", () => "late")).rejects.toBe(closed); - gate.resolve(); - await expect(running).resolves.toBe("running"); - await expect(queued).rejects.toBe(closed); - await drained; - }); -}); diff --git a/packages/agent/test/harness/memory-conformance.test.ts b/packages/agent/test/harness/memory-conformance.test.ts index dc850c284..56d1f5fb6 100644 --- a/packages/agent/test/harness/memory-conformance.test.ts +++ b/packages/agent/test/harness/memory-conformance.test.ts @@ -50,7 +50,6 @@ describe("MemoryStorage commit statistics", () => { }; try { await session.mutate( - "main", (mutator) => mutator.commit( [ @@ -69,7 +68,6 @@ describe("MemoryStorage commit statistics", () => { await session.close(BACKGROUND_CONTEXT); const reopened = await repo.open(session.metadata, BACKGROUND_CONTEXT); const result = await reopened.mutate( - "main", (mutator) => mutator.commit([storedValues.setValue(storedValues.sessionName, "reopened")], BACKGROUND_CONTEXT), BACKGROUND_CONTEXT, diff --git a/packages/agent/test/harness/memory-session-repo.test.ts b/packages/agent/test/harness/memory-session-repo.test.ts index cad864b10..9ab925b45 100644 --- a/packages/agent/test/harness/memory-session-repo.test.ts +++ b/packages/agent/test/harness/memory-session-repo.test.ts @@ -22,14 +22,14 @@ describe("MemorySessionRepo metadata", () => { it("returns a fresh facade after close while retaining one session and storage", async () => { const repo = new MemorySessionRepo({ now: () => NOW }); const first = await repo.create({ id: "session" }, BACKGROUND_CONTEXT); - const firstView = first.view("main"); + const firstBranch = await first.createBranch("main", null, BACKGROUND_CONTEXT); const admittedWrite = first.setName("preserved", BACKGROUND_CONTEXT); await expect(repo.open(first.metadata, BACKGROUND_CONTEXT)).rejects.toThrow("already open"); await Promise.all([admittedWrite, first.close(BACKGROUND_CONTEXT)]); await expect(first.getName(BACKGROUND_CONTEXT)).rejects.toThrow("Session is closed"); await expect(first.scanBranch({ start: "entry" }, BACKGROUND_CONTEXT)).rejects.toThrow("Session is closed"); - await expect(firstView.getName(BACKGROUND_CONTEXT)).rejects.toThrow("Session is closed"); + await expect(firstBranch.getTipId(BACKGROUND_CONTEXT)).rejects.toThrow("Session is closed"); const second = await repo.open(first.metadata, BACKGROUND_CONTEXT); expect(second).not.toBe(first); @@ -41,7 +41,7 @@ describe("MemorySessionRepo metadata", () => { it("waits for an explicit mutation before closing its facade", async () => { const repo = new MemorySessionRepo({ now: () => NOW }); const session = await repo.create({ id: "session" }, BACKGROUND_CONTEXT); - const mutation = await session.beginMutation("main", BACKGROUND_CONTEXT); + const mutation = await session.beginMutation(BACKGROUND_CONTEXT); let closed = false; const closing = session.close(BACKGROUND_CONTEXT).then(() => { closed = true; @@ -57,21 +57,55 @@ describe("MemorySessionRepo metadata", () => { await Promise.all([reopened.close(BACKGROUND_CONTEXT), repo.close(BACKGROUND_CONTEXT)]); }); + it("rejects an explicit scope that had not acquired before facade close", async () => { + const repo = new MemorySessionRepo({ now: () => NOW }); + const session = await repo.create({ id: "session" }, BACKGROUND_CONTEXT); + const first = await session.beginMutation(BACKGROUND_CONTEXT); + const secondPromise = session.beginMutation(BACKGROUND_CONTEXT); + void secondPromise.catch(() => {}); + let closed = false; + const closing = session.close(BACKGROUND_CONTEXT).then(() => { + closed = true; + }); + + await first.end(BACKGROUND_CONTEXT); + await expect(secondPromise).rejects.toThrow("Session is closed"); + await closing; + expect(closed).toBe(true); + + const reopened = await repo.open(session.metadata, BACKGROUND_CONTEXT); + await Promise.all([reopened.close(BACKGROUND_CONTEXT), repo.close(BACKGROUND_CONTEXT)]); + }); + it("captures fork options before waiting for its snapshot boundary", async () => { const repo = new MemorySessionRepo({ now: () => NOW }); const source = await repo.create({ id: "source" }, BACKGROUND_CONTEXT); const rootId = "00000000-0000-7000-8000-000000000001"; const childId = "00000000-0000-7000-8000-000000000002"; - const mutation = await source.beginMutation("main", BACKGROUND_CONTEXT); + const mutation = await source.beginMutation(BACKGROUND_CONTEXT); const commit = mutation.commit( [ - sessionWrites.insertEntry({ id: rootId, parentId: null, type: "custom", customType: "root" }), - sessionWrites.insertEntry({ id: childId, parentId: rootId, type: "custom", customType: "child" }), - storedValues.setValue(storedValues.laneLeaf("main"), childId), + sessionWrites.insertEntry({ + id: rootId, + parentId: null, + type: "custom", + customType: "root", + }), + sessionWrites.insertEntry({ + id: childId, + parentId: rootId, + type: "custom", + customType: "child", + }), + storedValues.setValue(storedValues.branchTip("main"), childId), ], BACKGROUND_CONTEXT, ); - const options = { id: "fork", entryId: childId, position: "before" as "before" | "at" }; + const options = { + id: "fork", + entryId: childId, + position: "before" as "before" | "at", + }; const fork = repo.fork(source.metadata, options, BACKGROUND_CONTEXT); options.entryId = rootId; options.position = "at"; @@ -82,7 +116,8 @@ describe("MemorySessionRepo metadata", () => { source.scanBranch({ start: childId, order: "oldestFirst" }, BACKGROUND_CONTEXT), ).resolves.toMatchObject([{ id: rootId }, { id: childId }]); const forked = await fork; - expect(await forked.getLeafId(BACKGROUND_CONTEXT)).toBe(rootId); + const forkedMain = await forked.branch("main", BACKGROUND_CONTEXT); + expect(await forkedMain?.getTipId(BACKGROUND_CONTEXT)).toBe(rootId); await Promise.all([ source.close(BACKGROUND_CONTEXT), forked.close(BACKGROUND_CONTEXT), diff --git a/packages/agent/test/harness/mutation-line.test.ts b/packages/agent/test/harness/mutation-line.test.ts new file mode 100644 index 000000000..761e17808 --- /dev/null +++ b/packages/agent/test/harness/mutation-line.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it } from "vitest"; +import { MutationLine } from "../../src/harness/session/mutation-line.ts"; + +function deferred(): { promise: Promise; resolve(): void } { + let resolvePromise!: () => void; + const promise = new Promise((resolve) => { + resolvePromise = resolve; + }); + return { promise, resolve: resolvePromise }; +} + +describe("MutationLine", () => { + it("serializes every Session mutation", async () => { + const line = new MutationLine(); + const gate = deferred(); + const order: string[] = []; + const first = line.run(async () => { + order.push("first:start"); + await gate.promise; + order.push("first:end"); + return "first"; + }); + const second = line.run(() => { + order.push("second"); + return "second"; + }); + + await Promise.resolve(); + expect(order).toEqual(["first:start"]); + gate.resolve(); + await expect(first).resolves.toBe("first"); + await expect(second).resolves.toBe("second"); + expect(order).toEqual(["first:start", "first:end", "second"]); + }); + + it("continues after a failed job while preserving the original failure", async () => { + const line = new MutationLine(); + const rejection = new Error("mutation failed"); + + await expect( + line.run(() => { + throw rejection; + }), + ).rejects.toBe(rejection); + await expect(line.run(() => "next")).resolves.toBe("next"); + }); + + it("seals queued and future jobs while draining the running job", async () => { + const line = new MutationLine(); + const gate = deferred(); + const running = line.run(async () => { + await gate.promise; + return "running"; + }); + await Promise.resolve(); + const queued = line.run(() => "queued"); + const closed = new Error("closed"); + + const drained = line.seal(closed); + await expect(line.run(() => "late")).rejects.toBe(closed); + gate.resolve(); + await expect(running).resolves.toBe("running"); + await expect(queued).rejects.toBe(closed); + await drained; + }); +}); diff --git a/packages/agent/test/harness/runtime/accept.test.ts b/packages/agent/test/harness/runtime/accept.test.ts index 5915a8f25..d3f9ff0e9 100644 --- a/packages/agent/test/harness/runtime/accept.test.ts +++ b/packages/agent/test/harness/runtime/accept.test.ts @@ -5,6 +5,7 @@ import { DEFAULT_COMPACTION_SETTINGS } from "../../../src/harness/compaction/com import { BACKGROUND_CONTEXT, createContextKey, withContextValue } from "../../../src/harness/context.ts"; import type { Result } from "../../../src/harness/result.ts"; import { createAgentHarness, Harness } from "../../../src/harness/runtime/harness.ts"; +import { Lane } from "../../../src/harness/runtime/lane.ts"; import { MemoryStorage } from "../../../src/harness/session/memory.ts"; import { StorageBackedSession } from "../../../src/harness/session/session.ts"; import { InstrumentedStorage } from "../../../src/harness/session/testing/index.ts"; @@ -13,6 +14,11 @@ import type { AgentMessage, Session } from "../../../src/index.ts"; import { ControlledMemoryStorage, deferred, FailingMemoryStorage } from "./test-utils.ts"; const sessions: Session[] = []; +const configuration = { + model: { provider: "faux", modelId: "faux-1" }, + thinkingLevel: "off" as const, + activeToolNames: [], +}; async function createSession(storage = new InstrumentedStorage(new MemoryStorage())): Promise<{ session: Session; @@ -24,11 +30,11 @@ async function createSession(storage = new InstrumentedStorage(new MemoryStorage ); sessions.push(session); await session.mutate( - "main", (mutator) => mutator.commit( [ - storedValues.setValue(storedValues.laneLeaf("main"), null), + storedValues.setValue(storedValues.branchTip("main"), null), + storedValues.setValue(storedValues.laneConfig("main"), configuration), storedValues.setValue(storedValues.laneState("main"), { currentOperationId: null, pendingNextRun: [], @@ -53,6 +59,7 @@ async function createHarness( resources: Parameters[0]["resources"] = {}, ): Promise<{ harness: Harness; + lane: Lane; session: Session; storage: InstrumentedStorage; models: ReturnType; @@ -62,8 +69,10 @@ async function createHarness( const harnessOptions = { ...options(created.session), resources }; const { harness } = await createAgentHarness(harnessOptions, BACKGROUND_CONTEXT); if (!(harness instanceof Harness)) throw new Error("Expected runtime Harness"); + const lane = await harness.lane("main", BACKGROUND_CONTEXT); + if (!(lane instanceof Lane)) throw new Error("Expected runtime Lane"); created.storage.clearCommitAttempts(); - return { harness, ...created, models: harnessOptions.models }; + return { harness, lane, ...created, models: harnessOptions.models }; } function unwrap(result: Result): T { @@ -100,10 +109,10 @@ describe("runtime atomic run acceptance", () => { ], ], ] as const)("accepts normalized %s prompts into starting", async (_name, request, expectedContent) => { - const { harness, session } = await createHarness(); + const { lane, session } = await createHarness(); - const admission = unwrap(await harness.accept(request, BACKGROUND_CONTEXT)); - const operation = harness.state.operation; + const admission = unwrap(await lane.accept(request, BACKGROUND_CONTEXT)); + const operation = lane.state.operation; if (operation?.state.kind !== "run") throw new Error("Expected accepted run"); const entryId = operation.meta.intent.kind === "run" ? operation.meta.intent.promptEntryIds[0] : undefined; if (entryId === undefined) throw new Error("Expected prompt entry"); @@ -121,7 +130,7 @@ describe("runtime atomic run acceptance", () => { }); it("preserves supplied message arrays and commits the exact acceptance write families once", async () => { - const { harness, session, storage, models } = await createHarness(); + const { harness, lane, storage, models } = await createHarness(); const messages: AgentMessage[] = [ { role: "user", content: "one", timestamp: 10 }, { role: "user", content: "two", timestamp: 11 }, @@ -137,7 +146,7 @@ describe("runtime atomic run acceptance", () => { } const admission = unwrap( - await harness.accept({ kind: "prompt", operationId: "operation", prompt: messages }, context), + await lane.accept({ kind: "prompt", operationId: "operation", prompt: messages }, context), ); expect(admission).toEqual({ operationId: "operation", kind: "run", startedAt: expect.any(Number) }); @@ -147,16 +156,16 @@ describe("runtime atomic run acceptance", () => { .getCommitAttempts()[0] ?.map((write) => (write.kind === "value" ? `${write.kind}:${write.op}` : write.kind)), ).toEqual(["entry", "entry", "value:set", "value:set", "value:set", "value:set"]); - const operation = harness.state.operation; + const operation = lane.state.operation; if (operation?.meta.intent.kind !== "run") throw new Error("Expected run metadata"); expect(operation.meta).toMatchObject({ operationId: "operation", lane: "main", - sourceLeafId: null, + sourceTipId: null, intent: { promptEntryIds: expect.any(Array) }, }); expect(operation.meta.intent.promptEntryIds).toHaveLength(2); - expect(await session.getLeafId(BACKGROUND_CONTEXT)).toBe(operation.meta.intent.promptEntryIds[1]); + expect(await lane.getTipId(BACKGROUND_CONTEXT)).toBe(operation.meta.intent.promptEntryIds[1]); expect(seen.map(({ event }) => event.type)).toEqual([ "run_start", "message_start", @@ -175,9 +184,8 @@ describe("runtime atomic run acceptance", () => { const second = "pending-second"; const firstMessage = { role: "user" as const, content: "first", timestamp: 1 }; const secondMessage = { role: "user" as const, content: "second", timestamp: 2 }; - const { harness, session, storage } = await createHarness(async (source) => { + const { harness, lane, session, storage } = await createHarness(async (source) => { await source.mutate( - "main", (mutator) => mutator.commit( [ @@ -204,14 +212,14 @@ describe("runtime atomic run acceptance", () => { events.push(event.type); }); - unwrap(await harness.accept({ kind: "prompt", prompt: "" }, BACKGROUND_CONTEXT)); + unwrap(await lane.accept({ kind: "prompt", prompt: "" }, BACKGROUND_CONTEXT)); - const operation = harness.state.operation; + const operation = lane.state.operation; if (operation?.meta.intent.kind !== "run" || operation.state.kind !== "run") { throw new Error("Expected accepted run"); } expect(operation.meta.intent.promptEntryIds).toEqual([]); - expect(operation.meta.sourceLeafId).toBeNull(); + expect(operation.meta.sourceTipId).toBeNull(); expect(operation.state.inbox).toEqual({ steer: [], followUp: [], writes: [] }); expect( (await session.scanBranch({ start: second, order: "oldestFirst" }, BACKGROUND_CONTEXT)).map(({ id }) => id), @@ -235,12 +243,12 @@ describe("runtime atomic run acceptance", () => { }; const skill = await createHarness(undefined, resources); unwrap( - await skill.harness.accept( + await skill.lane.accept( { kind: "skill", name: "review", additionalInstructions: "Be strict" }, BACKGROUND_CONTEXT, ), ); - const skillEntry = await skill.session.getEntry(skill.harness.state.leafId!, BACKGROUND_CONTEXT); + const skillEntry = await skill.session.getEntry(skill.lane.state.tipId!, BACKGROUND_CONTEXT); expect(skillEntry).toMatchObject({ type: "message", message: { content: [{ type: "text", text: expect.stringContaining(' { const template = await createHarness(undefined, resources); unwrap( - await template.harness.accept({ kind: "prompt_template", name: "fix", args: ["A", "B"] }, BACKGROUND_CONTEXT), + await template.lane.accept({ kind: "prompt_template", name: "fix", args: ["A", "B"] }, BACKGROUND_CONTEXT), ); - const templateEntry = await template.session.getEntry(template.harness.state.leafId!, BACKGROUND_CONTEXT); + const templateEntry = await template.session.getEntry(template.lane.state.tipId!, BACKGROUND_CONTEXT); expect(templateEntry).toMatchObject({ type: "message", message: { content: [{ type: "text", text: "Fix A then A B" }] }, @@ -258,22 +266,22 @@ describe("runtime atomic run acceptance", () => { }); it("returns expected pre-acceptance errors without writing", async () => { - const { harness, storage } = await createHarness(); + const { lane, storage } = await createHarness(); const pending = fauxAssistantMessage([], { stopReason: "pending" }); - expect(await harness.accept({ kind: "prompt", prompt: "" }, BACKGROUND_CONTEXT)).toMatchObject({ + expect(await lane.accept({ kind: "prompt", prompt: "" }, BACKGROUND_CONTEXT)).toMatchObject({ ok: false, error: { _tag: "InvalidMessage", reason: "empty" }, }); - expect(await harness.accept({ kind: "prompt", prompt: pending }, BACKGROUND_CONTEXT)).toMatchObject({ + expect(await lane.accept({ kind: "prompt", prompt: pending }, BACKGROUND_CONTEXT)).toMatchObject({ ok: false, error: { _tag: "InvalidMessage", reason: "pending_assistant" }, }); - expect(await harness.accept({ kind: "skill", name: "missing" }, BACKGROUND_CONTEXT)).toMatchObject({ + expect(await lane.accept({ kind: "skill", name: "missing" }, BACKGROUND_CONTEXT)).toMatchObject({ ok: false, error: { _tag: "UnknownSkill" }, }); - expect(await harness.accept({ kind: "prompt_template", name: "missing" }, BACKGROUND_CONTEXT)).toMatchObject({ + expect(await lane.accept({ kind: "prompt_template", name: "missing" }, BACKGROUND_CONTEXT)).toMatchObject({ ok: false, error: { _tag: "UnknownTemplate" }, }); @@ -281,11 +289,11 @@ describe("runtime atomic run acceptance", () => { }); it("serializes concurrent accepts so exactly one wins", async () => { - const { harness, storage } = await createHarness(); + const { lane, storage } = await createHarness(); const results = await Promise.all([ - harness.accept({ kind: "prompt", prompt: "first" }, BACKGROUND_CONTEXT), - harness.accept({ kind: "prompt", prompt: "second" }, BACKGROUND_CONTEXT), + lane.accept({ kind: "prompt", prompt: "first" }, BACKGROUND_CONTEXT), + lane.accept({ kind: "prompt", prompt: "second" }, BACKGROUND_CONTEXT), ]); expect(results.filter((result) => result.ok)).toHaveLength(1); @@ -298,11 +306,11 @@ describe("runtime atomic run acceptance", () => { const session = new StorageBackedSession({ id: "failing", createdAt: 1, storageVersion: 1 }, storage); sessions.push(session); await session.mutate( - "main", (mutator) => mutator.commit( [ - storedValues.setValue(storedValues.laneLeaf("main"), null), + storedValues.setValue(storedValues.branchTip("main"), null), + storedValues.setValue(storedValues.laneConfig("main"), configuration), storedValues.setValue(storedValues.laneState("main"), { currentOperationId: null, pendingNextRun: [], @@ -314,28 +322,30 @@ describe("runtime atomic run acceptance", () => { ); const { harness } = await createAgentHarness(options(session), BACKGROUND_CONTEXT); if (!(harness instanceof Harness)) throw new Error("Expected runtime Harness"); + const lane = await harness.lane("main", BACKGROUND_CONTEXT); + if (!(lane instanceof Lane)) throw new Error("Expected runtime Lane"); storage.failure = new Error("accept failed"); - await expect(harness.accept({ kind: "prompt", prompt: "hello" }, BACKGROUND_CONTEXT)).rejects.toBeInstanceOf( + await expect(lane.accept({ kind: "prompt", prompt: "hello" }, BACKGROUND_CONTEXT)).rejects.toBeInstanceOf( HarnessFault, ); - expect(harness.state.operation).toBeNull(); + expect(lane.state.operation).toBeNull(); }); it("delivers acceptance listeners after publishing state and permits serialized reads", async () => { - const { harness } = await createHarness(); + const { harness, lane } = await createHarness(); let inspected = false; harness.events.on("run_start", async () => { - inspected = (await harness.inspectExecution(BACKGROUND_CONTEXT)).current?.status === "open"; + inspected = (await lane.inspectExecution(BACKGROUND_CONTEXT)).current?.status === "open"; }); - unwrap(await harness.accept({ kind: "prompt", prompt: "hello" }, BACKGROUND_CONTEXT)); + unwrap(await lane.accept({ kind: "prompt", prompt: "hello" }, BACKGROUND_CONTEXT)); expect(inspected).toBe(true); }); it("does not resolve acceptance before its direct listeners settle", async () => { - const { harness } = await createHarness(); + const { harness, lane } = await createHarness(); const started = deferred(); const release = deferred(); harness.events.on("run_start", async () => { @@ -343,7 +353,7 @@ describe("runtime atomic run acceptance", () => { await release.promise; }); - const acceptance = harness.accept({ kind: "prompt", prompt: "hello" }, BACKGROUND_CONTEXT); + const acceptance = lane.accept({ kind: "prompt", prompt: "hello" }, BACKGROUND_CONTEXT); await started.promise; let resolved = false; void acceptance.then(() => { @@ -357,10 +367,10 @@ describe("runtime atomic run acceptance", () => { }); it("returns Closed when acceptance starts after close", async () => { - const { harness, storage } = await createHarness(); + const { harness, lane, storage } = await createHarness(); await harness.close(BACKGROUND_CONTEXT); - expect(await harness.accept({ kind: "prompt", prompt: "late" }, BACKGROUND_CONTEXT)).toMatchObject({ + expect(await lane.accept({ kind: "prompt", prompt: "late" }, BACKGROUND_CONTEXT)).toMatchObject({ ok: false, error: { _tag: "Closed" }, }); @@ -372,11 +382,11 @@ describe("runtime atomic run acceptance", () => { const session = new StorageBackedSession({ id: "closing", createdAt: 1, storageVersion: 1 }, storage); sessions.push(session); await session.mutate( - "main", (mutator) => mutator.commit( [ - storedValues.setValue(storedValues.laneLeaf("main"), null), + storedValues.setValue(storedValues.branchTip("main"), null), + storedValues.setValue(storedValues.laneConfig("main"), configuration), storedValues.setValue(storedValues.laneState("main"), { currentOperationId: null, pendingNextRun: [], @@ -387,13 +397,14 @@ describe("runtime atomic run acceptance", () => { BACKGROUND_CONTEXT, ); const { harness } = await createAgentHarness(options(session), BACKGROUND_CONTEXT); + const lane = await harness.lane("main", BACKGROUND_CONTEXT); const started = deferred(); const release = deferred(); storage.beforeNextCommit = async () => { started.resolve(); await release.promise; }; - const acceptance = harness.accept({ kind: "prompt", prompt: "hello" }, BACKGROUND_CONTEXT); + const acceptance = lane.accept({ kind: "prompt", prompt: "hello" }, BACKGROUND_CONTEXT); await started.promise; const closing = harness.close(BACKGROUND_CONTEXT); release.resolve(); diff --git a/packages/agent/test/harness/runtime/drive-generation.test.ts b/packages/agent/test/harness/runtime/drive-generation.test.ts index 389272bd5..3b5bb019c 100644 --- a/packages/agent/test/harness/runtime/drive-generation.test.ts +++ b/packages/agent/test/harness/runtime/drive-generation.test.ts @@ -87,11 +87,10 @@ async function createFixture(backend: MemoryStorage = new MemoryStorage({ now: ( activeToolNames: [], }; await session.mutate( - "main", (mutator) => mutator.commit( [ - storedValues.setValue(storedValues.laneLeaf("main"), null), + storedValues.setValue(storedValues.branchTip("main"), null), storedValues.setValue(storedValues.laneConfig("main"), configuration), storedValues.setValue(storedValues.laneState("main"), { currentOperationId: null, @@ -208,7 +207,7 @@ describe("runtime generation checkpoint", () => { expect(await startRun(fixture.lane, fixture.drive, starting)).toEqual({ kind: "continue" }); let run = currentRun(fixture.lane); if (run.phase.kind !== "checkpoint") throw new Error("missing checkpoint"); - expect(run.phase.triggerEntryId).toBe(fixture.lane.state.leafId); + expect(run.phase.triggerEntryId).toBe(fixture.lane.state.tipId); expect(fixture.events.map((event) => event.type)).toContain("entry_added"); await expectProjectionRestores(fixture); @@ -263,7 +262,7 @@ describe("runtime generation checkpoint", () => { const drained = currentRun(fixture.lane); if (drained.phase.kind !== "checkpoint") throw new Error("missing drained checkpoint"); - expect(fixture.lane.state.leafId).toBe(customId); + expect(fixture.lane.state.tipId).toBe(customId); expect(drained.phase.triggerEntryId).toBe(messageId); expect(drained.phase).toMatchObject({ continuation: { kind: "need_assistant", overflowRecoveryUsed: false }, @@ -350,7 +349,7 @@ describe("runtime assistant generation", () => { ).toEqual([ "entry", "usage", - "value:set:pi.lane.leaf", + "value:set:pi.branch.tip", "list:delete:pi.pending.assistant_frame", "value:set:pi.op.state", ]); diff --git a/packages/agent/test/harness/runtime/drive-terminal.test.ts b/packages/agent/test/harness/runtime/drive-terminal.test.ts index 51e433547..316d453cc 100644 --- a/packages/agent/test/harness/runtime/drive-terminal.test.ts +++ b/packages/agent/test/harness/runtime/drive-terminal.test.ts @@ -48,7 +48,7 @@ function meta(operationId: string, state: OperationState): OperationMeta { : state.kind === "compaction" ? { kind: "compaction" } : { kind: "navigation", targetId: state.targetId, summarize: state.summarize }; - return { operationId, lane: "main", sourceLeafId: null, startedAt: 1, intent }; + return { operationId, lane: "main", sourceTipId: null, startedAt: 1, intent }; } async function createSession(): Promise<{ session: Session; storage: MemoryStorage }> { @@ -62,13 +62,9 @@ async function createSession(): Promise<{ session: Session; storage: MemoryStora } async function commit(session: Session, writes: Write[]): Promise { - await session.mutate( - "main", - async (mutator) => { - await mutator.commit(writes, BACKGROUND_CONTEXT); - }, - BACKGROUND_CONTEXT, - ); + await session.mutate(async (mutator) => { + await mutator.commit(writes, BACKGROUND_CONTEXT); + }, BACKGROUND_CONTEXT); } function address(write: Write): string { @@ -285,17 +281,17 @@ describe("runtime terminal outcome hydration", () => { operationId: "run", kind: "run", outcome: "completed", - leafId: "assistant", + tipId: "assistant", finalAssistantEntryId: "assistant", runCompletion: "assistant", }, - { operationId: "compact", kind: "compaction", outcome: "completed", leafId: "compaction" }, + { operationId: "compact", kind: "compaction", outcome: "completed", tipId: "compaction" }, { operationId: "navigate", kind: "navigation", outcome: "completed", - oldLeafId: "old", - leafId: "summary", + oldTipId: "old", + tipId: "summary", summaryEntryId: "summary", }, ]; @@ -309,7 +305,7 @@ describe("runtime terminal outcome hydration", () => { operation: "run", runId: "run", kind: "completed", - leafId: "assistant", + tipId: "assistant", finalEntryId: "assistant", finalMessage: assistant, }, @@ -317,15 +313,15 @@ describe("runtime terminal outcome hydration", () => { operation: "compaction", runId: "compact", kind: "completed", - leafId: "compaction", + tipId: "compaction", entry: expect.objectContaining({ id: "compaction", type: "compaction", summary: "summary" }), }, { operation: "navigation", runId: "navigate", kind: "completed", - oldLeafId: "old", - newLeafId: "summary", + oldTipId: "old", + newTipId: "summary", summaryEntry: expect.objectContaining({ id: "summary", type: "branch_summary", summary: "branch" }), }, ]); @@ -340,16 +336,16 @@ describe("runtime terminal outcome hydration", () => { operationId: "run", kind: "run", outcome: "completed", - leafId: "tool-result", + tipId: "tool-result", runCompletion: "terminated_tools", }, - { operationId: "compact", kind: "compaction", outcome: "declined", leafId: "leaf" }, + { operationId: "compact", kind: "compaction", outcome: "declined", tipId: "leaf" }, { operationId: "navigate", kind: "navigation", outcome: "aborted", - oldLeafId: "old", - leafId: "old", + oldTipId: "old", + tipId: "old", }, ]; @@ -358,9 +354,9 @@ describe("runtime terminal outcome hydration", () => { ); expect(outcomes).toEqual([ - { operation: "run", runId: "run", kind: "completed", leafId: "tool-result" }, - { operation: "compaction", runId: "compact", kind: "declined", leafId: "leaf" }, - { operation: "navigation", runId: "navigate", kind: "aborted", leafId: "old" }, + { operation: "run", runId: "run", kind: "completed", tipId: "tool-result" }, + { operation: "compaction", runId: "compact", kind: "declined", tipId: "leaf" }, + { operation: "navigation", runId: "navigate", kind: "aborted", tipId: "old" }, ]); expect(getEntries).not.toHaveBeenCalled(); }); diff --git a/packages/agent/test/harness/runtime/harness.test.ts b/packages/agent/test/harness/runtime/harness.test.ts index 839dad0a5..5afffcb0c 100644 --- a/packages/agent/test/harness/runtime/harness.test.ts +++ b/packages/agent/test/harness/runtime/harness.test.ts @@ -1,1000 +1,229 @@ import { createModels, fauxProvider } from "@earendil-works/pi-ai"; -import { Type } from "typebox"; import { afterEach, describe, expect, it, vi } from "vitest"; -import { AgentHarness, HarnessClosed, HarnessFault } from "../../../src/harness/agent-harness.ts"; -import { DEFAULT_COMPACTION_SETTINGS } from "../../../src/harness/compaction/compaction.ts"; +import { AgentHarness, HarnessFault, InvalidLane, UnknownTarget } from "../../../src/harness/agent-harness.ts"; import { BACKGROUND_CONTEXT } from "../../../src/harness/context.ts"; -import type { Result } from "../../../src/harness/result.ts"; -import { createAgentHarness, Harness } from "../../../src/harness/runtime/harness.ts"; +import { Harness } from "../../../src/harness/runtime/harness.ts"; import { Lane } from "../../../src/harness/runtime/lane.ts"; -import { MemorySessionRepo, type MemoryStorage } from "../../../src/harness/session/memory.ts"; +import { MemorySessionRepo, MemoryStorage } from "../../../src/harness/session/memory.ts"; import { StorageBackedSession } from "../../../src/harness/session/session.ts"; -import type { - LaneConfiguration, - NavigationState, - OperationMeta, - OperationState, - RunState, - Session, -} from "../../../src/harness/session/types.ts"; -import * as storedValues from "../../../src/harness/session/values.ts"; -import type { AgentHarnessTool } from "../../../src/harness/types.ts"; -import { ControlledMemoryStorage, deferred, FailingMemoryStorage } from "./test-utils.ts"; +import type { LaneConfiguration, Session } from "../../../src/harness/session/types.ts"; +import { branchTip, laneConfig, laneState, pendingEntry, setValue } from "../../../src/harness/session/values.ts"; +import { deferred } from "./test-utils.ts"; -const repos: MemorySessionRepo[] = []; -const standaloneSessions: Session[] = []; -const configuredMain: LaneConfiguration = { - model: { provider: "configured", modelId: "main" }, - thinkingLevel: "low", - activeToolNames: ["configured-tool"], -}; -const toolParameters = Type.Object({}); -const applicationValue = storedValues.value<{ durable: boolean }>("test.application.value"); +const sessions: Session[] = []; -function tool(name: string): AgentHarnessTool { +function harnessOptions(session: Session) { + const provider = fauxProvider(); + const models = createModels(); + models.setProvider(provider.provider); return { - name, - label: name, - description: name, - parameters: toolParameters, - replay: "safe", - execute: async () => ({ content: [{ type: "text", text: "unused" }], details: {} }), + session, + models, + model: provider.getModel(), + thinkingLevel: "medium" as const, + activeToolNames: ["read", "bash"], }; } -async function createSession(): Promise { - const repo = new MemorySessionRepo(); - repos.push(repo); - return repo.create({}, BACKGROUND_CONTEXT); -} - -async function configureMain(session: Session, configuration: LaneConfiguration = configuredMain): Promise { - await session.mutate( - "main", - (mutator) => - mutator.commit([storedValues.setValue(storedValues.laneConfig("main"), configuration)], BACKGROUND_CONTEXT), - BACKGROUND_CONTEXT, - ); -} - -async function createStorageSession(storage: MemoryStorage): Promise { - const session = new StorageBackedSession( - { id: `runtime-storage-${standaloneSessions.length}`, createdAt: 1, storageVersion: 1 }, - storage, - ); - standaloneSessions.push(session); - await session.mutate( - "main", - (mutator) => - mutator.commit( - [ - storedValues.setValue(storedValues.laneLeaf("main"), null), - storedValues.setValue(storedValues.laneState("main"), { currentOperationId: null, pendingNextRun: [] }), - ], - BACKGROUND_CONTEXT, - ), - BACKGROUND_CONTEXT, - ); +async function createSession(id = `session-${sessions.length}`): Promise { + const session = new StorageBackedSession({ id, createdAt: 1, storageVersion: 1 }, new MemoryStorage()); + sessions.push(session); return session; } -async function createFailingSession(): Promise<{ session: Session; storage: FailingMemoryStorage }> { - const storage = new FailingMemoryStorage(); - return { session: await createStorageSession(storage), storage }; +async function createHarness(session?: Session): Promise> { + session ??= await createSession(); + const created = await AgentHarness.create(harnessOptions(session), BACKGROUND_CONTEXT); + if (!(created.harness instanceof Harness)) throw new Error("Expected runtime Harness"); + return created.harness; } -function unwrap(result: Result): T { - if (!result.ok) throw result.error; - return result.value; -} - -function modelOptions(session: Session) { - const faux = fauxProvider(); - const models = createModels(); - models.setProvider(faux.provider); - return { session, models, model: faux.getModel() }; -} - -function operationMeta(session: Session, intent: OperationMeta["intent"], startedAt = 1, lane = "main"): OperationMeta { - return { operationId: session.idGenerator.next(), lane, sourceLeafId: null, startedAt, intent }; -} - -async function installOperation(session: Session, meta: OperationMeta, state: OperationState): Promise { - await session.mutate( - meta.lane, - (mutator) => - mutator.commit( - [ - storedValues.setValue(storedValues.operationMeta(meta.operationId), meta), - storedValues.setValue(storedValues.operationState(meta.operationId), state), - storedValues.setValue(storedValues.laneState(meta.lane), { - currentOperationId: meta.operationId, - pendingNextRun: [], - }), - ], - BACKGROUND_CONTEXT, - ), - BACKGROUND_CONTEXT, - ); -} - -function runState(triggerEntryId: string): RunState { - return { - kind: "run", - control: { status: "running" }, - settings: { - compaction: DEFAULT_COMPACTION_SETTINGS, - steeringMode: "all", - followUpMode: "all", - toolExecution: "parallel", - }, - phase: { - kind: "checkpoint", - continuation: { kind: "need_assistant", overflowRecoveryUsed: false }, - triggerEntryId, - }, - inbox: { steer: [], followUp: [], writes: [] }, - latestAssistantEntryId: null, - }; -} +const configured = { + model: { provider: "faux", modelId: "faux-1" }, + thinkingLevel: "low", + activeToolNames: ["read"], +} satisfies LaneConfiguration; afterEach(async () => { - for (const repo of repos.splice(0)) await repo.close(BACKGROUND_CONTEXT); - for (const session of standaloneSessions.splice(0)) await session.close(BACKGROUND_CONTEXT); + for (const session of sessions.splice(0)) await session.close(BACKGROUND_CONTEXT); }); -describe("runtime AgentHarness", () => { - it("is selected by the public AgentHarness constructor", async () => { +describe("runtime Harness lane management", () => { + it("attaches to a fresh Session without creating an implicit main lane", async () => { const session = await createSession(); + const harness = await createHarness(session); - const { harness } = await AgentHarness.create(modelOptions(session), BACKGROUND_CONTEXT); - - expect(harness).toBeInstanceOf(Harness); + expect(await harness.lanes(BACKGROUND_CONTEXT)).toEqual([]); + expect(await session.branch("main", BACKGROUND_CONTEXT)).toBeUndefined(); + expect("accept" in harness).toBe(false); + expect("getTipId" in harness).toBe(false); + expect("appendMessage" in harness).toBe(false); }); - it("rejects duplicate tool names as caller input", async () => { + it("atomically gets or creates a complete AgentLane", async () => { const session = await createSession(); - const duplicate = tool("duplicate"); - - await expect( - createAgentHarness({ ...modelOptions(session), tools: [duplicate, duplicate] }, BACKGROUND_CONTEXT), - ).rejects.toBeInstanceOf(TypeError); - }); - - it("owns initial config and publishes replacements before ordered events", async () => { - const session = await createSession(); - const initialTools = [tool("initial")]; - const initialResources = { skills: [] }; - const initialStreamOptions = { timeoutMs: 10 }; - const initialRetry = { enabled: true, maxRetries: 2, baseDelayMs: 20 }; - const initialCompaction = { enabled: false, reserveTokens: 100, keepRecentTokens: 200 }; - const { harness } = await createAgentHarness( - { - ...modelOptions(session), - tools: initialTools, - resources: initialResources, - streamOptions: initialStreamOptions, - retry: initialRetry, - compaction: initialCompaction, - steeringMode: "one-at-a-time", - followUpMode: "one-at-a-time", - toolExecution: "sequential", - }, - BACKGROUND_CONTEXT, - ); - - expect(await harness.getTools(BACKGROUND_CONTEXT)).toBe(initialTools); - expect(await harness.getResources(BACKGROUND_CONTEXT)).toBe(initialResources); - expect(await harness.getStreamOptions(BACKGROUND_CONTEXT)).toBe(initialStreamOptions); - expect(await harness.getRetryPolicy(BACKGROUND_CONTEXT)).toBe(initialRetry); - expect(await harness.getCompactionSettings(BACKGROUND_CONTEXT)).toBe(initialCompaction); - expect(await harness.getSteeringMode(BACKGROUND_CONTEXT)).toBe("one-at-a-time"); - expect(await harness.getFollowUpMode(BACKGROUND_CONTEXT)).toBe("one-at-a-time"); - + const harness = await createHarness(session); const events: string[] = []; - let retryAtEvent: typeof initialRetry | undefined; - harness.events.on("config_update", async (event) => { - events.push(event.property); - if (event.property === "retryPolicy") retryAtEvent = await harness.getRetryPolicy(BACKGROUND_CONTEXT); - }); - const nextTools = [tool("next")]; - const nextResources = { promptTemplates: [] }; - const nextStreamOptions = { maxRetries: 4 }; - const nextRetry = { enabled: false, maxRetries: 0, baseDelayMs: 0 }; - const nextCompaction = { enabled: true, reserveTokens: 300, keepRecentTokens: 400 }; - - await harness.setTools(nextTools, BACKGROUND_CONTEXT); - await harness.setResources(nextResources, BACKGROUND_CONTEXT); - await harness.setStreamOptions(nextStreamOptions, BACKGROUND_CONTEXT); - await harness.setRetryPolicy(nextRetry, BACKGROUND_CONTEXT); - await harness.setCompactionSettings(nextCompaction, BACKGROUND_CONTEXT); - await harness.setSteeringMode("all", BACKGROUND_CONTEXT); - await harness.setFollowUpMode("all", BACKGROUND_CONTEXT); - - expect(await harness.getTools(BACKGROUND_CONTEXT)).toBe(nextTools); - expect(await harness.getResources(BACKGROUND_CONTEXT)).toBe(nextResources); - expect(await harness.getStreamOptions(BACKGROUND_CONTEXT)).toBe(nextStreamOptions); - expect(await harness.getRetryPolicy(BACKGROUND_CONTEXT)).toBe(nextRetry); - expect(await harness.getCompactionSettings(BACKGROUND_CONTEXT)).toBe(nextCompaction); - expect(await harness.getSteeringMode(BACKGROUND_CONTEXT)).toBe("all"); - expect(await harness.getFollowUpMode(BACKGROUND_CONTEXT)).toBe("all"); - expect(retryAtEvent).toBe(nextRetry); - expect(events).toEqual([ - "tools", - "resources", - "streamOptions", - "retryPolicy", - "compactionSettings", - "steeringMode", - "followUpMode", - ]); - - const penultimateResources = { skills: [] }; - const finalResources = { promptTemplates: [] }; - await Promise.all([ - harness.setResources(penultimateResources, BACKGROUND_CONTEXT), - harness.setResources(finalResources, BACKGROUND_CONTEXT), - ]); - expect(await harness.getResources(BACKGROUND_CONTEXT)).toBe(finalResources); - expect(events.slice(-2)).toEqual(["resources", "resources"]); - }); - - it("emits lane config changes after committed memory publication", async () => { - const session = await createSession(); - const options = modelOptions(session); - const { harness } = await createAgentHarness(options, BACKGROUND_CONTEXT); - const events: unknown[] = []; - let thinkingAtEvent: string | undefined; - harness.events.on("config_update", async (event) => { - events.push(event); - if (event.property === "thinkingLevel") thinkingAtEvent = await harness.getThinkingLevel(BACKGROUND_CONTEXT); + harness.events.on("lane_created", (event) => { + events.push(event.lane); }); - await harness.setModel(options.model, BACKGROUND_CONTEXT); - await harness.setThinkingLevel("high", BACKGROUND_CONTEXT); - await harness.setActiveTools(["read"], BACKGROUND_CONTEXT); + const lane = await harness.lane("main", BACKGROUND_CONTEXT); + const same = await harness.lane("main", { createAt: "ignored" }, BACKGROUND_CONTEXT); - const model = { provider: options.model.provider, modelId: options.model.id }; - expect(events).toEqual([ - { type: "config_update", property: "model", previous: model, value: model, lane: "main" }, - { type: "config_update", property: "thinkingLevel", previous: "off", value: "high", lane: "main" }, - { type: "config_update", property: "activeTools", previous: [], value: ["read"], lane: "main" }, - ]); - expect(thinkingAtEvent).toBe("high"); - }); - - it("serializes inspection behind earlier lane commits", async () => { - const storage = new ControlledMemoryStorage(); - const session = await createStorageSession(storage); - const { harness } = await createAgentHarness(modelOptions(session), BACKGROUND_CONTEXT); - const commitStarted = deferred(); - const releaseCommit = deferred(); - storage.beforeNextCommit = async () => { - commitStarted.resolve(); - await releaseCommit.promise; - }; - const setting = harness.setThinkingLevel("high", BACKGROUND_CONTEXT); - await commitStarted.promise; - let inspected = false; - const inspection = harness.inspectExecution(BACKGROUND_CONTEXT).then((value) => { - inspected = true; - return value; - }); - await new Promise((resolve) => setTimeout(resolve, 0)); - expect(inspected).toBe(false); - - releaseCommit.resolve(); - await setting; - expect((await inspection).configuredModel).toEqual({ - provider: modelOptions(session).model.provider, - modelId: modelOptions(session).model.id, - }); - expect(await harness.getThinkingLevel(BACKGROUND_CONTEXT)).toBe("high"); - }); - - it("rejects invalid config without publication or fault", async () => { - const session = await createSession(); - const initialTools = [tool("initial")]; - const { harness } = await createAgentHarness( - { ...modelOptions(session), tools: initialTools }, - BACKGROUND_CONTEXT, - ); - const events: string[] = []; - harness.events.on("config_update", (event) => { - events.push(event.property); - }); - const duplicate = tool("duplicate"); - - expect(() => harness.setTools([duplicate, duplicate], BACKGROUND_CONTEXT)).toThrow(TypeError); - expect(() => - harness.setRetryPolicy({ enabled: true, maxRetries: -1, baseDelayMs: 0 }, BACKGROUND_CONTEXT), - ).toThrow(RangeError); - expect(() => - harness.setCompactionSettings({ enabled: true, reserveTokens: -1, keepRecentTokens: 0 }, BACKGROUND_CONTEXT), - ).toThrow(RangeError); - - expect(await harness.getTools(BACKGROUND_CONTEXT)).toBe(initialTools); - expect(await harness.getRetryPolicy(BACKGROUND_CONTEXT)).toEqual({ - enabled: true, - maxRetries: 3, - baseDelayMs: 1_000, - }); - expect(await harness.getCompactionSettings(BACKGROUND_CONTEXT)).toBe(DEFAULT_COMPACTION_SETTINGS); - expect(events).toEqual([]); - expect(await harness.lanes(BACKGROUND_CONTEXT)).toHaveLength(1); - }); - - it("validates initial config before writing durable state", async () => { - const invalidRetrySession = await createSession(); - await expect( - createAgentHarness( - { - ...modelOptions(invalidRetrySession), - retry: { enabled: true, maxRetries: -1, baseDelayMs: 0 }, - }, - BACKGROUND_CONTEXT, - ), - ).rejects.toBeInstanceOf(RangeError); - expect(await invalidRetrySession.getValue(storedValues.laneConfig("main"), BACKGROUND_CONTEXT)).toBeUndefined(); - - const invalidCompactionSession = await createSession(); - await expect( - createAgentHarness( - { - ...modelOptions(invalidCompactionSession), - compaction: { enabled: true, reserveTokens: -1, keepRecentTokens: 0 }, - }, - BACKGROUND_CONTEXT, - ), - ).rejects.toBeInstanceOf(RangeError); - expect( - await invalidCompactionSession.getValue(storedValues.laneConfig("main"), BACKGROUND_CONTEXT), - ).toBeUndefined(); - }); - - it("seeds main and returns the concrete harness as its main lane", async () => { - const session = await createSession(); - const options = modelOptions(session); - - const { harness, open } = await createAgentHarness( - { - ...options, - thinkingLevel: "high", - activeToolNames: ["read"], - }, - BACKGROUND_CONTEXT, - ); - - expect(harness).toBeInstanceOf(Harness); - expect(await harness.lane("main", BACKGROUND_CONTEXT)).toBe(harness); - expect(await harness.getLeafId(BACKGROUND_CONTEXT)).toBeNull(); - expect(open).toEqual([]); - expect((await session.getValue(storedValues.laneConfig("main"), BACKGROUND_CONTEXT))?.value).toEqual({ - model: { provider: options.model.provider, modelId: options.model.id }, - thinkingLevel: "high", - activeToolNames: ["read"], - }); - }); - - it("appends idle entries through owned lane state", async () => { - const session = await createSession(); - const { harness } = await createAgentHarness(modelOptions(session), BACKGROUND_CONTEXT); - const events: string[] = []; - for (const type of ["message_start", "message_end", "entry_added"] as const) { - harness.events.on(type, async (event) => { - events.push(event.type); - await harness.inspectExecution(BACKGROUND_CONTEXT); - }); - } - - const entryId = await harness.sessionTree.appendMessage( - { role: "user", content: "hello", timestamp: 1 }, - BACKGROUND_CONTEXT, - ); - - expect(await harness.getLeafId(BACKGROUND_CONTEXT)).toBe(entryId); - expect((await session.getValue(storedValues.laneLeaf("main"), BACKGROUND_CONTEXT))?.value).toBe(entryId); - expect(await harness.sessionTree.findEntriesOnBranch({ order: "oldestFirst" }, BACKGROUND_CONTEXT)).toMatchObject( - [{ id: entryId, parentId: null, message: { role: "user", content: "hello" } }], - ); - expect(events).toEqual(["message_start", "message_end", "entry_added"]); - }); - - it("restores every configured lane without replacing its configuration", async () => { - const session = await createSession(); - const workerConfiguration: LaneConfiguration = { - model: { provider: "configured", modelId: "worker" }, - thinkingLevel: "medium", - activeToolNames: [], - }; - await session.mutate( - "main", - (mutator) => - mutator.commit( - [storedValues.setValue(storedValues.laneConfig("main"), configuredMain)], - BACKGROUND_CONTEXT, - ), - 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), - ]); - - const { harness } = await createAgentHarness(modelOptions(session), BACKGROUND_CONTEXT); - const worker = await harness.lane("worker", BACKGROUND_CONTEXT); - - expect(worker).toBeInstanceOf(Lane); - expect(await worker?.getLeafId(BACKGROUND_CONTEXT)).toBeNull(); - expect((await harness.lanes(BACKGROUND_CONTEXT)).map((lane) => lane.name).sort()).toEqual(["main", "worker"]); - expect( - await Promise.all([ - session.getValue(storedValues.laneConfig("main"), BACKGROUND_CONTEXT), - session.getValue(storedValues.laneConfig("worker"), BACKGROUND_CONTEXT), - ]), - ).toEqual(before); - }); - - it("creates a lane from the captured seed and publishes it after commit", async () => { - const session = await createSession(); - const options = modelOptions(session); - const { harness } = await createAgentHarness( - { - ...options, - thinkingLevel: "high", - activeToolNames: ["read"], - }, - BACKGROUND_CONTEXT, - ); - const anchor = await harness.sessionTree.appendCustomEntry("anchor", undefined, BACKGROUND_CONTEXT); - await harness.setThinkingLevel("low", BACKGROUND_CONTEXT); - let durableConfigurationAtEvent: LaneConfiguration | undefined; - let laneVisibleAtEvent = false; - let laneInspectionAtEvent: string | undefined; - harness.events.on("lane_created", async () => { - durableConfigurationAtEvent = (await session.getValue(storedValues.laneConfig("worker"), BACKGROUND_CONTEXT)) - ?.value; - const published = await harness.lane("worker", BACKGROUND_CONTEXT); - laneVisibleAtEvent = published !== undefined; - laneInspectionAtEvent = (await published?.inspectExecution(BACKGROUND_CONTEXT))?.lane; - }); - - const worker = unwrap(await harness.createLane("worker", anchor, BACKGROUND_CONTEXT)); - - expect(await harness.lane("worker", BACKGROUND_CONTEXT)).toBe(worker); - expect(await worker.getLeafId(BACKGROUND_CONTEXT)).toBe(anchor); - expect(await worker.getThinkingLevel(BACKGROUND_CONTEXT)).toBe("high"); - expect(await worker.getActiveTools(BACKGROUND_CONTEXT)).toEqual(["read"]); - expect(durableConfigurationAtEvent).toEqual({ - model: { provider: options.model.provider, modelId: options.model.id }, - thinkingLevel: "high", - activeToolNames: ["read"], - }); - expect(laneVisibleAtEvent).toBe(true); - expect(laneInspectionAtEvent).toBe("worker"); - expect((await session.getValue(storedValues.laneState("worker"), BACKGROUND_CONTEXT))?.value).toEqual({ + expect(lane).toBeInstanceOf(Lane); + expect(same).toBe(lane); + expect(await lane.getTipId(BACKGROUND_CONTEXT)).toBeNull(); + expect(await lane.getThinkingLevel(BACKGROUND_CONTEXT)).toBe("medium"); + expect(await lane.getActiveTools(BACKGROUND_CONTEXT)).toEqual(["read", "bash"]); + expect((await session.getValue(laneState("main"), BACKGROUND_CONTEXT))?.value).toEqual({ currentOperationId: null, pendingNextRun: [], }); + expect(events).toEqual(["main"]); + expect(await harness.lanes(BACKGROUND_CONTEXT)).toMatchObject([{ name: "main", tipId: null }]); }); - it("publishes a created lane before a duplicate observes it and awaits lane_created listeners", async () => { + it("returns one published AgentLane under concurrent acquisition", 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 harness = await createHarness(session); + const listener = vi.fn(); + harness.events.on("lane_created", listener); - 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), + const [first, second, third] = await Promise.all([ + harness.lane("main", BACKGROUND_CONTEXT), + harness.lane("main", BACKGROUND_CONTEXT), + harness.lane("main", 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); + expect(second).toBe(first); + expect(third).toBe(first); + expect(listener).toHaveBeenCalledTimes(1); }); - it("binds metadata and lane-creation recipients before releasing their mutation lines", async () => { + it("serializes commands from different AgentLanes on the one Session line", async () => { + const harness = await createHarness(); + const main = await harness.lane("main", BACKGROUND_CONTEXT); + const review = await harness.lane("review", BACKGROUND_CONTEXT); + if (!(main instanceof Lane) || !(review instanceof Lane)) throw new Error("Expected runtime Lanes"); + const started = deferred(); + const gate = deferred(); + const order: string[] = []; + const first = main.command(async () => { + order.push("main:start"); + started.resolve(); + await gate.promise; + order.push("main:end"); + return { kind: "return", result: undefined }; + }, BACKGROUND_CONTEXT); + await started.promise; + const second = review.command(() => { + order.push("review"); + return { kind: "return", result: undefined }; + }, BACKGROUND_CONTEXT); + await Promise.resolve(); + expect(order).toEqual(["main:start"]); + gate.resolve(); + await Promise.all([first, second]); + expect(order).toEqual(["main:start", "main:end", "review"]); + }); + + it("uses createAt only for a missing lane and validates the target", async () => { const session = await createSession(); - const { harness } = await createAgentHarness(modelOptions(session), BACKGROUND_CONTEXT); - const blockStarted = deferred(); - const releaseBlock = deferred(); - let shouldBlock = true; - harness.events.on("config_update", async (event) => { - if (event.property !== "resources" || !shouldBlock) return; - shouldBlock = false; - blockStarted.resolve(); - await releaseBlock.promise; - }); - const blocking = harness.setResources({ skills: [] }, BACKGROUND_CONTEXT); - await blockStarted.promise; - - const naming = harness.sessionTree.setName("bound", BACKGROUND_CONTEXT); - while ((await session.getName(BACKGROUND_CONTEXT)) !== "bound") await Promise.resolve(); - const lateValues = vi.fn(); - harness.events.on("value_update", lateValues); - releaseBlock.resolve(); - await Promise.all([blocking, naming]); - expect(lateValues).not.toHaveBeenCalled(); - - const secondStarted = deferred(); - const secondRelease = deferred(); - let secondBlock = true; - harness.events.on("config_update", async (event) => { - if (event.property !== "streamOptions" || !secondBlock) return; - secondBlock = false; - secondStarted.resolve(); - await secondRelease.promise; - }); - const secondBlocking = harness.setStreamOptions({ timeoutMs: 1 }, BACKGROUND_CONTEXT); - await secondStarted.promise; - const creating = harness.createLane("worker", null, BACKGROUND_CONTEXT); - while ((await session.getValue(storedValues.laneLeaf("worker"), BACKGROUND_CONTEXT)) === undefined) { - await Promise.resolve(); - } - const lateLanes = vi.fn(); - harness.events.on("lane_created", lateLanes); - secondRelease.resolve(); - await Promise.all([secondBlocking, creating]); - expect(lateLanes).not.toHaveBeenCalled(); - }); - - it("maps expected lane creation failures without faulting", async () => { - const session = await createSession(); - const { harness } = await createAgentHarness(modelOptions(session), BACKGROUND_CONTEXT); - unwrap(await harness.createLane("worker", null, BACKGROUND_CONTEXT)); - - expect(await harness.createLane("worker", null, BACKGROUND_CONTEXT)).toMatchObject({ - ok: false, - error: { _tag: "LaneExists", lane: "worker" }, - }); - expect(await harness.createLane("", null, BACKGROUND_CONTEXT)).toMatchObject({ - ok: false, - error: { _tag: "InvalidLane", lane: "", reason: "lane name must not be empty" }, - }); - expect(await harness.createLane("missing", "unknown-entry", BACKGROUND_CONTEXT)).toMatchObject({ - ok: false, - error: { _tag: "UnknownTarget", targetId: "unknown-entry" }, - }); - expect((await harness.lanes(BACKGROUND_CONTEXT)).map((lane) => lane.name).sort()).toEqual(["main", "worker"]); - }); - - it("faults without publishing a lane when creation commit fails", async () => { - const { session, storage } = await createFailingSession(); - const { harness } = await createAgentHarness(modelOptions(session), BACKGROUND_CONTEXT); - if (!(harness instanceof Harness)) throw new Error("missing runtime harness"); - const failure = new Error("create lane failed"); - storage.failure = failure; - - let rejected: unknown; - try { - await harness.createLane("worker", null, BACKGROUND_CONTEXT); - } catch (error) { - rejected = error; - } - - expect(rejected).toBeInstanceOf(HarnessFault); - if (!(rejected instanceof HarnessFault)) throw new Error("missing harness fault"); - expect(rejected.cause).toBe(failure); - await expect(harness.createLane("later", null, BACKGROUND_CONTEXT)).rejects.toBe(rejected); - expect(harness.lanesByName.has("worker")).toBe(false); - expect(await session.getValue(storedValues.laneConfig("worker"), BACKGROUND_CONTEXT)).toBeUndefined(); - expect(await session.getValue(storedValues.laneLeaf("worker"), BACKGROUND_CONTEXT)).toBeUndefined(); - expect(await session.getValue(storedValues.laneState("worker"), BACKGROUND_CONTEXT)).toBeUndefined(); - }); - - it("returns Closed when lane creation starts after close", async () => { - const session = await createSession(); - const { harness } = await createAgentHarness(modelOptions(session), BACKGROUND_CONTEXT); - await harness.close(BACKGROUND_CONTEXT); - - expect(await harness.createLane("late", null, BACKGROUND_CONTEXT)).toMatchObject({ - ok: false, - error: { _tag: "Closed" }, - }); - await expect(harness.getTools(BACKGROUND_CONTEXT)).rejects.toBeInstanceOf(HarnessClosed); - await expect(harness.setResources({}, BACKGROUND_CONTEXT)).rejects.toBeInstanceOf(HarnessClosed); - }); - - it("publishes an admitted lane creation before close finishes", async () => { - const storage = new ControlledMemoryStorage(); - const session = await createStorageSession(storage); - const { harness } = await createAgentHarness(modelOptions(session), BACKGROUND_CONTEXT); - if (!(harness instanceof Harness)) throw new Error("missing runtime harness"); - const commitStarted = deferred(); - const releaseCommit = deferred(); - storage.beforeNextCommit = async () => { - commitStarted.resolve(); - await releaseCommit.promise; - }; - const creating = harness.createLane("worker", null, BACKGROUND_CONTEXT); - await commitStarted.promise; - const closing = harness.close(BACKGROUND_CONTEXT); - releaseCommit.resolve(); - - const worker = unwrap(await creating); - await closing; - - expect(harness.lanesByName.get("worker")).toBe(worker); - await expect(worker.getLeafId(BACKGROUND_CONTEXT)).rejects.toBeInstanceOf(HarnessClosed); - }); - - it("attaches from the minimal projection without resolving identities or payload references", async () => { - const session = await createSession(); - const options = modelOptions(session); - await configureMain(session); - const meta = operationMeta(session, { kind: "run", promptEntryIds: ["missing-prompt"] }, 2); - const { operationId } = meta; - await installOperation(session, meta, { - ...runState("missing-trigger"), - phase: { - kind: "deferred", - deferred: { - status: "suspended", - stepId: session.idGenerator.next(), - sourceEntryId: "missing-source", - poll: 0, - configuration: { - model: { provider: "missing-provider", modelId: "missing-model" }, - thinkingLevel: "off", - activeToolNames: ["missing-tool"], - }, - streamOptions: {}, - }, - }, - }); - const getEntries = vi.spyOn(session, "getEntries"); - const getModel = vi.spyOn(options.models, "getModel"); - - const { harness, open } = await createAgentHarness( - { ...options, tools: [tool("available")] }, - BACKGROUND_CONTEXT, - ); - - expect(open).toEqual([{ lane: "main", operationId, kind: "run", startedAt: 2 }]); - expect(getEntries).not.toHaveBeenCalled(); - expect(getModel).not.toHaveBeenCalled(); - expect(await harness.inspectExecution(BACKGROUND_CONTEXT)).toMatchObject({ - configuredModel: configuredMain.model, - current: { - id: operationId, - status: "open", - capturedModel: { provider: "missing-provider", modelId: "missing-model" }, - }, - }); - }); - - it("does not classify unavailable captured request tools during attachment", async () => { - const session = await createSession(); - const options = modelOptions(session); - await configureMain(session); - const meta = operationMeta(session, { kind: "run", promptEntryIds: [] }); - const { operationId } = meta; - await installOperation(session, meta, { - ...runState("missing-trigger"), - phase: { - kind: "tools", - batch: { - assistantEntryId: "missing-assistant", - configuration: { ...configuredMain, activeToolNames: ["missing-tool"] }, - turnId: session.idGenerator.next(), - calls: [], - }, - }, - }); - - const { open } = await createAgentHarness(options, BACKGROUND_CONTEXT); - - expect(open).toEqual([{ lane: "main", operationId, kind: "run", startedAt: 1 }]); - }); - - it("marks restored cancellation as aborting in the open inventory", async () => { - const session = await createSession(); - await configureMain(session); - const meta = operationMeta(session, { kind: "run", promptEntryIds: [] }); - const { operationId } = meta; - await installOperation(session, meta, { - ...runState(session.idGenerator.next()), - control: { status: "cancel_requested", requestedAt: 2, drainedSteer: [], drainedFollowUp: [] }, - }); - - const { harness, open } = await createAgentHarness(modelOptions(session), BACKGROUND_CONTEXT); - - expect(open).toEqual([{ lane: "main", operationId, kind: "run", startedAt: 1, aborting: true }]); - expect(await harness.inspectExecution(BACKGROUND_CONTEXT)).toEqual({ - lane: "main", - leafId: null, - configuredModel: configuredMain.model, - current: { id: operationId, kind: "run", status: "aborting", startedAt: 1 }, - }); - expect(await harness.lanes(BACKGROUND_CONTEXT)).toEqual([ - { - name: "main", - leafId: null, - operation: { id: operationId, kind: "run", status: "aborting", startedAt: 1 }, - }, - ]); - }); - - it("inspects idle last results from owned state", async () => { - const session = await createSession(); - const operationId = session.idGenerator.next(); - const lastResult = { - operationId, - kind: "navigation" as const, - outcome: "completed" as const, - oldLeafId: null, - leafId: null, - }; await session.mutate( - "main", (mutator) => mutator.commit( [ - storedValues.setValue(storedValues.laneConfig("main"), configuredMain), - storedValues.setValue(storedValues.laneLastResult("main"), lastResult), + { + kind: "entry", + entry: { + id: "target", + parentId: null, + type: "custom", + customType: "target", + }, + }, ], BACKGROUND_CONTEXT, ), BACKGROUND_CONTEXT, ); - const { harness } = await createAgentHarness(modelOptions(session), BACKGROUND_CONTEXT); + const harness = await createHarness(session); + const lane = await harness.lane("review", { createAt: "target" }, BACKGROUND_CONTEXT); - expect(await harness.inspectExecution(BACKGROUND_CONTEXT)).toEqual({ - lane: "main", - leafId: null, - configuredModel: configuredMain.model, - current: null, - lastResult, - }); - }); - - it("persists application values through idle and active lane facades", async () => { - const session = await createSession(); - await configureMain(session); - const meta = operationMeta(session, { kind: "run", promptEntryIds: [] }); - const { operationId } = meta; - await installOperation(session, meta, runState(session.idGenerator.next())); - const { harness } = await createAgentHarness(modelOptions(session), BACKGROUND_CONTEXT); - const worker = unwrap(await harness.createLane("worker", null, BACKGROUND_CONTEXT)); - const updates: unknown[] = []; - harness.events.on("value_update", (event) => { - updates.push(event); - }); - - await worker.sessionTree.setName("idle-worker", BACKGROUND_CONTEXT); - await harness.sessionTree.setName("active-main", BACKGROUND_CONTEXT); - await harness.sessionTree.setLabel("target", "label", BACKGROUND_CONTEXT); - await harness.sessionTree.setValue(applicationValue, { durable: true }, BACKGROUND_CONTEXT); - - expect(await session.getName(BACKGROUND_CONTEXT)).toBe("active-main"); - expect(await session.getLabel("target", BACKGROUND_CONTEXT)).toBe("label"); - expect((await session.getValue(applicationValue, BACKGROUND_CONTEXT))?.value).toEqual({ durable: true }); - expect(updates).toEqual([ - { type: "value_update", value: "session_name", name: "idle-worker" }, - { type: "value_update", value: "session_name", name: "active-main" }, - { type: "value_update", value: "entry_label", targetId: "target", label: "label" }, - ]); - expect((await harness.inspectExecution(BACKGROUND_CONTEXT)).current?.id).toBe(operationId); - }); - - it("reports restored open operations without activating them", async () => { - const repo = new MemorySessionRepo(); - repos.push(repo); - const session = await repo.create({}, BACKGROUND_CONTEXT); - await configureMain(session); - const meta = operationMeta(session, { kind: "run", promptEntryIds: [] }); - const { operationId } = meta; - const state = runState(session.idGenerator.next()); - await installOperation(session, meta, state); - - const { harness, open } = await createAgentHarness(modelOptions(session), BACKGROUND_CONTEXT); - - expect(open).toEqual([{ lane: "main", operationId, kind: "run", startedAt: 1 }]); - expect(await harness.inspectExecution(BACKGROUND_CONTEXT)).toEqual({ - lane: "main", - leafId: null, - configuredModel: configuredMain.model, - current: { id: operationId, kind: "run", status: "open", startedAt: 1 }, - }); - expect(await harness.lanes(BACKGROUND_CONTEXT)).toEqual([ - { - name: "main", - leafId: null, - operation: { id: operationId, kind: "run", status: "open", startedAt: 1 }, - }, - ]); - const pendingId = await harness.sessionTree.appendCustomEntry("note", { text: "queued" }, BACKGROUND_CONTEXT); - if (!(harness instanceof Harness)) throw new Error("missing runtime harness"); - const updatedState = harness.state.operation?.state; - if (updatedState?.kind !== "run") throw new Error("missing updated run state"); - expect(updatedState.inbox.writes).toEqual([pendingId]); - expect((await session.getValue(storedValues.pendingEntry(pendingId), BACKGROUND_CONTEXT))?.value).toEqual({ - type: "custom", - customType: "note", - payload: { text: "queued" }, - }); - expect((await session.getValue(storedValues.laneLeaf("main"), BACKGROUND_CONTEXT))?.value).toBeNull(); - - const closing = harness.close(BACKGROUND_CONTEXT); - expect(harness.close(BACKGROUND_CONTEXT)).toBe(closing); - await closing; - await expect(harness.lanes(BACKGROUND_CONTEXT)).rejects.toBeInstanceOf(HarnessClosed); - await expect(harness.inspectExecution(BACKGROUND_CONTEXT)).rejects.toBeInstanceOf(HarnessClosed); - const reopened = await repo.open(session.metadata, BACKGROUND_CONTEXT); - expect( - (await reopened.getValue(storedValues.laneState("main"), BACKGROUND_CONTEXT))?.value.currentOperationId, - ).toBe(operationId); - expect((await reopened.getValue(storedValues.operationMeta(operationId), BACKGROUND_CONTEXT))?.value).toEqual( - meta, - ); - expect((await reopened.getValue(storedValues.operationState(operationId), BACKGROUND_CONTEXT))?.value).toEqual( - updatedState, + expect(await lane.getTipId(BACKGROUND_CONTEXT)).toBe("target"); + expect(await harness.lane("review", { createAt: "missing" }, BACKGROUND_CONTEXT)).toBe(lane); + await expect(harness.lane("missing", { createAt: "unknown" }, BACKGROUND_CONTEXT)).rejects.toBeInstanceOf( + UnknownTarget, ); + await expect(harness.lane("", BACKGROUND_CONTEXT)).rejects.toBeInstanceOf(InvalidLane); + await expect(harness.lane("bad\u0000name", BACKGROUND_CONTEXT)).rejects.toBeInstanceOf(InvalidLane); }); - it("uses owned state after creation and starts no option callbacks", async () => { - const session = await createSession(); - const forbidden = vi.fn(() => { - throw new Error("effect started"); - }); - const { harness } = await createAgentHarness( - { - ...modelOptions(session), - toolContext: forbidden, - systemPrompt: forbidden, - toProviderMessages: forbidden, - entryProjectors: { forbidden }, - }, - BACKGROUND_CONTEXT, - ); - const mutate = vi.spyOn(session, "mutate"); - const getValue = vi.spyOn(session, "getValue"); - const scanValues = vi.spyOn(session, "scanValues"); - const readList = vi.spyOn(session, "readList"); - - expect(await harness.lane("main", BACKGROUND_CONTEXT)).toBe(harness); - expect(await harness.lanes(BACKGROUND_CONTEXT)).toHaveLength(1); - expect(await harness.getLeafId(BACKGROUND_CONTEXT)).toBeNull(); - expect(await harness.getLastResult(BACKGROUND_CONTEXT)).toBeUndefined(); - expect(forbidden).not.toHaveBeenCalled(); - expect(mutate).toHaveBeenCalledOnce(); - expect(getValue).not.toHaveBeenCalled(); - expect(scanValues).not.toHaveBeenCalled(); - expect(readList).not.toHaveBeenCalled(); - expect(await harness.getTools(BACKGROUND_CONTEXT)).toEqual([]); - expect(await harness.getResources(BACKGROUND_CONTEXT)).toEqual({}); - expect(await harness.getStreamOptions(BACKGROUND_CONTEXT)).toEqual({}); - expect(await harness.getRetryPolicy(BACKGROUND_CONTEXT)).toEqual({ - enabled: true, - maxRetries: 3, - baseDelayMs: 1_000, - }); - expect(await harness.getCompactionSettings(BACKGROUND_CONTEXT)).toBe(DEFAULT_COMPACTION_SETTINGS); - expect(await harness.getSteeringMode(BACKGROUND_CONTEXT)).toBe("all"); - expect(await harness.getFollowUpMode(BACKGROUND_CONTEXT)).toBe("all"); - }); - - it("rejects append during a structural operation without faulting", async () => { - const session = await createSession(); - await configureMain(session); - const meta = operationMeta(session, { kind: "navigation", targetId: null, summarize: false }); - const { operationId } = meta; - const state: NavigationState = { - kind: "navigation", - control: { status: "running" }, - targetId: null, - summarize: false, - phase: { kind: "ready_to_commit" }, - }; - await installOperation(session, meta, state); - const { harness, open } = await createAgentHarness(modelOptions(session), BACKGROUND_CONTEXT); - - expect(open).toEqual([{ lane: "main", operationId, kind: "navigation", startedAt: 1 }]); - await expect( - harness.sessionTree.appendMessage({ role: "user", content: "later", timestamp: 2 }, BACKGROUND_CONTEXT), - ).rejects.toThrow(`Cannot append while structural operation ${operationId} is active`); - expect(await harness.lanes(BACKGROUND_CONTEXT)).toHaveLength(1); - await harness.setThinkingLevel("high", BACKGROUND_CONTEXT); - }); - - it("faults queued lane work before releasing a failed mutation", async () => { - const { session, storage } = await createFailingSession(); - const { harness } = await createAgentHarness(modelOptions(session), BACKGROUND_CONTEXT); - if (!(harness instanceof Harness)) throw new Error("missing runtime harness"); - unwrap(await harness.createLane("worker", null, BACKGROUND_CONTEXT)); - const worker = harness.lanesByName.get("worker"); - if (worker === undefined) throw new Error("missing runtime worker lane"); - const initialConfiguration = worker.state.configuration; - const faultEvent = new Promise((resolve) => harness.events.on("fault", resolve)); - const configUpdate = vi.fn(); - harness.events.on("config_update", configUpdate); - const failure = new Error("commit failed"); - storage.failure = failure; - const failed = worker.setThinkingLevel("high", BACKGROUND_CONTEXT); - const queued = worker.setActiveTools(["read"], BACKGROUND_CONTEXT); - - let rejected: unknown; - try { - await failed; - } catch (error) { - rejected = error; - } - - expect(rejected).toBeInstanceOf(HarnessFault); - if (!(rejected instanceof HarnessFault)) throw new Error("missing harness fault"); - expect(rejected.cause).toBe(failure); - expect(await faultEvent).toMatchObject({ type: "fault", code: "harness_fault" }); - expect(configUpdate).not.toHaveBeenCalled(); - await expect(queued).rejects.toBe(rejected); - await expect(harness.getLeafId(BACKGROUND_CONTEXT)).rejects.toBe(rejected); - await expect(worker.getThinkingLevel(BACKGROUND_CONTEXT)).rejects.toBe(rejected); - await expect(worker.inspectExecution(BACKGROUND_CONTEXT)).rejects.toBe(rejected); - await expect(harness.getTools(BACKGROUND_CONTEXT)).rejects.toBe(rejected); - await expect(harness.setResources({}, BACKGROUND_CONTEXT)).rejects.toBe(rejected); - expect(harness.fault(new Error("later"), BACKGROUND_CONTEXT)).toBe(rejected); - expect(worker.state.configuration).toBe(initialConfiguration); - expect((await session.getValue(storedValues.laneConfig("worker"), BACKGROUND_CONTEXT))?.value).toEqual( - initialConfiguration, - ); - await harness.close(BACKGROUND_CONTEXT); - }); - - it("wraps initialization invariant failures as harness faults", async () => { + it("attaches agent state to a data-only Branch without moving its tip", async () => { const session = await createSession(); await session.mutate( - "main", (mutator) => mutator.commit( [ - storedValues.setValue(storedValues.laneState("main"), { - currentOperationId: session.idGenerator.next(), + { + kind: "entry", + entry: { + id: "target", + parentId: null, + type: "custom", + customType: "target", + }, + }, + setValue(branchTip("main"), "target"), + ], + BACKGROUND_CONTEXT, + ), + BACKGROUND_CONTEXT, + ); + const harness = await createHarness(session); + + expect(await harness.lanes(BACKGROUND_CONTEXT)).toEqual([]); + const lane = await harness.lane("main", BACKGROUND_CONTEXT); + expect(await lane.getTipId(BACKGROUND_CONTEXT)).toBe("target"); + expect((await session.getValue(laneConfig("main"), BACKGROUND_CONTEXT))?.value).toEqual({ + model: { provider: "faux", modelId: "faux-1" }, + thinkingLevel: "medium", + activeToolNames: ["read", "bash"], + }); + }); + + it("keeps AgentLane appends operation-aware while exposing the Branch surface directly", async () => { + const session = await createSession(); + const harness = await createHarness(session); + const lane = await harness.lane("main", BACKGROUND_CONTEXT); + const idleId = await lane.appendCustomEntry("idle", undefined, BACKGROUND_CONTEXT); + expect(await lane.getTipId(BACKGROUND_CONTEXT)).toBe(idleId); + + const admission = await lane.accept({ kind: "prompt", prompt: "run" }, BACKGROUND_CONTEXT); + if (!admission.ok) throw admission.error; + const acceptedTip = await lane.getTipId(BACKGROUND_CONTEXT); + const pendingId = await lane.appendCustomEntry("pending", { queued: true }, BACKGROUND_CONTEXT); + + expect(await lane.getTipId(BACKGROUND_CONTEXT)).toBe(acceptedTip); + expect((await session.getValue(branchTip("main"), BACKGROUND_CONTEXT))?.value).toBe(acceptedTip); + expect((await session.getValue(pendingEntry(pendingId), BACKGROUND_CONTEXT))?.value).toEqual({ + type: "custom", + customType: "pending", + payload: { queued: true }, + }); + }); + + it("restores complete lanes without requiring main", async () => { + const session = await createSession(); + await session.mutate( + (mutator) => + mutator.commit( + [ + setValue(branchTip("review"), null), + setValue(laneConfig("review"), configured), + setValue(laneState("review"), { + currentOperationId: null, pendingNextRun: [], }), ], @@ -1003,6 +232,61 @@ describe("runtime AgentHarness", () => { BACKGROUND_CONTEXT, ); - await expect(createAgentHarness(modelOptions(session), BACKGROUND_CONTEXT)).rejects.toBeInstanceOf(HarnessFault); + const { harness, open } = await AgentHarness.create(harnessOptions(session), BACKGROUND_CONTEXT); + expect(open).toEqual([]); + expect((await harness.lanes(BACKGROUND_CONTEXT)).map(({ name }) => name)).toEqual(["review"]); + expect(await harness.lane("review", BACKGROUND_CONTEXT)).toBeDefined(); + expect(await session.branch("main", BACKGROUND_CONTEXT)).toBeUndefined(); + }); + + it("rejects partial durable lane state as a Harness fault", async () => { + const session = await createSession(); + await session.mutate( + (mutator) => + mutator.commit( + [setValue(branchTip("main"), null), setValue(laneConfig("main"), configured)], + BACKGROUND_CONTEXT, + ), + BACKGROUND_CONTEXT, + ); + + await expect(AgentHarness.create(harnessOptions(session), BACKGROUND_CONTEXT)).rejects.toBeInstanceOf( + HarnessFault, + ); + }); +}); + +describe("runtime Harness global metadata", () => { + it("preserves value_update publication and delivery", async () => { + const session = await createSession(); + const harness = await createHarness(session); + const seen: string[] = []; + harness.events.on("value_update", async (event) => { + seen.push(event.value === "session_name" ? `name:${await harness.getName(BACKGROUND_CONTEXT)}` : event.value); + }); + + await harness.setName("named", BACKGROUND_CONTEXT); + await harness.setLabel("entry", "label", BACKGROUND_CONTEXT); + + expect(await harness.getName(BACKGROUND_CONTEXT)).toBe("named"); + expect(await harness.getLabel("entry", BACKGROUND_CONTEXT)).toBe("label"); + expect(seen).toEqual(["name:named", "entry_label"]); + }); + + it("closes every lane and rejects later acquisition", async () => { + const harness = await createHarness(); + const lane = await harness.lane("main", BACKGROUND_CONTEXT); + await harness.close(BACKGROUND_CONTEXT); + + await expect(harness.lane("other", BACKGROUND_CONTEXT)).rejects.toThrow("closed"); + await expect(lane.getTipId(BACKGROUND_CONTEXT)).rejects.toThrow("closed"); + }); + + it("MemorySessionRepo creation also remains branchless", async () => { + const repo = new MemorySessionRepo(); + const session = await repo.create({ id: "repo-session" }, BACKGROUND_CONTEXT); + expect(await session.branch("main", BACKGROUND_CONTEXT)).toBeUndefined(); + await session.close(BACKGROUND_CONTEXT); + await repo.close(BACKGROUND_CONTEXT); }); }); diff --git a/packages/agent/test/harness/runtime/lane.test.ts b/packages/agent/test/harness/runtime/lane.test.ts index 92d9cb851..e5082b260 100644 --- a/packages/agent/test/harness/runtime/lane.test.ts +++ b/packages/agent/test/harness/runtime/lane.test.ts @@ -36,11 +36,10 @@ async function createLane( ); sessions.push(session); await session.mutate( - "main", (mutator) => mutator.commit( [ - storedValues.setValue(storedValues.laneLeaf("main"), null), + storedValues.setValue(storedValues.branchTip("main"), null), storedValues.setValue(storedValues.laneConfig("main"), configuration), storedValues.setValue(storedValues.laneState("main"), { currentOperationId: null, pendingNextRun: [] }), ], @@ -167,7 +166,7 @@ describe("runtime Lane commands", () => { await expect(lane.command(() => ({ kind: "reject", error: rejection }), BACKGROUND_CONTEXT)).rejects.toBe( rejection, ); - expect(await lane.getLeafId(BACKGROUND_CONTEXT)).toBeNull(); + expect(await lane.getTipId(BACKGROUND_CONTEXT)).toBeNull(); await lane.setThinkingLevel("high", BACKGROUND_CONTEXT); }); @@ -343,7 +342,7 @@ describe("runtime Lane commands", () => { const closed = new HarnessClosed(); lane.seal(closed); - await expect(lane.getLeafId(BACKGROUND_CONTEXT)).rejects.toBe(closed); + await expect(lane.getTipId(BACKGROUND_CONTEXT)).rejects.toBe(closed); await expect(lane.setThinkingLevel("low", BACKGROUND_CONTEXT)).rejects.toBe(closed); releaseCommit.resolve(); await admitted; diff --git a/packages/agent/test/harness/runtime/progress.test.ts b/packages/agent/test/harness/runtime/progress.test.ts index b25f68c97..f5886065f 100644 --- a/packages/agent/test/harness/runtime/progress.test.ts +++ b/packages/agent/test/harness/runtime/progress.test.ts @@ -83,12 +83,12 @@ async function createFixture( const meta = { operationId, lane: "main", - sourceLeafId: null, + sourceTipId: null, startedAt: 1, intent: { kind: "run" as const, promptEntryIds: [] }, }; const projection: RuntimeLaneState = { - leafId: null, + tipId: null, configuration, pendingNextRun: [], operation: { meta, state }, @@ -105,11 +105,10 @@ async function createFixture( () => runtimeConfig, ); await session.mutate( - "main", (mutator) => mutator.commit( [ - storedValues.setValue(storedValues.laneLeaf("main"), null), + storedValues.setValue(storedValues.branchTip("main"), null), storedValues.setValue(storedValues.laneConfig("main"), configuration), storedValues.setValue(storedValues.laneState("main"), { currentOperationId: operationId, diff --git a/packages/agent/test/harness/runtime/restore.test.ts b/packages/agent/test/harness/runtime/restore.test.ts index ef6ac78e0..af6e354ba 100644 --- a/packages/agent/test/harness/runtime/restore.test.ts +++ b/packages/agent/test/harness/runtime/restore.test.ts @@ -18,9 +18,18 @@ async function createSession(): Promise { repos.push(repo); const session = await repo.create({}, BACKGROUND_CONTEXT); await session.mutate( - "main", (mutator) => - mutator.commit([storedValues.setValue(storedValues.laneConfig("main"), configuration)], BACKGROUND_CONTEXT), + mutator.commit( + [ + storedValues.setValue(storedValues.branchTip("main"), null), + storedValues.setValue(storedValues.laneConfig("main"), configuration), + storedValues.setValue(storedValues.laneState("main"), { + currentOperationId: null, + pendingNextRun: [], + }), + ], + BACKGROUND_CONTEXT, + ), BACKGROUND_CONTEXT, ); return session; @@ -57,11 +66,10 @@ describe("runtime lane restore", () => { operationId: session.idGenerator.next(), kind: "navigation" as const, outcome: "completed" as const, - oldLeafId: null, - leafId: null, + oldTipId: null, + tipId: null, }; await session.mutate( - "main", (mutator) => mutator.commit([storedValues.setValue(storedValues.laneLastResult("main"), result)], BACKGROUND_CONTEXT), BACKGROUND_CONTEXT, @@ -70,7 +78,7 @@ describe("runtime lane restore", () => { const state = await restoreLane(session, "main", BACKGROUND_CONTEXT); expect(state).toEqual({ - leafId: null, + tipId: null, configuration, pendingNextRun: [], lastResult: result, @@ -85,13 +93,12 @@ describe("runtime lane restore", () => { const meta: OperationMeta = { operationId, lane: "main", - sourceLeafId: null, + sourceTipId: null, startedAt: 1, intent: { kind: "run", promptEntryIds: [] }, }; const state = runState(missingTriggerId); await session.mutate( - "main", (mutator) => mutator.commit( [ @@ -119,7 +126,7 @@ describe("runtime lane restore", () => { const base: OperationMeta = { operationId, lane: "main", - sourceLeafId: null, + sourceTipId: null, startedAt: 1, intent: { kind: "run", promptEntryIds: [] }, }; @@ -130,7 +137,6 @@ describe("runtime lane restore", () => { ? { ...base, lane: "worker" } : { ...base, intent: { kind: "navigation", targetId: null, summarize: false } }; await session.mutate( - "main", (mutator) => mutator.commit( [ @@ -151,12 +157,12 @@ describe("runtime lane restore", () => { }); it.each([ - [storedValues.laneLeaf("main").namespace, storedValues.deleteValue(storedValues.laneLeaf("main"))], + [storedValues.branchTip("main").namespace, storedValues.deleteValue(storedValues.branchTip("main"))], [storedValues.laneConfig("main").namespace, storedValues.deleteValue(storedValues.laneConfig("main"))], [storedValues.laneState("main").namespace, storedValues.deleteValue(storedValues.laneState("main"))], ] as const)("requires %s", async (namespace, write) => { const session = await createSession(); - await session.mutate("main", (mutator) => mutator.commit([write], BACKGROUND_CONTEXT), BACKGROUND_CONTEXT); + await session.mutate((mutator) => mutator.commit([write], BACKGROUND_CONTEXT), BACKGROUND_CONTEXT); await expect(restoreLane(session, "main", BACKGROUND_CONTEXT)).rejects.toThrow(`missing ${namespace.slice(3)}`); }); @@ -169,13 +175,12 @@ describe("runtime lane restore", () => { const meta: OperationMeta = { operationId, lane: "main", - sourceLeafId: null, + sourceTipId: null, startedAt: 1, intent: { kind: "run", promptEntryIds: [] }, }; const state = runState(session.idGenerator.next()); await session.mutate( - "main", (mutator) => mutator.commit( [ @@ -208,18 +213,31 @@ describe("runtime lane restore", () => { thinkingLevel: "high", activeToolNames: ["read"], }; - await session.createLane("worker", null, workerConfiguration, undefined, BACKGROUND_CONTEXT); + await session.mutate( + (mutator) => + mutator.commit( + [ + storedValues.setValue(storedValues.branchTip("worker"), null), + storedValues.setValue(storedValues.laneConfig("worker"), workerConfiguration), + storedValues.setValue(storedValues.laneState("worker"), { + currentOperationId: null, + pendingNextRun: [], + }), + ], + BACKGROUND_CONTEXT, + ), + BACKGROUND_CONTEXT, + ); const operationId = session.idGenerator.next(); const meta: OperationMeta = { operationId, lane: "worker", - sourceLeafId: null, + sourceTipId: null, startedAt: 1, intent: { kind: "run", promptEntryIds: [] }, }; const state = runState(session.idGenerator.next()); await session.mutate( - "worker", (mutator) => mutator.commit( [ @@ -235,7 +253,7 @@ describe("runtime lane restore", () => { BACKGROUND_CONTEXT, ); const before = { - leaves: await session.scanValues(storedValues.laneLeafInventoryPrefix(), BACKGROUND_CONTEXT), + leaves: await session.scanValues(storedValues.branchTipInventoryPrefix(), BACKGROUND_CONTEXT), mainConfiguration: await session.getValue(storedValues.laneConfig("main"), BACKGROUND_CONTEXT), workerConfiguration: await session.getValue(storedValues.laneConfig("worker"), BACKGROUND_CONTEXT), mainState: await session.getValue(storedValues.laneState("main"), BACKGROUND_CONTEXT), @@ -252,10 +270,10 @@ describe("runtime lane restore", () => { configuration: workerConfiguration, operation: { meta, state }, }); - expect(mutate.mock.calls.map(([lane]) => lane).sort()).toEqual(["main", "worker"]); + expect(mutate).toHaveBeenCalledTimes(1); expect(readList).not.toHaveBeenCalled(); expect({ - leaves: await session.scanValues(storedValues.laneLeafInventoryPrefix(), BACKGROUND_CONTEXT), + leaves: await session.scanValues(storedValues.branchTipInventoryPrefix(), BACKGROUND_CONTEXT), mainConfiguration: await session.getValue(storedValues.laneConfig("main"), BACKGROUND_CONTEXT), workerConfiguration: await session.getValue(storedValues.laneConfig("worker"), BACKGROUND_CONTEXT), mainState: await session.getValue(storedValues.laneState("main"), BACKGROUND_CONTEXT), @@ -264,14 +282,17 @@ describe("runtime lane restore", () => { readList.mockRestore(); }); - it("requires main in the trusted lane inventory", async () => { + it("allows an empty inventory but rejects lane values without a Branch", async () => { + const repo = new MemorySessionRepo(); + repos.push(repo); + const empty = await repo.create({}, BACKGROUND_CONTEXT); + await expect(restoreSession(empty, BACKGROUND_CONTEXT)).resolves.toEqual(new Map()); + const session = await createSession(); await session.mutate( - "main", - (mutator) => mutator.commit([storedValues.deleteValue(storedValues.laneLeaf("main"))], BACKGROUND_CONTEXT), + (mutator) => mutator.commit([storedValues.deleteValue(storedValues.branchTip("main"))], BACKGROUND_CONTEXT), BACKGROUND_CONTEXT, ); - - await expect(restoreSession(session, BACKGROUND_CONTEXT)).rejects.toThrow("Session is missing main lane"); + await expect(restoreSession(session, BACKGROUND_CONTEXT)).rejects.toThrow("incomplete durable state"); }); }); diff --git a/packages/agent/test/harness/runtime/watch.test.ts b/packages/agent/test/harness/runtime/watch.test.ts index 8e795b9a4..f159b5c3b 100644 --- a/packages/agent/test/harness/runtime/watch.test.ts +++ b/packages/agent/test/harness/runtime/watch.test.ts @@ -4,6 +4,7 @@ import { type HarnessEvent, HarnessFault, type WatchHandle } from "../../../src/ import { DEFAULT_COMPACTION_SETTINGS } from "../../../src/harness/compaction/compaction.ts"; import { BACKGROUND_CONTEXT, type Context, createContextKey, withContextValue } from "../../../src/harness/context.ts"; import { createAgentHarness, Harness } from "../../../src/harness/runtime/harness.ts"; +import { Lane } from "../../../src/harness/runtime/lane.ts"; import * as sessionWrites from "../../../src/harness/session/commit.ts"; import { MemoryStorage } from "../../../src/harness/session/memory.ts"; import { StorageBackedSession } from "../../../src/harness/session/session.ts"; @@ -32,11 +33,10 @@ async function createSession(storage: MemoryStorage = new MemoryStorage()): Prom ); sessions.push(session); await session.mutate( - "main", (mutator) => mutator.commit( [ - storedValues.setValue(storedValues.laneLeaf("main"), null), + storedValues.setValue(storedValues.branchTip("main"), null), storedValues.setValue(storedValues.laneConfig("main"), configuration), storedValues.setValue(storedValues.laneState("main"), { currentOperationId: null, @@ -51,7 +51,7 @@ async function createSession(storage: MemoryStorage = new MemoryStorage()): Prom } async function commit(session: Session, writes: Write[]): Promise { - await session.mutate("main", (mutator) => mutator.commit(writes, BACKGROUND_CONTEXT), BACKGROUND_CONTEXT); + await session.mutate((mutator) => mutator.commit(writes, BACKGROUND_CONTEXT), BACKGROUND_CONTEXT); } function runState(phase: RunPhase, control: RunState["control"] = { status: "running" }): RunState { @@ -70,13 +70,17 @@ function runState(phase: RunPhase, control: RunState["control"] = { status: "run }; } -async function attach(session: Session): Promise> { +async function attach( + session: Session, +): Promise & Pick, "events">> { const provider = fauxProvider(); const models = createModels(); models.setProvider(provider.provider); const { harness } = await createAgentHarness({ session, models, model: provider.getModel() }, BACKGROUND_CONTEXT); if (!(harness instanceof Harness)) throw new Error("Expected runtime Harness"); - return harness; + const lane = await harness.lane("main", BACKGROUND_CONTEXT); + if (!(lane instanceof Lane)) throw new Error("Expected runtime Lane"); + return Object.assign(lane, { events: harness.events }); } class BlockingScanStorage extends MemoryStorage { @@ -117,7 +121,7 @@ describe("runtime lane watch", () => { type: "message", message: { role: "user", content: "after", timestamp: 2 }, }), - storedValues.setValue(storedValues.laneLeaf("main"), "after"), + storedValues.setValue(storedValues.branchTip("main"), "after"), ]); const harness = await attach(session); @@ -125,17 +129,17 @@ describe("runtime lane watch", () => { expect(first.snapshot.transcript.map(({ id }) => id)).toEqual(["compact", "after"]); expect(first.snapshot).toMatchObject({ lane: "main", - leafId: "after", + tipId: "after", operation: null, queues: { steer: [], followUp: [], nextRun: [] }, pendingWrites: [], faulted: false, }); first.snapshot.transcript.length = 0; - first.snapshot.leafId = null; + first.snapshot.tipId = null; const second = await harness.watch(BACKGROUND_CONTEXT); expect(second.snapshot.transcript.map(({ id }) => id)).toEqual(["compact", "after"]); - expect(second.snapshot.leafId).toBe("after"); + expect(second.snapshot.tipId).toBe("after"); first.unsubscribe(); second.unsubscribe(); }); @@ -180,7 +184,7 @@ describe("runtime lane watch", () => { const meta: OperationMeta = { operationId, lane: "main", - sourceLeafId: null, + sourceTipId: null, startedAt: 1, intent: { kind: "run", promptEntryIds: [] }, }; @@ -194,7 +198,7 @@ describe("runtime lane watch", () => { ), storedValues.setValue(storedValues.operationMeta(operationId), meta), storedValues.setValue(storedValues.operationState(operationId), state), - storedValues.setValue(storedValues.laneLeaf("main"), "source"), + storedValues.setValue(storedValues.branchTip("main"), "source"), storedValues.setValue(storedValues.laneState("main"), { currentOperationId: operationId, pendingNextRun: [ids.next], @@ -230,7 +234,7 @@ describe("runtime lane watch", () => { storedValues.setValue(storedValues.operationMeta(operationId), { operationId, lane: "main", - sourceLeafId: null, + sourceTipId: null, startedAt: 1, intent: { kind: "run", promptEntryIds: [] }, }), @@ -282,7 +286,7 @@ describe("runtime lane watch", () => { storedValues.setValue(storedValues.operationMeta(frameOperationId), { operationId: frameOperationId, lane: "main", - sourceLeafId: null, + sourceTipId: null, startedAt: 1, intent: { kind: "run", promptEntryIds: [] }, }), @@ -333,7 +337,7 @@ describe("runtime lane watch", () => { storedValues.setValue(storedValues.operationMeta(toolOperationId), { operationId: toolOperationId, lane: "main", - sourceLeafId: null, + sourceTipId: null, startedAt: 1, intent: { kind: "run", promptEntryIds: [] }, }), @@ -365,7 +369,7 @@ describe("runtime lane watch", () => { content: [{ type: "text", text: "partial" }], details: { bytes: 1 }, }), - storedValues.setValue(storedValues.laneLeaf("main"), "assistant"), + storedValues.setValue(storedValues.branchTip("main"), "assistant"), storedValues.setValue(storedValues.laneState("main"), { currentOperationId: toolOperationId, pendingNextRun: [], @@ -423,7 +427,7 @@ describe("runtime lane watch", () => { const session = await createSession(storage); await commit(session, [ sessionWrites.insertEntry({ id: "root", parentId: null, type: "custom", customType: "root" }), - storedValues.setValue(storedValues.laneLeaf("main"), "root"), + storedValues.setValue(storedValues.branchTip("main"), "root"), ]); const harness = await attach(session); storage.block = true; @@ -431,7 +435,7 @@ describe("runtime lane watch", () => { await storage.started.promise; const sourceKey = createContextKey("watch.event.source"); const sourceContext = withContextValue(sourceKey, "append", BACKGROUND_CONTEXT); - const append = harness.sessionTree.appendMessage({ role: "user", content: "later", timestamp: 2 }, sourceContext); + const append = harness.appendMessage({ role: "user", content: "later", timestamp: 2 }, sourceContext); storage.release.resolve(); const watch = await watchPromise; expect(watch.snapshot.transcript.map(({ id }) => id)).toEqual(["root"]); @@ -449,7 +453,7 @@ describe("runtime lane watch", () => { it("returns snapshot-after without replay when publication wins", async () => { const session = await createSession(); const harness = await attach(session); - const entryId = await harness.sessionTree.appendMessage( + const entryId = await harness.appendMessage( { role: "user", content: "existing", timestamp: 1 }, BACKGROUND_CONTEXT, ); diff --git a/packages/agent/test/harness/session-create-branch.test.ts b/packages/agent/test/harness/session-create-branch.test.ts new file mode 100644 index 000000000..df286777c --- /dev/null +++ b/packages/agent/test/harness/session-create-branch.test.ts @@ -0,0 +1,77 @@ +import { describe, expect, it } from "vitest"; +import { BACKGROUND_CONTEXT } from "../../src/harness/context.ts"; +import { insertEntry } from "../../src/harness/session/commit.ts"; +import { MemoryStorage } from "../../src/harness/session/memory.ts"; +import { + SessionBranchExistsError, + SessionInvalidBranchError, + SessionUnknownTargetError, + StorageBackedSession, +} from "../../src/harness/session/session.ts"; +import { branchTip, laneConfig, laneState } from "../../src/harness/session/values.ts"; + +function createSession(): StorageBackedSession { + return new StorageBackedSession( + { id: "session", createdAt: 1, storageVersion: 1 }, + new MemoryStorage({ now: () => 10 }), + ); +} + +describe("Session.createBranch", () => { + it("creates only the data Branch at a validated target", async () => { + const session = createSession(); + await session.mutate( + (mutator) => + mutator.commit( + [ + insertEntry({ + id: "target", + parentId: null, + type: "custom", + customType: "target", + }), + ], + BACKGROUND_CONTEXT, + ), + BACKGROUND_CONTEXT, + ); + + const branch = await session.createBranch("main", "target", BACKGROUND_CONTEXT); + expect(await branch.getTipId(BACKGROUND_CONTEXT)).toBe("target"); + expect((await session.getValue(branchTip("main"), BACKGROUND_CONTEXT))?.value).toBe("target"); + expect(await session.getValue(laneConfig("main"), BACKGROUND_CONTEXT)).toBeUndefined(); + expect(await session.getValue(laneState("main"), BACKGROUND_CONTEXT)).toBeUndefined(); + await session.close(BACKGROUND_CONTEXT); + }); + + it("validates names and non-null targets", async () => { + const session = createSession(); + await expect(session.createBranch("", null, BACKGROUND_CONTEXT)).rejects.toBeInstanceOf( + SessionInvalidBranchError, + ); + await expect(session.createBranch("bad\u0000name", null, BACKGROUND_CONTEXT)).rejects.toBeInstanceOf( + SessionInvalidBranchError, + ); + await expect(session.createBranch("main", "missing", BACKGROUND_CONTEXT)).rejects.toBeInstanceOf( + SessionUnknownTargetError, + ); + expect(await session.branch("main", BACKGROUND_CONTEXT)).toBeUndefined(); + await session.close(BACKGROUND_CONTEXT); + }); + + it("rejects duplicates atomically, including concurrent creation", async () => { + const session = createSession(); + const results = await Promise.allSettled([ + session.createBranch("main", null, BACKGROUND_CONTEXT), + session.createBranch("main", null, BACKGROUND_CONTEXT), + ]); + expect(results.filter(({ status }) => status === "fulfilled")).toHaveLength(1); + const rejected = results.find(({ status }) => status === "rejected"); + expect(rejected).toMatchObject({ + status: "rejected", + reason: expect.any(SessionBranchExistsError), + }); + expect(await session.branch("main", BACKGROUND_CONTEXT)).toBeDefined(); + await session.close(BACKGROUND_CONTEXT); + }); +}); diff --git a/packages/agent/test/harness/session-create-lane.test.ts b/packages/agent/test/harness/session-create-lane.test.ts deleted file mode 100644 index 16d8559c7..000000000 --- a/packages/agent/test/harness/session-create-lane.test.ts +++ /dev/null @@ -1,416 +0,0 @@ -import { BACKGROUND_CONTEXT } from "../../src/harness/context.ts"; -// Implements Part 8, slice 2. -// Full state validation is R1; harness-wide close admission is R6. - -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 { - SessionInvariantError, - SessionLaneExistsError, - StorageBackedSession, -} from "../../src/harness/session/session.ts"; -import { InstrumentedStorage, StorageDecorator } from "../../src/harness/session/testing/index.ts"; -import type { - Entry, - LaneConfiguration, - LaneState, - Session, - SessionMetadata, - Write, -} from "../../src/harness/session/types.ts"; -import * as storedValues from "../../src/harness/session/values.ts"; - -const NOW = 1_700_000_000_000; -const ROOT_ID = "00000000-0000-7000-8000-000000000001"; -const MISSING_ID = "00000000-0000-7000-8000-000000000099"; -const metadata = { - id: "session", - createdAt: NOW, - storageVersion: 1, -} satisfies SessionMetadata; -const configuration = { - model: { provider: "provider", modelId: "model" }, - thinkingLevel: "off", - activeToolNames: ["read"], -} satisfies LaneConfiguration; -const idleLaneState = { currentOperationId: null, pendingNextRun: [] } satisfies LaneState; - -function rootTransaction(): Write[] { - return [ - sessionWrites.insertEntry({ id: ROOT_ID, parentId: null, type: "custom", customType: "root" }), - storedValues.setValue(storedValues.laneLeaf("main"), ROOT_ID), - storedValues.setValue(storedValues.laneState("main"), idleLaneState), - ]; -} - -function commitSession(session: Session, transaction: Write[]) { - return session.mutate("main", (mutator) => mutator.commit(transaction, BACKGROUND_CONTEXT), BACKGROUND_CONTEXT); -} - -function expectedLaneWrites(name: string, at: string | null, value: LaneConfiguration): Write[] { - return [ - storedValues.setValue(storedValues.laneConfig(name), value), - storedValues.setValue(storedValues.laneLeaf(name), at), - storedValues.setValue(storedValues.laneState(name), idleLaneState), - ]; -} - -function deferred(): { promise: Promise; resolve: () => void } { - let resolve: (() => void) | undefined; - const promise = new Promise((settle) => { - resolve = settle; - }); - return { - promise, - resolve: () => { - if (resolve === undefined) throw new Error("Deferred promise was not initialized"); - resolve(); - }, - }; -} - -class RejectingCommitStorage extends StorageDecorator { - rejection: Error | undefined; - - override commit(transaction: Write[]) { - return this.rejection === undefined - ? super.commit(transaction, BACKGROUND_CONTEXT) - : Promise.reject(this.rejection); - } -} - -class BlockingCommitStorage extends StorageDecorator { - block = false; - private readonly admittedGate = deferred(); - private readonly releaseGate = deferred(); - - get admitted(): Promise { - return this.admittedGate.promise; - } - - override async commit(transaction: Write[]) { - if (this.block) { - this.admittedGate.resolve(); - await this.releaseGate.promise; - } - return super.commit(transaction, BACKGROUND_CONTEXT); - } - - release(): void { - this.releaseGate.resolve(); - } -} - -describe("StorageBackedSession.createLane", () => { - it("completes the package-internal Session contract", () => { - expectTypeOf().toMatchTypeOf(); - }); - - 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; - - const creating = session.createLane( - "shared", - 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)]); - 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); - }); - - it("atomically creates configured lane views at an entry or at the root", async () => { - const storage = new InstrumentedStorage(new MemoryStorage({ now: () => NOW })); - const session = new StorageBackedSession(metadata, storage); - await commitSession(session, rootTransaction()); - storage.clearCommitAttempts(); - - 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), - expectedLaneWrites("empty", null, configuration), - ]); - expect(await rooted.getLeafId(BACKGROUND_CONTEXT)).toBe(ROOT_ID); - expect((await rooted.findEntriesOnBranch(undefined, BACKGROUND_CONTEXT)).map((entry: Entry) => entry.id)).toEqual( - [ROOT_ID], - ); - expect(await empty.getLeafId(BACKGROUND_CONTEXT)).toBeNull(); - expect(await empty.findEntriesOnBranch(undefined, BACKGROUND_CONTEXT)).toEqual([]); - expect(await session.getValue(storedValues.laneConfig("rooted"), BACKGROUND_CONTEXT)).toMatchObject({ - value: configuration, - }); - expect(await session.getValue(storedValues.laneState("rooted"), BACKGROUND_CONTEXT)).toMatchObject({ - value: idleLaneState, - }); - await session.close(BACKGROUND_CONTEXT); - }); - - it("passes configuration directly to storage", async () => { - const storage = new InstrumentedStorage(new MemoryStorage({ now: () => NOW })); - const session = new StorageBackedSession(metadata, storage); - await commitSession(session, rootTransaction()); - storage.clearCommitAttempts(); - const supplied = { - model: { ...configuration.model }, - thinkingLevel: configuration.thinkingLevel, - activeToolNames: [...configuration.activeToolNames], - }; - - 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); - await session.close(BACKGROUND_CONTEXT); - }); - - // Slice 2 defines classifiable session failures. Mapping them to AgentHarness - // LaneExists/InvalidLane/UnknownTarget results and publishing events belong to later runtime slices. - it("rejects invalid lane names before storage admission with a classifiable validation error", async () => { - const storage = new InstrumentedStorage(new MemoryStorage({ now: () => NOW })); - const session = new StorageBackedSession(metadata, storage); - await commitSession(session, rootTransaction()); - storage.clearCommitAttempts(); - let callbackCalled = false; - - 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); - }); - - it("rejects existing configured lanes and fresh unconfigured main without writing", async () => { - 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, undefined, BACKGROUND_CONTEXT); - storage.clearCommitAttempts(); - - for (const name of ["existing", "main"]) { - await expect( - session.createLane(name, null, configuration, undefined, BACKGROUND_CONTEXT), - ).rejects.toMatchObject({ - name: "SessionLaneExistsError", - lane: name, - }); - } - expect(storage.getCommitAttempts()).toEqual([]); - await session.close(BACKGROUND_CONTEXT); - }); - - it("rejects unknown non-null anchors without writing", async () => { - const storage = new InstrumentedStorage(new MemoryStorage({ now: () => NOW })); - const session = new StorageBackedSession(metadata, storage); - await commitSession(session, rootTransaction()); - storage.clearCommitAttempts(); - - await expect( - session.createLane("missing-target", MISSING_ID, configuration, undefined, BACKGROUND_CONTEXT), - ).rejects.toMatchObject({ - name: "SessionUnknownTargetError", - targetId: MISSING_ID, - }); - expect(storage.getCommitAttempts()).toEqual([]); - await session.close(BACKGROUND_CONTEXT); - }); - - it("fails fast on every partial lane-register combination instead of repairing it", async () => { - const partialWrites: Write[] = [ - storedValues.setValue(storedValues.laneConfig("broken"), configuration), - storedValues.setValue(storedValues.laneLeaf("broken"), null), - storedValues.setValue(storedValues.laneState("broken"), idleLaneState), - storedValues.setValue(storedValues.laneLastResult("broken"), { - operationId: ROOT_ID, - kind: "navigation", - leafId: null, - oldLeafId: ROOT_ID, - outcome: "completed", - }), - ]; - - for (let mask = 1; mask < 1 << partialWrites.length; mask++) { - const hasAllRequiredRegisters = (mask & 0b0111) === 0b0111; - if (hasAllRequiredRegisters) continue; - const storage = new InstrumentedStorage(new MemoryStorage({ now: () => NOW })); - const session = new StorageBackedSession(metadata, storage); - await commitSession( - session, - partialWrites.filter((_, index) => (mask & (1 << index)) !== 0), - ); - storage.clearCommitAttempts(); - - await expect( - session.createLane("broken", null, configuration, undefined, BACKGROUND_CONTEXT), - ).rejects.toBeInstanceOf(SessionInvariantError); - expect(storage.getCommitAttempts()).toEqual([]); - await session.close(BACKGROUND_CONTEXT); - } - }); - - it("serializes concurrent duplicate creation so exactly one transaction wins", async () => { - const storage = new InstrumentedStorage(new MemoryStorage({ now: () => NOW })); - const session = new StorageBackedSession(metadata, storage); - await commitSession(session, rootTransaction()); - storage.clearCommitAttempts(); - - const results = await Promise.allSettled([ - 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"); - const rejected = results.filter((result) => result.status === "rejected"); - expect(fulfilled).toHaveLength(1); - expect(rejected).toHaveLength(1); - expect(rejected[0]).toMatchObject({ reason: { name: "SessionLaneExistsError", lane: "race" } }); - expect(storage.getCommitAttempts()).toHaveLength(1); - const leaf = await session.view("race").getLeafId(BACKGROUND_CONTEXT); - expect([ROOT_ID, null]).toContain(leaf); - await session.close(BACKGROUND_CONTEXT); - }); - - it("orders lane creation with appends submitted through the prospective view", async () => { - const session = new StorageBackedSession(metadata, new MemoryStorage({ now: () => NOW })); - await commitSession(session, rootTransaction()); - - 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); - expect(await session.getEntry(entryId, BACKGROUND_CONTEXT)).toMatchObject({ - parentId: ROOT_ID, - customType: "note", - }); - - const rejectedAppend = session.view("append-first").appendCustomEntry("note", undefined, 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); - expect(await session.findEntries({ customType: "note" }, BACKGROUND_CONTEXT)).toHaveLength(1); - await session.close(BACKGROUND_CONTEXT); - }); - - it("propagates commit failure without publishing partial lane state", async () => { - const storage = new RejectingCommitStorage(new MemoryStorage({ now: () => NOW })); - const session = new StorageBackedSession(metadata, storage); - await commitSession(session, rootTransaction()); - const rejection = new Error("commit failed"); - storage.rejection = rejection; - let callbackCalled = false; - - 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(); - expect(await session.getValue(storedValues.laneState("failed"), BACKGROUND_CONTEXT)).toBeUndefined(); - 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, 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, undefined, BACKGROUND_CONTEXT)).rejects.toThrow( - "Session is closed", - ); - }); - - it("rejects queued duplicate creation when close seals the lane mutation line", async () => { - const storage = new BlockingCommitStorage(new MemoryStorage({ now: () => NOW })); - const session = new StorageBackedSession(metadata, storage); - await commitSession(session, rootTransaction()); - storage.block = true; - - const admitted = session.createLane("queued", ROOT_ID, configuration, undefined, BACKGROUND_CONTEXT); - await storage.admitted; - const queued = session.createLane("queued", null, configuration, undefined, BACKGROUND_CONTEXT); - const close = session.close(BACKGROUND_CONTEXT); - storage.release(); - - await admitted; - await expect(queued).rejects.toThrow("Session is closed"); - await close; - }); -}); diff --git a/packages/agent/test/harness/session-tree.test.ts b/packages/agent/test/harness/session-tree.test.ts deleted file mode 100644 index 88a5c41e4..000000000 --- a/packages/agent/test/harness/session-tree.test.ts +++ /dev/null @@ -1,534 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { BACKGROUND_CONTEXT } from "../../src/harness/context.ts"; -import * as sessionWrites from "../../src/harness/session/commit.ts"; -import { LaneMutationLine } from "../../src/harness/session/lane-mutations.ts"; -import { MemoryStorage } from "../../src/harness/session/memory.ts"; -import { SessionInvariantError, StorageBackedSession } from "../../src/harness/session/session.ts"; -import { InstrumentedStorage } from "../../src/harness/session/testing/index.ts"; -import type { - LaneState, - NewEntry, - OperationMeta, - RunState, - Session, - SessionMetadata, - Write, -} from "../../src/harness/session/types.ts"; -import * as storedValues from "../../src/harness/session/values.ts"; - -const NOW = 1_700_000_000_000; -const ROOT_ID = "00000000-0000-7000-8000-000000000001"; -const CHILD_ID = "00000000-0000-7000-8000-000000000002"; -const CUSTOM_ID = "00000000-0000-7000-8000-000000000003"; -const OTHER_ID = "00000000-0000-7000-8000-000000000004"; -const OPERATION_ID = "00000000-0000-7000-8000-000000000005"; -const applicationState = storedValues.value("test.application.state"); -const objectApplicationState = storedValues.value<{ nested: string[] }>("test.application.object"); -const metadata = { - id: "session", - createdAt: NOW, - storageVersion: 1, -} satisfies SessionMetadata; - -function customEntry(id: string, parentId: string | null, customType = "note"): NewEntry { - return { id, parentId, type: "custom", customType, data: { id } }; -} - -const idleLaneState = { currentOperationId: null, pendingNextRun: [] } satisfies LaneState; -const operation = { - operationId: OPERATION_ID, - lane: "main", - sourceLeafId: ROOT_ID, - startedAt: NOW, - intent: { kind: "run", promptEntryIds: [] }, -} satisfies OperationMeta; -const runState = { - kind: "run", - control: { status: "running" }, - settings: { - compaction: { enabled: true, reserveTokens: 1, keepRecentTokens: 1 }, - steeringMode: "all", - followUpMode: "all", - toolExecution: "parallel", - }, - phase: { - kind: "checkpoint", - continuation: { kind: "may_finish", includeFinalAssistant: false }, - triggerEntryId: ROOT_ID, - }, - inbox: { steer: [], followUp: [], writes: [] }, - latestAssistantEntryId: null, -} satisfies RunState; - -function commitSession(session: Session, transaction: Write[]) { - return session.mutate("main", (mutator) => mutator.commit(transaction, BACKGROUND_CONTEXT), BACKGROUND_CONTEXT); -} - -function laneWrites(leafId: string | null, state: LaneState = idleLaneState) { - return [ - storedValues.setValue(storedValues.laneLeaf("main"), leafId), - storedValues.setValue(storedValues.laneState("main"), state), - ] as const; -} - -async function createTreeSession(): Promise { - const session = new StorageBackedSession(metadata, new MemoryStorage({ now: () => NOW })); - await commitSession(session, [ - sessionWrites.insertEntry(customEntry(ROOT_ID, null, "root")), - sessionWrites.insertEntry({ - id: CHILD_ID, - parentId: ROOT_ID, - type: "message", - message: { role: "user", content: "child", timestamp: NOW }, - }), - sessionWrites.insertEntry(customEntry(CUSTOM_ID, CHILD_ID)), - sessionWrites.insertEntry(customEntry(OTHER_ID, ROOT_ID, "other")), - storedValues.setValue(storedValues.laneLeaf("main"), CUSTOM_ID), - storedValues.setValue(storedValues.laneLeaf("other"), OTHER_ID), - ]); - return session; -} - -class CountingLaneMutationLine extends LaneMutationLine { - runCount = 0; - - override run(lane: string, operation: () => T | Promise) { - this.runCount++; - return super.run(lane, operation); - } -} - -class RejectingCommitStorage extends MemoryStorage { - rejection: Error | undefined; - - override commit(transaction: Parameters[0]) { - return this.rejection === undefined - ? super.commit(transaction, BACKGROUND_CONTEXT) - : Promise.reject(this.rejection); - } -} - -class BlockingCommitStorage extends MemoryStorage { - block = false; - admitted = false; - private releaseCommit: (() => void) | undefined; - - override async commit(transaction: Parameters[0]) { - if (this.block) { - this.admitted = true; - await new Promise((resolve) => { - this.releaseCommit = resolve; - }); - } - return super.commit(transaction, BACKGROUND_CONTEXT); - } - - release(): void { - if (this.releaseCommit === undefined) throw new Error("No blocked commit"); - this.releaseCommit(); - } -} - -describe("StorageBackedSession SessionTree", () => { - it("creates lane-bound views over shared entries, values, and stats", async () => { - const session = await createTreeSession(); - const other = session.view("other"); - - expect(await session.getLeafId(BACKGROUND_CONTEXT)).toBe(CUSTOM_ID); - expect(await other.getLeafId(BACKGROUND_CONTEXT)).toBe(OTHER_ID); - expect(await session.getEntry(CHILD_ID, BACKGROUND_CONTEXT)).toMatchObject({ id: CHILD_ID, parentId: ROOT_ID }); - expect(await session.getEntry("00000000-0000-7000-8000-000000000099", BACKGROUND_CONTEXT)).toBeUndefined(); - expect(await session.getStats(BACKGROUND_CONTEXT)).toMatchObject({ messageCount: 1 }); - expect(await other.getStats(BACKGROUND_CONTEXT)).toEqual(await session.getStats(BACKGROUND_CONTEXT)); - - await session.setName("shared", BACKGROUND_CONTEXT); - expect(await other.getName(BACKGROUND_CONTEXT)).toBe("shared"); - await session.close(BACKGROUND_CONTEXT); - }); - - it("rejects missing lanes", async () => { - const session = await createTreeSession(); - await expect(session.view("missing").getLeafId(BACKGROUND_CONTEXT)).rejects.toThrow("Unknown lane"); - await session.close(BACKGROUND_CONTEXT); - }); - - it("reads, sets, and deletes global values while preserving JSON null", async () => { - const session = await createTreeSession(); - const other = session.view("other"); - - expect(await session.getName(BACKGROUND_CONTEXT)).toBeUndefined(); - expect(await session.getLabel(ROOT_ID, BACKGROUND_CONTEXT)).toBeUndefined(); - expect(await session.getValue(applicationState, BACKGROUND_CONTEXT)).toBeUndefined(); - - await session.setName("name", BACKGROUND_CONTEXT); - await other.setLabel(ROOT_ID, "root label", BACKGROUND_CONTEXT); - await session.setValue(applicationState, null, BACKGROUND_CONTEXT); - expect(await other.getName(BACKGROUND_CONTEXT)).toBe("name"); - expect(await session.getLabel(ROOT_ID, BACKGROUND_CONTEXT)).toBe("root label"); - expect((await other.getValue(applicationState, BACKGROUND_CONTEXT))?.value).toBeNull(); - - await other.setName(undefined, BACKGROUND_CONTEXT); - await session.setLabel(ROOT_ID, undefined, BACKGROUND_CONTEXT); - await other.deleteValue(applicationState, BACKGROUND_CONTEXT); - expect(await session.getName(BACKGROUND_CONTEXT)).toBeUndefined(); - expect(await other.getLabel(ROOT_ID, BACKGROUND_CONTEXT)).toBeUndefined(); - expect(await session.getValue(applicationState, BACKGROUND_CONTEXT)).toBeUndefined(); - await session.close(BACKGROUND_CONTEXT); - }); - - it("passes application values directly to storage", async () => { - const session = await createTreeSession(); - const value = { nested: ["original"] }; - - await session.setValue(objectApplicationState, value, BACKGROUND_CONTEXT); - expect((await session.getValue(objectApplicationState, BACKGROUND_CONTEXT))?.value).toBe(value); - await session.close(BACKGROUND_CONTEXT); - }); - - it("exposes bound application values and lists through every lane view", async () => { - const storage = new InstrumentedStorage(new MemoryStorage({ now: () => NOW })); - const session = new StorageBackedSession(metadata, storage); - await commitSession(session, [...laneWrites(null)]); - storage.clearCommitAttempts(); - const scalar = storedValues.value<{ count: number }>("test.application.scalar"); - const events = (workspace: string) => storedValues.list("test.application.events", workspace); - const workspaceEvents = events("workspace"); - const other = session.view("main"); - - await session.setValue(scalar, { count: 1 }, BACKGROUND_CONTEXT); - await other.appendList(workspaceEvents, "first", BACKGROUND_CONTEXT); - await session.appendList(workspaceEvents, "second", BACKGROUND_CONTEXT); - expect((await other.getValue(scalar, BACKGROUND_CONTEXT))?.value).toEqual({ count: 1 }); - expect( - (await session.readList(workspaceEvents, { limit: 1 }, BACKGROUND_CONTEXT)).map(({ value }) => value), - ).toEqual(["first"]); - expect(storage.getCommitAttempts()).toHaveLength(3); - expect(storage.getCommitAttempts().map((writes) => writes.map(({ kind }) => kind))).toEqual([ - ["value"], - ["list"], - ["list"], - ]); - - await other.deleteList(workspaceEvents, BACKGROUND_CONTEXT); - await session.deleteValue(scalar, BACKGROUND_CONTEXT); - expect(await session.readList(workspaceEvents, undefined, BACKGROUND_CONTEXT)).toEqual([]); - expect(await session.getValue(scalar, BACKGROUND_CONTEXT)).toBeUndefined(); - await session.close(BACKGROUND_CONTEXT); - }); - - it("applies global query ordering, filters, exclusive cursors, and limits", async () => { - const session = await createTreeSession(); - const all = await session.findEntries(undefined, BACKGROUND_CONTEXT); - const custom = all.find((entry) => entry.id === CUSTOM_ID); - if (custom === undefined) throw new Error("Expected custom entry"); - - expect(all.map((entry) => entry.id)).toEqual([OTHER_ID, CUSTOM_ID, CHILD_ID, ROOT_ID]); - expect( - (await session.findEntries({ order: "asc", type: "custom" }, BACKGROUND_CONTEXT)).map((entry) => entry.id), - ).toEqual([ROOT_ID, CUSTOM_ID, OTHER_ID]); - expect( - (await session.findEntries({ customType: "note", limit: 1 }, BACKGROUND_CONTEXT)).map((entry) => entry.id), - ).toEqual([CUSTOM_ID]); - expect( - (await session.findEntries({ cursor: { seq: custom.seq } }, BACKGROUND_CONTEXT)).map((entry) => entry.id), - ).toEqual([CHILD_ID, ROOT_ID]); - expect( - (await session.findEntries({ order: "asc", cursor: { seq: custom.seq } }, BACKGROUND_CONTEXT)).map( - (entry) => entry.id, - ), - ).toEqual([OTHER_ID]); - expect((await session.findEntry({ type: "message" }, BACKGROUND_CONTEXT))?.id).toBe(CHILD_ID); - expect(await session.findEntry({ customType: "missing" }, BACKGROUND_CONTEXT)).toBeUndefined(); - await session.close(BACKGROUND_CONTEXT); - }); - - it("applies branch defaults, inclusive stops, filters, cursors, and lane leaves", async () => { - const session = await createTreeSession(); - const path = await session.findEntriesOnBranch(undefined, BACKGROUND_CONTEXT); - const child = path.find((entry) => entry.id === CHILD_ID); - if (child === undefined) throw new Error("Expected child entry"); - - expect(path.map((entry) => entry.id)).toEqual([CUSTOM_ID, CHILD_ID, ROOT_ID]); - expect( - (await session.findEntriesOnBranch({ stopAtId: CHILD_ID }, BACKGROUND_CONTEXT)).map((entry) => entry.id), - ).toEqual([CUSTOM_ID, CHILD_ID]); - expect( - (await session.view("other").findEntriesOnBranch(undefined, BACKGROUND_CONTEXT)).map((entry) => entry.id), - ).toEqual([OTHER_ID, ROOT_ID]); - expect( - (await session.findEntriesOnBranch({ stopAtType: "message", type: "custom" }, BACKGROUND_CONTEXT)).map( - (entry) => entry.id, - ), - ).toEqual([CUSTOM_ID]); - expect( - ( - await session.findEntriesOnBranch( - { order: "oldestFirst", cursor: { seq: child.seq }, limit: 1 }, - BACKGROUND_CONTEXT, - ) - ).map((entry) => entry.id), - ).toEqual([CUSTOM_ID]); - expect((await session.findEntryOnBranch({ customType: "root" }, BACKGROUND_CONTEXT))?.id).toBe(ROOT_ID); - expect(await session.findEntryOnBranch({ customType: "missing" }, BACKGROUND_CONTEXT)).toBeUndefined(); - await session.close(BACKGROUND_CONTEXT); - }); - - it("returns an empty default branch for a lane at the root", async () => { - const session = new StorageBackedSession(metadata, new MemoryStorage({ now: () => NOW })); - await commitSession(session, [storedValues.setValue(storedValues.laneLeaf("main"), null)]); - - expect(await session.findEntriesOnBranch(undefined, BACKGROUND_CONTEXT)).toEqual([]); - expect(await session.findEntryOnBranch(undefined, BACKGROUND_CONTEXT)).toBeUndefined(); - await session.close(BACKGROUND_CONTEXT); - }); - - it("atomically appends messages and custom entries to the bound lane", async () => { - const storage = new InstrumentedStorage(new MemoryStorage({ now: () => NOW })); - const session = new StorageBackedSession(metadata, storage); - await commitSession(session, [ - ...laneWrites(null), - storedValues.setValue(storedValues.laneLeaf("other"), null), - storedValues.setValue(storedValues.laneState("other"), idleLaneState), - ]); - storage.clearCommitAttempts(); - - const messageId = await session.appendMessage( - { role: "user", content: "hello", timestamp: NOW }, - BACKGROUND_CONTEXT, - ); - const customId = await session.appendCustomEntry("note", { nested: ["value"] }, BACKGROUND_CONTEXT); - const withoutDataId = await session.view("other").appendCustomEntry("marker", undefined, BACKGROUND_CONTEXT); - - expect(storage.getCommitAttempts()).toEqual([ - [ - sessionWrites.insertEntry({ - id: messageId, - parentId: null, - type: "message", - message: { role: "user", content: "hello", timestamp: NOW }, - }), - storedValues.setValue(storedValues.laneLeaf("main"), messageId), - ], - [ - sessionWrites.insertEntry({ - id: customId, - parentId: messageId, - type: "custom", - customType: "note", - data: { nested: ["value"] }, - }), - storedValues.setValue(storedValues.laneLeaf("main"), customId), - ], - [ - sessionWrites.insertEntry({ - id: withoutDataId, - parentId: null, - type: "custom", - customType: "marker", - }), - storedValues.setValue(storedValues.laneLeaf("other"), withoutDataId), - ], - ]); - expect(await session.getLeafId(BACKGROUND_CONTEXT)).toBe(customId); - expect(await session.view("other").getLeafId(BACKGROUND_CONTEXT)).toBe(withoutDataId); - expect(await session.getEntry(messageId, BACKGROUND_CONTEXT)).toMatchObject({ parentId: null, type: "message" }); - expect(await session.getEntry(customId, BACKGROUND_CONTEXT)).toEqual( - expect.objectContaining({ - parentId: messageId, - type: "custom", - customType: "note", - data: { nested: ["value"] }, - }), - ); - const withoutData = await session.getEntry(withoutDataId, BACKGROUND_CONTEXT); - expect(withoutData).toEqual(expect.objectContaining({ parentId: null, type: "custom", customType: "marker" })); - expect(withoutData).not.toHaveProperty("data"); - await session.close(BACKGROUND_CONTEXT); - }); - - it("defers appends into an active run without moving the lane leaf", async () => { - const storage = new InstrumentedStorage(new MemoryStorage({ now: () => NOW })); - const session = new StorageBackedSession(metadata, storage); - await commitSession(session, [ - sessionWrites.insertEntry(customEntry(ROOT_ID, null)), - ...laneWrites(ROOT_ID, { currentOperationId: OPERATION_ID, pendingNextRun: [] }), - storedValues.setValue(storedValues.operationMeta(OPERATION_ID), operation), - storedValues.setValue(storedValues.operationState(OPERATION_ID), runState), - ]); - storage.clearCommitAttempts(); - - const [customId, messageId] = await Promise.all([ - session.appendCustomEntry("note", { deferred: true }, BACKGROUND_CONTEXT), - session.appendMessage({ role: "user", content: "later", timestamp: NOW }, BACKGROUND_CONTEXT), - ]); - - expect(await session.getLeafId(BACKGROUND_CONTEXT)).toBe(ROOT_ID); - expect(await session.getEntries([customId, messageId], BACKGROUND_CONTEXT)).toEqual(new Map()); - expect(await session.getValue(storedValues.pendingEntry(customId), BACKGROUND_CONTEXT)).toMatchObject({ - value: { type: "custom", customType: "note", payload: { deferred: true } }, - }); - expect(await session.getValue(storedValues.pendingEntry(messageId), BACKGROUND_CONTEXT)).toMatchObject({ - value: { type: "message", payload: { role: "user", content: "later", timestamp: NOW } }, - }); - expect(await session.getValue(storedValues.operationState(OPERATION_ID), BACKGROUND_CONTEXT)).toMatchObject({ - value: { inbox: { writes: [customId, messageId] } }, - }); - expect(storage.getCommitAttempts()).toHaveLength(2); - await session.close(BACKGROUND_CONTEXT); - }); - - it("rejects without writing while structural-operation waiting is not implemented", async () => { - const storage = new InstrumentedStorage(new MemoryStorage({ now: () => NOW })); - const session = new StorageBackedSession(metadata, storage); - const structuralState = { - kind: "compaction", - control: { status: "running" }, - structural: { taskId: CHILD_ID, status: "deciding" }, - } as const; - await commitSession(session, [ - sessionWrites.insertEntry(customEntry(ROOT_ID, null)), - ...laneWrites(ROOT_ID, { currentOperationId: OPERATION_ID, pendingNextRun: [] }), - storedValues.setValue(storedValues.operationMeta(OPERATION_ID), { - ...operation, - intent: { kind: "compaction" }, - }), - storedValues.setValue(storedValues.operationState(OPERATION_ID), structuralState), - ]); - - storage.clearCommitAttempts(); - const append = session.appendCustomEntry("during-structural", undefined, BACKGROUND_CONTEXT); - await expect(append).rejects.toThrow("Cannot append while structural operation"); - expect(storage.getCommitAttempts()).toEqual([]); - expect(await session.getLeafId(BACKGROUND_CONTEXT)).toBe(ROOT_ID); - await session.close(BACKGROUND_CONTEXT); - }); - - it("diverges lanes that append from the same shared leaf", async () => { - const session = new StorageBackedSession(metadata, new MemoryStorage({ now: () => NOW })); - await commitSession(session, [sessionWrites.insertEntry(customEntry(ROOT_ID, null)), ...laneWrites(ROOT_ID)]); - const configuration = { - model: { provider: "provider", modelId: "model" }, - thinkingLevel: "off" as const, - activeToolNames: [], - }; - 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), - review.appendCustomEntry("review-child", undefined, BACKGROUND_CONTEXT), - ]); - - expect((await session.findEntriesOnBranch(undefined, BACKGROUND_CONTEXT)).map((entry) => entry.id)).toEqual([ - mainId, - ROOT_ID, - ]); - expect((await review.findEntriesOnBranch(undefined, BACKGROUND_CONTEXT)).map((entry) => entry.id)).toEqual([ - reviewId, - ROOT_ID, - ]); - expect((await session.getEntry(mainId, BACKGROUND_CONTEXT))?.parentId).toBe(ROOT_ID); - expect((await session.getEntry(reviewId, BACKGROUND_CONTEXT))?.parentId).toBe(ROOT_ID); - await session.close(BACKGROUND_CONTEXT); - }); - - it("serializes concurrent appends into one linear lane branch", async () => { - const session = new StorageBackedSession(metadata, new MemoryStorage({ now: () => NOW })); - await commitSession(session, [sessionWrites.insertEntry(customEntry(ROOT_ID, null)), ...laneWrites(ROOT_ID)]); - - const [firstId, secondId] = await Promise.all([ - session.appendCustomEntry("first", undefined, BACKGROUND_CONTEXT), - session.appendCustomEntry("second", undefined, BACKGROUND_CONTEXT), - ]); - - expect((await session.findEntriesOnBranch(undefined, BACKGROUND_CONTEXT)).map((entry) => entry.id)).toEqual([ - secondId, - firstId, - ROOT_ID, - ]); - await session.close(BACKGROUND_CONTEXT); - }); - - it("passes append payloads directly to storage", async () => { - const storage = new InstrumentedStorage(new MemoryStorage({ now: () => NOW })); - const laneMutationLine = new CountingLaneMutationLine(); - const session = new StorageBackedSession(metadata, storage, { laneMutationLine }); - await commitSession(session, [...laneWrites(null)]); - storage.clearCommitAttempts(); - const data = { nested: ["original"] }; - const runCountBeforeAppends = laneMutationLine.runCount; - - const id = await session.appendCustomEntry("note", data, BACKGROUND_CONTEXT); - const entry = await session.getEntry(id, BACKGROUND_CONTEXT); - if (entry?.type !== "custom") throw new Error("Expected custom entry"); - expect(entry.data).toBe(data); - expect(storage.getCommitAttempts()).toHaveLength(1); - const message = { role: "user" as const, content: "original", timestamp: NOW }; - const messageId = await session.appendMessage(message, BACKGROUND_CONTEXT); - const messageEntry = await session.getEntry(messageId, BACKGROUND_CONTEXT); - if (messageEntry?.type !== "message") throw new Error("Expected message entry"); - expect(messageEntry.message).toBe(message); - expect(laneMutationLine.runCount - runCountBeforeAppends).toBe(2); - await session.close(BACKGROUND_CONTEXT); - }); - - it("fails fast when active operation registers are inconsistent", async () => { - const storage = new InstrumentedStorage(new MemoryStorage({ now: () => NOW })); - const session = new StorageBackedSession(metadata, storage); - await commitSession(session, [...laneWrites(null, { currentOperationId: OPERATION_ID, pendingNextRun: [] })]); - storage.clearCommitAttempts(); - - const append = session.appendCustomEntry("note", undefined, BACKGROUND_CONTEXT); - await expect(append).rejects.toBeInstanceOf(SessionInvariantError); - await expect(append).rejects.toThrow("missing op.meta"); - expect(storage.getCommitAttempts()).toEqual([]); - await session.close(BACKGROUND_CONTEXT); - }); - - it("propagates append storage failures without moving the leaf", async () => { - const storage = new RejectingCommitStorage({ now: () => NOW }); - await storage.commit([sessionWrites.insertEntry(customEntry(ROOT_ID, null)), ...laneWrites(ROOT_ID)]); - const session = new StorageBackedSession(metadata, storage); - const rejection = new Error("commit failed"); - storage.rejection = rejection; - - await expect(session.appendCustomEntry("note", undefined, BACKGROUND_CONTEXT)).rejects.toBe(rejection); - storage.rejection = undefined; - expect(await session.getLeafId(BACKGROUND_CONTEXT)).toBe(ROOT_ID); - expect((await session.findEntries(undefined, BACKGROUND_CONTEXT)).map((entry) => entry.id)).toEqual([ROOT_ID]); - await session.close(BACKGROUND_CONTEXT); - }); - - it("drains an append whose storage commit was admitted before close", async () => { - const storage = new BlockingCommitStorage({ now: () => NOW }); - const session = new StorageBackedSession(metadata, storage); - await commitSession(session, [...laneWrites(null)]); - storage.block = true; - - const append = session.appendCustomEntry("admitted", undefined, BACKGROUND_CONTEXT); - while (!storage.admitted) await Promise.resolve(); - const close = session.close(BACKGROUND_CONTEXT); - storage.release(); - - const id = await append; - await close; - expect(id).toMatch(/^[0-9a-f-]+$/); - }); - - it("rejects all SessionTree operations after close, including existing views", async () => { - const session = await createTreeSession(); - const view = session.view("other"); - await session.close(BACKGROUND_CONTEXT); - - const operations: Array<() => Promise> = [ - () => session.getLeafId(BACKGROUND_CONTEXT), - () => view.getEntry(ROOT_ID, BACKGROUND_CONTEXT), - () => session.getStats(BACKGROUND_CONTEXT), - () => view.getName(BACKGROUND_CONTEXT), - () => session.setName("closed", BACKGROUND_CONTEXT), - () => view.findEntries(undefined, BACKGROUND_CONTEXT), - () => session.findEntriesOnBranch(undefined, BACKGROUND_CONTEXT), - () => view.appendCustomEntry("closed", undefined, BACKGROUND_CONTEXT), - ]; - for (const operation of operations) await expect(operation()).rejects.toThrow("Session is closed"); - }); -}); diff --git a/packages/agent/test/harness/storage-backed-session.test.ts b/packages/agent/test/harness/storage-backed-session.test.ts index f2cb42ec3..d6ceeac14 100644 --- a/packages/agent/test/harness/storage-backed-session.test.ts +++ b/packages/agent/test/harness/storage-backed-session.test.ts @@ -5,7 +5,7 @@ import type { CustomMessage } from "../../src/harness/messages.ts"; import * as sessionWrites from "../../src/harness/session/commit.ts"; import { MemoryStorage } from "../../src/harness/session/memory.ts"; import { StorageBackedSession } from "../../src/harness/session/session.ts"; -import { InstrumentedStorage } from "../../src/harness/session/testing/index.ts"; +import { GatingStorage, InstrumentedStorage } from "../../src/harness/session/testing/index.ts"; import type { MessageEntry, NewEntry, @@ -15,6 +15,7 @@ import type { Write, } from "../../src/harness/session/types.ts"; import * as storedValues from "../../src/harness/session/values.ts"; +import { deferred } from "./runtime/test-utils.ts"; const NOW = 1_700_000_000_000; const ENTRY_ID = "00000000-0000-7000-8000-000000000001"; @@ -26,7 +27,7 @@ const metadata = { } satisfies SessionMetadata; function commitSession(session: Session, transaction: Write[]) { - return session.mutate("main", (mutator) => mutator.commit(transaction, BACKGROUND_CONTEXT), BACKGROUND_CONTEXT); + return session.mutate((mutator) => mutator.commit(transaction, BACKGROUND_CONTEXT), BACKGROUND_CONTEXT); } describe("StorageBackedSession", () => { @@ -59,7 +60,6 @@ describe("StorageBackedSession", () => { const events = storedValues.list("test.application.events"); const result = await session.mutate( - "main", (mutator) => mutator.commit( [ @@ -93,22 +93,93 @@ describe("StorageBackedSession", () => { await session.close(BACKGROUND_CONTEXT); }); - it("holds an explicit lane barrier through commit until end", async () => { - const storage = new InstrumentedStorage(new MemoryStorage({ now: () => NOW })); - const session = new StorageBackedSession(metadata, storage); - const mutation = await session.beginMutation("main", BACKGROUND_CONTEXT); - let queuedStarted = false; - const queued = session.mutate( - "main", - () => { - queuedStarted = true; - }, + it("serializes read-modify-write callbacks on the single Session line", async () => { + const session = new StorageBackedSession(metadata, new MemoryStorage({ now: () => NOW })); + const counter = storedValues.value("test.counter"); + const increment = () => + session.mutate(async (mutator) => { + const next = ((await mutator.getValue(counter, BACKGROUND_CONTEXT))?.value ?? 0) + 1; + await mutator.commit([storedValues.setValue(counter, next)], BACKGROUND_CONTEXT); + return next; + }, BACKGROUND_CONTEXT); + + await expect(Promise.all([increment(), increment()])).resolves.toEqual([1, 2]); + expect((await session.getValue(counter, BACKGROUND_CONTEXT))?.value).toBe(2); + await session.close(BACKGROUND_CONTEXT); + }); + + it("keeps separate direct reads and writes deliberately non-atomic", async () => { + const session = new StorageBackedSession(metadata, new MemoryStorage({ now: () => NOW })); + const counter = storedValues.value("test.counter"); + const bothRead = deferred(); + let readers = 0; + const increment = async () => { + const next = ((await session.getValue(counter, BACKGROUND_CONTEXT))?.value ?? 0) + 1; + readers++; + if (readers === 2) bothRead.resolve(); + await bothRead.promise; + await session.setValue(counter, next, BACKGROUND_CONTEXT); + }; + + await Promise.all([increment(), increment()]); + expect((await session.getValue(counter, BACKGROUND_CONTEXT))?.value).toBe(1); + await session.close(BACKGROUND_CONTEXT); + }); + + it("queues a nested public writer until its owning callback returns", async () => { + const session = new StorageBackedSession(metadata, new MemoryStorage({ now: () => NOW })); + let nested: Promise | undefined; + await session.mutate(async () => { + nested = session.setValue(storedValues.sessionName, "nested", BACKGROUND_CONTEXT); + let settled = false; + void nested.finally(() => { + settled = true; + }); + await Promise.resolve(); + expect(settled).toBe(false); + }, BACKGROUND_CONTEXT); + if (nested === undefined) throw new Error("Expected nested writer"); + await nested; + expect(await session.getName(BACKGROUND_CONTEXT)).toBe("nested"); + await session.close(BACKGROUND_CONTEXT); + }); + + it("exposes either side of an atomic multi-write commit to direct reads", async () => { + const first = storedValues.value("test.atomic", "first"); + const second = storedValues.value("test.atomic", "second"); + const storage = new GatingStorage(new MemoryStorage({ now: () => NOW })); + await storage.commit( + [storedValues.setValue(first, "old"), storedValues.setValue(second, "old")], BACKGROUND_CONTEXT, ); + storage.arm(); + const session = new StorageBackedSession(metadata, storage); + const committing = commitSession(session, [ + storedValues.setValue(first, "new"), + storedValues.setValue(second, "new"), + ]); + await storage.waitPending(); + + expect((await session.getValue(first, BACKGROUND_CONTEXT))?.value).toBe("old"); + expect((await session.getValue(second, BACKGROUND_CONTEXT))?.value).toBe("old"); + await storage.next(); + await committing; + expect((await session.getValue(first, BACKGROUND_CONTEXT))?.value).toBe("new"); + expect((await session.getValue(second, BACKGROUND_CONTEXT))?.value).toBe("new"); + await session.close(BACKGROUND_CONTEXT); + }); + + it("holds the explicit Session barrier through commit until end", async () => { + const storage = new InstrumentedStorage(new MemoryStorage({ now: () => NOW })); + const session = new StorageBackedSession(metadata, storage); + const mutation = await session.beginMutation(BACKGROUND_CONTEXT); + let queuedStarted = false; + const queued = session.mutate(() => { + queuedStarted = true; + }, BACKGROUND_CONTEXT); await Promise.resolve(); expect(queuedStarted).toBe(false); - expect(mutation.lane).toBe("main"); expect(await mutation.getValue(storedValues.sessionName, BACKGROUND_CONTEXT)).toBeUndefined(); const result = await mutation.commit([], BACKGROUND_CONTEXT); expect(result.seqs).toEqual([]); @@ -124,10 +195,28 @@ describe("StorageBackedSession", () => { await session.close(BACKGROUND_CONTEXT); }); + it("allows direct reads to observe a committed value before the mutation scope ends", async () => { + const session = new StorageBackedSession(metadata, new MemoryStorage({ now: () => NOW })); + const mutation = await session.beginMutation(BACKGROUND_CONTEXT); + expect(await session.getValue(storedValues.sessionName, BACKGROUND_CONTEXT)).toBeUndefined(); + await mutation.commit([storedValues.setValue(storedValues.sessionName, "visible")], BACKGROUND_CONTEXT); + let queuedStarted = false; + const queued = session.mutate(() => { + queuedStarted = true; + }, BACKGROUND_CONTEXT); + + expect((await session.getValue(storedValues.sessionName, BACKGROUND_CONTEXT))?.value).toBe("visible"); + expect(queuedStarted).toBe(false); + await mutation.end(BACKGROUND_CONTEXT); + await queued; + expect(queuedStarted).toBe(true); + await session.close(BACKGROUND_CONTEXT); + }); + it("ends an explicit mutation without committing and lets close finish", async () => { const storage = new InstrumentedStorage(new MemoryStorage({ now: () => NOW })); const session = new StorageBackedSession(metadata, storage); - const mutation = await session.beginMutation("main", BACKGROUND_CONTEXT); + const mutation = await session.beginMutation(BACKGROUND_CONTEXT); let closed = false; const closing = session.close(BACKGROUND_CONTEXT).then(() => { closed = true; @@ -154,16 +243,12 @@ describe("StorageBackedSession", () => { session.scanBranch({ start: childId, order: "oldestFirst" }, BACKGROUND_CONTEXT), ).resolves.toMatchObject([{ id: ENTRY_ID }, { id: childId }]); let captured: SessionMutator | undefined; - await session.mutate( - "main", - async (mutator) => { - captured = mutator; - await expect(mutator.scanBranch({ start: childId, limit: 1 }, BACKGROUND_CONTEXT)).resolves.toMatchObject([ - { id: childId }, - ]); - }, - BACKGROUND_CONTEXT, - ); + await session.mutate(async (mutator) => { + captured = mutator; + await expect(mutator.scanBranch({ start: childId, limit: 1 }, BACKGROUND_CONTEXT)).resolves.toMatchObject([ + { id: childId }, + ]); + }, BACKGROUND_CONTEXT); const invalidated = captured; if (invalidated === undefined) throw new Error("Expected captured mutator"); expect(() => invalidated.scanBranch({ start: childId }, BACKGROUND_CONTEXT)).toThrow( @@ -230,17 +315,12 @@ describe("StorageBackedSession", () => { const session = new StorageBackedSession(metadata, storage); let captured: SessionMutator | undefined; - await session.mutate( - "review", - async (mutator) => { - captured = mutator; - expect(mutator.lane).toBe("review"); - expect(await mutator.getValue(storedValues.sessionName, BACKGROUND_CONTEXT)).toBeUndefined(); - await mutator.commit([storedValues.setValue(storedValues.sessionName, "committed")], BACKGROUND_CONTEXT); - await expect(mutator.commit([], BACKGROUND_CONTEXT)).rejects.toThrow("commit already attempted"); - }, - BACKGROUND_CONTEXT, - ); + await session.mutate(async (mutator) => { + captured = mutator; + expect(await mutator.getValue(storedValues.sessionName, BACKGROUND_CONTEXT)).toBeUndefined(); + await mutator.commit([storedValues.setValue(storedValues.sessionName, "committed")], BACKGROUND_CONTEXT); + await expect(mutator.commit([], BACKGROUND_CONTEXT)).rejects.toThrow("commit already attempted"); + }, BACKGROUND_CONTEXT); expect(storage.getCommitAttempts()).toHaveLength(1); expect(await session.getName(BACKGROUND_CONTEXT)).toBe("committed"); @@ -258,14 +338,10 @@ describe("StorageBackedSession", () => { ] satisfies Write[]; await expect( - session.mutate( - "main", - async (mutator) => { - await expect(mutator.commit(transaction, BACKGROUND_CONTEXT)).rejects.toThrow("Missing parent entry"); - await expect(mutator.commit([], BACKGROUND_CONTEXT)).rejects.toThrow("commit already attempted"); - }, - BACKGROUND_CONTEXT, - ), + session.mutate(async (mutator) => { + await expect(mutator.commit(transaction, BACKGROUND_CONTEXT)).rejects.toThrow("Missing parent entry"); + await expect(mutator.commit([], BACKGROUND_CONTEXT)).rejects.toThrow("commit already attempted"); + }, BACKGROUND_CONTEXT), ).resolves.toBeUndefined(); expect(storage.getCommitAttempts()).toHaveLength(1); await session.close(BACKGROUND_CONTEXT); @@ -310,7 +386,7 @@ describe("StorageBackedSession", () => { const session = new StorageBackedSession(metadata, storage); await Promise.all([session.close(BACKGROUND_CONTEXT), session.close(BACKGROUND_CONTEXT)]); - await expect(session.mutate("main", () => undefined, BACKGROUND_CONTEXT)).rejects.toThrow("Session is closed"); + await expect(session.mutate(() => undefined, BACKGROUND_CONTEXT)).rejects.toThrow("Session is closed"); await expect(session.getEntries([], BACKGROUND_CONTEXT)).rejects.toThrow("Session is closed"); await expect(session.getValue(storedValues.sessionName, BACKGROUND_CONTEXT)).rejects.toThrow("Session is closed"); await expect(session.scanValues(storedValues.sessionName, BACKGROUND_CONTEXT)).rejects.toThrow( diff --git a/packages/agent/test/harness/types.test.ts b/packages/agent/test/harness/types.test.ts index c3d8a802f..2bb63eedb 100644 --- a/packages/agent/test/harness/types.test.ts +++ b/packages/agent/test/harness/types.test.ts @@ -10,6 +10,7 @@ import type { AgentLane, AgentMessage, AgentTool, + Branch, BranchScan, CancelQueuedResult, CheckpointPhase, @@ -56,7 +57,6 @@ import type { SessionSearchService, SessionSnapshot, SessionStats, - SessionTree, SettledAssistantMessage, Storage, StorageBranchScan, @@ -237,21 +237,21 @@ const operations = [ { operationId: "run", lane: "main", - sourceLeafId: null, + sourceTipId: null, startedAt: 1, intent: { kind: "run", promptEntryIds: ["prompt"] }, }, { operationId: "compaction", lane: "main", - sourceLeafId: "source", + sourceTipId: "source", startedAt: 2, intent: { kind: "compaction", customInstructions: "compact" }, }, { operationId: "navigation", lane: "main", - sourceLeafId: "source", + sourceTipId: "source", startedAt: 3, intent: { kind: "navigation", targetId: "target", summarize: true, label: "target" }, }, @@ -260,13 +260,13 @@ const operations = [ const lastResult = { operationId: "run", kind: "run", - leafId: "leaf", + tipId: "leaf", finalAssistantEntryId: "assistant", outcome: "completed", runCompletion: "assistant", } satisfies LaneLastResult; const valueWrites: ValueSetWrite[] = [ - storedValues.setValue(storedValues.laneLeaf("main"), "leaf"), + storedValues.setValue(storedValues.branchTip("main"), "leaf"), storedValues.setValue(storedValues.laneConfig("main"), configuration), storedValues.setValue(storedValues.laneState("main"), { currentOperationId: "run", pendingNextRun: [] }), storedValues.setValue(storedValues.laneLastResult("main"), lastResult), @@ -377,17 +377,11 @@ it("covers storage, session, repository, search, and identity signatures", () => context: Context, ) => Promise<{ firstSeq: number; seqs: number[]; timestamp: number; stats: SessionStats }> >(); - expectTypeOf().toEqualTypeOf< - (lane: string, context: Context) => Promise - >(); + expectTypeOf().toEqualTypeOf<(context: Context) => Promise>(); expectTypeOf().toEqualTypeOf(); expectTypeOf().toEqualTypeOf<(context: Context) => Promise>(); expectTypeOf().toEqualTypeOf< - ( - lane: string, - mutation: (mutator: SessionMutator, context: Context) => T | Promise, - context: Context, - ) => Promise + (mutation: (mutator: SessionMutator, context: Context) => T | Promise, context: Context) => Promise >(); expectTypeOf().toEqualTypeOf< ( @@ -398,14 +392,8 @@ it("covers storage, session, repository, search, and identity signatures", () => expectTypeOf().toEqualTypeOf< (query: StorageBranchScan, context: Context) => Promise >(); - expectTypeOf().toEqualTypeOf< - ( - name: string, - at: string | null, - laneConfiguration: LaneConfiguration, - onCommitted: ((context: Context) => void | Promise) | undefined, - context: Context, - ) => Promise + expectTypeOf().toEqualTypeOf< + (name: string, at: string | null, context: Context) => Promise >(); expectTypeOf().toEqualTypeOf< (options: SessionCreateOptions, context: Context) => Promise @@ -480,6 +468,9 @@ it("covers Part 5 results, events, hooks, snapshots, tools, and stream options", expectTypeOf().toEqualTypeOf<"operationId" | "waitForRetry" | "pollDeferred">(); expectTypeOf().toEqualTypeOf<"settled" | "waiting">(); expectTypeOf().returns.toEqualTypeOf>(); + expectTypeOf().returns.toEqualTypeOf>(); + expectTypeOf>().toEqualTypeOf(); + expectTypeOf>().toEqualTypeOf(); expectTypeOf< Extract< keyof AgentLane, diff --git a/packages/agent/test/harness/values.test.ts b/packages/agent/test/harness/values.test.ts index f4cc8ff6b..ec757ee61 100644 --- a/packages/agent/test/harness/values.test.ts +++ b/packages/agent/test/harness/values.test.ts @@ -15,14 +15,14 @@ import type { } from "../../src/harness/session/types.ts"; import { appendList, + branchTip, + branchTipInventoryPrefix, deleteList, deleteValue, entryLabel, type ListElement, laneConfig, laneLastResult, - laneLeaf, - laneLeafInventoryPrefix, laneState, list, operationMeta, @@ -117,7 +117,7 @@ describe("built-in durable addresses", () => { const state = operationState("operation"); const frames = pendingAssistantFrames("operation", "response"); expectTypeOf(config).toEqualTypeOf>(); - expectTypeOf(laneLeaf("review")).toEqualTypeOf>(); + expectTypeOf(branchTip("review")).toEqualTypeOf>(); expectTypeOf(laneState("review")).toEqualTypeOf>(); expectTypeOf(laneLastResult("review")).toEqualTypeOf>(); expectTypeOf(meta).toEqualTypeOf>(); @@ -129,7 +129,7 @@ describe("built-in durable addresses", () => { expectTypeOf(frames).toEqualTypeOf>(); expect([ - laneLeaf("review"), + branchTip("review"), laneConfig("review"), laneState("review"), laneLastResult("review"), @@ -144,7 +144,7 @@ describe("built-in durable addresses", () => { sessionName, entryLabel("entry"), ]).toEqual([ - { kind: "value", namespace: "pi.lane.leaf", key: "review" }, + { kind: "value", namespace: "pi.branch.tip", key: "review" }, { kind: "value", namespace: "pi.lane.config", key: "review" }, { kind: "value", namespace: "pi.lane.state", key: "review" }, { kind: "value", namespace: "pi.lane.lastResult", key: "review" }, @@ -163,7 +163,7 @@ describe("built-in durable addresses", () => { it("exports exactly the five documented scan-prefix constructors", () => { const prefixes = [ - laneLeafInventoryPrefix(), + branchTipInventoryPrefix(), operationToolArgsPrefix("operation"), operationToolMemoPrefix("operation", "invocation"), operationPreparationPrefix("operation"), @@ -171,7 +171,7 @@ describe("built-in durable addresses", () => { ]; expect(prefixes).toHaveLength(5); expect(prefixes).toEqual([ - { kind: "value", namespace: "pi.lane.leaf", key: "" }, + { kind: "value", namespace: "pi.branch.tip", key: "" }, { kind: "value", namespace: "pi.op.tool_args", key: "operation:" }, { kind: "value", namespace: "pi.op.tool_memo", key: "operation:invocation:" }, { kind: "value", namespace: "pi.op.preparation", key: "operation:" }, diff --git a/packages/client/test/client.test.ts b/packages/client/test/client.test.ts index b60f951cb..32fc1d417 100644 --- a/packages/client/test/client.test.ts +++ b/packages/client/test/client.test.ts @@ -156,11 +156,11 @@ describe("Client service operations", () => { type: "response", id: "request-4", ok: true, - result: { ok: true, value: { kind: "completed", runId: "run-1", leafId: "leaf-1" } }, + result: { ok: true, value: { kind: "completed", runId: "run-1", tipId: "leaf-1" } }, }); await expect(prompting).resolves.toEqual({ ok: true, - value: { kind: "completed", runId: "run-1", leafId: "leaf-1" }, + value: { kind: "completed", runId: "run-1", tipId: "leaf-1" }, }); await client.dispose(); }); @@ -246,7 +246,7 @@ describe("Client service operations", () => { snapshot: { lane: "main", transcript: [], - leafId: null, + tipId: null, operation: null, queues: { steer: [], followUp: [], nextRun: [] }, pendingWrites: [], diff --git a/packages/coding-agent/src/experimental/harness-wire-adapter.ts b/packages/coding-agent/src/experimental/harness-wire-adapter.ts index e876f8999..f3f89a177 100644 --- a/packages/coding-agent/src/experimental/harness-wire-adapter.ts +++ b/packages/coding-agent/src/experimental/harness-wire-adapter.ts @@ -251,21 +251,21 @@ function toWireRunValue(value: Extract["value"]) switch (value.kind) { case "completed": return value.finalEntryId === undefined - ? { kind: "completed", runId: value.runId, leafId: value.leafId } + ? { kind: "completed", runId: value.runId, tipId: value.tipId } : { kind: "completed", runId: value.runId, - leafId: value.leafId, + tipId: value.tipId, finalEntryId: value.finalEntryId, finalMessage: toWireAssistantMessage(value.finalMessage), }; case "aborted": return value.finalEntryId === undefined - ? { kind: "aborted", runId: value.runId, leafId: value.leafId } + ? { kind: "aborted", runId: value.runId, tipId: value.tipId } : { kind: "aborted", runId: value.runId, - leafId: value.leafId, + tipId: value.tipId, finalEntryId: value.finalEntryId, finalMessage: toWireAssistantMessage(value.finalMessage), }; @@ -278,11 +278,11 @@ function toWireRunValue(value: Extract["value"]) : { details: toWireJsonValue(value.error.details, "operation error details") }), }; return value.finalEntryId === undefined - ? { kind: "failed", runId: value.runId, leafId: value.leafId, error } + ? { kind: "failed", runId: value.runId, tipId: value.tipId, error } : { kind: "failed", runId: value.runId, - leafId: value.leafId, + tipId: value.tipId, error, finalEntryId: value.finalEntryId, finalMessage: toWireAssistantMessage(value.finalMessage), @@ -293,7 +293,7 @@ function toWireRunValue(value: Extract["value"]) kind: "suspended", reason: "deferred", runId: value.runId, - leafId: value.leafId, + tipId: value.tipId, finalEntryId: value.finalEntryId, deferred: toWireDeferred(value.deferred), }; @@ -630,7 +630,7 @@ export function toWireLaneSnapshot(snapshot: HarnessLaneSnapshot): LaneSnapshot return { lane: snapshot.lane, transcript: snapshot.transcript.map(toWireEntry), - leafId: snapshot.leafId, + tipId: snapshot.tipId, operation: snapshot.operation === null ? null diff --git a/packages/coding-agent/src/experimental/services/chat-provider.ts b/packages/coding-agent/src/experimental/services/chat-provider.ts index 1cee01cc0..3f71e384f 100644 --- a/packages/coding-agent/src/experimental/services/chat-provider.ts +++ b/packages/coding-agent/src/experimental/services/chat-provider.ts @@ -1,5 +1,5 @@ import { - type AgentHarness, + type AgentLane, type RunResult as HarnessRunResult, ServiceSliceNotImplemented, } from "@earendil-works/pi-agent-core"; @@ -9,18 +9,18 @@ import { toHarnessPromptArguments } from "../harness-wire-adapter.ts"; import { Chat, type ChatPromptRequest, type ChatPromptResponse, type Chat as ChatService } from "./chat.ts"; import type { SessionFacetAttributes } from "./session-facet.ts"; -export function createChatService(harness: AgentHarness): ChatService { +export function createChatService(lane: AgentLane): ChatService { return { async prompt(request, context) { const prompt = toHarnessPromptArguments(toPromptArguments(request)); const result = typeof prompt[0] === "string" - ? await harness.prompt(prompt[0], prompt[1], context) - : await harness.prompt(prompt[0], context); + ? await lane.prompt(prompt[0], prompt[1], context) + : await lane.prompt(prompt[0], context); return toChatPromptResponse(result); }, async requestAbort(operationId, context) { - const result = await harness.requestAbort(operationId, context); + const result = await lane.requestAbort(operationId, context); if (!result.ok) throw new Error(result.error.message); }, async steer() { @@ -50,7 +50,7 @@ export function createChatService(harness: AgentHarness): ChatService { export const chatServiceFacet = defineFacet({ id: "@pi/chat", setup(env) { - env.provide(Chat, createChatService(env.harness)); + env.provide(Chat, createChatService(env.lane)); }, }); diff --git a/packages/coding-agent/src/experimental/services/models-provider.ts b/packages/coding-agent/src/experimental/services/models-provider.ts index e0b553f3e..f300a414a 100644 --- a/packages/coding-agent/src/experimental/services/models-provider.ts +++ b/packages/coding-agent/src/experimental/services/models-provider.ts @@ -1,5 +1,5 @@ import { - type AgentHarness, + type AgentLane, BACKGROUND_CONTEXT, type Context, type MutableReplicatedState, @@ -16,7 +16,7 @@ export interface ModelsServiceRuntime { } export function createModelsService( - harness: AgentHarness, + lane: AgentLane, modelRuntime: ModelRuntime | undefined, createState: (initial: ModelsState) => MutableReplicatedState, ): ModelsServiceRuntime { @@ -27,17 +27,14 @@ export function createModelsService( refresh: { status: "idle" }, }); const readConfiguration = async (context: Context): Promise => { - const [selected, thinkingLevel] = await Promise.all([ - harness.getModel(context), - harness.getThinkingLevel(context), - ]); + const [selected, thinkingLevel] = await Promise.all([lane.getModel(context), lane.getThinkingLevel(context)]); return { model: selected === undefined ? null : { provider: selected.provider, modelId: selected.id }, thinkingLevel, }; }; const readCatalog = async (context: Context): Promise => { - const selected = await harness.getModel(context); + const selected = await lane.getModel(context); const available = modelRuntime?.getAvailableSnapshot() ?? []; const catalog = selected === undefined || includesModel(available, selected) ? available : [...available, selected]; @@ -55,13 +52,13 @@ export function createModelsService( const service: ModelsService = { state, async cycleThinking(context) { - const selected = await harness.getModel(context); + const selected = await lane.getModel(context); if (selected === undefined) return; const levels = getSupportedThinkingLevels(selected); - const current = await harness.getThinkingLevel(context); + const current = await lane.getThinkingLevel(context); const index = levels.indexOf(current); const next = levels[(index + 1) % levels.length] ?? "off"; - await harness.setThinkingLevel(next, context); + await lane.setThinkingLevel(next, context); state.set({ ...state.value, configuration: await readConfiguration(context) }, context); }, async refresh(context) { @@ -93,7 +90,7 @@ export function createModelsService( async select(model, context) { const selected = modelRuntime?.getModel(model.provider, model.modelId); if (selected === undefined) throw new Error(`Unknown model: ${model.provider}/${model.modelId}`); - await harness.setModel(selected, context); + await lane.setModel(selected, context); state.set({ ...state.value, configuration: await readConfiguration(context) }, context); }, }; @@ -109,7 +106,7 @@ export function createModelsService( export const modelsServiceFacet = defineFacet({ id: "@pi/models", setup(env) { - const runtime = createModelsService(env.harness, env.modelRuntime, env.remoteState); + const runtime = createModelsService(env.lane, env.modelRuntime, env.remoteState); env.provide(Models, runtime.service); env.onActivate(() => runtime.activate(BACKGROUND_CONTEXT)); }, diff --git a/packages/coding-agent/src/experimental/services/session-facet.ts b/packages/coding-agent/src/experimental/services/session-facet.ts index 007c4655c..d715a2999 100644 --- a/packages/coding-agent/src/experimental/services/session-facet.ts +++ b/packages/coding-agent/src/experimental/services/session-facet.ts @@ -1,10 +1,11 @@ -import type { AgentHarness } from "@earendil-works/pi-agent-core"; +import type { AgentHarness, AgentLane } from "@earendil-works/pi-agent-core"; import type { ModelRuntime } from "../../core/model-runtime.ts"; import type { Facet } from "../facets.ts"; /** Host capabilities added to the common environment for one Session facet. */ export interface SessionFacetAttributes { readonly harness: AgentHarness; + readonly lane: AgentLane; readonly modelRuntime: ModelRuntime | undefined; } diff --git a/packages/coding-agent/src/experimental/services/worker.ts b/packages/coding-agent/src/experimental/services/worker.ts index e29a1ac1b..0501f0d45 100644 --- a/packages/coding-agent/src/experimental/services/worker.ts +++ b/packages/coding-agent/src/experimental/services/worker.ts @@ -1,5 +1,6 @@ import type { AgentHarness, + AgentLane, Context, ServiceProviderUpdate as CoreServiceProviderUpdate, ServiceProviderSubscription, @@ -29,6 +30,7 @@ export type ServiceOperationResult = Static export interface SessionWorkerRuntime { readonly harness: AgentHarness; + readonly lane?: AgentLane; readonly modelRuntime?: ModelRuntime; readonly facets?: readonly SessionFacet[]; } @@ -58,13 +60,14 @@ const BUILTIN_SESSION_FACETS = [ export async function createSessionWorkerServices(options: { readonly harness: AgentHarness; + readonly lane: AgentLane; readonly modelRuntime: ModelRuntime | undefined; readonly facets: readonly SessionFacet[]; publish(scope: WorkerServiceScope, subscriptionId: string, update: ProtocolServiceProviderUpdate): Promise; }): Promise { const facetServices = await assembleFacetServices({ facets: [...BUILTIN_SESSION_FACETS, ...options.facets], - attributes: { harness: options.harness, modelRuntime: options.modelRuntime }, + attributes: { harness: options.harness, lane: options.lane, modelRuntime: options.modelRuntime }, }); const provider = facetServices.provider; diff --git a/packages/coding-agent/src/experimental/session-worker.ts b/packages/coding-agent/src/experimental/session-worker.ts index a1da5d9e7..ffcf2a83c 100644 --- a/packages/coding-agent/src/experimental/session-worker.ts +++ b/packages/coding-agent/src/experimental/session-worker.ts @@ -4,6 +4,7 @@ import { isAbsolute } from "node:path"; import { AgentHarness, type AgentHarness as AgentHarnessInstance, + type AgentLane, BACKGROUND_CONTEXT, type Context, createBashTool, @@ -573,14 +574,17 @@ async function run(options: SessionWorkerOptions, createHarness: CreateSessionWo const repo = new JsonlSessionRepo({ fileSystem: executionEnv, sessionsRoot: sessionDir }); let session: Session | undefined; let harness: AgentHarnessInstance | undefined; + let lane: AgentLane | undefined; let services: SessionWorkerServices | undefined; try { session = await repo.open(metadata, TODO_CONTEXT); const created = await createHarness(session, options, executionEnv); const runtime: SessionWorkerRuntime = "harness" in created ? created : { harness: created }; harness = runtime.harness; + lane = runtime.lane ?? (await harness.lane("main", TODO_CONTEXT)); services = await createSessionWorkerServices({ harness, + lane, modelRuntime: runtime.modelRuntime, facets: runtime.facets ?? [], publish: (scope, subscriptionId, update) => @@ -604,7 +608,7 @@ async function run(options: SessionWorkerOptions, createHarness: CreateSessionWo const laneWatches = new Map< string, - { readonly scope: WorkerOperationScope; readonly handle: Awaited> } + { readonly scope: WorkerOperationScope; readonly handle: Awaited> } >(); const activeRequests = new Map< string, @@ -677,12 +681,12 @@ async function run(options: SessionWorkerOptions, createHarness: CreateSessionWo const args = toHarnessPromptArguments(prompt); const result = typeof args[0] === "string" - ? await harness.prompt(args[0], args[1], context) - : await harness.prompt(args[0], context); + ? await lane.prompt(args[0], args[1], context) + : await lane.prompt(args[0], context); return toWireRunResult(result); }, watch: async ({ scope, context }: WorkerOperationContext, ..._args: never[]) => { - const handle = await harness.watch(context); + const handle = await lane.watch(context); const watchId = randomUUID(); laneWatches.set(watchId, { scope, handle }); return { watchId, snapshot: toWireLaneSnapshot(handle.snapshot) }; @@ -915,30 +919,31 @@ async function createCodingAgentHarness( ) ).harness; try { - const currentActiveToolNames = await harness.getActiveTools(TODO_CONTEXT); + const lane = await harness.lane("main", TODO_CONTEXT); + const currentActiveToolNames = await lane.getActiveTools(TODO_CONTEXT); if ( currentActiveToolNames.length !== activeToolNames.length || currentActiveToolNames.some((name, index) => name !== activeToolNames[index]) ) { - await harness.setActiveTools(activeToolNames, TODO_CONTEXT); + await lane.setActiveTools(activeToolNames, TODO_CONTEXT); } if (options.model !== undefined) { - const currentModel = await harness.getModel(TODO_CONTEXT); + const currentModel = await lane.getModel(TODO_CONTEXT); if ( !currentModel || currentModel.provider !== resolved.model.provider || currentModel.id !== resolved.model.id ) { - await harness.setModel(resolved.model, TODO_CONTEXT); + await lane.setModel(resolved.model, TODO_CONTEXT); } if ( resolved.thinkingLevel !== undefined && - (await harness.getThinkingLevel(TODO_CONTEXT)) !== resolved.thinkingLevel + (await lane.getThinkingLevel(TODO_CONTEXT)) !== resolved.thinkingLevel ) { - await harness.setThinkingLevel(resolved.thinkingLevel, TODO_CONTEXT); + await lane.setThinkingLevel(resolved.thinkingLevel, TODO_CONTEXT); } } - return { harness, modelRuntime }; + return { harness, lane, modelRuntime }; } catch (error) { try { await harness.close(TODO_CONTEXT); diff --git a/packages/coding-agent/test/experimental-chat-service.test.ts b/packages/coding-agent/test/experimental-chat-service.test.ts index e089f503f..dd655427d 100644 --- a/packages/coding-agent/test/experimental-chat-service.test.ts +++ b/packages/coding-agent/test/experimental-chat-service.test.ts @@ -1,5 +1,6 @@ import { type AgentHarness, + type AgentLane, BACKGROUND_CONTEXT, Closed, type RunResult as HarnessRunResult, @@ -39,7 +40,7 @@ describe("Chat service", () => { operation: "run" as const, kind: "completed" as const, runId: "operation-1", - leafId: "entry-1", + tipId: "entry-1", }, })); const requestAbort = vi.fn(async () => ({ @@ -51,10 +52,10 @@ describe("Chat service", () => { followUp: [], }, })); - const harness = { prompt, requestAbort } as unknown as AgentHarness; + const lane = { prompt, requestAbort } as unknown as AgentLane; const generation = await assembleFacetServices({ facets: [chatServiceFacet], - attributes: { harness, modelRuntime: undefined }, + attributes: { harness: {} as unknown as AgentHarness, lane, modelRuntime: undefined }, }); try { await expect( @@ -86,7 +87,7 @@ describe("Chat service", () => { expect( toChatPromptResponse({ ok: true, - value: { operation: "run", kind: "completed", runId: "operation-1", leafId: "entry-1" }, + value: { operation: "run", kind: "completed", runId: "operation-1", tipId: "entry-1" }, }), ).toEqual({ accepted: true, operationId: "operation-1", error: null }); expect( @@ -96,7 +97,7 @@ describe("Chat service", () => { operation: "run", kind: "failed", runId: "operation-2", - leafId: "entry-1", + tipId: "entry-1", error: { code: "provider", message: "failed", details: { status: 500 } }, }, }), diff --git a/packages/coding-agent/test/experimental-harness-wire-adapter.test.ts b/packages/coding-agent/test/experimental-harness-wire-adapter.test.ts index f9d717ef3..fa7ba5f34 100644 --- a/packages/coding-agent/test/experimental-harness-wire-adapter.test.ts +++ b/packages/coding-agent/test/experimental-harness-wire-adapter.test.ts @@ -120,7 +120,7 @@ describe("Harness wire adapter", () => { test.each([ { ok: true, - value: { operation: "run", kind: "completed", runId: "run-1", leafId: "leaf-1" }, + value: { operation: "run", kind: "completed", runId: "run-1", tipId: "leaf-1" }, }, { ok: true, @@ -128,14 +128,14 @@ describe("Harness wire adapter", () => { operation: "run", kind: "completed", runId: "run-1", - leafId: "leaf-1", + tipId: "leaf-1", finalEntryId: "entry-1", finalMessage, }, }, { ok: true, - value: { operation: "run", kind: "aborted", runId: "run-1", leafId: "leaf-1" }, + value: { operation: "run", kind: "aborted", runId: "run-1", tipId: "leaf-1" }, }, { ok: true, @@ -143,7 +143,7 @@ describe("Harness wire adapter", () => { operation: "run", kind: "aborted", runId: "run-1", - leafId: "leaf-1", + tipId: "leaf-1", finalEntryId: "entry-1", finalMessage, }, @@ -154,7 +154,7 @@ describe("Harness wire adapter", () => { operation: "run", kind: "failed", runId: "run-1", - leafId: "leaf-1", + tipId: "leaf-1", error: { code: "provider", message: "failed", details: { status: 500 } }, }, }, @@ -164,7 +164,7 @@ describe("Harness wire adapter", () => { operation: "run", kind: "failed", runId: "run-1", - leafId: "leaf-1", + tipId: "leaf-1", error: { code: "provider", message: "failed" }, finalEntryId: "entry-1", finalMessage, @@ -177,7 +177,7 @@ describe("Harness wire adapter", () => { kind: "suspended", reason: "deferred", runId: "run-1", - leafId: "leaf-1", + tipId: "leaf-1", finalEntryId: "entry-1", deferred: { provider: "test", modelId: "model", api: "test", id: "deferred-1", data: { row: 1 } }, }, @@ -221,7 +221,7 @@ describe("Harness wire adapter", () => { message: { role: "user", content: "hello", timestamp: 1 }, }, ], - leafId: "entry-1", + tipId: "entry-1", operation: null, queues: { steer: [], followUp: [], nextRun: [] }, pendingWrites: [], @@ -267,7 +267,7 @@ describe("Harness wire adapter", () => { operation: "run", kind: "completed", runId: "run-1", - leafId: "leaf-1", + tipId: "leaf-1", finalEntryId: "entry-1", finalMessage: unsafeMessage, }, diff --git a/packages/coding-agent/test/experimental-remote-runtime.test.ts b/packages/coding-agent/test/experimental-remote-runtime.test.ts index 2915eee97..281592ad8 100644 --- a/packages/coding-agent/test/experimental-remote-runtime.test.ts +++ b/packages/coding-agent/test/experimental-remote-runtime.test.ts @@ -644,7 +644,7 @@ describe("experimental durable server composition", () => { ok: true, value: { kind: "completed", - leafId: expect.any(String), + tipId: expect.any(String), finalEntryId: expect.any(String), finalMessage: { role: "assistant", diff --git a/packages/coding-agent/test/experimental-session-support.ts b/packages/coding-agent/test/experimental-session-support.ts index f71763d0a..202865e60 100644 --- a/packages/coding-agent/test/experimental-session-support.ts +++ b/packages/coding-agent/test/experimental-session-support.ts @@ -52,8 +52,10 @@ export async function readExperimentalSessionState( const matches = (await repo.list(undefined, BACKGROUND_CONTEXT)).filter((metadata) => metadata.id === sessionId); if (matches.length !== 1) throw new Error(`Expected one Session ${sessionId}, found ${matches.length}`); session = await repo.open(matches[0]!, BACKGROUND_CONTEXT); + const main = await session.branch("main", BACKGROUND_CONTEXT); + if (main === undefined) throw new Error("Expected Session main Branch"); const [branch, configuration] = await Promise.all([ - session.findEntriesOnBranch({ order: "oldestFirst" }, BACKGROUND_CONTEXT), + main.findEntries({ order: "oldestFirst" }, BACKGROUND_CONTEXT), session.getValue(laneConfig("main"), BACKGROUND_CONTEXT), ]); return { branch, model: configuration?.value.model, activeTools: configuration?.value.activeToolNames ?? [] }; diff --git a/packages/coding-agent/test/experimental-session-worker-manager.test.ts b/packages/coding-agent/test/experimental-session-worker-manager.test.ts index 845b3e89b..57dab9bcb 100644 --- a/packages/coding-agent/test/experimental-session-worker-manager.test.ts +++ b/packages/coding-agent/test/experimental-session-worker-manager.test.ts @@ -192,7 +192,7 @@ describe("Session worker operations", () => { scope, result: { ok: true, - value: { kind: "completed", runId: "run-1", leafId: "leaf-1" }, + value: { kind: "completed", runId: "run-1", tipId: "leaf-1" }, }, }, }, @@ -202,7 +202,7 @@ describe("Session worker operations", () => { await expect(attachment.prompt(["Hello"], BACKGROUND_CONTEXT)).resolves.toEqual({ ok: true, - value: { kind: "completed", runId: "run-1", leafId: "leaf-1" }, + value: { kind: "completed", runId: "run-1", tipId: "leaf-1" }, }); const operation = coordinator.sent .map(({ payload }) => asObject(payload)) @@ -233,7 +233,7 @@ describe("Session worker operations", () => { scope: payload.scope, result: { ok: true, - value: { kind: "completed", runId: "run-1", leafId: "leaf-1" }, + value: { kind: "completed", runId: "run-1", tipId: "leaf-1" }, }, }, }, @@ -264,7 +264,7 @@ describe("Session worker operations", () => { scope: null, result: { ok: true, - value: { kind: "completed", runId: "run-1", leafId: "leaf-1" }, + value: { kind: "completed", runId: "run-1", tipId: "leaf-1" }, }, }, }, diff --git a/packages/protocol/src/harness.ts b/packages/protocol/src/harness.ts index 50c27e79e..50330b409 100644 --- a/packages/protocol/src/harness.ts +++ b/packages/protocol/src/harness.ts @@ -169,32 +169,32 @@ const OperationErrorSchema = StrictObject({ details: Type.Optional(JsonValueSchema), }); const RunValueSchema = Type.Union([ - StrictObject({ kind: Type.Literal("completed"), runId: IdSchema, leafId: IdSchema }), + StrictObject({ kind: Type.Literal("completed"), runId: IdSchema, tipId: IdSchema }), StrictObject({ kind: Type.Literal("completed"), runId: IdSchema, - leafId: IdSchema, + tipId: IdSchema, finalEntryId: IdSchema, finalMessage: AssistantMessageSchema, }), - StrictObject({ kind: Type.Literal("aborted"), runId: IdSchema, leafId: IdSchema }), + StrictObject({ kind: Type.Literal("aborted"), runId: IdSchema, tipId: IdSchema }), StrictObject({ kind: Type.Literal("aborted"), runId: IdSchema, - leafId: IdSchema, + tipId: IdSchema, finalEntryId: IdSchema, finalMessage: AssistantMessageSchema, }), StrictObject({ kind: Type.Literal("failed"), runId: IdSchema, - leafId: IdSchema, + tipId: IdSchema, error: OperationErrorSchema, }), StrictObject({ kind: Type.Literal("failed"), runId: IdSchema, - leafId: IdSchema, + tipId: IdSchema, error: OperationErrorSchema, finalEntryId: IdSchema, finalMessage: AssistantMessageSchema, @@ -203,7 +203,7 @@ const RunValueSchema = Type.Union([ kind: Type.Literal("suspended"), reason: Type.Literal("deferred"), runId: IdSchema, - leafId: IdSchema, + tipId: IdSchema, finalEntryId: IdSchema, deferred: DeferredHandleSchema, }), @@ -379,7 +379,7 @@ const LaneOperationSchema = StrictObject({ export const LaneSnapshotSchema = StrictObject({ lane: Type.String(), transcript: Type.Array(LaneEntrySchema), - leafId: Type.Union([IdSchema, Type.Null()]), + tipId: Type.Union([IdSchema, Type.Null()]), operation: Type.Union([LaneOperationSchema, Type.Null()]), queues: StrictObject({ steer: Type.Array(QueuedItemSchema), @@ -411,7 +411,7 @@ const LaneEventBase = { const RunEndBase = { type: Type.Literal("run_end"), runId: IdSchema, - leafId: Type.Union([IdSchema, Type.Null()]), + tipId: Type.Union([IdSchema, Type.Null()]), ...LaneEventBase, }; export const LaneEventSchema = Type.Union([ diff --git a/packages/protocol/test/protocol.test.ts b/packages/protocol/test/protocol.test.ts index a3dc65028..aba6ff654 100644 --- a/packages/protocol/test/protocol.test.ts +++ b/packages/protocol/test/protocol.test.ts @@ -54,7 +54,7 @@ const summary = { const laneSnapshot = { lane: "main", transcript: [], - leafId: null, + tipId: null, operation: null, queues: { steer: [], followUp: [], nextRun: [] }, pendingWrites: [], @@ -63,7 +63,7 @@ const laneSnapshot = { const completedRunResult = { ok: true, - value: { kind: "completed", runId: "run-1", leafId: "leaf-1" }, + value: { kind: "completed", runId: "run-1", tipId: "leaf-1" }, } as const satisfies RunResult; describe("RPC manifest", () => { @@ -444,7 +444,7 @@ describe("protocol validation", () => { type: "run_end", lane: "main", runId: "run-1", - leafId: "leaf-1", + tipId: "leaf-1", outcome: "completed", finalEntryId: "entry-1", }, @@ -470,14 +470,14 @@ describe("protocol validation", () => { }); test.each([ - { ok: true, value: { kind: "completed", runId: "run-1", leafId: "leaf-1" } }, - { ok: true, value: { kind: "aborted", runId: "run-1", leafId: "leaf-1" } }, + { ok: true, value: { kind: "completed", runId: "run-1", tipId: "leaf-1" } }, + { ok: true, value: { kind: "aborted", runId: "run-1", tipId: "leaf-1" } }, { ok: true, value: { kind: "failed", runId: "run-1", - leafId: "leaf-1", + tipId: "leaf-1", error: { code: "provider", message: "provider failed" }, }, }, @@ -506,8 +506,8 @@ describe("protocol validation", () => { action: { kind: "confirm", description: "Confirm" }, }, }, - { ok: true, value: { kind: "failed", runId: "run-1", leafId: "leaf-1" } }, - { ok: true, value: { kind: "completed", runId: "run-1", leafId: "leaf-1", finalEntryId: "entry-1" } }, + { ok: true, value: { kind: "failed", runId: "run-1", tipId: "leaf-1" } }, + { ok: true, value: { kind: "completed", runId: "run-1", tipId: "leaf-1", finalEntryId: "entry-1" } }, { ok: false, error: { _tag: "Closed", message: "closed", extra: true } }, ] as const)("rejects malformed structural Harness RunResult", (result) => { expect(Check(RunResultSchema, result)).toBe(false); @@ -524,7 +524,7 @@ describe("protocol validation", () => { value: { kind: "failed", runId: "run-1", - leafId: "leaf-1", + tipId: "leaf-1", error: { code: "provider", message: "failed", details }, }, }), diff --git a/packages/server/src/testing/host.ts b/packages/server/src/testing/host.ts index 8c3082873..8ade433a9 100644 --- a/packages/server/src/testing/host.ts +++ b/packages/server/src/testing/host.ts @@ -26,7 +26,7 @@ interface OpenGate { const emptyLaneSnapshot: LaneSnapshot = { lane: "main", transcript: [], - leafId: null, + tipId: null, operation: null, queues: { steer: [], followUp: [], nextRun: [] }, pendingWrites: [], @@ -149,7 +149,7 @@ export class TestHarness { } const result = this.nextPromptResult ?? { ok: true, - value: { kind: "completed", runId: "run-1", leafId: "leaf-1" }, + value: { kind: "completed", runId: "run-1", tipId: "leaf-1" }, }; this.nextPromptResult = undefined; return result; diff --git a/packages/server/test/conformance.test.ts b/packages/server/test/conformance.test.ts index ab7efd27d..6b1206c28 100644 --- a/packages/server/test/conformance.test.ts +++ b/packages/server/test/conformance.test.ts @@ -131,7 +131,7 @@ describe("Session protocol", () => { attachClient: async () => ({ prompt: async () => ({ ok: true, - value: { kind: "completed" as const, runId: "run-1", leafId: "leaf-1" }, + value: { kind: "completed" as const, runId: "run-1", tipId: "leaf-1" }, }), release: () => {}, }), @@ -423,7 +423,7 @@ describe("Session protocol", () => { type: "response", id: "request-3", ok: true, - result: { ok: true, value: { kind: "completed", runId: "run-1", leafId: "leaf-1" } }, + result: { ok: true, value: { kind: "completed", runId: "run-1", tipId: "leaf-1" } }, }); expect(host.latestHarness("session-1").promptCalls).toEqual([["Hello"]]); }); @@ -660,7 +660,7 @@ describe("routed Session acquisition failures", () => { return { prompt: async () => ({ ok: true, - value: { kind: "completed" as const, runId: "run-1", leafId: "leaf-1" }, + value: { kind: "completed" as const, runId: "run-1", tipId: "leaf-1" }, }), release: () => { releaseCount += 1; diff --git a/packages/session-backends/sqlite-node/src/sqlite/repo.ts b/packages/session-backends/sqlite-node/src/sqlite/repo.ts index b2eeb52f3..0dbd02a74 100644 --- a/packages/session-backends/sqlite-node/src/sqlite/repo.ts +++ b/packages/session-backends/sqlite-node/src/sqlite/repo.ts @@ -1,7 +1,7 @@ import { access, mkdir, open as openFile, readdir, rm } from "node:fs/promises"; import { join } from "node:path"; import type { Context, Entry, ForkOptions, SessionCreateOptions, StoredValue } from "@earendil-works/pi-agent-core"; -import { createForkSnapshot, laneLeaf, StorageBackedSession } from "@earendil-works/pi-agent-core"; +import { branchTip, createForkSnapshot, StorageBackedSession } from "@earendil-works/pi-agent-core"; import { uuidv7 } from "@earendil-works/pi-ai"; import { applyInitialSchema } from "./migrations.ts"; import { appendEntryToBranchIndex, scanBranchEntries } from "./session/branch-entries.ts"; @@ -15,7 +15,7 @@ import { readSessionRow, type SqliteSessionMetadata, } from "./session/session-row.ts"; -import { insertInitialMainLaneValues, readAllScalarValueRows, setScalarValueRow } from "./session/values.ts"; +import { readAllScalarValueRows, setScalarValueRow } from "./session/values.ts"; import { claimWriterLease, readWriterLease, @@ -33,7 +33,7 @@ export const SQLITE_SESSION_EXTENSION = ".sqlite"; const DEFAULT_WRITER_LEASE_MS = 30_000; const WRITER_LEASE_RENEW_INTERVAL_MS = DEFAULT_WRITER_LEASE_MS / 2; -const FIRST_AVAILABLE_COMMIT_SEQ = 3; +const FIRST_AVAILABLE_COMMIT_SEQ = 1; export type SqliteSessionCreateOptions = SessionCreateOptions; @@ -98,12 +98,12 @@ function readForkSourceEntries( options: ForkOptions, ): Entry[] { if (options.scope === "tree") return readSourceEntries(db, sessionId); - const mainAddress = laneLeaf("main"); - const mainLeaf = scalarValues.find( + const mainAddress = branchTip("main"); + const mainTip = scalarValues.find( (stored) => stored.address.namespace === mainAddress.namespace && stored.address.key === mainAddress.key, ) as StoredValue | undefined; - if (mainLeaf === undefined) throw new Error("Source session is missing main lane"); - const requested = options.entryId ?? mainLeaf.value; + if (mainTip === undefined) throw new Error("Source session is missing main branch"); + const requested = options.entryId ?? mainTip.value; return requested === null ? [] : scanBranchEntries(db, sessionId, { start: requested, order: "oldestFirst" }); } @@ -188,7 +188,6 @@ export class SqliteSessionRepo { lease = activeDb.transaction(() => { if (hasSessionRow(activeDb, id)) throw new Error(`SQLite session already exists: ${id}`); insertSessionRow(activeDb, metadata, SQLITE_STORAGE_VERSION, FIRST_AVAILABLE_COMMIT_SEQ); - insertInitialMainLaneValues(activeDb, id); return claimWriterLease(activeDb, id, uuidv7(this.now()), this.now(), DEFAULT_WRITER_LEASE_MS); }); initialized = true; diff --git a/packages/session-backends/sqlite-node/src/sqlite/session.ts b/packages/session-backends/sqlite-node/src/sqlite/session.ts index 7ac90e89d..e352c9423 100644 --- a/packages/session-backends/sqlite-node/src/sqlite/session.ts +++ b/packages/session-backends/sqlite-node/src/sqlite/session.ts @@ -1,18 +1,14 @@ import type { - AgentMessage, - BranchScan, + Branch, Context, Entry, EntryQuery, - JsonValue, - LaneConfiguration, ListElement, ListReadOptions, Session, SessionMutation, - SessionMutator, + SessionMutationCallback, SessionStats, - SessionTree, StorageBranchScan, StoredValue, Value, @@ -60,22 +56,65 @@ export class SqliteOpenSession implements Session { this.renewalTimer.unref?.(); } - beginMutation(lane: string, context: Context): Promise { - return this.admit(() => this.session.beginMutation(lane, context)); + async beginMutation(context: Context): Promise { + let resolveFinished!: () => void; + const finished = new Promise((resolve) => { + resolveFinished = resolve; + }); + this.admitted.add(finished); + let source: SessionMutation; + try { + source = await this.admit(() => this.session.beginMutation(context)); + } catch (error) { + this.admitted.delete(finished); + resolveFinished(); + throw error; + } + if (this.state !== "open") { + await source.end(context); + this.admitted.delete(finished); + resolveFinished(); + throw this.closedError; + } + let ended = false; + return { + commit: (writes, commitContext) => source.commit(writes, commitContext), + end: async (endContext) => { + try { + await source.end(endContext); + } finally { + if (!ended) { + ended = true; + this.admitted.delete(finished); + resolveFinished(); + } + } + }, + getEntries: (ids, readContext) => source.getEntries(ids, readContext), + getValue: (address, readContext) => source.getValue(address, readContext), + scanValues: (prefix, readContext) => source.scanValues(prefix, readContext), + readList: (address, options, readContext) => source.readList(address, options, readContext), + scanBranch: (query, readContext) => source.scanBranch(query, readContext), + }; } - mutate( - lane: string, - mutation: (mutator: SessionMutator, context: Context) => T | Promise, - context: Context, - ): Promise { - return this.admit(() => this.session.mutate(lane, mutation, context)); + mutate(mutation: SessionMutationCallback, context: Context): Promise { + return this.admit(() => + this.session.mutate((mutator, mutationContext) => { + if (this.state !== "open") throw this.closedError; + return mutation(mutator, mutationContext); + }, context), + ); } getEntries(ids: string[], context: Context): Promise> { return this.admit(() => this.session.getEntries(ids, context)); } + getEntry(id: string, context: Context): Promise { + return this.admit(() => this.session.getEntry(id, context)); + } + getValue(address: Value, context: Context): Promise | undefined> { return this.admit(() => this.session.getValue(address, context)); } @@ -96,58 +135,35 @@ export class SqliteOpenSession implements Session { return this.admit(() => this.session.scanBranch(query, context)); } - view(lane: string): SessionTree { - const view = this.session.view(lane); - return { - getLeafId: (context) => this.admit(() => view.getLeafId(context)), - getEntry: (id, context) => this.admit(() => view.getEntry(id, context)), - getStats: (context) => this.admit(() => view.getStats(context)), - getValue: (address, context) => this.admit(() => view.getValue(address, context)), - scanValues: (prefix, context) => this.admit(() => view.scanValues(prefix, context)), - readList: (address, options, context) => this.admit(() => view.readList(address, options, context)), - setValue: (address, next, context) => this.admit(() => view.setValue(address, next, context)), - deleteValue: (address, context) => this.admit(() => view.deleteValue(address, context)), - appendList: (address, element, context) => this.admit(() => view.appendList(address, element, context)), - deleteList: (address, context) => this.admit(() => view.deleteList(address, context)), - getName: (context) => this.admit(() => view.getName(context)), - setName: (name, context) => this.admit(() => view.setName(name, context)), - getLabel: (targetId, context) => this.admit(() => view.getLabel(targetId, context)), - setLabel: (targetId, label, context) => this.admit(() => view.setLabel(targetId, label, context)), - findEntries: (query, context) => this.admit(() => view.findEntries(query, context)), - findEntry: (query, context) => this.admit(() => view.findEntry(query, context)), - findEntriesOnBranch: (query, context) => this.admit(() => view.findEntriesOnBranch(query, context)), - findEntryOnBranch: (query, context) => this.admit(() => view.findEntryOnBranch(query, context)), - appendMessage: (message, context) => this.admit(() => view.appendMessage(message, context)), - appendCustomEntry: (customType, data, context) => - this.admit(() => view.appendCustomEntry(customType, data, context)), - }; - } - - createLane( - 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, onCommitted, context); - return this.view(name); - }); - } - - getLeafId(context: Context): Promise { - return this.admit(() => this.session.getLeafId(context)); - } - - getEntry(id: string, context: Context): Promise { - return this.admit(() => this.session.getEntry(id, context)); - } - getStats(context: Context): Promise { return this.admit(() => this.session.getStats(context)); } + getName(context: Context): Promise { + return this.admit(() => this.session.getName(context)); + } + + getLabel(targetId: string, context: Context): Promise { + return this.admit(() => this.session.getLabel(targetId, context)); + } + + findEntries(query: EntryQuery | undefined, context: Context): Promise { + return this.admit(() => this.session.findEntries(query, context)); + } + + findEntry(query: EntryQuery | undefined, context: Context): Promise { + return this.admit(() => this.session.findEntry(query, context)); + } + + async branch(name: string, context: Context): Promise { + const branch = await this.admit(() => this.session.branch(name, context)); + return branch === undefined ? undefined : this.wrapBranch(branch); + } + + async createBranch(name: string, at: string | null, context: Context): Promise { + return this.wrapBranch(await this.admit(() => this.session.createBranch(name, at, context))); + } + setValue(address: Value, next: NoInfer, context: Context): Promise { return this.admit(() => this.session.setValue(address, next, context)); } @@ -164,46 +180,14 @@ export class SqliteOpenSession implements Session { return this.admit(() => this.session.deleteList(address, context)); } - getName(context: Context): Promise { - return this.admit(() => this.session.getName(context)); - } - setName(name: string | undefined, context: Context): Promise { return this.admit(() => this.session.setName(name, context)); } - getLabel(targetId: string, context: Context): Promise { - return this.admit(() => this.session.getLabel(targetId, context)); - } - setLabel(targetId: string, label: string | undefined, context: Context): Promise { return this.admit(() => this.session.setLabel(targetId, label, context)); } - findEntries(query: EntryQuery | undefined, context: Context): Promise { - return this.admit(() => this.session.findEntries(query, context)); - } - - findEntry(query: EntryQuery | undefined, context: Context): Promise { - return this.admit(() => this.session.findEntry(query, context)); - } - - findEntriesOnBranch(query: BranchScan | undefined, context: Context): Promise { - return this.admit(() => this.session.findEntriesOnBranch(query, context)); - } - - findEntryOnBranch(query: BranchScan | undefined, context: Context): Promise { - return this.admit(() => this.session.findEntryOnBranch(query, context)); - } - - appendMessage(message: AgentMessage, context: Context): Promise { - return this.admit(() => this.session.appendMessage(message, context)); - } - - appendCustomEntry(customType: string, data: JsonValue | undefined, context: Context): Promise { - return this.admit(() => this.session.appendCustomEntry(customType, data, context)); - } - close(context: Context): Promise { if (this.closePromise !== undefined) return this.closePromise; this.state = "closing"; @@ -221,6 +205,18 @@ export class SqliteOpenSession implements Session { return this.closePromise; } + private wrapBranch(branch: Branch): Branch { + return { + name: branch.name, + getTipId: (context) => this.admit(() => branch.getTipId(context)), + findEntries: (query, context) => this.admit(() => branch.findEntries(query, context)), + findEntry: (query, context) => this.admit(() => branch.findEntry(query, context)), + appendMessage: (message, context) => this.admit(() => branch.appendMessage(message, context)), + appendCustomEntry: (customType, data, context) => + this.admit(() => branch.appendCustomEntry(customType, data, context)), + }; + } + private admit(operation: () => Promise): Promise { if (this.state !== "open") return Promise.reject(this.closedError); if (this.leaseError !== undefined) return Promise.reject(this.leaseError); diff --git a/packages/session-backends/sqlite-node/src/sqlite/session/values.ts b/packages/session-backends/sqlite-node/src/sqlite/session/values.ts index 2cb353d03..1063e5e2c 100644 --- a/packages/session-backends/sqlite-node/src/sqlite/session/values.ts +++ b/packages/session-backends/sqlite-node/src/sqlite/session/values.ts @@ -1,11 +1,8 @@ import { type ListElement, type ListReadOptions, - laneLeaf, - laneState, resolveListReadOptions, type StoredValue, - setValue, type Value, type ValueList, value, @@ -25,17 +22,6 @@ export interface ListValueRow { value: string; } -export function insertInitialMainLaneValues(db: SqliteDatabase, sessionId: string): void { - const writes = [ - setValue(laneLeaf("main"), null), - setValue(laneState("main"), { currentOperationId: null, pendingNextRun: [] }), - ]; - for (const [index, write] of writes.entries()) { - if (write.op !== "set") throw new Error("Expected initial scalar value write"); - setScalarValueRow(db, sessionId, write.namespace, write.key, index + 1, write.value); - } -} - export function setScalarValueRow( db: SqliteDatabase, sessionId: string, diff --git a/packages/session-backends/sqlite-node/src/sqlite/storage.ts b/packages/session-backends/sqlite-node/src/sqlite/storage.ts index 06016d843..6e5c1e464 100644 --- a/packages/session-backends/sqlite-node/src/sqlite/storage.ts +++ b/packages/session-backends/sqlite-node/src/sqlite/storage.ts @@ -17,7 +17,7 @@ import type { ValueList, Write, } from "@earendil-works/pi-agent-core"; -import { laneLeaf, prepareStorageCommit } from "@earendil-works/pi-agent-core"; +import { branchTip, prepareStorageCommit } from "@earendil-works/pi-agent-core"; import { appendEntryToBranchIndex, scanBranchEntries, scanBranchEntryStructures } from "./session/branch-entries.ts"; import { decodeEntryRow, EntryRowWriter, readAllEntryRows, readEntryRows, scanEntryRows } from "./session/entries.ts"; import { advanceNextSeq, readNextSeq } from "./session/session-sequences.ts"; @@ -154,12 +154,12 @@ export class SqliteStorage implements Storage { private readSnapshotEntries(options: ForkOptions, scalarValues: readonly StoredValue[]): Entry[] { if (options.scope === "tree") return readAllEntryRows(this.db, this.sessionId).map(decodeEntryRow); - const mainAddress = laneLeaf("main"); - const mainLeaf = scalarValues.find( + const mainAddress = branchTip("main"); + const mainTip = scalarValues.find( (stored) => stored.address.namespace === mainAddress.namespace && stored.address.key === mainAddress.key, ) as StoredValue | undefined; - if (mainLeaf === undefined) throw new Error("Source session is missing main lane"); - const requested = options.entryId ?? mainLeaf.value; + if (mainTip === undefined) throw new Error("Source session is missing main branch"); + const requested = options.entryId ?? mainTip.value; return requested === null ? [] : scanBranchEntries(this.db, this.sessionId, { start: requested, order: "oldestFirst" }); diff --git a/packages/session-backends/sqlite-node/test/repo.test.ts b/packages/session-backends/sqlite-node/test/repo.test.ts index d179e93c9..234f2aceb 100644 --- a/packages/session-backends/sqlite-node/test/repo.test.ts +++ b/packages/session-backends/sqlite-node/test/repo.test.ts @@ -27,7 +27,7 @@ async function withDb(path: string, run: (db: SqliteDatabase) => Promise | } describe("SqliteSessionRepo", () => { - it("creates one initialized session file with main lane values", async () => { + it("creates one branchless initialized session file", async () => { await withTempDir(async (directory) => { const repo = new SqliteSessionRepo({ directory, @@ -58,26 +58,13 @@ describe("SqliteSessionRepo", () => { totalTokens: 0, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, }), - next_seq: 3, + next_seq: 1, }); expect( sql`SELECT namespace, key, seq, value FROM scalar_values WHERE session_id = ${"session"} ORDER BY seq`.all( db, ), - ).toEqual([ - { - namespace: storedValues.laneLeaf("main").namespace, - key: "main", - seq: 1, - value: "null", - }, - { - namespace: storedValues.laneState("main").namespace, - key: "main", - seq: 2, - value: JSON.stringify({ currentOperationId: null, pendingNextRun: [] }), - }, - ]); + ).toEqual([]); expect(sql`SELECT COUNT(*) AS count FROM list_values WHERE session_id = ${"session"}`.get(db)).toEqual({ count: 0, }); @@ -95,7 +82,6 @@ describe("SqliteSessionRepo", () => { }); const session = await repo.create({ id: "session" }, BACKGROUND_CONTEXT); await session.mutate( - "main", (mutator) => mutator.commit( [ @@ -124,7 +110,7 @@ describe("SqliteSessionRepo", () => { now: () => 1_700_000_000_000, }); const session = await repo.create({ id: "session" }, BACKGROUND_CONTEXT); - const mutation = await session.beginMutation("main", BACKGROUND_CONTEXT); + const mutation = await session.beginMutation(BACKGROUND_CONTEXT); const result = await mutation.commit( [storedValues.setValue(storedValues.sessionName, "explicit")], BACKGROUND_CONTEXT, @@ -303,7 +289,6 @@ describe("SqliteSessionRepo", () => { expect(left.metadata.path).toBe(databasePath); expect(right.metadata.path).toBe(databasePath); await left.mutate( - "main", (mutator) => mutator.commit( [ @@ -315,7 +300,6 @@ describe("SqliteSessionRepo", () => { BACKGROUND_CONTEXT, ); await right.mutate( - "main", (mutator) => mutator.commit( [ @@ -358,7 +342,6 @@ describe("SqliteSessionRepo", () => { }); const source = await repo.create({ id: "source" }, BACKGROUND_CONTEXT); await source.mutate( - "main", (mutator) => mutator.commit( [ @@ -369,7 +352,7 @@ describe("SqliteSessionRepo", () => { type: "message", message: { role: "user", content: "child", timestamp: 1 }, }), - storedValues.setValue(storedValues.laneLeaf("main"), "child"), + storedValues.setValue(storedValues.branchTip("main"), "child"), sessionWrites.insertUsage({ id: "usage", adjustment: false,