import { mkdtemp, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import type { Context, JsonValue } from "@earendil-works/chord"; import { fauxAssistantMessage, fauxText, fauxToolCall, type Models, type ToolResultMessage, Type, } from "@earendil-works/pi-ai"; import { type AgentEvent, type Conversation, type ConversationId, createSession, defineDoc, defineTask, defineTool, GenerationTask, type Harness, type JoinPolicy, LiveDoc, MemoryStorage, type NextTaskState, type Registry, type Storage, type TaskId, type TaskOptions, type TaskRuntime, type ToolRegistration, ToolResultEntry, type Tx, watchEvents, } from "@earendil-works/pi-durable"; import { afterEach, describe, expect, it } from "vitest"; import { openNodeSqliteStorage } from "../src/storage/sqlite/node.ts"; import { allEntries, chatSetup, openChat, waitFor } from "./chat-support.ts"; import { addHooks, addTask, addTool } from "./harness-support.ts"; import { ControlledStorage, context } from "./session-support.ts"; import { aborted, type Deferred, deferred, openTasks, settled } from "./task-support.ts"; // ─── A scriptable task ────────────────────────────────────────────────────── type NodeInput = { name: string }; type NodeCheckpoint = { phase: "run" } | { phase: "resume"; round: number }; type NodeRuntime = TaskRuntime; type Next = NextTaskState; /** Per-name behavior of `Node`; unscripted parts use the defaults below. */ type Behavior = { run?(runtime: NodeRuntime, ctx: Context): Promise; resume?(runtime: NodeRuntime, ctx: Context, round: number): Promise; abort?(runtime: NodeRuntime, ctx: Context): Promise; }; /** How a default run ends once its gate opens: its outcome, or a throw that faults it. */ type Ending = "completed" | "failed" | "throw"; const behaviors = new Map(); const gates = new Map>(); /** Handler starts and ends in order, such as `run:a`, `abort:a`. */ const log: string[] = []; function gate(name: string): Deferred { let found = gates.get(name); if (found === undefined) { found = deferred(); gates.set(name, found); } return found; } function open(name: string, ending: Ending = "completed"): void { gate(name).resolve(ending); } function script(name: string, behavior: Behavior): void { behaviors.set(name, behavior); } /** Wait for the gate or the abort signal, then end as the gate says. */ async function defaultRun(runtime: NodeRuntime, ctx: Context, name: string): Promise { const ending = await Promise.race([gate(name).promise, aborted(runtime.signal)]); if (ending === "throw") throw new Error(`${name} threw`); await runtime.commit(() => end(ending, name), ctx); } function end(ending: "completed" | "failed", name: string): Next { return ending === "completed" ? { status: "terminal", outcome: { status: "completed", result: name } } : { status: "terminal", outcome: { status: "failed", error: { message: `${name} failed` } } }; } /** A task that runs its name's behavior. */ const Node = defineTask({ name: "test.node", version: 1, initial: () => ({ phase: "run" }), phases: { run: async (task, runtime, ctx) => { const name = task.input.name; log.push(`run:${name}`); const behavior = behaviors.get(name)?.run; await (behavior === undefined ? defaultRun(runtime, ctx, name) : behavior(runtime, ctx)); }, resume: async (task, runtime, ctx) => { const name = task.input.name; log.push(`resume:${name}`); const behavior = behaviors.get(name)?.resume; if (behavior !== undefined) return behavior(runtime, ctx, task.state.checkpoint.round); await runtime.commit(() => end("completed", name), ctx); }, }, abort: async (task, runtime, ctx) => { const name = task.input.name; log.push(`abort:${name}`); const behavior = behaviors.get(name)?.abort; if (behavior !== undefined) return behavior(runtime, ctx); await runtime.commit(() => ({ status: "terminal", outcome: { status: "aborted", result: name } }), ctx); }, }); /** Never registered: aborting it can only orphan it. */ const Unregistered = defineTask({ name: "test.unregistered", version: 1, initial: () => ({ phase: "run" }), phases: { run: async () => {} }, abort: async () => {}, }); const TaskNotes = defineDoc<{ text: string }>({ kind: "test.task-notes", version: 1, scope: "task", initial: () => ({ text: "" }), }); const OWN_CONVERSATION: TaskOptions = { ownership: { kind: "conversation" } }; function owned(owner: TaskId): TaskOptions { return { ownership: { kind: "task", taskId: owner } }; } /** A child task named `name`, owned by `owner`. */ function spawn(tx: Tx, owner: TaskId, name: string): Promise> { return tx.createTask(Node, { name }, owned(owner)); } /** Wait on `on`, resuming in round `round`. */ function waitOn(on: readonly TaskId[], policy: JoinPolicy, round = 1): Next { return { status: "waiting", checkpoint: { phase: "resume", round }, on, policy }; } async function start(conversation: Conversation, name: string, options: TaskOptions = OWN_CONVERSATION) { return conversation.commit((tx) => tx.createTask(Node, { name }, options), context); } type Opened = { harness: Harness; root: Conversation; registry: Registry; reports: unknown[] }; async function openNodes(storage: Storage = new MemoryStorage()): Promise { const opened = await openTasks(storage, [Node]); const root = await opened.harness.root(context); opened.harness.resume(); return { ...opened, root }; } async function state(harness: Harness, id: TaskId) { return (await harness.getTask(id, context))!.state; } async function outcomeOf(harness: Harness, id: TaskId): Promise { return (await harness.waitForTask(id, context)).state.outcome.status; } async function until(check: () => boolean | Promise): Promise { await waitFor(check); } const directories = new Set(); async function sqlitePath(): Promise { const directory = await mkdtemp(join(tmpdir(), "pi-durable-structured-")); directories.add(directory); return join(directory, "session.sqlite"); } afterEach(async () => { behaviors.clear(); gates.clear(); log.length = 0; for (const directory of directories) await rm(directory, { recursive: true, force: true }); directories.clear(); }); // ─── Ownership ────────────────────────────────────────────────────────────── describe("task ownership", () => { it("creates a child in its owner's conversation with its owner recorded", async () => { const { harness, root } = await openNodes(); let child: TaskId | undefined; script("parent", { run: (runtime, ctx) => runtime.commit(async (tx) => { child = await spawn(tx, runtime.taskId, "child"); return waitOn([child], "allSettled"); }, ctx), }); const parent = await start(root, "parent"); await until(() => child !== undefined); const record = (await harness.getTask(child!, context))!; expect(record).toMatchObject({ owner: parent, conversationId: root.id, background: false }); expect((await harness.getTask(parent, context))!.owner).toBeUndefined(); open("child"); expect(await outcomeOf(harness, parent)).toBe("completed"); await harness.close(context); }); it("rejects no ownership, a missing owner, a child in another conversation, and a background child", async () => { const { harness, root } = await openNodes(); const parent = await start(root, "parent"); const other = await harness.createConversation({ ownership: { kind: "ownerless" } }, context); const create = (options: TaskOptions) => root.commit((tx) => tx.createTask(Node, { name: "x" }, options), context); await expect(create({} as TaskOptions)).rejects.toThrow(); await expect(create(owned(999_999 as TaskId))).rejects.toThrow("does not exist"); await expect(create({ ...owned(parent), conversationId: other.id })).rejects.toThrow("owner's conversation"); await expect(create({ ...owned(parent), background: true })).rejects.toThrow("cannot be background"); // The owner's conversation is the default, even from a commit bound to another conversation. const child = await other.commit((tx) => spawn(tx, parent, "child"), context); expect((await harness.getTask(child, context))!.conversationId).toBe(root.id); open("parent"); open("child"); await harness.waitForTask(parent, context); await harness.close(context); }); it("rejects new owned work below an owner that is completing, terminal, or abort-marked", async () => { const { harness, root } = await openNodes(); script("parent", { run: async (runtime, ctx) => { await runtime.commit(async (tx) => { await spawn(tx, runtime.taskId, "child"); return { status: "running", checkpoint: { phase: "resume", round: 1 } }; }, ctx); }, }); const parent = await start(root, "parent"); await until(async () => (await state(harness, parent)).status === "completing"); const createChild = () => root.commit((tx) => spawn(tx, parent, "late"), context); const createConversation = () => root.commit((tx) => tx.createConversation({ ownership: { kind: "task", taskId: parent } }), context); await expect(createChild()).rejects.toThrow("is completing"); await expect(createConversation()).rejects.toThrow("is completing"); open("child"); await harness.waitForTask(parent, context); await expect(createChild()).rejects.toThrow("is terminal"); await expect(createConversation()).rejects.toThrow("is terminal"); script("slow", { abort: async (runtime, ctx) => { await gate("abort.slow").promise; await runtime.commit(() => ({ status: "terminal", outcome: { status: "aborted" } }), ctx); }, }); const slow = await start(root, "slow"); await until(() => log.includes("run:slow")); await harness.abortTask(slow, context); await expect(root.commit((tx) => spawn(tx, slow, "late"), context)).rejects.toThrow("is abort-marked"); open("abort.slow"); expect(await outcomeOf(harness, slow)).toBe("aborted"); await harness.close(context); }); it("cannot create a child in its finishing commit, but work it starts in an owned conversation holds it", async () => { const { harness, root } = await openNodes(); script("eager", { run: (runtime, ctx) => runtime.commit(async (tx) => { await spawn(tx, runtime.taskId, "never"); return end("completed", "eager"); }, ctx), }); const eager = await start(root, "eager"); expect((await harness.waitForTask(eager, context)).state.outcome).toMatchObject({ status: "faulted", error: { message: expect.stringContaining("is completing") }, }); // The rejected commit wrote nothing. const nodes = await harness.commit((tx) => tx.scanTasks({ kind: "test.node" }, 20), context); expect(nodes.items.map((task) => (task.input as NodeInput).name)).toEqual(["eager"]); let child: ConversationId | undefined; script("host", { run: (runtime, ctx) => runtime.commit(async (tx) => { child = (await tx.createConversation({ ownership: { kind: "task", taskId: runtime.taskId } })).id; return { status: "running", checkpoint: { phase: "resume", round: 1 } }; }, ctx), resume: (runtime, ctx) => runtime.commit(async (tx) => { await tx.createTask(Node, { name: "inner" }, { ...OWN_CONVERSATION, conversationId: child! }); return end("completed", "host"); }, ctx), }); const host = await start(root, "host"); await until(async () => (await state(harness, host)).status === "completing"); expect(await settled(harness.waitForTask(host, context))).toBe(false); open("inner"); expect(await outcomeOf(harness, host)).toBe("completed"); await harness.close(context); }); }); // ─── Waiting ──────────────────────────────────────────────────────────────── /** Script `parent` to spawn `children` in one commit and wait on them with `policy`; it records their outcomes. */ function parentOf(parent: string, children: readonly string[], policy: JoinPolicy) { const found = { ids: [] as TaskId[], outcomes: [] as string[] }; script(parent, { run: (runtime, ctx) => runtime.commit(async (tx) => { for (const name of children) found.ids.push(await spawn(tx, runtime.taskId, name)); return waitOn(found.ids, policy); }, ctx), resume: async (runtime, ctx) => { found.outcomes = (await runtime.outcomes(found.ids, ctx)).map((outcome) => outcome.status); await runtime.commit(() => end("completed", parent), ctx); }, }); return found; } describe("waiting", () => { it("resumes once every awaited task is terminal and reads their outcomes in order (allSettled)", async () => { const { harness, root } = await openNodes(); const found = { ids: [] as TaskId[], outcomes: [] as string[] }; script("parent", { run: (runtime, ctx) => runtime.commit(async (tx) => { for (const name of ["ok", "fails", "throws", "aborted"]) found.ids.push(await spawn(tx, runtime.taskId, name)); found.ids.push(await tx.createTask(Unregistered, { name: "orphan" }, owned(runtime.taskId))); return waitOn(found.ids, "allSettled"); }, ctx), resume: async (runtime, ctx) => { found.outcomes = (await runtime.outcomes(found.ids, ctx)).map((outcome) => outcome.status); await runtime.commit(() => end("completed", "parent"), ctx); }, }); const parent = await start(root, "parent"); await until(() => found.ids.length === 5); await until(async () => (await state(harness, parent)).status === "waiting"); open("ok"); open("fails", "failed"); open("throws", "throw"); await harness.abortTask(found.ids[3]!, context); expect(await harness.abortTask(found.ids[4]!, context)).toBe("marked"); expect(await outcomeOf(harness, parent)).toBe("completed"); expect(found.outcomes).toEqual(["completed", "failed", "faulted", "aborted", "orphaned"]); // allSettled never marks siblings. expect(log.filter((line) => line.startsWith("abort:"))).toEqual(["abort:aborted"]); await harness.close(context); }); for (const ending of ["failed", "throw"] as const) { it(`fails fast when a child ends ${ending === "throw" ? "faulted" : "failed"}: its live siblings are aborted, the parent is not`, async () => { const { harness, root } = await openNodes(); const found = parentOf("checkout", ["p1", "p2", "p3", "p4"], "failFast"); const parent = await start(root, "checkout"); await until(async () => (await state(harness, parent)).status === "waiting"); open("p2", ending); expect(await outcomeOf(harness, parent)).toBe("completed"); expect(found.outcomes).toEqual(["aborted", ending === "throw" ? "faulted" : "failed", "aborted", "aborted"]); expect((await harness.getTask(parent, context))!.abortRequested).toBe(false); expect(log.filter((line) => line.startsWith("abort:")).sort()).toEqual(["abort:p1", "abort:p3", "abort:p4"]); await harness.close(context); }); } it("fails fast on a held failure before the failing child drains", async () => { const { harness, root } = await openNodes(); const found = { ids: [] as TaskId[] }; script("p1", { run: async (runtime, ctx) => { await runtime.commit(async (tx) => { await spawn(tx, runtime.taskId, "grandchild"); return { status: "running", checkpoint: { phase: "resume", round: 1 } }; }, ctx); }, resume: (runtime, ctx) => runtime.commit(() => end("failed", "p1"), ctx), }); script("grandchild", { abort: async (runtime, ctx) => { await gate("abort.grandchild").promise; await runtime.commit(() => ({ status: "terminal", outcome: { status: "aborted" } }), ctx); }, }); let outside: TaskId | undefined; script("parent", { run: (runtime, ctx) => runtime.commit(async (tx) => { for (const name of ["p1", "p2"]) found.ids.push(await spawn(tx, runtime.taskId, name)); outside = await spawn(tx, runtime.taskId, "outside"); return waitOn(found.ids, "failFast"); }, ctx), }); const parent = await start(root, "parent"); await until(() => found.ids.length === 2); // p1 holds `failed` while its grandchild's abort handler runs; p2 is already aborted. expect(await outcomeOf(harness, found.ids[1]!)).toBe("aborted"); expect(await state(harness, found.ids[0]!)).toMatchObject({ status: "completing", outcome: { status: "failed" }, }); expect((await state(harness, parent)).status).toBe("waiting"); open("abort.grandchild"); await until(() => log.includes("resume:parent")); // Only the other tasks in `on` are marked: not the failed one, not the parent, not a child outside `on`. const marked = async (id: TaskId) => (await harness.getTask(id, context))!.abortRequested; expect(await outcomeOf(harness, found.ids[0]!)).toBe("failed"); expect([await marked(found.ids[0]!), await marked(found.ids[1]!), await marked(outside!)]).toEqual([ false, true, false, ]); expect(await marked(parent)).toBe(false); expect((await state(harness, outside!)).status).toBe("running"); // Finished, the parent holds for the child outside `on`. await until(async () => (await state(harness, parent)).status === "completing"); open("outside"); expect(await outcomeOf(harness, parent)).toBe("completed"); await harness.close(context); }); it("waits with allSettled on tasks it does not own, including already terminal ones", async () => { const { harness, root } = await openNodes(); const done = await start(root, "done"); open("done"); await harness.waitForTask(done, context); const live = await start(root, "live"); let outcomes: string[] = []; script("parent", { run: (runtime, ctx) => runtime.commit(() => waitOn([done, live], "allSettled"), ctx), resume: async (runtime, ctx) => { outcomes = (await runtime.outcomes([done, live], ctx)).map((outcome) => outcome.status); await runtime.commit(() => end("completed", "parent"), ctx); }, }); const parent = await start(root, "parent"); await until(async () => (await state(harness, parent)).status === "waiting"); // A task it does not own is not its work: the parent may finish while it lives, but here it waits for it. open("live", "failed"); expect(await outcomeOf(harness, parent)).toBe("completed"); expect(outcomes).toEqual(["completed", "failed"]); await harness.close(context); }); it("rejects waits on itself, its owner, a missing task, and failFast on a task it does not own", async () => { const { harness, root } = await openNodes(); const other = await start(root, "other"); const cases: [string, (runtime: NodeRuntime) => readonly TaskId[], JoinPolicy, string][] = [ ["self", (runtime) => [runtime.taskId], "allSettled", "is a wait cycle"], ["missing", () => [999_999 as TaskId], "allSettled", "does not exist"], ["foreign", () => [other], "failFast", "only on tasks it owns"], ]; for (const [name, on, policy, message] of cases) { script(name, { run: (runtime, ctx) => runtime.commit(() => waitOn(on(runtime), policy), ctx) }); const id = await start(root, name); expect((await harness.waitForTask(id, context)).state.outcome).toMatchObject({ status: "faulted", error: { message: expect.stringContaining(message) }, }); } // A child waiting on its owner could never resume. let child: TaskId | undefined; let parent: TaskId | undefined; script("child", { run: (runtime, ctx) => runtime.commit(() => waitOn([parent!], "allSettled"), ctx) }); script("parent", { run: (runtime, ctx) => runtime.commit(async (tx) => { child = await spawn(tx, runtime.taskId, "child"); return waitOn([child], "allSettled"); }, ctx), }); parent = await start(root, "parent"); expect((await harness.waitForTask(parent, context)).state.outcome.status).toBe("completed"); expect((await harness.waitForTask(child!, context)).state.outcome).toMatchObject({ status: "faulted", error: { message: expect.stringContaining("is a wait cycle") }, }); open("other"); await harness.close(context); }); // #10411; this test and the next two are from #10533 by @odysseus0. it("rejects the wait that closes a cycle", async () => { const { harness, root } = await openNodes(); let a: TaskId | undefined; let b: TaskId | undefined; script("a", { run: (runtime, ctx) => runtime.commit(() => waitOn([b!], "allSettled"), ctx) }); script("b", { run: (runtime, ctx) => runtime.commit(() => waitOn([a!], "allSettled"), ctx) }); await root.commit(async (tx) => { a = await tx.createTask(Node, { name: "a" }, OWN_CONVERSATION); b = await tx.createTask(Node, { name: "b" }, OWN_CONVERSATION); }, context); // a -> b and b -> a are waits, and whichever comes second closes the cycle. const outcomes = await Promise.all([outcomeOf(harness, a!), outcomeOf(harness, b!)]); expect(outcomes.sort()).toEqual(["completed", "faulted"]); await harness.close(context); }); // #10411 it("rejects the wait that closes a cycle through a held owner", async () => { const { harness, root } = await openNodes(); let held: TaskId | undefined; let other: TaskId | undefined; const child = spawnAndFinish("held", "child"); script("child", { run: (runtime, ctx) => runtime.commit(() => waitOn([other!], "allSettled"), ctx) }); script("other", { run: (runtime, ctx) => runtime.commit(() => waitOn([held!], "allSettled"), ctx) }); await root.commit(async (tx) => { held = await tx.createTask(Node, { name: "held" }, OWN_CONVERSATION); other = await tx.createTask(Node, { name: "other" }, OWN_CONVERSATION); }, context); // held -> child is a hold; child -> other -> held are waits, and whichever wait comes second closes the cycle. expect(await outcomeOf(harness, held!)).toBe("completed"); const outcomes = await Promise.all([outcomeOf(harness, child.id!), outcomeOf(harness, other!)]); expect(outcomes.sort()).toEqual(["completed", "faulted"]); await harness.close(context); }); // #10411 it("rejects a cycle through holds two levels deep", async () => { const { harness, root } = await openNodes(); let held: TaskId | undefined; let other: TaskId | undefined; const child = spawnAndFinish("held", "child"); const grandchild = spawnAndFinish("child", "grandchild"); script("grandchild", { run: (runtime, ctx) => runtime.commit(() => waitOn([other!], "allSettled"), ctx) }); script("other", { run: (runtime, ctx) => runtime.commit(() => waitOn([held!], "allSettled"), ctx) }); await root.commit(async (tx) => { held = await tx.createTask(Node, { name: "held" }, OWN_CONVERSATION); other = await tx.createTask(Node, { name: "other" }, OWN_CONVERSATION); }, context); // held -> child -> grandchild are holds; grandchild -> other -> held are waits. await until(() => grandchild.id !== undefined); const outcomes = await Promise.all([outcomeOf(harness, grandchild.id!), outcomeOf(harness, other!)]); expect(outcomes.sort()).toEqual(["completed", "faulted"]); expect(await outcomeOf(harness, child.id!)).toBe("completed"); await harness.close(context); }); // #10411 it("lets a background task wait on the owner of its conversation", async () => { const { harness, root } = await openNodes(); let owner: TaskId | undefined; script("background", { run: (runtime, ctx) => runtime.commit(() => waitOn([owner!], "allSettled"), ctx) }); const background = await root.commit(async (tx) => { owner = await tx.createTask(Node, { name: "owner" }, OWN_CONVERSATION); const conversation = await tx.createConversation({ ownership: { kind: "task", taskId: owner } }); const options = { ...OWN_CONVERSATION, conversationId: conversation.id, background: true }; return tx.createTask(Node, { name: "background" }, options); }, context); // background -> owner is a wait but owner -> background is no hold, so there is no cycle. await until(async () => (await state(harness, background)).status === "waiting"); open("owner"); expect(await outcomeOf(harness, background)).toBe("completed"); await harness.close(context); }); // #10411: the issue's orchestrator, worker, and reviewer. it("rejects a cycle of a hold and two waits that each look reasonable", async () => { const { harness, root } = await openNodes(); const ids: Record = {}; const reviewerWaits = deferred(); // The orchestrator spawns a worker and finishes; the running worker holds it as completing. script("orchestrator", { run: (runtime, ctx) => runtime.commit(async (tx) => { ids.worker = await spawn(tx, runtime.taskId, "worker"); return { status: "running", checkpoint: { phase: "resume", round: 1 } }; }, ctx), }); // The reviewer waits for the orchestrator's result. script("reviewer", { run: async (runtime, ctx) => { await runtime.commit(() => waitOn([ids.orchestrator!], "allSettled"), ctx); reviewerWaits.resolve(); }, }); // The worker waits for the reviewer, closing the cycle. script("worker", { run: async (runtime, ctx) => { await reviewerWaits.promise; await runtime.commit(() => waitOn([ids.reviewer!], "allSettled"), ctx); }, }); ids.orchestrator = await start(root, "orchestrator"); await until(async () => (await state(harness, ids.orchestrator!)).status === "completing"); ids.reviewer = await start(root, "reviewer"); const outcomes = await Promise.all( ["orchestrator", "worker", "reviewer"].map((name) => outcomeOf(harness, ids[name]!)), ); expect(outcomes).toEqual(["completed", "faulted", "completed"]); const worker = (await harness.waitForTask(ids.worker!, context)).state.outcome; expect(JSON.stringify(worker)).toContain( `${ids.worker} -> ${ids.reviewer} -> ${ids.orchestrator} -> ${ids.worker} is a wait cycle`, ); await harness.close(context); }); // #10411 it("allows a diamond of waits", async () => { const { harness, root } = await openNodes(); const ids: Record = {}; // A diamond: top waits on left and right, both wait on bottom. No cycle. script("top", { run: (runtime, ctx) => runtime.commit(() => waitOn([ids.left!, ids.right!], "allSettled"), ctx), }); script("left", { run: (runtime, ctx) => runtime.commit(() => waitOn([ids.bottom!], "allSettled"), ctx) }); script("right", { run: (runtime, ctx) => runtime.commit(() => waitOn([ids.bottom!], "allSettled"), ctx) }); await root.commit(async (tx) => { ids.bottom = await tx.createTask(Node, { name: "bottom" }, OWN_CONVERSATION); ids.left = await tx.createTask(Node, { name: "left" }, OWN_CONVERSATION); ids.right = await tx.createTask(Node, { name: "right" }, OWN_CONVERSATION); ids.top = await tx.createTask(Node, { name: "top" }, OWN_CONVERSATION); }, context); open("bottom"); expect(await outcomeOf(harness, ids.top)).toBe("completed"); await harness.close(context); }); // #10411 it("follows no wait of an abort-marked task, which waits only for its owned work", async () => { const { harness, root } = await openNodes(); const ids: Record = {}; const cleanup = deferred(); // b owns c and waits on a; aborting b leaves c's cleanup pending, so b waits only for c. script("b", { run: (runtime, ctx) => runtime.commit(async (tx) => { ids.c = await spawn(tx, runtime.taskId, "c"); return waitOn([ids.a!], "allSettled"); }, ctx), }); script("c", { abort: async (runtime, ctx) => { await cleanup.promise; await runtime.commit(() => ({ status: "terminal", outcome: { status: "aborted", result: "c" } }), ctx); }, }); const aWaits = deferred(); script("a", { run: async (runtime, ctx) => { await aWaits.promise; await runtime.commit(() => waitOn([ids.b!], "allSettled"), ctx); }, resume: (runtime, ctx) => runtime.commit(() => end("completed", "a"), ctx), }); await root.commit(async (tx) => { ids.a = await tx.createTask(Node, { name: "a" }, OWN_CONVERSATION); ids.b = await tx.createTask(Node, { name: "b" }, OWN_CONVERSATION); }, context); await until(async () => (await state(harness, ids.b!)).status === "waiting"); expect(await harness.abortTask(ids.b!, context)).toBe("marked"); await until(() => log.includes("abort:c")); // a -> b would close a -> b -> a through b's stale wait; b now waits only for c. aWaits.resolve(); await until(async () => (await state(harness, ids.a!)).status === "waiting"); cleanup.resolve(); expect(await outcomeOf(harness, ids.b!)).toBe("aborted"); expect(await outcomeOf(harness, ids.a!)).toBe("completed"); await harness.close(context); }); // #10411 it("rejects a longer cycle of waits", async () => { const { harness, root } = await openNodes(); const ids: Record = {}; const names = ["x", "y", "z"]; for (const [index, name] of names.entries()) { const next = names[(index + 1) % names.length]!; script(name, { run: (runtime, ctx) => runtime.commit(() => waitOn([ids[next]!], "allSettled"), ctx) }); } await root.commit(async (tx) => { for (const name of names) ids[name] = await tx.createTask(Node, { name }, OWN_CONVERSATION); }, context); const outcomes = await Promise.all(names.map((name) => outcomeOf(harness, ids[name]!))); expect(outcomes.filter((outcome) => outcome === "faulted")).toHaveLength(1); expect(outcomes.filter((outcome) => outcome === "completed")).toHaveLength(2); await harness.close(context); }); it("resumes at the next pass when it waits on nothing", async () => { const { harness, root } = await openNodes(); script("parent", { run: (runtime, ctx) => runtime.commit(() => waitOn([], "failFast"), ctx) }); expect(await outcomeOf(harness, await start(root, "parent"))).toBe("completed"); expect(log).toEqual(["run:parent", "resume:parent"]); await harness.close(context); }); it("keeps running phases after spawning and waits on subsets in sequence", async () => { const { harness, root } = await openNodes(); const ids: TaskId[] = []; script("parent", { run: async (runtime, ctx) => { await runtime.commit(async (tx) => { for (const name of ["a", "b", "c"]) ids.push(await spawn(tx, runtime.taskId, name)); return { status: "running", checkpoint: { phase: "resume", round: 0 } }; }, ctx); }, resume: async (runtime, ctx, round) => { log.push(`round:${round}`); if (round === 0) return runtime.commit(() => waitOn([ids[0]!], "allSettled", 1), ctx); if (round === 1) return runtime.commit(() => waitOn([ids[1]!, ids[2]!], "failFast", 2), ctx); await runtime.commit(() => end("completed", "parent"), ctx); }, }); const parent = await start(root, "parent"); await until(() => log.includes("round:0")); await until(async () => (await state(harness, parent)).status === "waiting"); open("b"); expect(await settled(harness.waitForTask(ids[1]!, context))).toBe(true); expect((await state(harness, parent)).status).toBe("waiting"); open("a"); await until(() => log.includes("round:1")); open("c"); expect(await outcomeOf(harness, parent)).toBe("completed"); expect(log.filter((line) => line.startsWith("round:"))).toEqual(["round:0", "round:1", "round:2"]); await harness.close(context); }); }); // ─── Completing ───────────────────────────────────────────────────────────── describe("completing", () => { it("holds a finished task until its owned work drains; waiters and task documents wait for the final commit", async () => { const { harness, root } = await openNodes(); let child: TaskId | undefined; script("parent", { run: async (runtime, ctx) => { await runtime.commit(async (tx) => { (await tx.doc(TaskNotes, runtime.taskId)).text = "notes"; child = await spawn(tx, runtime.taskId, "child"); return { status: "running", checkpoint: { phase: "resume", round: 1 } }; }, ctx); }, }); const parent = await start(root, "parent"); await until(async () => (await state(harness, parent)).status === "completing"); expect(await state(harness, parent)).toEqual({ status: "completing", outcome: { status: "completed", result: "parent" }, }); const waiter = harness.waitForTask(parent, context); expect(await settled(waiter)).toBe(false); expect(await harness.snapshot(TaskNotes, parent, context)).toEqual({ text: "notes" }); expect((await harness.inspect(context)).tasks.find((task) => task.record.id === parent)?.state).toEqual({ kind: "completing", }); expect(await settled(root.waitForIdle(context))).toBe(false); open("child"); expect((await waiter).state.outcome).toEqual({ status: "completed", result: "parent" }); expect(await harness.snapshot(TaskNotes, parent, context)).toBeUndefined(); await root.waitForIdle(context); expect(child).toBeDefined(); await harness.close(context); }); it("keeps holding for ordinary work created during the hold", async () => { const { harness, root } = await openNodes(); let conversation: ConversationId | undefined; script("parent", { run: async (runtime, ctx) => { await runtime.commit(async (tx) => { conversation = (await tx.createConversation({ ownership: { kind: "task", taskId: runtime.taskId } })).id; await tx.createTask(Node, { name: "first" }, { ...OWN_CONVERSATION, conversationId: conversation }); return { status: "running", checkpoint: { phase: "resume", round: 1 } }; }, ctx); }, }); const parent = await start(root, "parent"); await until(async () => (await state(harness, parent)).status === "completing"); const child = (await harness.conversation(conversation!, context))!; await start(child, "second"); open("first"); await until(() => log.includes("run:second")); expect((await state(harness, parent)).status).toBe("completing"); open("second"); expect(await outcomeOf(harness, parent)).toBe("completed"); await harness.close(context); }); for (const [label, ending] of [ ["a held failure", "failed"], ["a held scheduler fault", "throw"], ] as const) { it(`aborts the work below ${label}, then finishes with the held outcome`, async () => { const { harness, root } = await openNodes(); let child: TaskId | undefined; script("parent", { run: async (runtime, ctx) => { await runtime.commit(async (tx) => { child = await spawn(tx, runtime.taskId, "child"); return { status: "running", checkpoint: { phase: "resume", round: 1 } }; }, ctx); }, resume: async (runtime, ctx) => { if (ending === "throw") throw new Error("parent threw"); await runtime.commit(() => end("failed", "parent"), ctx); }, }); const parent = await start(root, "parent"); await until(() => child !== undefined); expect(await outcomeOf(harness, child!)).toBe("aborted"); expect(await outcomeOf(harness, parent)).toBe(ending === "throw" ? "faulted" : "failed"); await harness.close(context); }); } it("only marks a completing task when aborted: the work below is aborted, the held outcome stays", async () => { const { harness, root } = await openNodes(); let child: TaskId | undefined; script("parent", { run: async (runtime, ctx) => { await runtime.commit(async (tx) => { child = await spawn(tx, runtime.taskId, "child"); return { status: "running", checkpoint: { phase: "resume", round: 1 } }; }, ctx); }, }); const parent = await start(root, "parent"); await until(async () => (await state(harness, parent)).status === "completing"); expect(await harness.abortTask(parent, context)).toBe("marked"); expect(await outcomeOf(harness, child!)).toBe("aborted"); const settledParent = await harness.waitForTask(parent, context); expect(settledParent.state.outcome).toEqual({ status: "completed", result: "parent" }); expect(settledParent.abortRequested).toBe(true); expect(log).not.toContain("abort:parent"); expect(await harness.abortTask(parent, context)).toBe("terminal"); await harness.close(context); }); }); // ─── Abort order ──────────────────────────────────────────────────────────── describe("abort order", () => { /** Script `name` to spawn `child` and wait on it; its abort handler records the child's status first. */ function chain(harness: () => Harness, name: string, child: string): { id?: TaskId } { const found: { id?: TaskId } = {}; script(name, { run: (runtime, ctx) => runtime.commit(async (tx) => { found.id = await spawn(tx, runtime.taskId, child); return waitOn([found.id], "allSettled"); }, ctx), abort: async (runtime, ctx) => { log.push(`${name} saw ${(await harness().getTask(found.id!, context))!.state.status}`); await runtime.commit(() => ({ status: "terminal", outcome: { status: "aborted" } }), ctx); }, }); return found; } it("runs abort handlers bottom-up across three levels, each after the level below is terminal", async () => { let opened: Opened | undefined; const b = chain(() => opened!.harness, "a", "b"); const c = chain(() => opened!.harness, "b", "c"); opened = await openNodes(); const { harness, root } = opened; const a = await start(root, "a"); await until(() => log.includes("run:c")); await until(async () => (await state(harness, c.id!)).status === "running"); expect(await harness.abortTask(a, context)).toBe("marked"); expect(await outcomeOf(harness, a)).toBe("aborted"); expect(log.filter((line) => line.startsWith("abort:") || line.includes(" saw "))).toEqual([ "abort:c", "abort:b", "b saw terminal", "abort:a", "a saw terminal", ]); expect(b.id).toBeDefined(); await harness.close(context); }); it("does not wait for a task it waits on but does not own", async () => { const { harness, root } = await openNodes(); const other = await start(root, "other"); script("parent", { run: (runtime, ctx) => runtime.commit(() => waitOn([other], "allSettled"), ctx) }); const parent = await start(root, "parent"); await until(async () => (await state(harness, parent)).status === "waiting"); await harness.abortTask(parent, context); expect(await outcomeOf(harness, parent)).toBe("aborted"); expect((await state(harness, other)).status).toBe("running"); open("other"); await harness.close(context); }); it("reports an abort-marked task as waiting for its live owned work", async () => { const { harness, root } = await openNodes(); let child: TaskId | undefined; script("child", { abort: async (runtime, ctx) => { await gate("abort.child").promise; await runtime.commit(() => ({ status: "terminal", outcome: { status: "aborted" } }), ctx); }, }); script("parent", { run: (runtime, ctx) => runtime.commit(async (tx) => { child = await spawn(tx, runtime.taskId, "child"); return waitOn([child], "allSettled"); }, ctx), }); const parent = await start(root, "parent"); await until(async () => (await state(harness, parent)).status === "waiting"); await harness.abortTask(parent, context); await until(() => log.includes("abort:child")); const inspected = (await harness.inspect(context)).tasks.find((task) => task.record.id === parent); expect(inspected?.state).toEqual({ kind: "waiting", on: [child] }); open("abort.child"); expect(await outcomeOf(harness, parent)).toBe("aborted"); await harness.close(context); }); it("faults an abort handler that tries to wait", async () => { const { harness, root } = await openNodes(); script("parent", { abort: (runtime, ctx) => runtime.commit(() => waitOn([], "allSettled"), ctx) }); const parent = await start(root, "parent"); await until(() => log.includes("run:parent")); await harness.abortTask(parent, context); expect((await harness.waitForTask(parent, context)).state.outcome).toMatchObject({ status: "faulted", error: { message: expect.stringContaining("cannot wait") }, }); await harness.close(context); }); it("orphans a blocked task only after its owned work drained", async () => { const { harness, root } = await openNodes(); const { owner, child } = await root.commit(async (tx) => { const owner = await tx.createTask(Unregistered, { name: "owner" }, OWN_CONVERSATION); return { owner, child: await spawn(tx, owner, "child") }; }, context); await until(() => log.includes("run:child")); expect(await harness.abortTask(owner, context)).toBe("marked"); expect(await outcomeOf(harness, child)).toBe("aborted"); expect((await harness.waitForTask(owner, context)).state.outcome).toEqual({ status: "orphaned", reason: "missing_task", }); await harness.close(context); }); it("cascades through task and conversation edges, bottom-up", async () => { const { harness, root } = await openNodes(); let b: TaskId | undefined; let x: TaskId | undefined; script("a", { run: (runtime, ctx) => runtime.commit(async (tx) => { b = await spawn(tx, runtime.taskId, "b"); return waitOn([b], "allSettled"); }, ctx), }); script("b", { run: async (runtime, ctx) => { await runtime.commit(async (tx) => { const conversation = await tx.createConversation({ ownership: { kind: "task", taskId: runtime.taskId }, }); x = await tx.createTask(Node, { name: "x" }, { ...OWN_CONVERSATION, conversationId: conversation.id }); return { status: "running", checkpoint: { phase: "resume", round: 1 } }; }, ctx); }, resume: async (runtime) => { await aborted(runtime.signal); }, }); const a = await start(root, "a"); await until(() => log.includes("run:x")); await harness.abortTask(a, context); expect(await outcomeOf(harness, a)).toBe("aborted"); expect(await outcomeOf(harness, x!)).toBe("aborted"); expect(log.filter((line) => line.startsWith("abort:"))).toEqual(["abort:x", "abort:b", "abort:a"]); await harness.close(context); }); }); // ─── Background and terminal owners ───────────────────────────────────────── describe("boundaries", () => { it("keeps background work through Conversation.abort(); { background: true } aborts it and waits", async () => { const { harness, root } = await openNodes(); const foreground = await start(root, "foreground"); let child: TaskId | undefined; let below: TaskId | undefined; script("background", { run: async (runtime, ctx) => { await runtime.commit(async (tx) => { child = await spawn(tx, runtime.taskId, "child"); const conversation = await tx.createConversation({ ownership: { kind: "task", taskId: runtime.taskId }, }); below = await tx.createTask( Node, { name: "below" }, { ...OWN_CONVERSATION, conversationId: conversation.id }, ); return waitOn([child], "allSettled"); }, ctx); }, }); const background = await start(root, "background", { ...OWN_CONVERSATION, background: true }); await until(() => log.includes("run:below") && log.includes("run:child")); await root.abort(context); expect(await outcomeOf(harness, foreground)).toBe("aborted"); for (const id of [background, child!, below!]) expect((await state(harness, id)).status).not.toBe("terminal"); await root.abort(context, { background: true }); for (const id of [background, child!, below!]) expect((await state(harness, id)).status).toBe("terminal"); await harness.close(context); }); it("never cascades from a terminal owner: an aborted subagent's conversation runs new work normally", async () => { const { harness, root } = await openNodes(); let conversation: ConversationId | undefined; script("agent", { run: async (runtime, ctx) => { await runtime.commit(async (tx) => { conversation = (await tx.createConversation({ ownership: { kind: "task", taskId: runtime.taskId } })).id; return { status: "running", checkpoint: { phase: "resume", round: 1 } }; }, ctx); }, resume: async (runtime) => { await aborted(runtime.signal); }, }); const agent = await start(root, "agent"); await until(() => log.includes("resume:agent")); await harness.abortTask(agent, context); expect(await outcomeOf(harness, agent)).toBe("aborted"); const child = (await harness.conversation(conversation!, context))!; const question = await start(child, "question"); open("question"); expect(await outcomeOf(harness, question)).toBe("completed"); await harness.close(context); }); }); // ─── Recovery ─────────────────────────────────────────────────────────────── type Seeded = { parent: TaskId; children: TaskId[] }; /** * Write records a crash could leave, without a Harness: `parent` with children named `children`, then `edit` replaces * states through the internal task write. */ async function seed( path: string, children: readonly string[], edit: (set: (id: TaskId, patch: object) => Promise, seeded: Seeded) => Promise, ): Promise { const session = createSession(await openNodeSqliteStorage(path)); const seeded = await session.commit(async (tx) => { const root = await tx.createConversation({ ownership: { kind: "ownerless" } }); const parent = await tx.createTask(Node, { name: "parent" }, { ...OWN_CONVERSATION, conversationId: root.id }); const ids: TaskId[] = []; for (const name of children) ids.push(await spawn(tx, parent, name)); return { parent, children: ids }; }, context); await edit(async (id, patch) => { await session.commit(async (tx) => { const record = (await tx.task(id))!; (tx as unknown as { setTask(value: unknown): void }).setTask({ ...record, ...patch }); }, context); }, seeded); await session.close(context); return seeded; } const COMPLETED = { status: "terminal", outcome: { status: "completed", result: "done" } }; const FAILED = { status: "terminal", outcome: { status: "failed", error: { message: "declined" } } }; describe("recovery", () => { it("resumes a parent whose awaited children finished before the crash", async () => { const path = await sqlitePath(); const { parent, children } = await seed(path, ["c1"], async (set, { parent, children }) => { await set(children[0]!, { state: COMPLETED }); await set(parent, { state: { status: "waiting", checkpoint: { phase: "resume", round: 1 }, on: children, policy: "allSettled" }, }); }); const { harness } = await openNodes(await openNodeSqliteStorage(path)); expect(await outcomeOf(harness, parent)).toBe("completed"); expect(log).toEqual(["resume:parent"]); expect(children).toHaveLength(1); await harness.close(context); }); it("marks failFast siblings a crash left unmarked", async () => { const path = await sqlitePath(); const { parent, children } = await seed(path, ["c1", "c2"], async (set, { parent, children }) => { await set(children[0]!, { state: FAILED }); await set(parent, { state: { status: "waiting", checkpoint: { phase: "resume", round: 1 }, on: children, policy: "failFast" }, }); }); const { harness } = await openNodes(await openNodeSqliteStorage(path)); expect(await outcomeOf(harness, children[1]!)).toBe("aborted"); expect(await outcomeOf(harness, parent)).toBe("completed"); expect(log).not.toContain("run:c2"); await harness.close(context); }); it("finalizes a held outcome whose work drained before the crash, and keeps one whose work lives", async () => { const path = await sqlitePath(); const held = { status: "completing", outcome: { status: "completed", result: "parent" } }; const { parent, children } = await seed(path, ["c1"], async (set, { parent }) => { await set(parent, { state: held }); }); let opened = await openNodes(await openNodeSqliteStorage(path)); await until(() => log.includes("run:c1")); expect(await state(opened.harness, parent)).toEqual(held); await opened.harness.close(context); opened = await openNodes(await openNodeSqliteStorage(path)); open("c1"); expect(await outcomeOf(opened.harness, children[0]!)).toBe("completed"); expect(await outcomeOf(opened.harness, parent)).toBe("completed"); await opened.harness.close(context); const drained = await sqlitePath(); const second = await seed(drained, ["c1"], async (set, { parent, children }) => { await set(children[0]!, { state: COMPLETED }); await set(parent, { state: held }); }); opened = await openNodes(await openNodeSqliteStorage(drained)); expect(await outcomeOf(opened.harness, second.parent)).toBe("completed"); await opened.harness.close(context); }); it("resumes a bottom-up abort a crash interrupted before the cascade", async () => { const path = await sqlitePath(); const { parent, children } = await seed(path, ["c1"], async (set, { parent, children }) => { await set(parent, { abortRequested: true, state: { status: "waiting", checkpoint: { phase: "resume", round: 1 }, on: children, policy: "allSettled" }, }); }); const { harness } = await openNodes(await openNodeSqliteStorage(path)); expect(await outcomeOf(harness, parent)).toBe("aborted"); expect(await outcomeOf(harness, children[0]!)).toBe("aborted"); expect(log.filter((line) => line.startsWith("abort:"))).toEqual(["abort:c1", "abort:parent"]); await harness.close(context); }); it("reopens a checkout waiting on live payments and finishes it", async () => { const path = await sqlitePath(); const found = parentOf("checkout", ["p1", "p2"], "failFast"); let opened = await openNodes(await openNodeSqliteStorage(path)); const parent = await start(opened.root, "checkout"); await until(async () => (await state(opened.harness, parent)).status === "waiting"); await until(() => log.includes("run:p1") && log.includes("run:p2")); await opened.harness.close(context); opened = await openNodes(await openNodeSqliteStorage(path)); found.ids.splice( 0, found.ids.length, ...((await state(opened.harness, parent)) as unknown as { on: TaskId[] }).on, ); open("p1"); open("p2"); expect(await outcomeOf(opened.harness, parent)).toBe("completed"); expect(found.outcomes).toEqual(["completed", "completed"]); await opened.harness.close(context); }); }); // ─── Built-in tool rounds ─────────────────────────────────────────────────── function blockingTool(name: string): { started: Promise; registration: ToolRegistration } { const started = deferred(); return { started: started.promise, registration: defineTool({ name, description: `The ${name} tool`, parameters: Type.Object({}), executionMode: "sequential", execute: async (_args, api, callContext) => { api.output("partial"); started.resolve(); return aborted(callContext.abortSignal!); }, }), }; } function toolCalls(...ids: readonly [string, string][]) { return fauxAssistantMessage( ids.map(([name, id]) => fauxToolCall(name, {}, { id })), { stopReason: "toolUse" }, ); } describe("tool rounds", () => { it("owns its tool tasks, waits for them, and hands the run to a conversation-owned generation", async () => { const setup = chatSetup(); addTool( setup.registry, defineTool({ name: "noop", description: "Does nothing", parameters: Type.Object({}), execute: async () => ({ output: [] }), }), ); setup.faux.setResponses([toolCalls(["noop", "c1"], ["noop", "c2"]), fauxAssistantMessage([fauxText("done")])]); const { harness, root } = await openChat(new MemoryStorage(), setup); expect((await (await root.submit({ type: "input", content: "go" }, context)).wait(context)).status).toBe("done"); const tasks = (await harness.commit((tx) => tx.scanTasks({ conversationId: root.id }, 20), context)).items; const [first, second] = tasks.filter((task) => task.kind === "pi.generation"); expect(tasks.filter((task) => task.kind === "pi.tool").map((task) => task.owner)).toEqual([first!.id, first!.id]); expect(first!.owner).toBeUndefined(); expect(second!.owner).toBeUndefined(); const assistant = (await allEntries(root)).find((entry) => entry.kind === "pi.assistant")!; expect(first!.state).toEqual({ status: "terminal", outcome: { status: "completed", result: { entryId: assistant.id } }, }); await harness.close(context); }); it("aborts a parallel round with its generation: tools first, then the generation, with every result written", async () => { const setup = chatSetup(); const one = blockingTool("one"); const two = blockingTool("two"); addTool(setup.registry, defineTool({ ...one.registration, executionMode: "parallel" })); addTool(setup.registry, defineTool({ ...two.registration, executionMode: "parallel" })); setup.faux.setResponses([toolCalls(["one", "c1"], ["two", "c2"])]); const { harness, root } = await openChat(new MemoryStorage(), setup); const submission = await root.submit({ type: "input", content: "go" }, context); await Promise.all([one.started, two.started]); const generation = (await harness.snapshot(LiveDoc, root.id, context))!.run!.taskId; await harness.abortTask(generation, context); expect(await submission.wait(context)).toMatchObject({ status: "unanswered", reason: "aborted" }); const results = (await allEntries(root)).filter((entry) => ToolResultEntry.is(entry)); expect(results.map((entry) => (entry.model![0] as ToolResultMessage).toolCallId)).toEqual(["c1", "c2"]); expect(await harness.snapshot(LiveDoc, root.id, context)).toEqual({}); expect((await harness.waitForTask(generation, context)).state.outcome.status).toBe("aborted"); await harness.close(context); }); it("answers the unstarted calls of an aborted sequential round with aborted results, in call order", async () => { const setup = chatSetup(); const one = blockingTool("one"); addTool(setup.registry, one.registration); addTool(setup.registry, defineTool({ ...blockingTool("two").registration, name: "two" })); setup.faux.setResponses([toolCalls(["one", "c1"], ["two", "c2"], ["two", "c3"])]); const { harness, root } = await openChat(new MemoryStorage(), setup); const submission = await root.submit({ type: "input", content: "go" }, context); await one.started; expect( (await harness.snapshot(LiveDoc, root.id, context))!.tools!.map((slot) => slot.taskId !== undefined), ).toEqual([true, false, false]); const generation = (await harness.snapshot(LiveDoc, root.id, context))!.run!.taskId; await harness.abortTask(generation, context); expect(await submission.wait(context)).toMatchObject({ status: "unanswered", reason: "aborted" }); const results = (await allEntries(root)) .filter((entry) => ToolResultEntry.is(entry)) .map((entry) => entry.model![0] as ToolResultMessage); expect(results.map((result) => result.toolCallId)).toEqual(["c1", "c2", "c3"]); expect(results.map((result) => result.isError)).toEqual([true, true, true]); expect(results[1]!.content).toEqual([ { type: "text", text: "\n[error] Tool two was aborted\n" }, ]); const tools = ( await harness.commit((tx) => tx.scanTasks({ conversationId: root.id, kind: "pi.tool" }, 20), context) ).items; expect(tools).toHaveLength(1); await harness.close(context); }); it("ends a turn at the generation's hold, before its successor's turn starts", async () => { const setup = chatSetup(); addTask(setup.registry, Node); addTool( setup.registry, defineTool({ name: "noop", description: "Does nothing", parameters: Type.Object({}), execute: async () => ({ output: [] }), }), ); // An extension's hook starts work owned by the generation, which holds it while the next turn runs. let opened: Harness | undefined; addHooks(setup.registry, GenerationTask, { afterTools: async (_assistant, _results, api, callContext) => { await opened!.commit((tx) => tx.createTask(Node, { name: "hooked" }, owned(api.taskId)), callContext); }, }); setup.faux.setResponses([toolCalls(["noop", "c1"]), fauxAssistantMessage([fauxText("done")])]); const { harness, root } = await openChat(new MemoryStorage(), setup); opened = harness; const stream = await watchEvents(harness, root.id, context); const events: AgentEvent[] = []; stream.start(async (batch) => { events.push(...batch); }); await (await root.submit({ type: "input", content: "go" }, context)).wait(context); const first = (await harness.commit((tx) => tx.scanTasks({ kind: "pi.generation" }, 1), context)).items[0]!; expect((await state(harness, first.id)).status).toBe("completing"); const turns = () => events.filter((event) => event.type.startsWith("turn_")).map((event) => event.type); await waitFor(() => turns().length === 4); expect(turns()).toEqual(["turn_start", "turn_end", "turn_start", "turn_end"]); open("hooked"); expect(await outcomeOf(harness, first.id)).toBe("completed"); await waitFor(() => turns().length === 4); await stream.stop(); await harness.close(context); }); it("keeps run control with a faulted generation until its owned work drains, and fails the Harness on a failed final commit", async () => { const base = chatSetup(); const models = new Proxy(base.models, { get(target, property) { if (property === "streamSimple") return invalidFinalStream; const value = Reflect.get(target, property, target); return typeof value === "function" ? value.bind(target) : value; }, }); const setup = { ...base, models }; addTask(setup.registry, Node); let opened: Harness | undefined; addHooks(setup.registry, GenerationTask, { beforeRequest: async (_request, api, callContext) => { await opened!.commit((tx) => tx.createTask(Node, { name: "hooked" }, owned(api.taskId)), callContext); return undefined; }, }); script("hooked", { abort: async (runtime, ctx) => { await gate("abort.hooked").promise; await runtime.commit(() => ({ status: "terminal", outcome: { status: "aborted" } }), ctx); }, }); let reject: TaskId | undefined; const failure = new Error("disk gone"); class Failing extends ControlledStorage { override async commit( writes: Parameters[0], ctx: Parameters[1], ) { const finalizes = writes.some( (write) => write.type === "task" && write.value.id === reject && write.value.state.status === "terminal", ); if (finalizes) { reject = undefined; throw failure; } return super.commit(writes, ctx); } } const storage = new Failing(); const { harness, root } = await openChat(storage, setup); opened = harness; const submission = await root.submit({ type: "input", content: "hi" }, context); await waitFor(async () => { const generation = (await harness.snapshot(LiveDoc, root.id, context))?.run?.taskId; return generation !== undefined && (await state(harness, generation)).status === "completing"; }); const generation = (await harness.snapshot(LiveDoc, root.id, context))!.run!.taskId; expect(await state(harness, generation)).toMatchObject({ status: "completing", outcome: { status: "faulted" } }); expect(await settled(submission.wait(context))).toBe(false); expect((await harness.snapshot(LiveDoc, root.id, context))!.run?.taskId).toBe(generation); // The faulted outcome is cancellation intent: the hooked work is aborted, then the run settles. await waitFor(() => log.includes("abort:hooked")); reject = generation; open("abort.hooked"); // The final commit, with the run's cleanup, fails: nothing of the cleanup lands, and the Harness fails. expect(await harness.closed).toEqual({ reason: "failed", error: failure }); await expect(submission.wait(context)).rejects.toMatchObject({ name: "SessionFailed", cause: failure }); await harness.close(context); // Reopening finalizes the run; the partial becomes one aborted entry. const reopened = await openChat(storage.reopen(), setup); opened = reopened.harness; const again = (await reopened.harness.submission(submission.id, context))!; expect(await again.wait(context)).toMatchObject({ status: "unanswered", reason: "faulted" }); expect(await reopened.harness.snapshot(LiveDoc, reopened.root.id, context)).toEqual({}); expect((await allEntries(reopened.root)).map((entry) => entry.kind)).toEqual(["pi.user", "pi.assistant"]); await reopened.harness.close(context); }); }); /** A stream whose final message is not strict JSON, so the classification commit throws and the task faults. */ function invalidFinalStream(): ReturnType { const events = async function* () { yield { type: "start", partial: fauxAssistantMessage("partial", { stopReason: "pending" }) }; await new Promise((resolve) => setTimeout(resolve, 300)); }; const final = { ...fauxAssistantMessage("final"), invalid: () => {} }; return { [Symbol.asyncIterator]: events, result: async () => final } as unknown as ReturnType< Models["streamSimple"] >; } // ─── Review follow-ups ────────────────────────────────────────────────────── /** A waiter of its own kind, so its definition can be removed or replaced. */ function versioned(version: 1 | 2) { return defineTask<{ on: TaskId[] }, { phase: "wait" } | { phase: "resume" } | { phase: "migrated" }, string>({ name: "test.versioned", version, initial: () => ({ phase: "wait" }), phases: { wait: (task, runtime, ctx) => runtime.commit( () => ({ status: "waiting", checkpoint: { phase: "resume" }, on: task.input.on, policy: "allSettled" }), ctx, ), resume: (_task, runtime, ctx) => runtime.commit(() => ({ status: "terminal", outcome: { status: "completed", result: "v1" } }), ctx), migrated: (_task, runtime, ctx) => runtime.commit(() => ({ status: "terminal", outcome: { status: "completed", result: "v2" } }), ctx), }, abort: (_task, runtime, ctx) => runtime.commit(() => ({ status: "terminal", outcome: { status: "aborted" } }), ctx), ...(version === 2 ? { migrate: (input: unknown) => ({ input: input as { on: TaskId[] }, checkpoint: { phase: "migrated" as const }, }), } : {}), }); } /** Script `name` to spawn `child` and then complete, holding while the child lives. */ function spawnAndFinish(name: string, child: string): { id?: TaskId } { const found: { id?: TaskId } = {}; script(name, { run: async (runtime, ctx) => { await runtime.commit(async (tx) => { found.id = await spawn(tx, runtime.taskId, child); return { status: "running", checkpoint: { phase: "resume", round: 1 } }; }, ctx); }, }); return found; } describe("definitions and waits", () => { it("keeps a waiting task blocked without its definition and resumes it migrated under a newer one", async () => { const { harness, root, registry } = await openNodes(); const registration = addTask(registry, versioned(1)); const other = await start(root, "other"); const waiter = await root.commit((tx) => tx.createTask(versioned(1), { on: [other] }, OWN_CONVERSATION), context); await until(async () => (await state(harness, waiter)).status === "waiting"); registration.dispose(); open("other"); await harness.waitForTask(other, context); const inspected = async () => (await harness.inspect(context)).tasks.find((task) => task.record.id === waiter)?.state; expect(await inspected()).toEqual({ kind: "blocked", reason: "missing_task" }); expect((await state(harness, waiter)).status).toBe("waiting"); addTask(registry, versioned(2)); expect((await harness.waitForTask(waiter, context)).state.outcome).toEqual({ status: "completed", result: "v2" }); await harness.close(context); }); it("orphans an aborted waiting task without its definition at once, leaving the task it waits on running", async () => { const { harness, root, registry } = await openNodes(); const registration = addTask(registry, versioned(1)); const other = await start(root, "other"); const waiter = await root.commit((tx) => tx.createTask(versioned(1), { on: [other] }, OWN_CONVERSATION), context); await until(async () => (await state(harness, waiter)).status === "waiting"); registration.dispose(); expect(await harness.abortTask(waiter, context)).toBe("marked"); expect((await state(harness, waiter)).outcome).toEqual({ status: "orphaned", reason: "missing_task" }); expect((await state(harness, other)).status).toBe("running"); open("other"); await harness.close(context); }); it("never migrates a held outcome: a newer definition leaves it and its version alone", async () => { const { harness, root, registry, reports } = await openNodes(); const holder = (version: number) => defineTask({ name: "test.holder", version, initial: () => ({ phase: "run" }), phases: { run: async (task, runtime, ctx) => { await runtime.commit(async (tx) => { await spawn(tx, task.id, "child"); return undefined; }, ctx); await runtime.commit( () => ({ status: "terminal", outcome: { status: "completed", result: "held" } }), ctx, ); }, }, abort: (_task, runtime, ctx) => runtime.commit(() => ({ status: "terminal", outcome: { status: "aborted" } }), ctx), migrate: () => { throw new Error("never migrates"); }, }); addTask(registry, holder(1)); const parent = await root.commit((tx) => tx.createTask(holder(1), null, OWN_CONVERSATION), context); await until(async () => (await state(harness, parent)).status === "completing"); // The same extension name replaces the old one in place. addTask(registry, holder(2)); open("child"); const settledParent = await harness.waitForTask(parent, context); expect(settledParent.state.outcome).toEqual({ status: "completed", result: "held" }); expect(settledParent.version).toBe(1); expect(reports).toEqual([]); await harness.close(context); }); it("treats a held task as live: outcomes() rejects, and a task waiting on it resumes at its final commit", async () => { const { harness, root } = await openNodes(); spawnAndFinish("held", "child"); const held = await start(root, "held"); await until(async () => (await state(harness, held)).status === "completing"); let rejection = ""; script("reader", { run: async (runtime, ctx) => { rejection = await runtime.outcomes([held], ctx).then( () => "resolved", (error: Error) => error.message, ); await runtime.commit(() => waitOn([held], "allSettled"), ctx); }, }); const reader = await start(root, "reader"); await until(async () => (await state(harness, reader)).status === "waiting"); expect(rejection).toBe(`Task ${held} is not terminal`); open("child"); expect(await outcomeOf(harness, reader)).toBe("completed"); expect(log.indexOf("resume:reader")).toBeGreaterThan(-1); await harness.close(context); }); }); describe("conversation abort and boundaries", () => { it("marks a held completed task with Conversation.abort(): the work below is aborted, the outcome stays", async () => { const { harness, root } = await openNodes(); const child = spawnAndFinish("held", "child"); const held = await start(root, "held"); await until(async () => (await state(harness, held)).status === "completing"); await root.abort(context); expect(await outcomeOf(harness, child.id!)).toBe("aborted"); const record = await harness.waitForTask(held, context); expect(record.state.outcome.status).toBe("completed"); expect(record.abortRequested).toBe(true); await harness.close(context); }); it("marks and awaits only the background work reached when { background: true } is admitted", async () => { const { harness, root } = await openNodes(); script("first", { abort: async (runtime, ctx) => { await gate("abort.first").promise; await runtime.commit(() => ({ status: "terminal", outcome: { status: "aborted" } }), ctx); }, }); const background = { ...OWN_CONVERSATION, background: true }; const first = await start(root, "first", background); await until(() => log.includes("run:first")); const aborting = root.abort(context, { background: true }); await until(async () => (await harness.getTask(first, context))!.abortRequested); const later = await start(root, "later", background); open("abort.first"); await aborting; expect((await state(harness, later)).status).not.toBe("terminal"); expect((await harness.getTask(later, context))!.abortRequested).toBe(false); await harness.abortTask(later, context); await harness.close(context); }); it("stops a cascade at an unmarked background task, but not at a marked one", async () => { const { harness, root } = await openNodes(); script("owner", { abort: async (runtime, ctx) => { await gate("abort.owner").promise; await runtime.commit(() => ({ status: "terminal", outcome: { status: "aborted" } }), ctx); }, }); const tree = await root.commit(async (tx) => { const owner = await tx.createTask(Node, { name: "owner" }, OWN_CONVERSATION); const outer = await tx.createConversation({ ownership: { kind: "task", taskId: owner } }); const background = await tx.createTask( Node, { name: "background" }, { ...OWN_CONVERSATION, conversationId: outer.id, background: true }, ); const inner = await tx.createConversation({ ownership: { kind: "task", taskId: background } }); return { owner, outer: outer.id, background, inner: inner.id }; }, context); await until(() => log.includes("run:owner") && log.includes("run:background")); // The owner's abort handler starts at once: background work is not its ordinary owned work. await harness.abortTask(tree.owner, context); await until(() => log.includes("abort:owner")); const inner = (await harness.conversation(tree.inner, context))!; const outer = (await harness.conversation(tree.outer, context))!; const shielded = await start(inner, "shielded"); const exposed = await start(outer, "exposed"); expect(await outcomeOf(harness, exposed)).toBe("aborted"); expect((await harness.getTask(shielded, context))!.abortRequested).toBe(false); // Marked directly, the background task cascades into its own subtree. await harness.abortTask(tree.background, context); expect(await outcomeOf(harness, shielded)).toBe("aborted"); expect(await outcomeOf(harness, tree.background)).toBe("aborted"); open("abort.owner"); expect(await outcomeOf(harness, tree.owner)).toBe("aborted"); await harness.close(context); }); it("fails the Harness on a failed finalization, which reopening applies", async () => { let reject: TaskId | undefined; const failure = new Error("disk gone"); class Failing extends ControlledStorage { override async commit( writes: Parameters[0], ctx: Parameters[1], ) { const finalizes = writes.some( (write) => write.type === "task" && write.value.id === reject && write.value.state.status === "terminal", ); if (finalizes) { reject = undefined; throw failure; } return super.commit(writes, ctx); } } const storage = new Failing(); const { harness, root, reports } = await openNodes(storage); spawnAndFinish("parent", "child"); const parent = await start(root, "parent"); await until(async () => (await state(harness, parent)).status === "completing"); reject = parent; open("child"); expect(await harness.closed).toEqual({ reason: "failed", error: failure }); expect(reports).toEqual([failure]); await harness.close(context); const reopened = await openNodes(storage.reopen()); expect(await outcomeOf(reopened.harness, parent)).toBe("completed"); await reopened.harness.close(context); }); }); describe("recovery through ownership edges", () => { it("shows an abort-marked owner waiting for work in its owned conversation before resume after reopen", async () => { const path = await sqlitePath(); const session = createSession(await openNodeSqliteStorage(path)); const tree = await session.commit(async (tx) => { const conversation = await tx.createConversation({ ownership: { kind: "ownerless" } }); const owner = await tx.createTask( Node, { name: "owner" }, { ...OWN_CONVERSATION, conversationId: conversation.id }, ); const child = await tx.createConversation({ ownership: { kind: "task", taskId: owner } }); const inner = await tx.createTask(Node, { name: "inner" }, { ...OWN_CONVERSATION, conversationId: child.id }); return { owner, inner }; }, context); await session.commit(async (tx) => { const record = (await tx.task(tree.owner))!; (tx as unknown as { setTask(value: unknown): void }).setTask({ ...record, abortRequested: true }); }, context); await session.close(context); const { harness } = await openTasks(await openNodeSqliteStorage(path), [Node]); const inspected = (await harness.inspect(context)).tasks.find((task) => task.record.id === tree.owner); expect(inspected?.state).toEqual({ kind: "waiting", on: [tree.inner] }); harness.resume(); expect(await outcomeOf(harness, tree.owner)).toBe("aborted"); expect(log.filter((line) => line.startsWith("abort:"))).toEqual(["abort:inner", "abort:owner"]); await harness.close(context); }); it("keeps the root busy after reopen for work below a child task's owned conversation", async () => { const path = await sqlitePath(); let inner: TaskId | undefined; script("child", { run: async (runtime, ctx) => { await runtime.commit(async (tx) => { const conversation = await tx.createConversation({ ownership: { kind: "task", taskId: runtime.taskId }, }); inner = await tx.createTask( Node, { name: "inner" }, { ...OWN_CONVERSATION, conversationId: conversation.id }, ); return { status: "running", checkpoint: { phase: "resume", round: 1 } }; }, ctx); }, }); parentOf("parent", ["child"], "allSettled"); let opened = await openNodes(await openNodeSqliteStorage(path)); const parent = await start(opened.root, "parent"); await until(() => log.includes("run:inner")); await opened.harness.close(context); opened = await openNodes(await openNodeSqliteStorage(path)); expect(await settled(opened.root.waitForIdle(context))).toBe(false); open("inner"); await opened.root.waitForIdle(context); expect(await outcomeOf(opened.harness, parent)).toBe("completed"); expect(inner).toBeDefined(); await opened.harness.close(context); }); it("keeps a finished background ancestor a boundary after reopen, which { background: true } crosses", async () => { const path = await sqlitePath(); let conversation: ConversationId | undefined; script("child", { run: async (runtime, ctx) => { await runtime.commit(async (tx) => { conversation = (await tx.createConversation({ ownership: { kind: "task", taskId: runtime.taskId } })).id; return { status: "running", checkpoint: { phase: "resume", round: 1 } }; }, ctx); }, }); parentOf("background", ["child"], "allSettled"); let opened = await openNodes(await openNodeSqliteStorage(path)); const background = await start(opened.root, "background", { ...OWN_CONVERSATION, background: true }); await opened.harness.waitForTask(background, context); await opened.harness.close(context); opened = await openNodes(await openNodeSqliteStorage(path)); const below = await start((await opened.harness.conversation(conversation!, context))!, "below"); await until(() => log.includes("run:below")); await opened.root.waitForIdle(context); await opened.root.abort(context); expect((await state(opened.harness, below)).status).toBe("running"); await opened.root.abort(context, { background: true }); expect(await outcomeOf(opened.harness, below)).toBe("aborted"); await opened.harness.close(context); }); }); describe("tool rounds and events", () => { async function listen(harness: Harness, conversation: Conversation) { const stream = await watchEvents(harness, conversation.id, context); const events: AgentEvent[] = []; stream.start(async (batch) => { events.push(...batch); }); return { stream, events }; } /** Event types, with tool ends and message ends labelled by call ID and whether an entry came along. */ function labels(events: readonly AgentEvent[]): string[] { return events.flatMap((event) => { if (event.type === "tool_execution_end") return [`end:${event.toolCallId}:${event.entry !== undefined}`]; if (event.type === "message_end") { const message = event.entry.model?.[0]; return [message?.role === "toolResult" ? `result:${message.toolCallId}` : `message:${message?.role}`]; } return []; }); } it("ends an aborted sequential round's unstarted calls with their result entries, right before them", async () => { const setup = chatSetup(); const one = blockingTool("one"); addTool(setup.registry, one.registration); addTool(setup.registry, defineTool({ ...blockingTool("two").registration, name: "two" })); setup.faux.setResponses([toolCalls(["one", "c1"], ["two", "c2"], ["two", "c3"])]); const { harness, root } = await openChat(new MemoryStorage(), setup); const { stream, events } = await listen(harness, root); const submission = await root.submit({ type: "input", content: "go" }, context); await one.started; await harness.abortTask((await harness.snapshot(LiveDoc, root.id, context))!.run!.taskId, context); await submission.wait(context); await waitFor(() => labels(events).includes("result:c3")); expect(labels(events).filter((label) => !label.startsWith("message:"))).toEqual([ "end:c1:true", "result:c1", "end:c2:true", "result:c2", "end:c3:true", "result:c3", ]); await stream.stop(); await harness.close(context); }); it("emits one turn_end per generation, also for a stream attached while it holds", async () => { const setup = chatSetup(); addTask(setup.registry, Node); addTool( setup.registry, defineTool({ name: "noop", description: "Does nothing", parameters: Type.Object({}), execute: async () => ({ output: [] }), }), ); let opened: Harness | undefined; addHooks(setup.registry, GenerationTask, { afterTools: async (_assistant, _results, api, callContext) => { await opened!.commit((tx) => tx.createTask(Node, { name: "hooked" }, owned(api.taskId)), callContext); }, }); setup.faux.setResponses([toolCalls(["noop", "c1"]), fauxAssistantMessage([fauxText("done")])]); const { harness, root } = await openChat(new MemoryStorage(), setup); opened = harness; const early = await listen(harness, root); await (await root.submit({ type: "input", content: "go" }, context)).wait(context); const late = await listen(harness, root); open("hooked"); const [first] = (await harness.commit((tx) => tx.scanTasks({ kind: "pi.generation" }, 1), context)).items; await harness.waitForTask(first!.id, context); await root.commit((tx) => tx.appendEntry(root.id, { kind: "note" }), context); await waitFor(() => late.events.some((event) => event.type === "entry_appended")); const turns = (events: readonly AgentEvent[]) => events.filter((event) => event.type === "turn_end").length; expect(turns(early.events)).toBe(2); expect(turns(late.events)).toBe(0); await early.stream.stop(); await late.stream.stop(); await harness.close(context); }); it("lets a tool that owns live work finish its call at the hold while the generation waits for its final commit", async () => { const setup = chatSetup(); addTask(setup.registry, Node); addTool( setup.registry, defineTool({ name: "delegate", description: "Starts work in a conversation it owns", parameters: Type.Object({}), execute: async (_args, api, callContext) => { await api.commit(async (tx) => { const child = await tx.createConversation({ ownership: { kind: "task", taskId: api.taskId } }); await tx.createTask(Node, { name: "sub" }, { ...OWN_CONVERSATION, conversationId: child.id }); }, callContext); return { output: [{ type: "text", text: "started" }] }; }, }), ); setup.faux.setResponses([toolCalls(["delegate", "c1"]), fauxAssistantMessage([fauxText("done")])]); const { harness, root } = await openChat(new MemoryStorage(), setup); const { stream, events } = await listen(harness, root); const submission = await root.submit({ type: "input", content: "go" }, context); await waitFor(async () => (await harness.snapshot(LiveDoc, root.id, context))?.tools?.[0]?.status === "done"); const live = (await harness.snapshot(LiveDoc, root.id, context))!; const tool = live.tools![0]!.taskId!; expect(live.tools![0]!.entry).toBeDefined(); expect((await state(harness, tool)).status).toBe("completing"); expect((await state(harness, live.run!.taskId)).status).toBe("waiting"); await waitFor(() => labels(events).includes("end:c1:true")); expect(await settled(submission.wait(context))).toBe(false); open("sub"); expect((await submission.wait(context)).status).toBe("done"); expect(await outcomeOf(harness, tool)).toBe("completed"); await stream.stop(); await harness.close(context); }); it("holds a faulted tool's slot and task_failed until the work it owns drained", async () => { const setup = chatSetup(); addTask(setup.registry, Node); script("held", { abort: async (runtime, ctx) => { await gate("abort.held").promise; await runtime.commit(() => ({ status: "terminal", outcome: { status: "aborted" } }), ctx); }, }); addTool( setup.registry, defineTool({ name: "broken", description: "Starts owned work, then returns a result that is not strict JSON", parameters: Type.Object({}), execute: async (_args, api, callContext) => { await api.commit((tx) => tx.createTask(Node, { name: "held" }, owned(api.taskId)), callContext); return { output: [], details: { fn: (() => 1) as unknown as JsonValue } }; }, }), ); setup.faux.setResponses([toolCalls(["broken", "c1"]), fauxAssistantMessage([fauxText("done")])]); const { harness, root } = await openChat(new MemoryStorage(), setup); const { stream, events } = await listen(harness, root); const submission = await root.submit({ type: "input", content: "go" }, context); await waitFor(() => log.includes("abort:held")); const live = (await harness.snapshot(LiveDoc, root.id, context))!; const tool = live.tools![0]!.taskId!; expect(await state(harness, tool)).toMatchObject({ status: "completing", outcome: { status: "faulted" } }); expect(live.tools![0]!.status).not.toBe("done"); expect(events.some((event) => event.type === "task_failed")).toBe(false); open("abort.held"); expect((await submission.wait(context)).status).toBe("done"); expect(await outcomeOf(harness, tool)).toBe("faulted"); await waitFor(() => events.some((event) => event.type === "task_failed")); expect(labels(events)).toContain("end:c1:false"); await stream.stop(); await harness.close(context); }); });