feat(agent): add runtime2 lane creation

This commit is contained in:
Mario Zechner 2026-08-16 01:23:28 +02:00
parent 0697589ac0
commit d5d656d4ec
5 changed files with 206 additions and 36 deletions

View file

@ -1,9 +1,22 @@
import type { AgentHarness, AgentHarnessOptions, AgentLane, LaneInfo, SuspendedOperation } from "../agent-harness.ts";
import { HarnessClosed, HarnessFault } from "../agent-harness.ts";
import type {
AgentHarness,
AgentHarnessOptions,
AgentLane,
CreateLaneResult,
LaneInfo,
SuspendedOperation,
} from "../agent-harness.ts";
import { Closed, HarnessClosed, HarnessFault, InvalidLane, LaneExists, UnknownTarget } from "../agent-harness.ts";
import { HarnessEventBus } from "../events.ts";
import { HookRegistry } from "../hooks.ts";
import { Result } from "../result.ts";
import { RuntimeSliceNotImplemented } from "../runtime/types.ts";
import { SessionInvariantError } from "../session/session.ts";
import {
SessionInvalidLaneError,
SessionInvariantError,
SessionLaneExistsError,
SessionUnknownTargetError,
} from "../session/session.ts";
import type { LaneConfiguration, Session } from "../session/types.ts";
import { Lane } from "./lane.ts";
import { restoreSession } from "./restore.ts";
@ -69,8 +82,38 @@ export class Harness<TContext extends object | undefined> extends Lane implement
});
}
async createLane(): Promise<never> {
throw new RuntimeSliceNotImplemented("createLane");
async createLane(name: string, at: string | null): 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,
};
try {
await this.session.createLane(name, at, this.seed);
const lane = new Lane(name, this.session, this.models, state, (cause) => this.fault(cause));
if (this.closedError !== undefined) lane.seal(this.closedError);
this.lanesByName.set(name, lane);
await this.events.emit({ type: "lane_created", lane: name, at });
return Result.ok(lane);
} 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 }));
}
throw this.fault(error);
}
}
async getTools(): Promise<never> {

View file

@ -41,7 +41,7 @@ export class Lane implements AgentLane {
readonly sessionTree: SessionTree;
private readonly sessionView: SessionTree;
protected readonly session: Session;
private readonly models: Models;
protected readonly models: Models;
private readonly onFault: FaultHandler;
state: LaneState;
closedError: Error | undefined;

View file

@ -3,6 +3,7 @@ import { Type } from "typebox";
import { afterEach, describe, expect, it, vi } from "vitest";
import { HarnessClosed, HarnessFault } from "../../../src/harness/agent-harness.ts";
import { DEFAULT_COMPACTION_SETTINGS } from "../../../src/harness/compaction/compaction.ts";
import type { Result } from "../../../src/harness/result.ts";
import { RuntimeSliceNotImplemented } from "../../../src/harness/runtime/types.ts";
import { createAgentHarness, Harness } from "../../../src/harness/runtime2/harness.ts";
import { Lane } from "../../../src/harness/runtime2/lane.ts";
@ -17,6 +18,7 @@ import type {
Transaction,
} from "../../../src/harness/session/types.ts";
import type { AgentHarnessTool } from "../../../src/harness/types.ts";
import { ControlledMemoryStorage, deferred } from "./test-utils.ts";
class FailingMemoryStorage extends MemoryStorage {
failure: Error | undefined;
@ -42,10 +44,9 @@ async function createSession(): Promise<Session> {
return repo.create({});
}
async function createFailingSession(): Promise<{ session: Session; storage: FailingMemoryStorage }> {
const storage = new FailingMemoryStorage();
async function createStorageSession(storage: MemoryStorage): Promise<Session> {
const session = new StorageBackedSession(
{ id: `runtime2-fault-${standaloneSessions.length}`, createdAt: 1, storageVersion: 1 },
{ id: `runtime2-storage-${standaloneSessions.length}`, createdAt: 1, storageVersion: 1 },
storage,
);
standaloneSessions.push(session);
@ -63,7 +64,17 @@ async function createFailingSession(): Promise<{ session: Session; storage: Fail
],
}),
);
return { session, storage };
return session;
}
async function createFailingSession(): Promise<{ session: Session; storage: FailingMemoryStorage }> {
const storage = new FailingMemoryStorage();
return { session: await createStorageSession(storage), storage };
}
function unwrap<T>(result: Result<T, unknown>): T {
if (!result.ok) throw result.error;
return result.value;
}
function modelOptions(session: Session) {
@ -174,6 +185,119 @@ describe("runtime2 AgentHarness", () => {
expect(await session.listRegisters("lane.config")).toEqual(before);
});
it("creates a lane from the captured seed and publishes it after commit", async () => {
const session = await createSession();
const options = modelOptions(session);
const { harness } = await createAgentHarness({
...options,
thinkingLevel: "high",
activeToolNames: ["read"],
});
const anchor = await harness.sessionTree.appendCustomEntry("anchor");
await harness.setThinkingLevel("low");
let durableConfigurationAtEvent: LaneConfiguration | undefined;
let laneVisibleAtEvent = false;
harness.events.on("lane_created", async () => {
durableConfigurationAtEvent = (await session.getRegister("lane.config", "worker"))?.value;
laneVisibleAtEvent = (await harness.lane("worker")) !== undefined;
});
const worker = unwrap(await harness.createLane("worker", anchor));
expect(await harness.lane("worker")).toBe(worker);
expect(await worker.getLeafId()).toBe(anchor);
expect(await worker.getThinkingLevel()).toBe("high");
expect(await worker.getActiveTools()).toEqual(["read"]);
expect(durableConfigurationAtEvent).toEqual({
model: { provider: options.model.provider, modelId: options.model.id },
thinkingLevel: "high",
activeToolNames: ["read"],
});
expect(laneVisibleAtEvent).toBe(true);
expect((await session.getRegister("lane.state", "worker"))?.value).toEqual({
currentOperationId: null,
pendingNextRun: [],
});
});
it("maps expected lane creation failures without faulting", async () => {
const session = await createSession();
const { harness } = await createAgentHarness(modelOptions(session));
unwrap(await harness.createLane("worker", null));
expect(await harness.createLane("worker", null)).toMatchObject({
ok: false,
error: { _tag: "LaneExists", lane: "worker" },
});
expect(await harness.createLane("", null)).toMatchObject({
ok: false,
error: { _tag: "InvalidLane", lane: "", reason: "lane name must not be empty" },
});
expect(await harness.createLane("missing", "unknown-entry")).toMatchObject({
ok: false,
error: { _tag: "UnknownTarget", targetId: "unknown-entry" },
});
expect((await harness.lanes()).map((lane) => lane.name).sort()).toEqual(["main", "worker"]);
});
it("faults without publishing a lane when creation commit fails", async () => {
const { session, storage } = await createFailingSession();
const { harness } = await createAgentHarness(modelOptions(session));
if (!(harness instanceof Harness)) throw new Error("missing runtime2 harness");
const failure = new Error("create lane failed");
storage.failure = failure;
let rejected: unknown;
try {
await harness.createLane("worker", null);
} catch (error) {
rejected = error;
}
expect(rejected).toBeInstanceOf(HarnessFault);
if (!(rejected instanceof HarnessFault)) throw new Error("missing harness fault");
expect(rejected.cause).toBe(failure);
await expect(harness.createLane("later", null)).rejects.toBe(rejected);
expect(harness.lanesByName.has("worker")).toBe(false);
expect(await session.getRegister("lane.config", "worker")).toBeUndefined();
expect(await session.getRegister("lane.leaf", "worker")).toBeUndefined();
expect(await session.getRegister("lane.state", "worker")).toBeUndefined();
});
it("returns Closed when lane creation starts after close", async () => {
const session = await createSession();
const { harness } = await createAgentHarness(modelOptions(session));
await harness.close();
expect(await harness.createLane("late", null)).toMatchObject({
ok: false,
error: { _tag: "Closed" },
});
});
it("publishes an admitted lane creation before close finishes", async () => {
const storage = new ControlledMemoryStorage();
const session = await createStorageSession(storage);
const { harness } = await createAgentHarness(modelOptions(session));
if (!(harness instanceof Harness)) throw new Error("missing runtime2 harness");
const commitStarted = deferred();
const releaseCommit = deferred();
storage.beforeNextCommit = async () => {
commitStarted.resolve();
await releaseCommit.promise;
};
const creating = harness.createLane("worker", null);
await commitStarted.promise;
const closing = harness.close();
releaseCommit.resolve();
const worker = unwrap(await creating);
await closing;
expect(harness.lanesByName.get("worker")).toBe(worker);
await expect(worker.getLeafId()).rejects.toBeInstanceOf(HarnessClosed);
});
it("reports restored open operations without activating them", async () => {
const repo = new MemorySessionRepo();
repos.push(repo);
@ -312,12 +436,13 @@ describe("runtime2 AgentHarness", () => {
it("faults queued lane work before releasing a failed mutation", async () => {
const { session, storage } = await createFailingSession();
await session.createLane("worker", null, configuredMain);
const { harness } = await createAgentHarness(modelOptions(session));
if (!(harness instanceof Harness)) throw new Error("missing runtime2 harness");
unwrap(await harness.createLane("worker", null));
const worker = harness.lanesByName.get("worker");
if (worker === undefined) throw new Error("missing runtime2 worker lane");
const initialConfiguration = worker.state.configuration;
const faultEvent = new Promise<unknown>((resolve) => harness.events.on("fault", resolve));
const worker = await harness.lane("worker");
if (!(worker instanceof Lane)) throw new Error("missing runtime2 worker lane");
const failure = new Error("commit failed");
storage.failure = failure;
const failed = worker.setThinkingLevel("high");
@ -338,8 +463,8 @@ describe("runtime2 AgentHarness", () => {
await expect(harness.getLeafId()).rejects.toBe(rejected);
await expect(worker.getThinkingLevel()).rejects.toBe(rejected);
expect(harness.fault(new Error("later"))).toBe(rejected);
expect(worker.state.configuration).toEqual(configuredMain);
expect((await session.getRegister("lane.config", "worker"))?.value).toEqual(configuredMain);
expect(worker.state.configuration).toBe(initialConfiguration);
expect((await session.getRegister("lane.config", "worker"))?.value).toEqual(initialConfiguration);
await harness.close();
});

View file

@ -3,20 +3,9 @@ import { afterEach, describe, expect, it } from "vitest";
import { HarnessClosed } from "../../../src/harness/agent-harness.ts";
import { Lane } from "../../../src/harness/runtime2/lane.ts";
import { restoreLane } from "../../../src/harness/runtime2/restore.ts";
import { MemoryStorage } from "../../../src/harness/session/memory.ts";
import { StorageBackedSession } from "../../../src/harness/session/session.ts";
import type { CommitResult, LaneConfiguration, Session, Transaction } from "../../../src/harness/session/types.ts";
class ControlledMemoryStorage extends MemoryStorage {
beforeNextCommit: (() => Promise<void>) | undefined;
override async commit(transaction: Transaction): Promise<CommitResult> {
const beforeCommit = this.beforeNextCommit;
this.beforeNextCommit = undefined;
await beforeCommit?.();
return super.commit(transaction);
}
}
import type { LaneConfiguration, Session } from "../../../src/harness/session/types.ts";
import { ControlledMemoryStorage, deferred } from "./test-utils.ts";
const sessions: Session[] = [];
const configuration: LaneConfiguration = {
@ -25,14 +14,6 @@ const configuration: LaneConfiguration = {
activeToolNames: [],
};
function deferred(): { promise: Promise<void>; resolve(): void } {
let resolvePromise: (() => void) | undefined;
const promise = new Promise<void>((resolve) => {
resolvePromise = resolve;
});
return { promise, resolve: () => resolvePromise?.() };
}
async function createLane(): Promise<{
lane: Lane;
model: Model<Api>;

View file

@ -0,0 +1,21 @@
import { MemoryStorage } from "../../../src/harness/session/memory.ts";
import type { CommitResult, Transaction } from "../../../src/harness/session/types.ts";
export class ControlledMemoryStorage extends MemoryStorage {
beforeNextCommit: (() => Promise<void>) | undefined;
override async commit(transaction: Transaction): Promise<CommitResult> {
const beforeCommit = this.beforeNextCommit;
this.beforeNextCommit = undefined;
await beforeCommit?.();
return super.commit(transaction);
}
}
export function deferred(): { promise: Promise<void>; resolve(): void } {
let resolvePromise: (() => void) | undefined;
const promise = new Promise<void>((resolve) => {
resolvePromise = resolve;
});
return { promise, resolve: () => resolvePromise?.() };
}