agent.run()
DOCUMENTATION / HARNESSES & COMPUTERS

Based on AgentRun release · Synced .

@parcha/pi-durable-disk

A durable run journal and CLI for one host, with places served by agentrun-serve. A run is one directory of this machine: its conversation, its tasks and its files live together there, and whatever process opens the directory next carries on where the last one released it. It runs pi-durable agents, on Linux (macOS is for development; Windows is not supported). This is a beta (0.1.0-beta.N): a change that breaks a caller is listed in CHANGELOG.md when it ships.

What it is

  • A run is one directory. It holds pi's SQLite store (store/run.sqlite: the conversation and every task), the agent's workspace (work/); the store also keeps the run's record (its status, generation and holder). Layout of a run.
  • One process at a time holds it. The store's own lock refuses a second open on this machine, and a process whose store or claim fails is fenced and cannot write any more.
  • Release and open. release() records where the run stands, and the instance ends: returning from onOpen after await run.release() is enough (the examples call process.exit(0), which is fine too). The next process to open the directory is the next generation: it reopens the store, and pi-durable resumes the conversation and its tasks from the last commit.
  • One machine. The directory is the claim, so it keeps a second process on this machine off the run and nothing else. Placing a run on an Archil disk, or in a remote box, is the job of agentrun-serve (@parcha/agentrun-serve).

When to use it

  • Your process dies or is replaced. A crash, a kill -9 or a deploy: the next process to open the directory carries on from the last commit, and an effect whose intent was committed is never started twice.
  • The agent waits. A run whose work only sleeps in a retry or a long poll (longer than the park threshold) is released, holds no process, and its host starts it again at its deadline. With --serve, a run with no work at all is released too (serve and parking).
  • You want to look at a run from outside. Its record is a file: readLocalRunStatus(dir).

