mirror of
https://github.com/badlogic/pi-mono.git
synced 2026-11-05 23:37:25 +00:00
feat(agent): separate sessions, branches, and lanes
This commit is contained in:
parent
cbaec01048
commit
423e20cb78
82 changed files with 3933 additions and 4299 deletions
|
|
@ -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.
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -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<Entry | undefined>;
|
||||
getStats(context: Context): Promise<SessionStats>;
|
||||
getValue<T>(address: Value<T>, context: Context): Promise<StoredValue<T> | undefined>;
|
||||
scanValues<T>(prefix: Value<T>, context: Context): Promise<StoredValue<T>[]>;
|
||||
readList<T>(address: ValueList<T>, options: ListReadOptions | undefined,
|
||||
context: Context): Promise<ListElement<T>[]>;
|
||||
setValue<T>(address: Value<T>, next: NoInfer<T>, context: Context): Promise<void>;
|
||||
deleteValue<T>(address: Value<T>, context: Context): Promise<void>;
|
||||
appendList<T>(address: ValueList<T>, element: NoInfer<T>, context: Context): Promise<void>;
|
||||
deleteList<T>(address: ValueList<T>, context: Context): Promise<void>;
|
||||
getName(context: Context): Promise<string | undefined>;
|
||||
setName(name: string | undefined, context: Context): Promise<void>;
|
||||
getLabel(targetId: string, context: Context): Promise<string | undefined>;
|
||||
setLabel(targetId: string, label: string | undefined, context: Context): Promise<void>;
|
||||
findEntries(query: EntryQuery | undefined, context: Context): Promise<Entry[]>;
|
||||
findEntry(query: EntryQuery | undefined, context: Context): Promise<Entry | undefined>;
|
||||
}
|
||||
|
||||
interface AgentPluginScope {
|
||||
readonly identity: SessionIdentity; // sessionId + workspaceId
|
||||
readonly main: AgentLanePluginView;
|
||||
lane(name: string, context: Context): Promise<AgentLanePluginView | undefined>;
|
||||
readonly sessionTree: SessionTree;
|
||||
lane(name: string, context: Context): Promise<AgentLanePluginView>;
|
||||
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<PromptQueueService>("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;
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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`
|
||||
|
|
|
|||
|
|
@ -138,9 +138,9 @@ export type AgentHarnessToolUpdateCallback<TDetails> = (
|
|||
|
||||
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.
|
||||
|
||||
|
|
|
|||
|
|
@ -13,17 +13,17 @@ The namespace/key pair is bound once when the address is constructed. Every late
|
|||
const state = value<ApplicationState>("my-app.state");
|
||||
const events = list<ApplicationEvent>("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<ApplicationEvent>("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<string | null>("pi.lane.leaf", lane);
|
||||
export const branchTip = (lane: string) =>
|
||||
value<string | null>("pi.branch.tip", lane);
|
||||
|
||||
export const operationState = (operationId: string) =>
|
||||
value<OperationState>("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<string | null>("pi.lane.leaf", lane);
|
||||
export const laneConfig = (lane: string) => value<LaneConfiguration>("pi.lane.config", lane);
|
||||
export const laneState = (lane: string) => value<LaneState>("pi.lane.state", lane);
|
||||
export const branchTip = (lane: string) =>
|
||||
value<string | null>("pi.branch.tip", lane);
|
||||
export const laneConfig = (lane: string) =>
|
||||
value<LaneConfiguration>("pi.lane.config", lane);
|
||||
export const laneState = (lane: string) =>
|
||||
value<LaneState>("pi.lane.state", lane);
|
||||
export const laneLastResult = (lane: string) =>
|
||||
value<LaneLastResult>("pi.lane.lastResult", lane);
|
||||
|
||||
/** Used only by scanValues() to enumerate configured lane names. */
|
||||
export const laneLeafInventoryPrefix = () => value<string | null>("pi.lane.leaf");
|
||||
/** Used only by scanValues() to enumerate Branch names. */
|
||||
export const branchTipInventoryPrefix = () =>
|
||||
value<string | null>("pi.branch.tip");
|
||||
|
||||
export const operationMeta = (operationId: string) =>
|
||||
value<OperationMeta>("pi.op.meta", operationId);
|
||||
|
|
@ -239,7 +243,7 @@ export const entryLabel = (entryId: string) => value<string>("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<T>(address: Value<T>, next: NoInfer<T>): Promise<void>;
|
||||
deleteValue<T>(address: Value<T>): Promise<void>;
|
||||
appendList<T>(address: ValueList<T>, element: NoInfer<T>): Promise<void>;
|
||||
deleteList<T>(address: ValueList<T>): Promise<void>;
|
||||
interface Session extends ValueReader {
|
||||
setValue<T>(
|
||||
address: Value<T>,
|
||||
next: NoInfer<T>,
|
||||
context: Context,
|
||||
): Promise<void>;
|
||||
deleteValue<T>(address: Value<T>, context: Context): Promise<void>;
|
||||
appendList<T>(
|
||||
address: ValueList<T>,
|
||||
element: NoInfer<T>,
|
||||
context: Context,
|
||||
): Promise<void>;
|
||||
deleteList<T>(address: ValueList<T>, context: Context): Promise<void>;
|
||||
}
|
||||
```
|
||||
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ Replace the retained register/custom-state storage surface with bound `Value<T>`
|
|||
## 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<OperationMeta>`; 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<T>(address: Value<T>): Promise<StoredValue<T> | undefined>;
|
||||
|
|
@ -45,7 +45,7 @@ scanValues<T>(prefix: Value<T>): Promise<StoredValue<T>[]>;
|
|||
readList<T>(address: ValueList<T>, options?: ListReadOptions): Promise<ListElement<T>[]>;
|
||||
```
|
||||
|
||||
`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<T>(address: Value<T>, next: NoInfer<T>): Promise<void>;
|
||||
|
|
@ -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/**'
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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<TResult>(
|
|||
}
|
||||
```
|
||||
|
||||
`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<TContext extends object | undefined>(
|
|||
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<UsageRow, "seq"> = { 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<TResult>` here from `lane.ts` |
|
||||
| `src/harness/runtime/lane.ts` | make the class `Lane<TContext extends object | undefined>`; 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<TContext> extends Lane<TContext>`; `buildLane` returns `Lane<TContext>`; pass `HookRegistry` and full `Config` into `Lane` |
|
||||
| `src/harness/runtime/harness.ts` | `Harness<TContext>` manages ordinary `Lane<TContext>` objects by composition; `buildLane` returns `Lane<TContext>`; pass `HookRegistry` and full `Config` into each Lane |
|
||||
| `src/harness/runtime/restore.ts` | name `Lane<TContext>` 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<CommitResult, "stats">` |
|
||||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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<string | null>;
|
||||
findEntriesOnBranch(...): Promise<Entry[]>;
|
||||
findEntryOnBranch(...): Promise<Entry | undefined>;
|
||||
appendMessage(...): Promise<string>;
|
||||
appendCustomEntry(...): Promise<string>;
|
||||
|
||||
// Session-global data that is not lane- or branch-owned.
|
||||
getEntry(...): Promise<Entry | undefined>;
|
||||
getStats(...): Promise<SessionStats>;
|
||||
findEntries(...): Promise<Entry[]>;
|
||||
findEntry(...): Promise<Entry | undefined>;
|
||||
getValue(...): Promise<StoredValue<unknown> | undefined>;
|
||||
setValue(...): Promise<void>;
|
||||
readList(...): Promise<ListElement<unknown>[]>;
|
||||
appendList(...): Promise<void>;
|
||||
getName(...): Promise<string | undefined>;
|
||||
setName(...): Promise<void>;
|
||||
getLabel(...): Promise<string | undefined>;
|
||||
setLabel(...): Promise<void>;
|
||||
}
|
||||
```
|
||||
|
||||
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<Map<string, Entry>>;
|
||||
getValue<T>(
|
||||
address: Value<T>,
|
||||
context: Context,
|
||||
): Promise<StoredValue<T> | undefined>;
|
||||
scanValues<T>(prefix: Value<T>, context: Context): Promise<StoredValue<T>[]>;
|
||||
readList<T>(
|
||||
address: ValueList<T>,
|
||||
options: ListReadOptions | undefined,
|
||||
context: Context,
|
||||
): Promise<ListElement<T>[]>;
|
||||
scanBranch(query: StorageBranchScan, context: Context): Promise<Entry[]>;
|
||||
}
|
||||
|
||||
export interface SessionMutation extends SessionReader {
|
||||
/** Exactly zero or one attempt. A second call rejects, including after failure. */
|
||||
commit(writes: Write[], context: Context): Promise<CommitResult>;
|
||||
/** Wait for any admitted commit, invalidate the capability, and release the Session line. */
|
||||
end(context: Context): Promise<void>;
|
||||
}
|
||||
|
||||
export type SessionMutator = Omit<SessionMutation, "end">;
|
||||
|
||||
export type SessionMutationCallback<TResult> = (
|
||||
mutator: SessionMutator,
|
||||
context: Context,
|
||||
) => TResult | Promise<TResult>;
|
||||
```
|
||||
|
||||
`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<string | null>;
|
||||
findEntries(
|
||||
query: BranchScan | undefined,
|
||||
context: Context,
|
||||
): Promise<Entry[]>;
|
||||
findEntry(
|
||||
query: BranchScan | undefined,
|
||||
context: Context,
|
||||
): Promise<Entry | undefined>;
|
||||
appendMessage(message: AgentMessage, context: Context): Promise<string>;
|
||||
appendCustomEntry(
|
||||
customType: string,
|
||||
data: JsonValue | undefined,
|
||||
context: Context,
|
||||
): Promise<string>;
|
||||
}
|
||||
```
|
||||
|
||||
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<Entry | undefined>;
|
||||
getStats(context: Context): Promise<SessionStats>;
|
||||
findEntries(
|
||||
query: EntryQuery | undefined,
|
||||
context: Context,
|
||||
): Promise<Entry[]>;
|
||||
findEntry(
|
||||
query: EntryQuery | undefined,
|
||||
context: Context,
|
||||
): Promise<Entry | undefined>;
|
||||
getName(context: Context): Promise<string | undefined>;
|
||||
getLabel(targetId: string, context: Context): Promise<string | undefined>;
|
||||
|
||||
// Existing Branch acquisition performs durable I/O and therefore receives Context.
|
||||
branch(name: string, context: Context): Promise<Branch | undefined>;
|
||||
createBranch(
|
||||
name: string,
|
||||
at: string | null,
|
||||
context: Context,
|
||||
): Promise<Branch>;
|
||||
|
||||
// Transportable explicit scope; RemoteSession maps begin/read/commit/end over RPC.
|
||||
beginMutation(context: Context): Promise<SessionMutation>;
|
||||
|
||||
// Trusted sharp edge. The callback holds the sole Session mutation line.
|
||||
mutate<TResult>(
|
||||
mutation: SessionMutationCallback<TResult>,
|
||||
context: Context,
|
||||
): Promise<TResult>;
|
||||
|
||||
// One-write conveniences implemented through mutate().
|
||||
setValue<T>(
|
||||
address: Value<T>,
|
||||
next: NoInfer<T>,
|
||||
context: Context,
|
||||
): Promise<void>;
|
||||
deleteValue<T>(address: Value<T>, context: Context): Promise<void>;
|
||||
appendList<T>(
|
||||
address: ValueList<T>,
|
||||
element: NoInfer<T>,
|
||||
context: Context,
|
||||
): Promise<void>;
|
||||
deleteList<T>(address: ValueList<T>, context: Context): Promise<void>;
|
||||
setName(name: string | undefined, context: Context): Promise<void>;
|
||||
setLabel(
|
||||
targetId: string,
|
||||
label: string | undefined,
|
||||
context: Context,
|
||||
): Promise<void>;
|
||||
|
||||
close(context: Context): Promise<void>;
|
||||
}
|
||||
```
|
||||
|
||||
`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<string | null>;
|
||||
findEntries(
|
||||
query: BranchScan | undefined,
|
||||
context: Context,
|
||||
): Promise<Entry[]>;
|
||||
findEntry(
|
||||
query: BranchScan | undefined,
|
||||
context: Context,
|
||||
): Promise<Entry | undefined>;
|
||||
appendMessage(message: AgentMessage, context: Context): Promise<string>;
|
||||
appendCustomEntry(
|
||||
customType: string,
|
||||
data: JsonValue | undefined,
|
||||
context: Context,
|
||||
): Promise<string>;
|
||||
|
||||
getLastResult(context: Context): Promise<LaneLastResult | undefined>;
|
||||
accept(
|
||||
request: OperationRequest,
|
||||
context: Context,
|
||||
): Promise<OperationAdmissionResult>;
|
||||
drive(options: DriveOptions, context: Context): Promise<DriveResult>;
|
||||
requestAbort(
|
||||
operationId: string,
|
||||
context: Context,
|
||||
): Promise<AbortRequestResult>;
|
||||
inspectExecution(context: Context): Promise<LaneExecutionInfo>;
|
||||
// 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<AgentLane>;
|
||||
lane(
|
||||
name: string,
|
||||
options: AcquireLaneOptions,
|
||||
context: Context,
|
||||
): Promise<AgentLane>;
|
||||
lanes(context: Context): Promise<LaneInfo[]>;
|
||||
|
||||
// Session-global metadata wrappers preserve existing value_update events.
|
||||
getName(context: Context): Promise<string | undefined>;
|
||||
setName(name: string | undefined, context: Context): Promise<void>;
|
||||
getLabel(targetId: string, context: Context): Promise<string | undefined>;
|
||||
setLabel(
|
||||
targetId: string,
|
||||
label: string | undefined,
|
||||
context: Context,
|
||||
): Promise<void>;
|
||||
|
||||
// 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<void> = Promise.resolve();
|
||||
private sealedError: Error | undefined;
|
||||
|
||||
run<TResult>(operation: () => TResult | Promise<TResult>): Promise<TResult>;
|
||||
seal(error: Error): Promise<void>;
|
||||
}
|
||||
```
|
||||
|
||||
`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<TContext> {
|
||||
readonly session: Session;
|
||||
readonly models: Models;
|
||||
readonly hooks: HookRegistry;
|
||||
readonly events: HarnessEventBus;
|
||||
readonly lanesByName = new Map<string, Lane<TContext>>();
|
||||
// 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.
|
||||
|
|
@ -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<AgentLane, LaneExists | InvalidLane | UnknownTarget | Closed>;
|
||||
|
||||
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<T> {
|
|||
|
||||
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<TContext extends object | undefined = objec
|
|||
|
||||
export interface AgentLane {
|
||||
readonly name: string;
|
||||
getLeafId(context: Context): Promise<string | null>;
|
||||
getTipId(context: Context): Promise<string | null>;
|
||||
findEntries(query: BranchScan | undefined, context: Context): Promise<Entry[]>;
|
||||
findEntry(query: BranchScan | undefined, context: Context): Promise<Entry | undefined>;
|
||||
appendMessage(message: AgentMessage, context: Context): Promise<string>;
|
||||
appendCustomEntry(customType: string, data: JsonValue | undefined, context: Context): Promise<string>;
|
||||
getLastResult(context: Context): Promise<LaneLastResult | undefined>;
|
||||
accept(request: OperationRequest, context: Context): Promise<OperationAdmissionResult>;
|
||||
drive(options: DriveOptions, context: Context): Promise<DriveResult>;
|
||||
|
|
@ -587,14 +587,21 @@ export interface AgentLane {
|
|||
setThinkingLevel(level: ThinkingLevel, context: Context): Promise<void>;
|
||||
getActiveTools(context: Context): Promise<string[]>;
|
||||
setActiveTools(names: string[], context: Context): Promise<void>;
|
||||
readonly sessionTree: SessionTree;
|
||||
watch(context: Context): Promise<WatchHandle<LaneSnapshot>>;
|
||||
}
|
||||
|
||||
export interface AgentHarness<TContext extends object | undefined = object | undefined> extends AgentLane {
|
||||
lane(name: string, context: Context): Promise<AgentLane | undefined>;
|
||||
createLane(name: string, at: string | null, context: Context): Promise<CreateLaneResult>;
|
||||
export interface AcquireLaneOptions {
|
||||
createAt?: string | null;
|
||||
}
|
||||
|
||||
export interface AgentHarness<TContext extends object | undefined = object | undefined> {
|
||||
lane(name: string, context: Context): Promise<AgentLane>;
|
||||
lane(name: string, options: AcquireLaneOptions, context: Context): Promise<AgentLane>;
|
||||
lanes(context: Context): Promise<LaneInfo[]>;
|
||||
getName(context: Context): Promise<string | undefined>;
|
||||
setName(name: string | undefined, context: Context): Promise<void>;
|
||||
getLabel(targetId: string, context: Context): Promise<string | undefined>;
|
||||
setLabel(targetId: string, label: string | undefined, context: Context): Promise<void>;
|
||||
getTools(context: Context): Promise<AgentHarnessTool<TContext>[]>;
|
||||
setTools(tools: AgentHarnessTool<TContext>[], context: Context): Promise<void>;
|
||||
getResources(context: Context): Promise<Resources>;
|
||||
|
|
|
|||
|
|
@ -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<SessionTree, "findEntriesOnBranch" | "getEntry">,
|
||||
oldLeafId: string | null,
|
||||
branch: Pick<Branch, "findEntries">,
|
||||
session: Pick<Session, "getEntry">,
|
||||
oldTipId: string | null,
|
||||
targetId: string,
|
||||
context: Context,
|
||||
): Promise<CollectEntriesResult> {
|
||||
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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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<TContext extends object | undefined>(
|
|||
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<TContext extends object | undefined>(
|
|||
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<MessageEntry> = { id, parentId, type: "message", message };
|
||||
parentId = id;
|
||||
|
|
@ -178,12 +178,12 @@ export async function startRun<TContext extends object | undefined>(
|
|||
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<TContext extends object | undefined>(
|
|||
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<TContext extends object | undefined>(
|
|||
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<TContext extends object | undefined>(
|
|||
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<MessageEntry> = { id, parentId, type: "message", message };
|
||||
parentId = id;
|
||||
|
|
@ -354,12 +354,12 @@ async function consumeQueuedMessages<TContext extends object | undefined>(
|
|||
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<TContext extends object | undefined>(
|
|||
};
|
||||
const entry: NewEntry<MessageEntry> = {
|
||||
id: followUpId,
|
||||
parentId: state.leafId,
|
||||
parentId: state.tipId,
|
||||
type: "message",
|
||||
message: followUpMessage,
|
||||
};
|
||||
|
|
@ -445,12 +445,12 @@ async function finishRun<TContext extends object | undefined>(
|
|||
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<TContext extends object | undefined>(
|
|||
};
|
||||
}
|
||||
|
||||
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<TContext extends object | undefined>(
|
|||
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<TContext extends object | undefined>(
|
|||
type: "run_end",
|
||||
lane: lane.name,
|
||||
runId: drive.operationId,
|
||||
leafId: state.leafId,
|
||||
tipId: state.tipId,
|
||||
outcome: "completed",
|
||||
...final,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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<TContext extends object | undefined>(
|
|||
) {
|
||||
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<TContext extends object | undefined>(
|
|||
};
|
||||
const responseEntry: NewEntry<MessageEntry> = {
|
||||
id: identity.responseEntryId,
|
||||
parentId: state.leafId,
|
||||
parentId: state.tipId,
|
||||
type: "message",
|
||||
message: committed,
|
||||
};
|
||||
|
|
@ -403,13 +403,13 @@ export function settleAssistant<TContext extends object | undefined>(
|
|||
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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<TContext extends object | undefined> extends Lane<TContext> implements AgentHarness<TContext> {
|
||||
/** Runtime implementation of AgentHarness. The harness manages lanes but is not itself a lane. */
|
||||
export class Harness<TContext extends object | undefined> implements AgentHarness<TContext> {
|
||||
readonly session: Session;
|
||||
readonly models: Models;
|
||||
readonly hooks: HookRegistry;
|
||||
readonly events: HarnessEventBus;
|
||||
readonly seed: LaneConfiguration;
|
||||
readonly lanesByName = new Map<string, Lane<TContext>>();
|
||||
private readonly seed: LaneConfiguration;
|
||||
private readonly configStore: { value: Config<TContext> };
|
||||
closePromise: Promise<void> | undefined;
|
||||
faultError: HarnessFault | undefined;
|
||||
private closePromise: Promise<void> | undefined;
|
||||
private closedError: Error | undefined;
|
||||
private faultError: HarnessFault | undefined;
|
||||
|
||||
constructor(options: AgentHarnessOptions<TContext>, seed: LaneConfiguration, restored: Map<string, LaneState>) {
|
||||
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<TContext extends object | undefined> extends Lane<TContext>
|
|||
context,
|
||||
),
|
||||
);
|
||||
const configStore: { value: Config<TContext> } = {
|
||||
this.configStore = {
|
||||
value: {
|
||||
tools: options.tools ?? [],
|
||||
resources: options.resources ?? {},
|
||||
|
|
@ -74,29 +83,96 @@ export class Harness<TContext extends object | undefined> extends Lane<TContext>
|
|||
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<AgentLane | undefined> {
|
||||
lane(name: string, context: Context): Promise<AgentLane>;
|
||||
lane(name: string, options: AcquireLaneOptions, context: Context): Promise<AgentLane>;
|
||||
async lane(
|
||||
name: string,
|
||||
optionsOrContext: AcquireLaneOptions | Context,
|
||||
maybeContext?: Context,
|
||||
): Promise<AgentLane> {
|
||||
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<TContext> | undefined;
|
||||
let delivery: Promise<void> | 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<LaneInfo[]> {
|
||||
|
|
@ -106,50 +182,58 @@ export class Harness<TContext extends object | undefined> extends Lane<TContext>
|
|||
);
|
||||
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<CreateLaneResult> {
|
||||
// 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<string | undefined> {
|
||||
this.assertOpen();
|
||||
return this.session.getName(context);
|
||||
}
|
||||
|
||||
async setName(name: string | undefined, context: Context): Promise<void> {
|
||||
this.assertOpen();
|
||||
let delivery: Promise<void> | 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<string | undefined> {
|
||||
this.assertOpen();
|
||||
return this.session.getLabel(targetId, context);
|
||||
}
|
||||
|
||||
async setLabel(targetId: string, label: string | undefined, context: Context): Promise<void> {
|
||||
this.assertOpen();
|
||||
let delivery: Promise<void> | 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<AgentHarnessTool<TContext>[]> {
|
||||
|
|
@ -215,6 +299,30 @@ export class Harness<TContext extends object | undefined> extends Lane<TContext>
|
|||
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<void> {
|
||||
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<TContext> {
|
||||
return new Lane<TContext>(
|
||||
name,
|
||||
|
|
@ -248,46 +356,9 @@ export class Harness<TContext extends object | undefined> extends Lane<TContext>
|
|||
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<void> {
|
||||
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<TContext extends object | undefined = o
|
|||
const seed: LaneConfiguration = {
|
||||
model: { provider: options.model.provider, modelId: options.model.id },
|
||||
thinkingLevel: options.thinkingLevel ?? "off",
|
||||
activeToolNames: options.activeToolNames ?? tools.map((tool) => 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<TContext extends object | undefined = o
|
|||
throw new HarnessFault("AgentHarness storage or invariant fault", error);
|
||||
}
|
||||
}
|
||||
|
||||
async function seedMain(session: Session, seed: LaneConfiguration, context: Context): Promise<void> {
|
||||
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,
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<TContext extends object | undefined> 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<TContext>;
|
||||
|
|
@ -141,18 +137,6 @@ export class Lane<TContext extends object | undefined> 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<TContext extends object | undefined> implements AgentLane {
|
|||
this.config = readConfig;
|
||||
}
|
||||
|
||||
async getLeafId(_context: Context): Promise<string | null> {
|
||||
async getTipId(_context: Context): Promise<string | null> {
|
||||
this.assertOpen();
|
||||
return this.state.leafId;
|
||||
return this.state.tipId;
|
||||
}
|
||||
|
||||
async getLastResult(_context: Context): Promise<LaneLastResult | undefined> {
|
||||
|
|
@ -197,13 +181,13 @@ export class Lane<TContext extends object | undefined> 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<TContext extends object | undefined> implements AgentLane {
|
|||
this.assertOpen();
|
||||
let outcome: LaneCommandOutcome<TResult>;
|
||||
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<TContext extends object | undefined> 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<TResult>(
|
||||
|
|
@ -267,47 +247,43 @@ export class Lane<TContext extends object | undefined> implements AgentLane {
|
|||
this.assertOpen();
|
||||
let outcome: LaneCommandOutcome<TResult | LostOwnership>;
|
||||
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<TContext extends object | undefined> 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<TContext extends object | undefined> 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<TContext extends object | undefined> implements AgentLane {
|
|||
};
|
||||
const next: LaneState = {
|
||||
...state,
|
||||
leafId: parentId,
|
||||
tipId: parentId,
|
||||
pendingNextRun: [],
|
||||
operation: { meta, state: operationState },
|
||||
};
|
||||
|
|
@ -476,7 +452,7 @@ export class Lane<TContext extends object | undefined> 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<TContext extends object | undefined> 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<TContext extends object | undefined> 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<TContext extends object | undefined> 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<TContext extends object | undefined> 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<TContext extends object | undefined> implements AgentLane {
|
|||
}, context);
|
||||
}
|
||||
|
||||
private async setName(name: string | undefined, context: Context): Promise<void> {
|
||||
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<void> {
|
||||
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<Entry[]> {
|
||||
async findEntries(query: BranchScan | undefined, context: Context): Promise<Entry[]> {
|
||||
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<Entry | undefined> {
|
||||
return (await this.findEntriesOnBranch({ ...query, limit: 1 }, context))[0];
|
||||
async findEntry(query: BranchScan | undefined, context: Context): Promise<Entry | undefined> {
|
||||
query ??= {};
|
||||
return (
|
||||
await this.findEntries({ ...query, limit: query.limit === undefined ? 1 : Math.min(query.limit, 1) }, context)
|
||||
)[0];
|
||||
}
|
||||
|
||||
appendMessage(message: AgentMessage, context: Context): Promise<string> {
|
||||
return this.append({ type: "message", payload: message }, context);
|
||||
}
|
||||
|
||||
appendCustomEntry(customType: string, data: JsonValue | undefined, context: Context): Promise<string> {
|
||||
return this.append({ type: "custom", customType, ...(data === undefined ? {} : { payload: data }) }, context);
|
||||
}
|
||||
|
||||
private append(pending: PendingEntry, context: Context): Promise<string> {
|
||||
|
|
@ -969,25 +932,25 @@ export class Lane<TContext extends object | undefined> 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<TContext extends object | undefined> implements AgentLane {
|
|||
}
|
||||
: {
|
||||
id,
|
||||
parentId: state.leafId,
|
||||
parentId: state.tipId,
|
||||
type: "custom",
|
||||
customType: pending.customType,
|
||||
...(pending.payload === undefined ? {} : { data: pending.payload }),
|
||||
|
|
|
|||
|
|
@ -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<Map<string, LaneState>> {
|
||||
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<Map<string, LaneState>> {
|
||||
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<string, LaneState>();
|
||||
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<LaneState> {
|
||||
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<LaneState> {
|
||||
const [leaf, configuration, laneState, lastResult] = await Promise.all([
|
||||
reader.getValue(laneLeaf(lane), context),
|
||||
export async function restoreLaneState(reader: SessionReader, lane: string, context: Context): Promise<LaneState> {
|
||||
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 }),
|
||||
|
|
|
|||
|
|
@ -39,7 +39,7 @@ export interface Config<TContext extends object | undefined> {
|
|||
|
||||
/** 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;
|
||||
|
|
|
|||
|
|
@ -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<T>(values: readonly StoredValue<unknown>[], 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<string, Entry>();
|
||||
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<string, Entry>,
|
||||
sourceLeaves: StoredValue<string | null>[],
|
||||
sourceTips: StoredValue<string | null>[],
|
||||
options: ForkOptions,
|
||||
): { entryIds: Set<string>; laneToLeafId: Map<string, string | null> } {
|
||||
): {
|
||||
entryIds: Set<string>;
|
||||
sourceToDestinationTip: Map<string, { name: string; tipId: string | null }>;
|
||||
} {
|
||||
const entryIds = new Set<string>();
|
||||
const laneToLeafId = new Map<string, string | null>();
|
||||
const sourceToDestinationTip = new Map<string, { name: string; tipId: string | null }>();
|
||||
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<string, Entry>,
|
||||
sourceLeaves: StoredValue<string | null>[],
|
||||
sourceTips: StoredValue<string | null>[],
|
||||
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`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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<ThinkingLevel>(["off", "minimal", "low", "medium", "high", "xhigh", "max"]);
|
||||
|
||||
function selectedConfiguration(
|
||||
entriesById: ReadonlyMap<string, LegacyV3Entry>,
|
||||
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<string>();
|
||||
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<string, LegacyV3Entry>,
|
||||
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,
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -1,38 +0,0 @@
|
|||
/** Serializes complete read-modify-write jobs independently per lane. */
|
||||
export class LaneMutationLine {
|
||||
private readonly tails = new Map<string, Promise<void>>();
|
||||
private sealedError: Error | undefined;
|
||||
|
||||
run<T>(lane: string, operation: () => T | Promise<T>): Promise<T> {
|
||||
if (this.sealedError !== undefined) return Promise.reject(this.sealedError);
|
||||
const previous = this.tails.get(lane);
|
||||
let result: Promise<T>;
|
||||
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<void> {
|
||||
this.sealedError ??= error;
|
||||
return Promise.all(this.tails.values()).then(() => undefined);
|
||||
}
|
||||
}
|
||||
|
|
@ -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<SessionMutation> {
|
||||
const source = await this.admit(() => this.session.beginMutation(lane, context));
|
||||
async beginMutation(context: Context): Promise<SessionMutation> {
|
||||
let resolveFinished!: () => void;
|
||||
const finished = new Promise<void>((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<T>(
|
||||
lane: string,
|
||||
mutation: (mutator: SessionMutator, context: Context) => T | Promise<T>,
|
||||
context: Context,
|
||||
): Promise<T> {
|
||||
return this.admit(() => this.session.mutate(lane, mutation, context));
|
||||
mutate<T>(mutation: SessionMutationCallback<T>, context: Context): Promise<T> {
|
||||
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<Map<string, Entry>> {
|
||||
return this.admit(() => this.session.getEntries(ids, context));
|
||||
}
|
||||
|
||||
getEntry(id: string, context: Context): Promise<Entry | undefined> {
|
||||
return this.admit(() => this.session.getEntry(id, context));
|
||||
}
|
||||
|
||||
getValue<T>(address: Value<T>, context: Context): Promise<StoredValue<T> | 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<void>) | undefined,
|
||||
context: Context,
|
||||
): Promise<SessionTree> {
|
||||
return this.admit(async () => {
|
||||
await this.session.createLane(name, at, configuration, onCommitted, context);
|
||||
return this.view(name);
|
||||
});
|
||||
}
|
||||
|
||||
getLeafId(context: Context): Promise<string | null> {
|
||||
return this.admit(() => this.session.getLeafId(context));
|
||||
}
|
||||
|
||||
getEntry(id: string, context: Context): Promise<Entry | undefined> {
|
||||
return this.admit(() => this.session.getEntry(id, context));
|
||||
}
|
||||
|
||||
getStats(context: Context): Promise<SessionStats> {
|
||||
return this.admit(() => this.session.getStats(context));
|
||||
}
|
||||
|
||||
getName(context: Context): Promise<string | undefined> {
|
||||
return this.admit(() => this.session.getName(context));
|
||||
}
|
||||
|
||||
getLabel(targetId: string, context: Context): Promise<string | undefined> {
|
||||
return this.admit(() => this.session.getLabel(targetId, context));
|
||||
}
|
||||
|
||||
findEntries(query: EntryQuery | undefined, context: Context): Promise<Entry[]> {
|
||||
return this.admit(() => this.session.findEntries(query, context));
|
||||
}
|
||||
|
||||
findEntry(query: EntryQuery | undefined, context: Context): Promise<Entry | undefined> {
|
||||
return this.admit(() => this.session.findEntry(query, context));
|
||||
}
|
||||
|
||||
async branch(name: string, context: Context): Promise<Branch | undefined> {
|
||||
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<Branch> {
|
||||
return this.wrapBranch(await this.admit(() => this.session.createBranch(name, at, context)));
|
||||
}
|
||||
|
||||
setValue<T>(address: Value<T>, next: NoInfer<T>, context: Context): Promise<void> {
|
||||
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<string | undefined> {
|
||||
return this.admit(() => this.session.getName(context));
|
||||
}
|
||||
|
||||
setName(name: string | undefined, context: Context): Promise<void> {
|
||||
return this.admit(() => this.session.setName(name, context));
|
||||
}
|
||||
|
||||
getLabel(targetId: string, context: Context): Promise<string | undefined> {
|
||||
return this.admit(() => this.session.getLabel(targetId, context));
|
||||
}
|
||||
|
||||
setLabel(targetId: string, label: string | undefined, context: Context): Promise<void> {
|
||||
return this.admit(() => this.session.setLabel(targetId, label, context));
|
||||
}
|
||||
|
||||
findEntries(query: EntryQuery | undefined, context: Context): Promise<Entry[]> {
|
||||
return this.admit(() => this.session.findEntries(query, context));
|
||||
}
|
||||
|
||||
findEntry(query: EntryQuery | undefined, context: Context): Promise<Entry | undefined> {
|
||||
return this.admit(() => this.session.findEntry(query, context));
|
||||
}
|
||||
|
||||
findEntriesOnBranch(query: BranchScan | undefined, context: Context): Promise<Entry[]> {
|
||||
return this.admit(() => this.session.findEntriesOnBranch(query, context));
|
||||
}
|
||||
|
||||
findEntryOnBranch(query: BranchScan | undefined, context: Context): Promise<Entry | undefined> {
|
||||
return this.admit(() => this.session.findEntryOnBranch(query, context));
|
||||
}
|
||||
|
||||
appendMessage(message: AgentMessage, context: Context): Promise<string> {
|
||||
return this.admit(() => this.session.appendMessage(message, context));
|
||||
}
|
||||
|
||||
appendCustomEntry(customType: string, data: JsonValue | undefined, context: Context): Promise<string> {
|
||||
return this.admit(() => this.session.appendCustomEntry(customType, data, context));
|
||||
}
|
||||
|
||||
close(_context: Context): Promise<void> {
|
||||
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<T>(operation: () => Promise<T>): Promise<T> {
|
||||
if (this.state !== "open") return Promise.reject(this.closedError);
|
||||
let result: Promise<T>;
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
23
packages/agent/src/harness/session/mutation-line.ts
Normal file
23
packages/agent/src/harness/session/mutation-line.ts
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
/** Serializes complete read-modify-write jobs for one Session. */
|
||||
export class MutationLine {
|
||||
private tail: Promise<void> = Promise.resolve();
|
||||
private sealedError: Error | undefined;
|
||||
|
||||
run<T>(operation: () => T | Promise<T>): Promise<T> {
|
||||
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<void> {
|
||||
this.sealedError ??= error;
|
||||
return this.tail;
|
||||
}
|
||||
}
|
||||
|
|
@ -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<CommitResult> | undefined;
|
||||
private endPromise: Promise<void> | 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<void> {
|
||||
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<string | null> {
|
||||
return this.session.getBranchTip(this.name, context);
|
||||
}
|
||||
|
||||
async findEntries(query: BranchScan | undefined, context: Context): Promise<Entry[]> {
|
||||
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<Entry | undefined> {
|
||||
query ??= {};
|
||||
return (
|
||||
await this.findEntries({ ...query, limit: query.limit === undefined ? 1 : Math.min(query.limit, 1) }, context)
|
||||
)[0];
|
||||
}
|
||||
|
||||
appendMessage(message: AgentMessage, context: Context): Promise<string> {
|
||||
return this.session.appendToBranch(this.name, { type: "message", message }, context);
|
||||
}
|
||||
|
||||
appendCustomEntry(customType: string, data: JsonValue | undefined, context: Context): Promise<string> {
|
||||
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<TMetadata extends SessionMetadata = SessionMetadata> implements Session<TMetadata> {
|
||||
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<string, StorageBackedBranch>();
|
||||
private readonly closedError = new Error("Session is closed");
|
||||
private state: "open" | "closing" | "closed" = "open";
|
||||
private closePromise: Promise<void> | undefined;
|
||||
|
|
@ -208,11 +231,11 @@ export class StorageBackedSession<TMetadata extends SessionMetadata = SessionMet
|
|||
this.metadata = metadata;
|
||||
this.idGenerator = options.idGenerator ?? { next: uuidv7 };
|
||||
this.storage = storage;
|
||||
this.laneMutationLine = options.laneMutationLine ?? new LaneMutationLine();
|
||||
this.mutationLine = options.mutationLine ?? new MutationLine();
|
||||
this.onClose = options.onClose;
|
||||
}
|
||||
|
||||
async beginMutation(lane: string, _context: Context): Promise<SessionMutation> {
|
||||
async beginMutation(_context: Context): Promise<SessionMutation> {
|
||||
this.assertOpen();
|
||||
let grant!: (mutation: SessionMutation) => void;
|
||||
let rejectGrant!: (error: unknown) => void;
|
||||
|
|
@ -224,20 +247,16 @@ export class StorageBackedSession<TMetadata extends SessionMetadata = SessionMet
|
|||
const finished = new Promise<void>((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<T>(
|
||||
lane: string,
|
||||
mutation: (mutator: SessionMutator, context: Context) => T | Promise<T>,
|
||||
context: Context,
|
||||
): Promise<T> {
|
||||
const mutator = await this.beginMutation(lane, context);
|
||||
async mutate<T>(mutation: SessionMutationCallback<T>, context: Context): Promise<T> {
|
||||
const mutator = await this.beginMutation(context);
|
||||
try {
|
||||
return await mutation(mutator, context);
|
||||
} finally {
|
||||
|
|
@ -250,6 +269,10 @@ export class StorageBackedSession<TMetadata extends SessionMetadata = SessionMet
|
|||
return this.storage.getEntries(ids, context);
|
||||
}
|
||||
|
||||
async getEntry(id: string, context: Context): Promise<Entry | undefined> {
|
||||
return (await this.getEntries([id], context)).get(id);
|
||||
}
|
||||
|
||||
async getValue<T>(address: Value<T>, context: Context): Promise<StoredValue<T> | undefined> {
|
||||
this.assertOpen();
|
||||
return this.storage.getValue(address, context);
|
||||
|
|
@ -274,136 +297,19 @@ export class StorageBackedSession<TMetadata extends SessionMetadata = SessionMet
|
|||
return this.storage.scanBranch(query, context);
|
||||
}
|
||||
|
||||
view(lane: string): SessionTree {
|
||||
return {
|
||||
getLeafId: (context) => 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<void>) | undefined,
|
||||
context: Context,
|
||||
): Promise<SessionTree> {
|
||||
this.assertOpen();
|
||||
let completion: Promise<void> | 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<string | null> {
|
||||
return this.getLeafIdForLane("main", context);
|
||||
}
|
||||
|
||||
async getEntry(id: string, context: Context): Promise<Entry | undefined> {
|
||||
return (await this.getEntries([id], context)).get(id);
|
||||
}
|
||||
|
||||
async getStats(context: Context): Promise<SessionStats> {
|
||||
this.assertOpen();
|
||||
return this.storage.getStats(context);
|
||||
}
|
||||
|
||||
setValue<T>(address: Value<T>, next: NoInfer<T>, context: Context): Promise<void> {
|
||||
return this.setValueForLane("main", address, next, context);
|
||||
}
|
||||
|
||||
deleteValue<T>(address: Value<T>, context: Context): Promise<void> {
|
||||
return this.deleteValueForLane("main", address, context);
|
||||
}
|
||||
|
||||
appendList<T>(address: ValueList<T>, element: NoInfer<T>, context: Context): Promise<void> {
|
||||
return this.appendListForLane("main", address, element, context);
|
||||
}
|
||||
|
||||
deleteList<T>(address: ValueList<T>, context: Context): Promise<void> {
|
||||
return this.deleteListForLane("main", address, context);
|
||||
}
|
||||
|
||||
async getName(context: Context): Promise<string | undefined> {
|
||||
return (await this.getValue(sessionName, context))?.value;
|
||||
}
|
||||
|
||||
setName(name: string | undefined, context: Context): Promise<void> {
|
||||
return this.setNameForLane("main", name, context);
|
||||
}
|
||||
|
||||
async getLabel(targetId: string, context: Context): Promise<string | undefined> {
|
||||
return (await this.getValue(entryLabel(targetId), context))?.value;
|
||||
}
|
||||
|
||||
setLabel(targetId: string, label: string | undefined, context: Context): Promise<void> {
|
||||
return this.setLabelForLane("main", targetId, label, context);
|
||||
}
|
||||
|
||||
async findEntries(query: EntryQuery | undefined, context: Context): Promise<Entry[]> {
|
||||
query ??= {};
|
||||
this.assertOpen();
|
||||
|
|
@ -430,44 +336,69 @@ export class StorageBackedSession<TMetadata extends SessionMetadata = SessionMet
|
|||
|
||||
async findEntry(query: EntryQuery | undefined, context: Context): Promise<Entry | undefined> {
|
||||
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<Entry[]> {
|
||||
return this.findEntriesOnBranchForLane("main", query, context);
|
||||
async branch(name: string, context: Context): Promise<Branch | undefined> {
|
||||
this.assertValidBranchName(name);
|
||||
if ((await this.getValue(branchTip(name), context)) === undefined) return undefined;
|
||||
return this.getOrCreateBranchObject(name);
|
||||
}
|
||||
|
||||
findEntryOnBranch(query: BranchScan | undefined, context: Context): Promise<Entry | undefined> {
|
||||
return this.findEntryOnBranchForLane("main", query, context);
|
||||
async createBranch(name: string, at: string | null, context: Context): Promise<Branch> {
|
||||
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<string> {
|
||||
return this.captureAppend("main", { type: "message", payload: message }, context);
|
||||
setValue<T>(address: Value<T>, next: NoInfer<T>, context: Context): Promise<void> {
|
||||
return this.mutate(async (mutator) => {
|
||||
await mutator.commit([setValueWrite(address, next)], context);
|
||||
}, context);
|
||||
}
|
||||
|
||||
appendCustomEntry(customType: string, data: JsonValue | undefined, context: Context): Promise<string> {
|
||||
return this.captureAppend(
|
||||
"main",
|
||||
{
|
||||
type: "custom",
|
||||
customType,
|
||||
...(data === undefined ? {} : { payload: data }),
|
||||
},
|
||||
context,
|
||||
);
|
||||
deleteValue<T>(address: Value<T>, context: Context): Promise<void> {
|
||||
return this.mutate(async (mutator) => {
|
||||
await mutator.commit([deleteValueWrite(address)], context);
|
||||
}, context);
|
||||
}
|
||||
|
||||
appendList<T>(address: ValueList<T>, element: NoInfer<T>, context: Context): Promise<void> {
|
||||
return this.mutate(async (mutator) => {
|
||||
await mutator.commit([appendListWrite(address, element)], context);
|
||||
}, context);
|
||||
}
|
||||
|
||||
deleteList<T>(address: ValueList<T>, context: Context): Promise<void> {
|
||||
return this.mutate(async (mutator) => {
|
||||
await mutator.commit([deleteListWrite(address)], context);
|
||||
}, context);
|
||||
}
|
||||
|
||||
setName(name: string | undefined, context: Context): Promise<void> {
|
||||
return name === undefined ? this.deleteValue(sessionName, context) : this.setValue(sessionName, name, context);
|
||||
}
|
||||
|
||||
setLabel(targetId: string, label: string | undefined, context: Context): Promise<void> {
|
||||
const address = entryLabel(targetId);
|
||||
return label === undefined ? this.deleteValue(address, context) : this.setValue(address, label, context);
|
||||
}
|
||||
|
||||
close(context: Context): Promise<void> {
|
||||
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<TMetadata extends SessionMetadata = SessionMet
|
|||
return this.closePromise;
|
||||
}
|
||||
|
||||
private async getLeafIdForLane(lane: string, context: Context): Promise<string | null> {
|
||||
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<string | null> {
|
||||
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<Entry[]> {
|
||||
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<Entry | undefined> {
|
||||
query ??= {};
|
||||
const entries = await this.findEntriesOnBranchForLane(
|
||||
lane,
|
||||
{
|
||||
...query,
|
||||
limit: query.limit === undefined ? 1 : Math.min(query.limit, 1),
|
||||
},
|
||||
context,
|
||||
);
|
||||
return entries[0];
|
||||
}
|
||||
|
||||
private setValueForLane<T>(lane: string, address: Value<T>, next: NoInfer<T>, context: Context): Promise<void> {
|
||||
return this.mutate(
|
||||
lane,
|
||||
async (mutator) => {
|
||||
await mutator.commit([setValueWrite(address, next)], context);
|
||||
},
|
||||
context,
|
||||
);
|
||||
}
|
||||
|
||||
private deleteValueForLane<T>(lane: string, address: Value<T>, context: Context): Promise<void> {
|
||||
return this.mutate(
|
||||
lane,
|
||||
async (mutator) => {
|
||||
await mutator.commit([deleteValueWrite(address)], context);
|
||||
},
|
||||
context,
|
||||
);
|
||||
}
|
||||
|
||||
private appendListForLane<T>(
|
||||
lane: string,
|
||||
address: ValueList<T>,
|
||||
element: NoInfer<T>,
|
||||
context: Context,
|
||||
): Promise<void> {
|
||||
return this.mutate(
|
||||
lane,
|
||||
async (mutator) => {
|
||||
await mutator.commit([appendListWrite(address, element)], context);
|
||||
},
|
||||
context,
|
||||
);
|
||||
}
|
||||
|
||||
private deleteListForLane<T>(lane: string, address: ValueList<T>, context: Context): Promise<void> {
|
||||
return this.mutate(
|
||||
lane,
|
||||
async (mutator) => {
|
||||
await mutator.commit([deleteListWrite(address)], context);
|
||||
},
|
||||
context,
|
||||
);
|
||||
}
|
||||
|
||||
private setNameForLane(lane: string, name: string | undefined, context: Context): Promise<void> {
|
||||
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<void> {
|
||||
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<string> {
|
||||
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<string> {
|
||||
return this.captureAppend(
|
||||
lane,
|
||||
{
|
||||
type: "custom",
|
||||
customType,
|
||||
...(data === undefined ? {} : { payload: data }),
|
||||
},
|
||||
context,
|
||||
);
|
||||
}
|
||||
|
||||
private async captureAppend(lane: string, pending: PendingEntry, context: Context): Promise<string> {
|
||||
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<string> {
|
||||
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<void> {
|
||||
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");
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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<SessionMetadata> {
|
||||
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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
},
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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<JsonValue>("test.application.value");
|
||||
const applicationList = list<JsonValue>("test.application.list");
|
||||
const configuration = {
|
||||
|
|
@ -37,6 +48,10 @@ const configuration = {
|
|||
activeToolNames: ["read"],
|
||||
} satisfies LaneConfiguration;
|
||||
|
||||
async function getBranchTip(session: Session, name = "main"): Promise<string | null | undefined> {
|
||||
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<TRepo>(
|
|||
factory: () => Promise<TRepo>,
|
||||
onClose?: () => void | Promise<void>,
|
||||
): () => Promise<RepoCaseContext<TRepo>> {
|
||||
return async () => ({ repo: await factory(), ...(onClose === undefined ? {} : { close: onClose }) });
|
||||
return async () => ({
|
||||
repo: await factory(),
|
||||
...(onClose === undefined ? {} : { close: onClose }),
|
||||
});
|
||||
}
|
||||
|
||||
function createCase<TRepo>(
|
||||
|
|
@ -121,23 +146,40 @@ export function createSessionRepoLifecycleConformance<TMetadata extends SessionM
|
|||
createCase(
|
||||
factory,
|
||||
"lifecycle",
|
||||
"creates an unconfigured main lane and rejects duplicate ids",
|
||||
"creates a session with no implicit branch and rejects duplicate ids",
|
||||
async ({ repo }) => {
|
||||
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<TMetadata extends SessionMet
|
|||
"rejects pending assistant messages without changing the tree",
|
||||
async ({ repo }) => {
|
||||
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<TMetadata extends SessionMet
|
|||
aborted: assistantMessage("aborted"),
|
||||
deferred: assistantMessage("deferred"),
|
||||
} satisfies Record<Exclude<StopReason, "pending">, 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<TMetadata extends SessionMet
|
|||
if (entry.type !== "message") throw new Error("Expected message entry");
|
||||
deepStrictEqual(entry.message, messages[index]);
|
||||
}
|
||||
strictEqual(await session.getLeafId(BACKGROUND_CONTEXT), ids.at(-1));
|
||||
strictEqual(await getBranchTip(session), ids.at(-1));
|
||||
await session.close(BACKGROUND_CONTEXT);
|
||||
}),
|
||||
];
|
||||
|
|
@ -259,18 +303,15 @@ export function createSessionRepoForkBehaviorConformance<TMetadata extends Sessi
|
|||
): readonly ConformanceCase[] {
|
||||
const factory = prepareRepoCaseFactory(backendFactory, onClose);
|
||||
return [
|
||||
createCase(factory, "forks", "forks a fresh session before first attachment", async ({ repo }) => {
|
||||
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<TMetadata extends Sessi
|
|||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
totalTokens: 0,
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
||||
cost: {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
total: 0,
|
||||
},
|
||||
},
|
||||
});
|
||||
await Promise.all([source.close(BACKGROUND_CONTEXT), fork.close(BACKGROUND_CONTEXT)]);
|
||||
|
|
@ -292,11 +339,15 @@ export function createSessionRepoForkBehaviorConformance<TMetadata extends Sessi
|
|||
async ({ repo }) => {
|
||||
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<TMetadata extends Sessi
|
|||
type: "custom",
|
||||
customType: "sibling",
|
||||
}),
|
||||
setValue(laneLeaf("main"), CHILD_ID),
|
||||
setValue(branchTip("main"), CHILD_ID),
|
||||
setValue(laneConfig("main"), configuration),
|
||||
setValue(laneState("main"), {
|
||||
currentOperationId: OPERATION_ID,
|
||||
|
|
@ -318,8 +369,8 @@ export function createSessionRepoForkBehaviorConformance<TMetadata extends Sessi
|
|||
setValue(laneLastResult("main"), {
|
||||
operationId: "previous",
|
||||
kind: "navigation",
|
||||
leafId: CHILD_ID,
|
||||
oldLeafId: ROOT_ID,
|
||||
tipId: CHILD_ID,
|
||||
oldTipId: ROOT_ID,
|
||||
outcome: "completed",
|
||||
}),
|
||||
setValue(sessionName, "source name"),
|
||||
|
|
@ -327,11 +378,14 @@ export function createSessionRepoForkBehaviorConformance<TMetadata extends Sessi
|
|||
appendList(applicationList, { copied: false }),
|
||||
setValue(entryLabel(ROOT_ID), "root label"),
|
||||
setValue(entryLabel(SIBLING_ID), "sibling label"),
|
||||
setValue(pendingEntry(PENDING_ID), { type: "custom", customType: "pending" }),
|
||||
setValue(pendingEntry(PENDING_ID), {
|
||||
type: "custom",
|
||||
customType: "pending",
|
||||
}),
|
||||
setValue(operationMeta(OPERATION_ID), {
|
||||
operationId: OPERATION_ID,
|
||||
lane: "main",
|
||||
sourceLeafId: CHILD_ID,
|
||||
sourceTipId: CHILD_ID,
|
||||
startedAt: 1,
|
||||
intent: { kind: "compaction" },
|
||||
}),
|
||||
|
|
@ -340,7 +394,9 @@ export function createSessionRepoForkBehaviorConformance<TMetadata extends Sessi
|
|||
control: { status: "running" },
|
||||
structural: { taskId: OPERATION_ID, status: "deciding" },
|
||||
}),
|
||||
setValue(operationToolArgs(OPERATION_ID, ROOT_ID, 0), { argument: true }),
|
||||
setValue(operationToolArgs(OPERATION_ID, ROOT_ID, 0), {
|
||||
argument: true,
|
||||
}),
|
||||
setValue(operationPreparation(OPERATION_ID, OPERATION_ID), {
|
||||
kind: "compaction",
|
||||
messagesToSummarize: [],
|
||||
|
|
@ -349,7 +405,11 @@ export function createSessionRepoForkBehaviorConformance<TMetadata extends Sessi
|
|||
isSplitTurn: false,
|
||||
tokensBefore: 0,
|
||||
fileOps: { read: [], written: [], edited: [] },
|
||||
settings: { enabled: true, reserveTokens: 1, keepRecentTokens: 1 },
|
||||
settings: {
|
||||
enabled: true,
|
||||
reserveTokens: 1,
|
||||
keepRecentTokens: 1,
|
||||
},
|
||||
}),
|
||||
insertUsage(usageRow()),
|
||||
],
|
||||
|
|
@ -368,7 +428,7 @@ export function createSessionRepoForkBehaviorConformance<TMetadata extends Sessi
|
|||
(await fork.findEntries({ order: "asc" }, BACKGROUND_CONTEXT)).map(({ id }) => 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<TMetadata extends Sessi
|
|||
async ({ repo }) => {
|
||||
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<TMetadata extends Sessi
|
|||
{ id: "before", entryId: CHILD_ID, position: "before" },
|
||||
BACKGROUND_CONTEXT,
|
||||
);
|
||||
strictEqual(await before.getLeafId(BACKGROUND_CONTEXT), ROOT_ID);
|
||||
strictEqual(await getBranchTip(before), ROOT_ID);
|
||||
deepStrictEqual(
|
||||
(await before.findEntries({ order: "asc" }, BACKGROUND_CONTEXT)).map(({ id }) => id),
|
||||
[ROOT_ID],
|
||||
|
|
@ -447,12 +511,16 @@ export function createSessionRepoForkBehaviorConformance<TMetadata extends Sessi
|
|||
createCase(factory, "forks", "forks a closed source session", async ({ repo }) => {
|
||||
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<TMetadata extends Sessi
|
|||
await source.close(BACKGROUND_CONTEXT);
|
||||
|
||||
const fork = await repo.fork(source.metadata, { id: "fork" }, BACKGROUND_CONTEXT);
|
||||
strictEqual(await fork.getLeafId(BACKGROUND_CONTEXT), ROOT_ID);
|
||||
strictEqual(await getBranchTip(fork), ROOT_ID);
|
||||
strictEqual(await fork.getValue(laneConfig("main"), BACKGROUND_CONTEXT), undefined);
|
||||
strictEqual(await fork.getValue(laneState("main"), BACKGROUND_CONTEXT), undefined);
|
||||
strictEqual(await fork.getValue(applicationValue, BACKGROUND_CONTEXT), undefined);
|
||||
deepStrictEqual(await fork.readList(applicationList, undefined, BACKGROUND_CONTEXT), []);
|
||||
await fork.close(BACKGROUND_CONTEXT);
|
||||
|
|
@ -472,23 +542,34 @@ export function createSessionRepoForkBehaviorConformance<TMetadata extends Sessi
|
|||
createCase(factory, "forks", "forks the whole configured tree with fresh lane state", async ({ repo }) => {
|
||||
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<TMetadata extends Sessi
|
|||
(await fork.findEntries({ order: "asc" }, BACKGROUND_CONTEXT)).map(({ id }) => 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<TMetadata
|
|||
const source = await repo.create({ id: "source" }, BACKGROUND_CONTEXT);
|
||||
const results = await Promise.allSettled([
|
||||
repo.create({ id: "destination" }, BACKGROUND_CONTEXT),
|
||||
repo.fork(source.metadata, { id: "destination" }, BACKGROUND_CONTEXT),
|
||||
repo.fork(source.metadata, { id: "destination", scope: "tree" }, BACKGROUND_CONTEXT),
|
||||
]);
|
||||
strictEqual(results[0].status, "fulfilled");
|
||||
strictEqual(results[1].status, "rejected");
|
||||
|
|
@ -540,7 +624,7 @@ export function createSessionRepoForkDestinationReservationConformance<TMetadata
|
|||
async ({ repo }) => {
|
||||
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<TMetadata extends
|
|||
"captures one coherent boundary between source commits",
|
||||
async ({ repo }) => {
|
||||
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<TMetadata extends
|
|||
);
|
||||
const fork = repo.fork(source.metadata, { id: "fork" }, BACKGROUND_CONTEXT);
|
||||
const secondCommit = source.mutate(
|
||||
"main",
|
||||
(mutator) =>
|
||||
mutator.commit(
|
||||
[
|
||||
|
|
@ -587,7 +675,7 @@ export function createSessionRepoForkSourceSnapshotConformance<TMetadata extends
|
|||
type: "custom",
|
||||
customType: "second",
|
||||
}),
|
||||
setValue(laneLeaf("main"), CHILD_ID),
|
||||
setValue(branchTip("main"), CHILD_ID),
|
||||
setValue(sessionName, "second name"),
|
||||
setValue(entryLabel(ROOT_ID), "second label"),
|
||||
],
|
||||
|
|
@ -599,7 +687,7 @@ export function createSessionRepoForkSourceSnapshotConformance<TMetadata extends
|
|||
const [, forked] = await Promise.all([firstCommit, fork]);
|
||||
await firstMutation.end(BACKGROUND_CONTEXT);
|
||||
await secondCommit;
|
||||
strictEqual(await forked.getLeafId(BACKGROUND_CONTEXT), ROOT_ID);
|
||||
strictEqual(await getBranchTip(forked), ROOT_ID);
|
||||
deepStrictEqual(
|
||||
(await forked.findEntries({ order: "asc" }, BACKGROUND_CONTEXT)).map(({ id }) => id),
|
||||
[ROOT_ID],
|
||||
|
|
|
|||
|
|
@ -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<StorageFixture>)
|
|||
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<StorageFixture>)
|
|||
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<StorageFixture>)
|
|||
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],
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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<Entry[]>;
|
||||
}
|
||||
|
||||
/** 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<CommitResult>;
|
||||
/** Wait for any commit attempt, invalidate the capability, and release the barrier. */
|
||||
end(context: Context): Promise<void>;
|
||||
}
|
||||
|
||||
/** 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<SessionMutation, "end">;
|
||||
|
||||
export interface SessionTree {
|
||||
getLeafId(context: Context): Promise<string | null>;
|
||||
getEntry(id: string, context: Context): Promise<Entry | undefined>;
|
||||
getStats(context: Context): Promise<SessionStats>;
|
||||
getValue<T>(address: Value<T>, context: Context): Promise<StoredValue<T> | undefined>;
|
||||
scanValues<T>(prefix: Value<T>, context: Context): Promise<StoredValue<T>[]>;
|
||||
readList<T>(
|
||||
address: ValueList<T>,
|
||||
options: ListReadOptions | undefined,
|
||||
context: Context,
|
||||
): Promise<ListElement<T>[]>;
|
||||
setValue<T>(address: Value<T>, next: NoInfer<T>, context: Context): Promise<void>;
|
||||
deleteValue<T>(address: Value<T>, context: Context): Promise<void>;
|
||||
appendList<T>(address: ValueList<T>, element: NoInfer<T>, context: Context): Promise<void>;
|
||||
deleteList<T>(address: ValueList<T>, context: Context): Promise<void>;
|
||||
getName(context: Context): Promise<string | undefined>;
|
||||
setName(name: string | undefined, context: Context): Promise<void>;
|
||||
getLabel(targetId: string, context: Context): Promise<string | undefined>;
|
||||
setLabel(targetId: string, label: string | undefined, context: Context): Promise<void>;
|
||||
findEntries(query: EntryQuery | undefined, context: Context): Promise<Entry[]>;
|
||||
findEntry(query: EntryQuery | undefined, context: Context): Promise<Entry | undefined>;
|
||||
findEntriesOnBranch(query: BranchScan | undefined, context: Context): Promise<Entry[]>;
|
||||
findEntryOnBranch(query: BranchScan | undefined, context: Context): Promise<Entry | undefined>;
|
||||
export type SessionMutationCallback<T> = (mutator: SessionMutator, context: Context) => T | Promise<T>;
|
||||
|
||||
export interface Branch {
|
||||
readonly name: string;
|
||||
getTipId(context: Context): Promise<string | null>;
|
||||
findEntries(query: BranchScan | undefined, context: Context): Promise<Entry[]>;
|
||||
findEntry(query: BranchScan | undefined, context: Context): Promise<Entry | undefined>;
|
||||
appendMessage(message: AgentMessage, context: Context): Promise<string>;
|
||||
appendCustomEntry(customType: string, data: JsonValue | undefined, context: Context): Promise<string>;
|
||||
}
|
||||
|
||||
export interface Session<TMetadata extends SessionMetadata = SessionMetadata> extends SessionTree, SessionReader {
|
||||
export interface Session<TMetadata extends SessionMetadata = SessionMetadata> extends SessionReader {
|
||||
readonly metadata: TMetadata;
|
||||
readonly idGenerator: IdGenerator;
|
||||
view(lane: string): SessionTree;
|
||||
beginMutation(lane: string, context: Context): Promise<SessionMutation>;
|
||||
mutate<T>(
|
||||
lane: string,
|
||||
mutation: (mutator: SessionMutator, context: Context) => T | Promise<T>,
|
||||
context: Context,
|
||||
): Promise<T>;
|
||||
createLane(
|
||||
name: string,
|
||||
at: string | null,
|
||||
configuration: LaneConfiguration,
|
||||
onCommitted: ((context: Context) => void | Promise<void>) | undefined,
|
||||
context: Context,
|
||||
): Promise<SessionTree>;
|
||||
getEntry(id: string, context: Context): Promise<Entry | undefined>;
|
||||
getStats(context: Context): Promise<SessionStats>;
|
||||
getName(context: Context): Promise<string | undefined>;
|
||||
getLabel(targetId: string, context: Context): Promise<string | undefined>;
|
||||
findEntries(query: EntryQuery | undefined, context: Context): Promise<Entry[]>;
|
||||
findEntry(query: EntryQuery | undefined, context: Context): Promise<Entry | undefined>;
|
||||
branch(name: string, context: Context): Promise<Branch | undefined>;
|
||||
createBranch(name: string, at: string | null, context: Context): Promise<Branch>;
|
||||
beginMutation(context: Context): Promise<SessionMutation>;
|
||||
/**
|
||||
* 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<T>(mutation: SessionMutationCallback<T>, context: Context): Promise<T>;
|
||||
setValue<T>(address: Value<T>, next: NoInfer<T>, context: Context): Promise<void>;
|
||||
deleteValue<T>(address: Value<T>, context: Context): Promise<void>;
|
||||
appendList<T>(address: ValueList<T>, element: NoInfer<T>, context: Context): Promise<void>;
|
||||
deleteList<T>(address: ValueList<T>, context: Context): Promise<void>;
|
||||
setName(name: string | undefined, context: Context): Promise<void>;
|
||||
setLabel(targetId: string, label: string | undefined, context: Context): Promise<void>;
|
||||
close(context: Context): Promise<void>;
|
||||
}
|
||||
|
||||
|
|
@ -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. */
|
||||
|
|
|
|||
|
|
@ -106,19 +106,41 @@ export function list<T>(namespace: string, key = ""): ValueList<T> {
|
|||
}
|
||||
|
||||
export function setValue<T>(address: Value<T>, next: NoInfer<T>): 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<T>(address: Value<T>): 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<T>(address: ValueList<T>, element: NoInfer<T>): 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<T>(address: ValueList<T>): 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<string | null>("pi.lane.leaf", lane);
|
||||
export const branchTip = (branch: string) => value<string | null>("pi.branch.tip", branch);
|
||||
export const branchTipInventoryPrefix = () => value<string | null>("pi.branch.tip");
|
||||
export const laneConfig = (lane: string) => value<LaneConfiguration>("pi.lane.config", lane);
|
||||
export const laneState = (lane: string) => value<LaneState>("pi.lane.state", lane);
|
||||
export const laneLastResult = (lane: string) => value<LaneLastResult>("pi.lane.lastResult", lane);
|
||||
export const laneLeafInventoryPrefix = () => value<string | null>("pi.lane.leaf");
|
||||
|
||||
export const operationMeta = (operationId: string) => value<OperationMeta>("pi.op.meta", operationId);
|
||||
export const operationState = (operationId: string) => value<OperationState>("pi.op.state", operationId);
|
||||
|
|
|
|||
|
|
@ -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<SessionTree, "findEntriesOnBranch" | "getEntry"> {
|
||||
function branchReader(entries: Entry[]): {
|
||||
branch: Pick<Branch, "findEntries">;
|
||||
session: Pick<Session, "getEntry">;
|
||||
} {
|
||||
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,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
110
packages/agent/test/harness/branch.test.ts
Normal file
110
packages/agent/test/harness/branch.test.ts
Normal file
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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<string | null> {
|
||||
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();
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -1,71 +0,0 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { LaneMutationLine } from "../../src/harness/session/lane-mutations.ts";
|
||||
|
||||
function deferred(): { promise: Promise<void>; resolve: () => void } {
|
||||
let resolve: (() => void) | undefined;
|
||||
const promise = new Promise<void>((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;
|
||||
});
|
||||
});
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
66
packages/agent/test/harness/mutation-line.test.ts
Normal file
66
packages/agent/test/harness/mutation-line.test.ts
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { MutationLine } from "../../src/harness/session/mutation-line.ts";
|
||||
|
||||
function deferred(): { promise: Promise<void>; resolve(): void } {
|
||||
let resolvePromise!: () => void;
|
||||
const promise = new Promise<void>((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;
|
||||
});
|
||||
});
|
||||
|
|
@ -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<typeof createAgentHarness>[0]["resources"] = {},
|
||||
): Promise<{
|
||||
harness: Harness<object | undefined>;
|
||||
lane: Lane<object | undefined>;
|
||||
session: Session;
|
||||
storage: InstrumentedStorage;
|
||||
models: ReturnType<typeof createModels>;
|
||||
|
|
@ -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<T>(result: Result<T, unknown>): 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('<skill name="review"') }] },
|
||||
|
|
@ -248,9 +256,9 @@ describe("runtime atomic run acceptance", () => {
|
|||
|
||||
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();
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
]);
|
||||
|
|
|
|||
|
|
@ -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<void> {
|
||||
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();
|
||||
});
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -18,9 +18,18 @@ async function createSession(): Promise<Session> {
|
|||
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");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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<void> {
|
||||
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<Harness<object | undefined>> {
|
||||
async function attach(
|
||||
session: Session,
|
||||
): Promise<Lane<object | undefined> & Pick<Harness<object | undefined>, "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<string>("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,
|
||||
);
|
||||
|
|
|
|||
77
packages/agent/test/harness/session-create-branch.test.ts
Normal file
77
packages/agent/test/harness/session-create-branch.test.ts
Normal file
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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<void>; resolve: () => void } {
|
||||
let resolve: (() => void) | undefined;
|
||||
const promise = new Promise<void>((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<void> {
|
||||
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<StorageBackedSession>().toMatchTypeOf<Session>();
|
||||
});
|
||||
|
||||
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;
|
||||
});
|
||||
});
|
||||
|
|
@ -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<unknown>("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<StorageBackedSession> {
|
||||
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<T>(lane: string, operation: () => T | Promise<T>) {
|
||||
this.runCount++;
|
||||
return super.run(lane, operation);
|
||||
}
|
||||
}
|
||||
|
||||
class RejectingCommitStorage extends MemoryStorage {
|
||||
rejection: Error | undefined;
|
||||
|
||||
override commit(transaction: Parameters<MemoryStorage["commit"]>[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<MemoryStorage["commit"]>[0]) {
|
||||
if (this.block) {
|
||||
this.admitted = true;
|
||||
await new Promise<void>((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<string>("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<unknown>> = [
|
||||
() => 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");
|
||||
});
|
||||
});
|
||||
|
|
@ -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<string>("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<number>("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<number>("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<void> | 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<string>("test.atomic", "first");
|
||||
const second = storedValues.value<string>("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(
|
||||
|
|
|
|||
|
|
@ -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<Session["beginMutation"]>().toEqualTypeOf<
|
||||
(lane: string, context: Context) => Promise<SessionMutation>
|
||||
>();
|
||||
expectTypeOf<Session["beginMutation"]>().toEqualTypeOf<(context: Context) => Promise<SessionMutation>>();
|
||||
expectTypeOf<SessionMutation["commit"]>().toEqualTypeOf<SessionMutator["commit"]>();
|
||||
expectTypeOf<SessionMutation["end"]>().toEqualTypeOf<(context: Context) => Promise<void>>();
|
||||
expectTypeOf<Session["mutate"]>().toEqualTypeOf<
|
||||
<T>(
|
||||
lane: string,
|
||||
mutation: (mutator: SessionMutator, context: Context) => T | Promise<T>,
|
||||
context: Context,
|
||||
) => Promise<T>
|
||||
<T>(mutation: (mutator: SessionMutator, context: Context) => T | Promise<T>, context: Context) => Promise<T>
|
||||
>();
|
||||
expectTypeOf<SessionMutator["commit"]>().toEqualTypeOf<
|
||||
(
|
||||
|
|
@ -398,14 +392,8 @@ it("covers storage, session, repository, search, and identity signatures", () =>
|
|||
expectTypeOf<SessionReader["scanBranch"]>().toEqualTypeOf<
|
||||
(query: StorageBranchScan, context: Context) => Promise<Entry[]>
|
||||
>();
|
||||
expectTypeOf<Session["createLane"]>().toEqualTypeOf<
|
||||
(
|
||||
name: string,
|
||||
at: string | null,
|
||||
laneConfiguration: LaneConfiguration,
|
||||
onCommitted: ((context: Context) => void | Promise<void>) | undefined,
|
||||
context: Context,
|
||||
) => Promise<SessionTree>
|
||||
expectTypeOf<Session["createBranch"]>().toEqualTypeOf<
|
||||
(name: string, at: string | null, context: Context) => Promise<Branch>
|
||||
>();
|
||||
expectTypeOf<SessionRepo["create"]>().toEqualTypeOf<
|
||||
(options: SessionCreateOptions, context: Context) => Promise<Session>
|
||||
|
|
@ -480,6 +468,9 @@ it("covers Part 5 results, events, hooks, snapshots, tools, and stream options",
|
|||
expectTypeOf<keyof DriveOptions>().toEqualTypeOf<"operationId" | "waitForRetry" | "pollDeferred">();
|
||||
expectTypeOf<DriveOutcome["kind"]>().toEqualTypeOf<"settled" | "waiting">();
|
||||
expectTypeOf<AgentLane["inspectExecution"]>().returns.toEqualTypeOf<Promise<LaneExecutionInfo>>();
|
||||
expectTypeOf<AgentLane["getTipId"]>().returns.toEqualTypeOf<Promise<string | null>>();
|
||||
expectTypeOf<Extract<keyof AgentLane, "sessionTree">>().toEqualTypeOf<never>();
|
||||
expectTypeOf<Extract<keyof AgentHarness, keyof AgentLane>>().toEqualTypeOf<never>();
|
||||
expectTypeOf<
|
||||
Extract<
|
||||
keyof AgentLane,
|
||||
|
|
|
|||
|
|
@ -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<Value<LaneConfiguration>>();
|
||||
expectTypeOf(laneLeaf("review")).toEqualTypeOf<Value<string | null>>();
|
||||
expectTypeOf(branchTip("review")).toEqualTypeOf<Value<string | null>>();
|
||||
expectTypeOf(laneState("review")).toEqualTypeOf<Value<LaneState>>();
|
||||
expectTypeOf(laneLastResult("review")).toEqualTypeOf<Value<LaneLastResult>>();
|
||||
expectTypeOf(meta).toEqualTypeOf<Value<OperationMeta>>();
|
||||
|
|
@ -129,7 +129,7 @@ describe("built-in durable addresses", () => {
|
|||
expectTypeOf(frames).toEqualTypeOf<ValueList<AssistantMessageFrame>>();
|
||||
|
||||
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:" },
|
||||
|
|
|
|||
|
|
@ -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: [],
|
||||
|
|
|
|||
|
|
@ -251,21 +251,21 @@ function toWireRunValue(value: Extract<HarnessRunResult, { ok: true }>["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<HarnessRunResult, { ok: true }>["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<HarnessRunResult, { ok: true }>["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
|
||||
|
|
|
|||
|
|
@ -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<SessionFacetAttributes>({
|
||||
id: "@pi/chat",
|
||||
setup(env) {
|
||||
env.provide(Chat, createChatService(env.harness));
|
||||
env.provide(Chat, createChatService(env.lane));
|
||||
},
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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<ModelsState>,
|
||||
): ModelsServiceRuntime {
|
||||
|
|
@ -27,17 +27,14 @@ export function createModelsService(
|
|||
refresh: { status: "idle" },
|
||||
});
|
||||
const readConfiguration = async (context: Context): Promise<ModelsState["configuration"]> => {
|
||||
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<ModelsState["catalog"]> => {
|
||||
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<SessionFacetAttributes>({
|
||||
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));
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import type {
|
||||
AgentHarness,
|
||||
AgentLane,
|
||||
Context,
|
||||
ServiceProviderUpdate as CoreServiceProviderUpdate,
|
||||
ServiceProviderSubscription,
|
||||
|
|
@ -29,6 +30,7 @@ export type ServiceOperationResult = Static<typeof ServiceOperationResultSchema>
|
|||
|
||||
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<void>;
|
||||
}): Promise<SessionWorkerServices> {
|
||||
const facetServices = await assembleFacetServices<SessionFacetAttributes>({
|
||||
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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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<JsonlSessionMetadata> | 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<ReturnType<AgentHarnessInstance["watch"]>> }
|
||||
{ readonly scope: WorkerOperationScope; readonly handle: Awaited<ReturnType<AgentLane["watch"]>> }
|
||||
>();
|
||||
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);
|
||||
|
|
|
|||
|
|
@ -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<SessionFacetAttributes>({
|
||||
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 } },
|
||||
},
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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 ?? [] };
|
||||
|
|
|
|||
|
|
@ -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" },
|
||||
},
|
||||
},
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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([
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
},
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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<string | null> | 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;
|
||||
|
|
|
|||
|
|
@ -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<SqliteSessionMetadata> {
|
|||
this.renewalTimer.unref?.();
|
||||
}
|
||||
|
||||
beginMutation(lane: string, context: Context): Promise<SessionMutation> {
|
||||
return this.admit(() => this.session.beginMutation(lane, context));
|
||||
async beginMutation(context: Context): Promise<SessionMutation> {
|
||||
let resolveFinished!: () => void;
|
||||
const finished = new Promise<void>((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<T>(
|
||||
lane: string,
|
||||
mutation: (mutator: SessionMutator, context: Context) => T | Promise<T>,
|
||||
context: Context,
|
||||
): Promise<T> {
|
||||
return this.admit(() => this.session.mutate(lane, mutation, context));
|
||||
mutate<T>(mutation: SessionMutationCallback<T>, context: Context): Promise<T> {
|
||||
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<Map<string, Entry>> {
|
||||
return this.admit(() => this.session.getEntries(ids, context));
|
||||
}
|
||||
|
||||
getEntry(id: string, context: Context): Promise<Entry | undefined> {
|
||||
return this.admit(() => this.session.getEntry(id, context));
|
||||
}
|
||||
|
||||
getValue<T>(address: Value<T>, context: Context): Promise<StoredValue<T> | undefined> {
|
||||
return this.admit(() => this.session.getValue(address, context));
|
||||
}
|
||||
|
|
@ -96,58 +135,35 @@ export class SqliteOpenSession implements Session<SqliteSessionMetadata> {
|
|||
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<void>) | undefined,
|
||||
context: Context,
|
||||
): Promise<SessionTree> {
|
||||
return this.admit(async () => {
|
||||
await this.session.createLane(name, at, configuration, onCommitted, context);
|
||||
return this.view(name);
|
||||
});
|
||||
}
|
||||
|
||||
getLeafId(context: Context): Promise<string | null> {
|
||||
return this.admit(() => this.session.getLeafId(context));
|
||||
}
|
||||
|
||||
getEntry(id: string, context: Context): Promise<Entry | undefined> {
|
||||
return this.admit(() => this.session.getEntry(id, context));
|
||||
}
|
||||
|
||||
getStats(context: Context): Promise<SessionStats> {
|
||||
return this.admit(() => this.session.getStats(context));
|
||||
}
|
||||
|
||||
getName(context: Context): Promise<string | undefined> {
|
||||
return this.admit(() => this.session.getName(context));
|
||||
}
|
||||
|
||||
getLabel(targetId: string, context: Context): Promise<string | undefined> {
|
||||
return this.admit(() => this.session.getLabel(targetId, context));
|
||||
}
|
||||
|
||||
findEntries(query: EntryQuery | undefined, context: Context): Promise<Entry[]> {
|
||||
return this.admit(() => this.session.findEntries(query, context));
|
||||
}
|
||||
|
||||
findEntry(query: EntryQuery | undefined, context: Context): Promise<Entry | undefined> {
|
||||
return this.admit(() => this.session.findEntry(query, context));
|
||||
}
|
||||
|
||||
async branch(name: string, context: Context): Promise<Branch | undefined> {
|
||||
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<Branch> {
|
||||
return this.wrapBranch(await this.admit(() => this.session.createBranch(name, at, context)));
|
||||
}
|
||||
|
||||
setValue<T>(address: Value<T>, next: NoInfer<T>, context: Context): Promise<void> {
|
||||
return this.admit(() => this.session.setValue(address, next, context));
|
||||
}
|
||||
|
|
@ -164,46 +180,14 @@ export class SqliteOpenSession implements Session<SqliteSessionMetadata> {
|
|||
return this.admit(() => this.session.deleteList(address, context));
|
||||
}
|
||||
|
||||
getName(context: Context): Promise<string | undefined> {
|
||||
return this.admit(() => this.session.getName(context));
|
||||
}
|
||||
|
||||
setName(name: string | undefined, context: Context): Promise<void> {
|
||||
return this.admit(() => this.session.setName(name, context));
|
||||
}
|
||||
|
||||
getLabel(targetId: string, context: Context): Promise<string | undefined> {
|
||||
return this.admit(() => this.session.getLabel(targetId, context));
|
||||
}
|
||||
|
||||
setLabel(targetId: string, label: string | undefined, context: Context): Promise<void> {
|
||||
return this.admit(() => this.session.setLabel(targetId, label, context));
|
||||
}
|
||||
|
||||
findEntries(query: EntryQuery | undefined, context: Context): Promise<Entry[]> {
|
||||
return this.admit(() => this.session.findEntries(query, context));
|
||||
}
|
||||
|
||||
findEntry(query: EntryQuery | undefined, context: Context): Promise<Entry | undefined> {
|
||||
return this.admit(() => this.session.findEntry(query, context));
|
||||
}
|
||||
|
||||
findEntriesOnBranch(query: BranchScan | undefined, context: Context): Promise<Entry[]> {
|
||||
return this.admit(() => this.session.findEntriesOnBranch(query, context));
|
||||
}
|
||||
|
||||
findEntryOnBranch(query: BranchScan | undefined, context: Context): Promise<Entry | undefined> {
|
||||
return this.admit(() => this.session.findEntryOnBranch(query, context));
|
||||
}
|
||||
|
||||
appendMessage(message: AgentMessage, context: Context): Promise<string> {
|
||||
return this.admit(() => this.session.appendMessage(message, context));
|
||||
}
|
||||
|
||||
appendCustomEntry(customType: string, data: JsonValue | undefined, context: Context): Promise<string> {
|
||||
return this.admit(() => this.session.appendCustomEntry(customType, data, context));
|
||||
}
|
||||
|
||||
close(context: Context): Promise<void> {
|
||||
if (this.closePromise !== undefined) return this.closePromise;
|
||||
this.state = "closing";
|
||||
|
|
@ -221,6 +205,18 @@ export class SqliteOpenSession implements Session<SqliteSessionMetadata> {
|
|||
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<T>(operation: () => Promise<T>): Promise<T> {
|
||||
if (this.state !== "open") return Promise.reject(this.closedError);
|
||||
if (this.leaseError !== undefined) return Promise.reject(this.leaseError);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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<unknown>[]): 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<string | null> | 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" });
|
||||
|
|
|
|||
|
|
@ -27,7 +27,7 @@ async function withDb<T>(path: string, run: (db: SqliteDatabase) => Promise<T> |
|
|||
}
|
||||
|
||||
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,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue