packages/agent/docs/work-packages/04-mutation-publication.md
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.
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.
A committing lane job currently uses a two-part event API:
inside Session.mutate:
commit
publish process-local state
delivery = events.enqueue(batch, context)
outside Session.mutate:
await delivery.start()
The split preserves the correct boundary, but it is a footgun: calling emit() too early, calling start() too early, or dropping start() can violate observation semantics or stall the global event tail. The historical Harness.createLane() repeats the choreography manually.
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.
Direct events.on() listeners remain passive but causally ordered:
hook or preparation
→ commit
→ publish process-local state
→ bind and append event batch
→ release lane mutation line
→ await direct listeners
→ resolve operation
→ later hook or transition
Passive means a listener cannot transform the in-flight operation and listener failures are isolated as handler_error. It does not mean fire-and-forget. An extension may update process-local state in an event listener and inspect it from a later hook. Awaiting also supplies producer backpressure.
A direct listener must not call a state-mutating harness API: an emitted mutation would queue a later event behind the event currently awaiting that listener. Read-only lane calls remain legal.
Deliberate exceptions remain unchanged:
after_tool and outcome publication, which drains all earlier updates through global FIFO without per-update backpressure.Every event-producing committing lane job performs, in the exact continuation that observes successful commit:
commit succeeds
→ publish complete process-local state
→ synchronously call emitBatch(batch, context)
- clone payloads
- bind current ordinary listeners and watchers
- append the complete batch to the global delivery tail
→ return from the mutation callback
There is no scheduler-owned after-release publication phase. Moving recipient binding into a later promise continuation creates a gap in which another task may observe committed state and register a listener before the old event binds recipients.
emitBatch() starts asynchronous delivery immediately and returns its completion promise. The mutation callback never awaits that promise. Listener code may begin after publication/binding but before the mutation line technically releases; a reentrant lane read queues behind the current job. The command carries the promise out of Session.mutate() and awaits it before resolving publicly.
This preserves the two legal watcher races:
watcher registration first
→ snapshot-before + complete buffered batch
commit publication first
→ snapshot-after + no old event
emitBatch() call publishes one contiguous batch.emitBatch() invocation order, including across lanes.Replace the public/internal delivery split with:
class HarnessEventBus implements Events {
emit(event: HarnessEvent, context: Context): Promise<void>;
emitBatch(events: readonly HarnessEvent[], context: Context): Promise<void>;
}
emitBatch():
handler_error as today;Synchronous publication defects such as an uncloneable internal payload still throw in the caller's commit continuation and follow the existing harness-fault path.
Delete:
HarnessEventDelivery;HarnessEventBus.enqueue();start();pendingStarts;close(error) seals listener/watch registration and future publication immediately. Already appended batches retain their bound recipients and drain through the existing tail; listener completion still does not block Harness close.
The commit branch of Lane.command() returns a plain internal outcome containing the caller result and optional delivery promise:
const events = decision.events?.(commit) ?? [];
const delivery = events.length === 0
? undefined
: this.onEvent(events, context);
return {
kind: "return",
result,
...(delivery === undefined ? {} : { delivery }),
};
onEvent returns Promise<void> and delegates to emitBatch(). It is called only after commit, complete process-local state publication, and synchronous result materialization, as the final action before returning the mutation outcome.
After Session.mutate() returns:
if (outcome.kind === "reject") throw outcome.error;
await outcome.delivery;
return outcome.result;
Expected no-commit rejections publish no events. Commit/materialization/publication errors retain the existing harness-fault semantics.
The historical Session.createLane() owns validation, commit, and the synchronous committed-publication callback. Context remains last:
createLane(
name: string,
at: string | null,
configuration: LaneConfiguration,
onCommitted: ((context: Context) => void | Promise<void>) | undefined,
context: Context,
): Promise<SessionTree>;
The implementation performs:
enter the prospective lane's mutation line
→ validate name, absence, complete lane shape, and target
→ commit lane configuration + leaf + idle lane state
→ synchronously invoke onCommitted(context) in that same commit continuation
→ retain its returned promise inside a non-thenable outcome object
→ return from the mutation callback and release the line
→ await the retained promise
→ return Session.view(name)
The callback receives neither SessionTree nor SessionMutator. It is only the process-local publication point. Its synchronous prefix must complete the publication needed before another same-lane job can run. Ordinary Session callers pass undefined.
If validation or commit fails, the callback is not invoked. If the callback throws after commit or its retained promise rejects after line release, the durable lane remains and the caller rejects; Harness converts that committed-publication defect to its existing fault path. A promise returned by the Harness callback is event delivery, whose listener failures are isolated by the bus.
The current lane validation/transaction implementation becomes private to Session. Delete exported createLaneWithMutator() and its direct tests.
Harness preconstructs a detached Lane, then calls Session:
const lane = this.buildLane(name, state);
await this.session.createLane(
name,
at,
this.seed,
(context) => {
if (this.closedError !== undefined) lane.seal(this.closedError);
this.lanesByName.set(name, lane);
return this.events.emitBatch(
[{ type: "lane_created", lane: name, at }],
context,
);
},
context,
);
return Result.ok(lane);
The callback's synchronous prefix publishes lanesByName and binds lane_created before the first creation job releases its line. A queued duplicate therefore cannot report LaneExists before the winner is visible through harness.lane(name).
If close or fault wins while commit is admitted, the callback publishes the new Lane sealed. Emission on the already-closed bus is a resolved no-op, matching the existing admitted-creation race. The successful admitted creation still returns its sealed Lane.
Modify:
packages/agent/src/harness/events.ts;packages/agent/src/harness/runtime2/lane.ts;packages/agent/src/harness/runtime2/harness.ts;packages/agent/src/harness/session/types.ts;createLane signature;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.
Update normative harness.md:
emitBatch binding and post-mutation awaiting;Session.createLane(onCommitted, context);Update the historical WP02 handoff only where it points forward or claims the old mechanism remains current. Do not rewrite its completed-package record as though WP04 behavior landed in WP02.
emitBatch binds ordinary listeners and watchers synchronously;emitBatch but before delayed delivery receives nothing;handler_error and does not reject delivery;LaneExists;lane_created listeners;lane_created listeners before resolving;undefined returns its view;createLaneWithMutator remains.accept() resolves;After documentation:
git diff --check -- \
packages/agent/docs/harness.md \
packages/agent/docs/work-packages/02-atomic-run-acceptance.md \
packages/agent/docs/work-packages/04-mutation-publication.md \
packages/agent/docs/work-packages/05-direct-durable-drive.md
After implementation, run every modified focused test, then:
git diff --check
npm run check
./test.sh
Review the final implementation with Fable before declaring WP04 complete. Do not commit without explicit user approval.
WP04 is complete when:
emitBatch() operation and no caller-operated gate;createLaneWithMutator() is gone;npm run check, and ./test.sh pass;