It is not for a one-shot script with no state to keep (pi-durable's in-memory storage does that).

Try it: no disk, no key

On Linux with Node 22.19 or later (or on macOS for development). In an empty directory:

npm install @parcha/pi-durable-disk@latest @earendil-works/pi-durable@1.5.0 @earendil-works/pi-ai@1.5.0 @earendil-works/chord@1.5.0

Save this as pick-up.mts (Node 22.19 or later runs the types away; .mts makes it an ES module whatever your package.json says). It is examples/pick-up-a-run/main.ts. It starts three processes, one after another, on one directory. Each opens the run, answers one question of the same chat, and releases it. pi-ai's faux model answers, so it needs no key:

// Pick up a run: a run is a directory, and whatever process opens it carries on where the last one released it. No disk, no
// key (pi-ai's faux model answers). Three processes, one after another, each open the same run, answer one question of the
// same chat and release it; the last still knows the first question, because the chat is in the directory, not in a process.
// One machine: the directory is the claim.
import { spawn } from "node:child_process";
import { rmSync } from "node:fs";
import { join } from "node:path";
import { createInterface } from "node:readline";
import { fileURLToPath } from "node:url";
import { BACKGROUND_CONTEXT as ctx } from "@earendil-works/chord/context";
import { createModels, fauxAssistantMessage, fauxProvider } from "@earendil-works/pi-ai";
import { createRegistry } from "@earendil-works/pi-durable";
import type { EntryRecord } from "@earendil-works/pi-durable";
import { localRun, openDurableRun, readLocalRunStatus } from "@parcha/pi-durable-disk";

const QUESTIONS = ["Where does your transcript live?", "Who had this run before you?", "Do you remember my first question?"];
const root = join(process.cwd(), "pick-up-a-run-run");
const text = (message: { content: unknown }): string =>
  typeof message.content === "string" ? message.content : (message.content as { text?: string }[]).map((part) => part.text ?? "").join("");

if (process.argv[2] === "instance") {
  let generation = 0;
  const faux = fauxProvider();
  faux.setResponses(Array.from({ length: 100 }, () => (context) => {
    const asked = context.messages.filter((message) => message.role === "user");
    return fauxAssistantMessage(`Answer ${asked.length}, by generation ${generation}. You first asked: "${text(asked[0]!)}"`);
  }));
  const models = createModels();
  models.setProvider(faux.provider);
  const run = await openDurableRun({ disk: "local", region: "local", id: "chat" }, { ...localRun(root), harness: { models, registry: createRegistry() } });
  generation = run.generation;
  const model = faux.getModel();
  const chat = await run.harness.root(ctx, { agent: { model: { provider: model.provider, modelId: model.id } } });
  const answers = async (): Promise<EntryRecord[]> => [...(await chat.entries({}, 100, undefined, ctx)).items].reverse().filter((entry) => entry.kind === "pi.assistant");
  const n = (await answers()).length + 1; // the next question: this process answers one and lets go
  console.log(`generation ${generation} opened the run (${n - 1} answered before)`);
  await (await chat.submit({ type: "input", content: QUESTIONS[n - 1], requestId: `question-${n}` }, ctx)).wait(ctx);
  console.log(`  ${QUESTIONS[n - 1]}\n  ${text((await answers()).at(-1)!.model![0]!)}`);
  await run.setStatus(n === QUESTIONS.length ? "done" : "paused");
  await run.release();
  process.exit(0);
}

// The supervisor: one process per question, one after another, on the same directory. It reads whole lines, and waits for
// `close` (the process gone and all its output read), not `exit`.
rmSync(root, { recursive: true, force: true });
console.log(`the run is ${root}`);
const output: string[] = [];
for (const [i] of QUESTIONS.entries()) {
  const child = spawn(process.execPath, [fileURLToPath(import.meta.url), "instance"], { stdio: ["ignore", "pipe", "inherit"] });
  createInterface({ input: child.stdout }).on("line", (line) => {
    console.log(line);
    output.push(line);
  });
  const code = await new Promise<number | null>((resolve) => child.on("close", resolve));
  if (code !== 0) {
    console.error(`not ok: the process for question ${i + 1} exited ${code}`);
    process.exit(1);
  }
}
const opened = output.flatMap((line) => /^generation (\d+) opened the run \((\d+) answered before\)$/.exec(line)?.slice(1).map(Number) ?? []);
const status = await readLocalRunStatus(root); // the run's record, read from outside, with no process running
const remembered = output.at(-1)?.includes(`You first asked: "${QUESTIONS[0]}"`);
if (opened.join() !== "1,0,2,1,3,2" || !remembered || status?.status !== "done" || status.generation !== 3) {
  console.error(`not ok: opened ${JSON.stringify(opened)}, remembered ${remembered}, status ${JSON.stringify(status && [status.status, status.generation])}`);
  process.exit(1);
}
console.log(`ok: three processes, one run: each picked up where the last released it, the last still knew the first question, and the run reads done at generation 3 (${root})`);

Run it (about two seconds; it writes the run under ./pick-up-a-run-run/):

node pick-up.mts

It ends with:

generation 3 opened the run (2 answered before)
  Do you remember my first question?
  Answer 3, by generation 3. You first asked: "Where does your transcript live?"
ok: three processes, one run: each picked up where the last released it, the last still knew the first question, and the run reads done at generation 3 (<this directory>/pick-up-a-run-run)

Nothing but the directory carried the conversation from one process to the next, so the third process knew the first question. The directory is the claim here, which guards one machine only.

What you can do with it

  • Park a run while it waits, and start it again at its deadline (serve-and-park).
  • Serve a run over HTTP: POST /submit with a requestId that is deduplicated in the run's store, so a retry that reaches the next incarnation gets the same submission (Serve and parking).
  • Seed a new run with a store and files before anything sees it (Seeding a new run).
  • Keep what was committed. Every commit is on the disk before anything is shown, and an effect whose intent was committed is never started twice.

examples/ lists every example, one property each, with what each needs and the first version that has what it imports.

What it costs

A run costs nothing but its directory: no account and no key. The model is yours: the examples use a scripted one, so the first run spends nothing; the package adds no model calls of its own. A run whose work only waits longer than the park threshold is released and holds no process; an instance with no work at all parks only with --serve or --park-idle (parking). Otherwise it stays up.

What it does not do

  • Guard a run against another machine. The directory is the claim, so it keeps a second process on this machine off the run and nothing else.
  • Sandbox an agent. Commands run as the run user, with no_new_privs and the file tools confined to work/, and a command can reach anything that user can. Run an agent you do not trust in your own sandbox (Security).
  • Make a third party idempotent. The package never starts the same effect twice, but a call your model decides to retry is a new call: give a paid API an idempotency key from your own business key.
  • Choose your model or hold its keys. You bring the provider and the key; they stay in your app.
  • Run on every platform. Linux is supported. macOS is for development (On macOS) and Windows is not supported.

Install

npm install @parcha/pi-durable-disk@latest @earendil-works/pi-durable@1.5.0 @earendil-works/pi-ai@1.5.0 @earendil-works/chord@1.5.0

@earendil-works/pi-durable and @earendil-works/chord are peer dependencies, 1.5.0 exactly; CI tests 1.5.0. Node 22.19 or later; Linux, or macOS for development.

@earendil-works/pi-coding-agent pins undici 8.10.2 exactly. To install undici 8.12.2 under them, add this override to your own package.json (npm applies overrides only in the project that runs the install):

"overrides": { "undici@8.10.2": "8.12.2" }

From a checkout (this package is packages/pi-durable-disk of the repository; the root build compiles dist/):

git clone https://github.com/Parcha-ai/agentrun.git && cd agentrun && npm ci --ignore-scripts && npm run build
cd packages/pi-durable-disk

The door hook (AppOptions.door)

By default serve's door (POST /submit, POST /abort, GET /status, GET /events) is pi's root conversation. An app with a harness of its own (a child process, another agent loop) hands the door to it with door, an AppOptions field. Without door, the door answers byte for byte as it did before the field existed (test/door-exchange.test.ts holds the recorded exchange).

interface DoorHook {
  /** Resolves when the input is TAKEN, not when the turn ends; the turn runs after, reported through ctx.emit and the conversation's entries. */
  submit(input: { requestId: string; content: UserInput; whenBusy: "steer" | "followUp" | "reject" }, ctx: DoorContext): Promise<{ accepted: true } | { accepted: false; reason: "busy" }>;
  abort(ctx: DoorContext): Promise<"aborted" | "idle">;
  /** Called once as the instance stops (a drain, a park, a release, a fence), busy or idle: on a drain after the abort, on a
   *  fence alongside the kill, bounded by the abort's bound. What the hook runs outside any turn stops here. It never fails a stop. */
  close(): Promise<void>;
  busy(): boolean;
  /** Its harness's id, named in the door's log lines (a close that fails or overruns its bound). */
  readonly harness: string;
}
type DoorContext = { run: DurableRun; signal: AbortSignal; emit(event: { type: "run_start" } | { type: "run_end" }): void; adopt(pid: number): void };

A hook's close() that throws, rejects or overruns the abort's bound is logged through serveRun's optional log ("door close failed" / "door close timed out", with the hook's harness and the error); the run CLI passes its own log.

