pi-mono/packages/durable/test/examples/13-recovery.ts
Mario Zechner b56702ad34 feat(durable): extensions and per-conversation agents (Package 21)
- Registry of named extensions (tools, sections, hooks, wraps, tasks) with install/uninstall;
  defineExtension, defineTool, section, hook, wrapTool, wrapSection
- pi.agent document (model, thinking level, extensions, tools filter, instructions, cwd),
  configure(), Conversation.agent()/configure(), agent option on root/createConversation/fork,
  task-owned conversations copy their owner's agent
- Harness-wide HarnessSettings read at every use; env function built per use
- Hooks and the agent resolved per task phase; tool calls resolved with the tools filter applied
- ToolRegistration<TParameters, TDetails> types execute() args from parameters
- HarnessOptions.conversationCreated; Tx.submissionByRequest() and Tx.createSubmission()
- FileSystem.id; edit/write serialize per file system id and canonical path
- CodingTools extension (read, write, edit, bash)
- Examples 06-31 on the new API, README and CHANGELOG
2026-10-01 01:28:22 +02:00

90 lines
3.8 KiB
TypeScript

// Close, reopen, and continue where a task stopped.
// Run from packages/durable:
// node --conditions=source --experimental-strip-types test/examples/13-recovery.ts
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context";
import { createModels } from "@earendil-works/pi-ai/models";
import { createRegistry, defineExtension, defineTask, Harness } from "../../src/index.ts";
import { openNodeSqliteStorage } from "../../src/storage/sqlite/node.ts";
const context = BACKGROUND_CONTEXT;
// Everything a task needs to continue is in storage, so a new Harness over the
// same storage picks up where the last one stopped. This example keeps its
// storage in a SQLite file so it survives closing.
const directory = await mkdtemp(join(tmpdir(), "pi-durable-example-"));
const databasePath = join(directory, "session.sqlite");
let reachedTick = (_n: number): void => {};
const Ticker = defineTask<{ to: number }, { phase: "tick"; n: number }, string>({
name: "example.ticker",
version: 1,
initial: () => ({ phase: "tick", n: 1 }),
phases: {
tick: async (task, runtime, taskContext) => {
const n = task.state.checkpoint.n;
// Save the intent before the effect. A memo keeps the first value
// written under its name, so if the process dies after printing but
// before the next checkpoint is saved, the rerun sees the memo and
// does not print the same tick twice.
if ((await runtime.memo(`printed-${n}`, taskContext)) === undefined) {
await runtime.memo(`printed-${n}`, true, taskContext);
console.log(`tick ${n}`);
}
reachedTick(n);
// Save the outcome: the next tick, or the final result.
await runtime.commit(
() =>
n === task.input.to
? { status: "terminal", outcome: { status: "completed", result: `counted to ${n}` } }
: { status: "running", checkpoint: { phase: "tick", n: n + 1 } },
taskContext,
);
// Wait a little between ticks. Closing the Harness cancels this wait;
// the checkpoint saved above is where the next Harness continues.
await runtime.sleep(Date.now() + 50, taskContext);
},
},
abort: async (_task, runtime, taskContext) => {
await runtime.commit(() => ({ status: "terminal", outcome: { status: "aborted" } }), taskContext);
},
});
const registry = createRegistry();
registry.install(defineExtension({ name: "ticker", tasks: [Ticker] }));
const open = async () =>
Harness.open(await openNodeSqliteStorage(databasePath), { models: createModels(), registry }, context);
// First run: start counting to 5, and close the Harness right after tick 2 is
// printed, before its next checkpoint is saved. That is the same situation as
// a crash between the effect and saving its outcome.
const firstRun = await open();
const tickerId = await (await firstRun.root(context)).commit(
(tx) => tx.createTask(Ticker, { to: 5 }, { ownership: { kind: "conversation" } }),
context,
);
const tickTwo = new Promise<void>((resolve) => {
reachedTick = (n) => {
if (n === 2) resolve();
};
});
firstRun.resume();
await tickTwo;
await firstRun.close(context);
reachedTick = () => {};
// Read the saved record through a Harness that never resumes, so nothing runs.
const reader = await open();
const saved = (await reader.getTask(tickerId, context))!;
await reader.close(context);
console.log("closed; saved checkpoint:", saved.state, "memos:", saved.memos);
// Second run: nothing to restart by hand. Waiting for the unfinished task
// enables scheduling and continues it. Tick 2 runs again because its outcome
// was never saved, but its memo says it was already printed.
const secondRun = await open();
const counted = await secondRun.waitForTask(tickerId, context);
console.log("after reopen:", counted.state.outcome);
await secondRun.close(context);
await rm(directory, { recursive: true, force: true });