packages/agent/docs/work-packages/06-session-branch-lane-separation.md
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:
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
Read completely before editing:
packages/agent/docs/harness.md.packages/agent/docs/work-packages/05-direct-durable-drive.md.packages/agent/src/harness/session/types.ts.packages/agent/src/harness/session/session.ts.packages/agent/src/harness/session/memory.ts.packages/agent/src/harness/session/jsonl/repo.ts and jsonl/storage.ts.packages/session-backends/sqlite-node/src/sqlite/session.ts, storage.ts, and repo implementation.packages/agent/src/harness/runtime/lane.ts, harness.ts, restore.ts, and types.ts.packages/agent/src/harness/agent-harness.ts.packages/agent/src/harness/session/fork.ts and repository conformance tests.Do not inspect deleted runtime implementations or Git history. Current source, harness.md, WP05, and this package are the only sources of truth.
SessionTree mixes unrelated ownershipSessionTree currently contains:
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.
Session silently means mainSession 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.
AgentHarness silently means mainAgentHarness 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.
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.
Owns:
A Session does not implement Branch and has no implicit branch.
Data-only capability describing one named path through the immutable entry tree.
Owns only:
A Branch has no model configuration, queues, operation state, drive, hooks, or agent policy.
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.
Owns global registries/configuration, hooks, events, lifecycle, and a map of AgentLanes. It is not an AgentLane and exposes no implicit-main operation methods.
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.
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.
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.
AgentLane keeps its operation/configuration/observation methods and directly adds the Branch surface:
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.
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 [].
Replace LaneMutationLine with a single MutationLine:
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().
Ordinary Session and Branch reads call Storage directly. They observe the latest fully applied atomic storage commit at the time each read executes:
mutate() are not a snapshot;Session.mutate().A mutation callback must not perform or await:
The callback may synchronously call emitBatch after publication to bind recipients and retain its delivery promise. The public operation awaits delivery after mutate() returns.
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.
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.
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:
pi.branch.tip and rename format-4 fields from leaf to tip rather than retain misleading new-code aliases;model_change, thinking_level_change, and active_tools_change independently; unsupported nearest values do not fall back to older history;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;activeToolNames array;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.
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.
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:
pendingEntry(id) and enqueue inbox.writes;Both return the entry id reserved before their mutation.
Replace inheritance with composition:
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.
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.
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.
All Session facades:
beginMutation(context) / SessionMutation.end(context) forwarding for local lifecycle and remote transport;end() and keep callback mutate() admitted through its implicit finally/end;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.
Preserve one coherent source Storage snapshot. Update terms from lane leaf to Branch tip.
main at the copied path;main must exist or fork rejects;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.Public drive remains disabled. Complete this package before resuming WP05 M4.
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.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.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.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.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/agent-controller-provider.ts.packages/coding-agent/src/experimental/services/models-provider.ts.packages/coding-agent/src/experimental/session-worker.ts.Modify focused tests
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;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.
Update packages/agent/docs/harness.md completely and consistently:
tipId terminology;Update WP05 before M4:
SessionTree/lane-line/inheritance assumptions;leafId source examples where the new type names require it;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;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.
mutate() callbacks serialize globally, including callbacks invoked from different AgentLanes;1, then 2;getValue + setValue calls remain deliberately non-atomic;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;seq and stats behavior is unchanged.branch(name, context) returns undefined for absence and receives the exact Context on reads;createBranch validates name, target, and duplicate atomically;sessionTree/nested Branch property;lanes() is empty;lane("main") creates complete main atomically;createAt anchors creation;lane_created;Session.mutate;value_update delivery;Do not add:
SessionTree, view, implicit Session main methods, Harness lane methods, or createLane;Session.mutate();AgentHarness.create() or harness.lane();Do not modify provider/tool behavior, durable execution phases, retry/deferred policy, or assistant-frame semantics beyond signature/name propagation required by this package.
Run every modified focused test, then:
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.
WP06 is complete when:
mutate() and explicit begin/commit/end remote transport share it, and neither accepts a lane key;SessionTree and implicit-main Session behavior are gone;harness.md and WP05 describe the new model consistently;