A submit with answers: false and asks (tool names) may not answer a question the conversation waits on: pi's root refuses it with 409 WAITING when its last model message is the result of a tool in asks, and both pi and a hook admit it only as whenBusy: "reject", so a busy run refuses it (409 BUSY) and it is never queued behind a turn that may end on such a tool. With parks (entry kinds), it is refused with 409 WAITING too, by pi and a hook alike, while a call waits in a park of one of those kinds: an entry saying {phase: "requested", id} with no {phase: "resolved", id} after it, read back to the turn's last user message (a hook's conversation has none: back to its start). A tool call waiting on its owner's approval parks so on every harness. The park is read in one transaction: for a hook, the commit that admits the input; for pi, the one just before pi's own admission. Only a new admission is read for: a requestId the door or pi took already answers its record, whatever has parked since.

With a hook, POST /submit (whose whenBusy defaults to followUp, as pi's) goes through, in this order: the bearer token (401); the run's state (503 OPENING, PARKING, RELEASED), so nothing reaches the hook once the run is fenced; the admission, one door.input entry in the root conversation holding the requestId, the content and its status, written through the run's fenced commit (a fence before it lands fails it, and the hook is not called); then hook.submit, once per requestId, bounded at doorTimeouts.submitMs (30 s). Each status change is a door.status entry on the same requestId, and the last one is the record's status. A requestId already admitted answers its status and never calls the hook again, in this process or the next: the record is in the store, and a lookup is one indexed read per entry.

A busy refusal is not an outcome. A whenBusy: "reject" input is refused (409 BUSY) with nothing written while the hook is busy, or a call in this instance is about to make it so. One the hook refuses as busy after its admission is written REJECTED_BUSY, and the same requestId, sent again, is admitted again (a new door.input on its record) and reaches the hook. doorKey(requestId, n) names the record's entries in turn, and doorTook(requestId, statusAt) reads whether the door took a request, or may have: its last status, unless that is REJECTED_BUSY. A host reads it in the store when the run has no live door.

Status Means A submit of that requestId answers
ADMITTED only: the hook is running in this instance, on a record this instance admitted 202 { status: "ADMITTED" }
ACCEPTED the hook took the input 200 { status: "ACCEPTED" }; never submitted again
REJECTED_BUSY the hook refused it as busy 409 BUSY, status: "REJECTED_BUSY"; sent again, it is admitted again
REJECTED_WAITING an input with answers: false came while a call waits in a park it names; never written 409 WAITING; sent again, it is read again
FAILED hook.submit threw, before taking the input 500 FAILED, status: "FAILED"
UNKNOWN the bound passed: the door aborted ctx.signal, called hook.abort (bounded at doorTimeouts.abortMs, 5 s) and stopped waiting; or the record was admitted by an earlier instance, or before the run was fenced, and no call here carries it 504 UNKNOWN, status: "UNKNOWN"; never submitted again

A late answer to an UNKNOWN submit is never dropped: the door writes the real outcome (ACCEPTED, REJECTED_BUSY, or FAILED if it threw) on the same requestId, unless the run is fenced by then (the fenced commit fails, and the record stays UNKNOWN). An abort that loses the race to delivery so ends ACCEPTED.

A record whose instance died, or was fenced, between the admission and the hook's outcome was dispatched or not, nobody knows: it is UNKNOWN, and final, since no later outcome can come from a dead or fenced instance. A retry answers it UNKNOWN ("admitted by an earlier instance, or before the run was fenced: whether the hook took it is unknown") as soon as no call in this instance carries it, and each open writes a door.status UNKNOWN for every such record through the fenced commit, so the store and the events say it too. A fence between the admission and the call writes it at once when the commit can still land. No incarnation submits an ADMITTED or UNKNOWN record again.

POST /abort calls hook.abort (bounded) and answers { result: "aborted" | "idle" }. GET /status's busy is hook.busy(). GET /events keeps its contract (a snapshot, then batches of events, then end) on the root conversation: entries come from pi's watchEvents, the hook's ctx.emit run events ({ type: "run_start" | "run_end", inputs: [] }) are merged in order, and snapshot.run is present while hook.busy(). There is no Last-Event-ID: resume = a fresh snapshot, as today.

A run_end pairs every run_start, and that is the hook's duty first: it emits run_end when its turn ends in any way, above all the ways the door cannot see (its harness child dies, its turn errors inside the harness). The door pairs a run_start itself only at the points it controls: when hook.submit throws after it, when a second run_start comes first, when an abort leaves the hook idle, when the door stops, and before a stream's end.

The door stops when the instance ends: a drain on SIGTERM, a fence (another instance may take the run) and a park. It aborts the hook, bounded, kills the process group of every harness child the hook registered with ctx.adopt(pid) (start the child detached) and still alive, and pairs an open run_start, so the instance never exits with one of those children alive, and a fenced run's child never acts on after the fence.

The admission is the journal of spec/receipts/Receipts.tla for one effect per requestId, with the same invariants: Admit is the door.input commit (I1_AdmitBeforeDispatch), Dispatch is the one hook.submit per requestId (I3_OneDispatchPerKey), Settle is the outcome on the same record (I7_ReceiptsNeverErased), and UNKNOWN is an outcome nobody knows, never dispatched again. spec/receipts/Door.tla checks the door's journal itself, across restarts, a fence and a late resolve.

The low-level API

What the package exports for a host of your own. src/index.ts lists every export, and the .d.ts files in dist/ are the reference. Library describes each in full.

Name What it is Example
openDurableRun(ref, options) Open a run: take the claim, probe the store's lock, open the store and the Harness, write the run's record in the store, resume. run.release() records where it stands and lets the claim go pick-up-a-run
localRun(root) The acquire and claimDir that put a run on a plain directory of this machine pick-up-a-run
seed (an option of openDurableRun) Write what a new run starts with, its store file and workspace files, once, before anything sees the run (Seeding a new run) seed-a-run
localPlace({ app }) Where one run lives and how its instance starts and ends, for a supervisor of your own (Places) place-outcome
PlaceOutcome How a runner ended: the run's status (complete or failed), its generation, and the app's record or code and message, and the models it used place-outcome

Also exported: serveRun, parking, readLocalRunStatus (a run's record, once no process holds it), and the typed errors. @parcha/pi-durable-disk/internals has no compatibility promise.

Reference

docs/reference.md has the rest: requirements, the app, the CLI, serve and parking, the failure table, security, the layout of a run and the library.

License

Apache-2.0, Parcha Labs, Inc. See LICENSE and NOTICE.