diff --git a/packages/agent/src/harness/runtime2/harness.ts b/packages/agent/src/harness/runtime2/harness.ts index 320e2b821..267b5ccef 100644 --- a/packages/agent/src/harness/runtime2/harness.ts +++ b/packages/agent/src/harness/runtime2/harness.ts @@ -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 extends Lane implement }); } - async createLane(): Promise { - throw new RuntimeSliceNotImplemented("createLane"); + async createLane(name: string, at: string | null): Promise { + // Public Result errors are expected caller/lifecycle outcomes. Harness faults reject every API call because the + // owned state can no longer advance safely; they are intentionally not members of CreateLaneResult. + if (this.faultError !== undefined) throw this.faultError; + if (this.closedError !== undefined) return Result.err(new Closed({ message: this.closedError.message })); + const state: LaneState = { + leafId: at, + configuration: this.seed, + pendingNextRun: [], + operation: null, + }; + 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 { diff --git a/packages/agent/src/harness/runtime2/lane.ts b/packages/agent/src/harness/runtime2/lane.ts index 1b50a26ad..9efa203eb 100644 --- a/packages/agent/src/harness/runtime2/lane.ts +++ b/packages/agent/src/harness/runtime2/lane.ts @@ -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; diff --git a/packages/agent/test/harness/runtime2/harness.test.ts b/packages/agent/test/harness/runtime2/harness.test.ts index d3842a20b..0de3ec03e 100644 --- a/packages/agent/test/harness/runtime2/harness.test.ts +++ b/packages/agent/test/harness/runtime2/harness.test.ts @@ -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 { return repo.create({}); } -async function createFailingSession(): Promise<{ session: Session; storage: FailingMemoryStorage }> { - const storage = new FailingMemoryStorage(); +async function createStorageSession(storage: MemoryStorage): Promise { 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(result: Result): 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((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(); }); diff --git a/packages/agent/test/harness/runtime2/lane.test.ts b/packages/agent/test/harness/runtime2/lane.test.ts index 26ceee990..9c953c7c7 100644 --- a/packages/agent/test/harness/runtime2/lane.test.ts +++ b/packages/agent/test/harness/runtime2/lane.test.ts @@ -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) | undefined; - - override async commit(transaction: Transaction): Promise { - 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; resolve(): void } { - let resolvePromise: (() => void) | undefined; - const promise = new Promise((resolve) => { - resolvePromise = resolve; - }); - return { promise, resolve: () => resolvePromise?.() }; -} - async function createLane(): Promise<{ lane: Lane; model: Model; diff --git a/packages/agent/test/harness/runtime2/test-utils.ts b/packages/agent/test/harness/runtime2/test-utils.ts new file mode 100644 index 000000000..7e49a9ffd --- /dev/null +++ b/packages/agent/test/harness/runtime2/test-utils.ts @@ -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) | undefined; + + override async commit(transaction: Transaction): Promise { + const beforeCommit = this.beforeNextCommit; + this.beforeNextCommit = undefined; + await beforeCommit?.(); + return super.commit(transaction); + } +} + +export function deferred(): { promise: Promise; resolve(): void } { + let resolvePromise: (() => void) | undefined; + const promise = new Promise((resolve) => { + resolvePromise = resolve; + }); + return { promise, resolve: () => resolvePromise?.() }; +}