pi-mono/packages/durable/src/codemode/codemode.ts
Mario Zechner 7b597d1481 feat(durable): durable code mode (@earendil-works/pi-durable/codemode)
A codemode tool whose scripts call the conversation's tools as nested calls and
survive a restart. The script runs deterministically (pi-codemode start()); each
result is journaled before it is delivered. A rerun starts the script again from
the stored start data, delivers the journaled results in order (nested calls
reattach by key c<id>), checks every recorded call count, holds calls the
journal lacks until replay ends and checks them against earlier attempts' keys,
then continues live. A call launches only once the count of the drain that made
it is committed, so a rerun can prove which calls are new. Divergence, a changed
module version or sandbox fingerprint, and a passed deadline end the call with a
stored error; a decided outcome is returned without a VM. store()/load() keep
values per conversation, rewindable and forked as of the fork's entry.

pi-codemode: CodemodeRun.finished, set when the outcome is decided.
2026-10-11 21:31:48 +00:00

722 lines
29 KiB
TypeScript

/**
* Durable code mode: a tool that runs model-written JavaScript in a QuickJS sandbox, whose script survives a restart.
*
* Problem: a script is in-memory VM state. A restart while
*
* const results = await Promise.all(paths.map((path) => tools.read({ path })));
*
* waits for the last of 200 reads would lose the script and every read it made.
*
* Solution: deterministic replay (`CodemodeSandboxBase.start()` in pi-codemode). The VM only runs when the host hands
* it a result; given the same start data, clock, seed, and results in the same order, it makes the same calls with
* the same arguments. The tool is replay-safe: after a restart, `execute()` runs again, starts the script again from
* the stored start data, and hands it the results it got before, in the order it got them, from a journal written
* before each delivery. Nested tool calls reattach to their tasks by key (`c<VM call id>`), so they never run twice
* unless their own replay policy reruns them; their results come from `NestedCallDoc`. Then the script continues live.
*
* Documents, scoped to the code mode call's task and gone when it settles:
* - `pi.codemode.run`: the start data, written once before the VM starts, and the outcome, written once when the call
* decides it (with the store writes, in one transaction), so a rerun after that returns it without a VM.
* - `pi.codemode.journal`: one entry per delivered result, in delivery order: the VM call ID, the clock value, the
* calls the VM had issued after processing it (once acknowledged), and a global's outcome, which nothing else stores.
*
* Each result's acknowledged call count is recorded with the next journal commit, or at once when the VM has processed
* everything delivered: a script waiting on a long call is where a restart usually finds it. A sequential script
* therefore costs one journal commit and one count commit per result, on top of its nested calls' own; that is the
* price of checking the most common crash point.
*
* A call is launched only once the count of calls issued in the drain that made it is committed. So every key an earlier
* attempt admitted is at most its last recorded count; a replay checks every recorded count, so when it releases its
* calls it has made every call an earlier attempt could have admitted, and later calls are new.
*
* A rerun refuses to replay (no new nested calls; nested calls admitted earlier may still resume on their own and are
* aborted by the call's cleanup) when this module's version or the VM's fingerprint changed, when the deadline passed,
* or when the script diverges: it makes another call, or calls in another order, than the journal says. Each ends the
* call with an error result.
*/
import { type Context, copyJson, type JsonValue } from "@earendil-works/chord";
import type { ImageContent, TextContent } from "@earendil-works/pi-ai";
import {
type CodemodeHostCall,
type CodemodeOutcome,
type CodemodeResult,
type CodemodeRun,
type CodemodeSandboxBase,
toCodemodeIdentifier,
} from "@earendil-works/pi-codemode/portable";
import { parseCodemodeSource } from "@earendil-works/pi-codemode/source";
import { type Static, Type } from "typebox";
import { defineDoc } from "../documents.ts";
import { errorMessage } from "../errors.ts";
import { defineExtension, defineTool } from "../harness/define.ts";
import { NestedCallDoc, type ToolTaskInput } from "../harness/tool.ts";
import type {
Extension,
NestedToolExecutionResult,
ToolExecutionApi,
ToolExecutionResult,
ToolRegistration,
} from "../harness/types.ts";
import type { JsonObject } from "../types.ts";
/**
* Version of everything in this module that turns stored results into what a script sees: result mapping, globals,
* start data. Bump it when that changes, so a call started before an upgrade is not replayed by different code.
*/
export const CODEMODE_MODULE_VERSION = 1;
export const CODEMODE_TOOL_NAME = "codemode";
/** One tool as the script sees it: `tools.<jsName>`. */
type DeclaredTool = { name: string; jsName: string; description: string };
/** What a call starts with; a rerun starts the VM from exactly this. */
type Start = {
version: number;
startedAt: number;
seed: number;
/** The VM's fingerprint, recorded by the first run before any call is admitted. */
fingerprint?: string;
/** `startedAt + timeout_ms` when the script set one. */
deadline?: number;
tools: DeclaredTool[];
globals: { name: string; spread: boolean }[];
store: Record<string, JsonValue>;
};
type RunState = {
start?: Start;
/** The outcome, decided once. */
result?: JsonObject;
};
export const CodemodeRunDoc = defineDoc<RunState>({
kind: "pi.codemode.run",
version: 1,
scope: "task",
initial: () => ({}),
});
type JournalEntry = {
/** The VM's call ID. Its result is the entry's position + 1 (`seq`). */
id: number;
/** The VM clock while it processes this result. */
at: number;
/** Calls the VM had issued after processing this result; absent until acknowledged. */
issuedAfter?: number;
/** A global's call and outcome; a tool's result is its nested call's. */
global?: { name: string; args?: JsonValue; outcome: JsonObject };
};
type JournalState = {
entries: JournalEntry[];
/** Calls the VM had issued after the script's first run, before any result; absent until acknowledged. */
firstIssued?: number;
};
export const CodemodeJournalDoc = defineDoc<JournalState>({
kind: "pi.codemode.journal",
version: 1,
scope: "task",
initial: () => ({ entries: [] }),
});
/**
* What scripts keep with `store()` and read with `load()`, per conversation. Rewindable and copied as of the fork
* point, so a fork sees the values as they were when its entry was committed.
*/
export const CodemodeStoreDoc = defineDoc<{ values: Record<string, JsonValue> }>({
kind: "pi.codemode.store",
version: 1,
scope: "conversation",
history: "rewindable",
fork: "asOf",
initial: () => ({ values: {} }),
});
/** A function scripts call as a top-level global; its result is journaled. */
export type CodemodeGlobal = {
readonly name: string;
readonly spread?: boolean;
execute(args: unknown, api: ToolExecutionApi, context: Context): Promise<JsonValue | undefined>;
};
const codemodeSchema = Type.Object({ code: Type.String({ description: "Raw JavaScript source." }) });
export type CodemodeToolInput = Static<typeof codemodeSchema>;
export type CodemodeToolOptions = {
/** Runs the scripts: `CodemodeSandbox` on Node, `RemoteCodemodeSandbox` on Cloudflare. */
readonly sandbox: CodemodeSandboxBase;
/** @internal Host functions scripts call as globals. */
readonly globals?: readonly CodemodeGlobal[];
};
/** The code mode tool. Scripts call the conversation's callable tools as `tools.<name>(args)`. */
export function createCodemodeTool(options: CodemodeToolOptions): ToolRegistration<typeof codemodeSchema> {
return defineTool({
name: CODEMODE_TOOL_NAME,
description:
"Run JavaScript that calls other tools. The input is raw JavaScript, run as an async function body in a QuickJS sandbox: top-level `await` and `return` work. No Node, file system, network, or timers. `await tools.<name>({ ...args })` resolves to the tool's result and rejects with an Error on failure. `text(value)` and `console.log()` add output.",
parameters: codemodeSchema,
callers: ["model"],
replay: "safe",
execute: (args, api, context) => new CodemodeCall(options, api, context).execute(args.code),
});
}
/** The `codemode` extension: the code mode tool. */
export function createCodemodeExtension(options: CodemodeToolOptions): Extension {
return defineExtension({ name: "codemode", tools: [createCodemodeTool(options)] });
}
/** What a script gets for a nested call: success resolves with its value; every error rejects (design §4.5). */
function outcomeOf(name: string, result: NestedToolExecutionResult): CodemodeOutcome {
const value = result.structuredOutput;
if (!result.isError) return { ok: true, value };
const message =
typeof value === "string" && value !== ""
? value
: Array.isArray(value)
? value.flatMap((item) => (isText(item) ? [item.text] : [])).join("\n")
: "";
const diagnostics = result.diagnostics
.filter((diagnostic) => diagnostic.severity === "error")
.map((diagnostic) => diagnostic.message)
.join("\n");
return {
ok: false,
message: message || diagnostics || `Tool ${name} failed`,
...(value === undefined ? {} : { properties: { structuredOutput: value } }),
};
}
function isText(item: unknown): item is TextContent {
return typeof item === "object" && item !== null && (item as TextContent).type === "text";
}
/** The tool result for a script's result. */
function toolResult(result: CodemodeResult): ToolExecutionResult {
const output: (TextContent | ImageContent)[] = [];
for (const item of result.output) output.push(item.type === "image" ? item : { type: "text", text: item.text });
if (result.ok && result.value !== undefined) {
output.push({
type: "text",
text: typeof result.value === "string" ? result.value : JSON.stringify(result.value),
});
}
if (!result.ok) {
const { error } = result;
output.push({ type: "text", text: `Script error (${error.kind}): ${error.stack ?? error.message}` });
}
return { output, isError: !result.ok };
}
function errorResult(message: string): ToolExecutionResult {
return { output: [{ type: "text", text: message }], isError: true };
}
/** One execution of the code mode tool: a first run, or a rerun after a restart that replays the journal. */
class CodemodeCall {
private readonly options: CodemodeToolOptions;
private readonly api: ToolExecutionApi;
private readonly context: Context;
private readonly controller = new AbortController();
private run!: CodemodeRun;
private start!: Start;
/** The journal as this attempt found it; replay delivers exactly these. */
private replayed: JournalEntry[] = [];
/** Entries in the journal, replayed and live; the next delivery is `length + 1`. */
private journalLength = 0;
/** Replayed entries delivered so far. */
private nextReplayed = 0;
private replaying = false;
/** Whether an earlier attempt ran this call: its nested call keys may already exist. */
private rerun = false;
/** Whether live calls may launch; before, they are held (see `release()`). */
private released = false;
/** The count an earlier attempt recorded after the script's first run. */
private firstIssued: number | undefined;
/** The highest issued count known to be committed: calls up to it may launch. */
private durableIssued = 0;
/** Live calls waiting for their issued count to be committed. */
private waiting: CodemodeHostCall[] = [];
/** Journal index of each replayed call ID. */
private replayIndex = new Map<number, number>();
/** Calls the VM issued so far; its call IDs are 1, 2, ... */
private issued = 0;
/** Replay: results of journaled calls, ready to deliver in journal order. */
private readonly ready = new Map<number, CodemodeOutcome>();
/** Replay: calls the journal does not have, admitted once replay is done. */
private held: CodemodeHostCall[] = [];
/** Live: finished calls waiting for their journal commit. */
private queue: { id: number; at: number; outcome: CodemodeOutcome; global?: JournalEntry["global"] }[] = [];
/** Acknowledged counts to record, by result number (0: the script's first run). */
private readonly acks = new Map<number, number>();
private committing = false;
/** Set once the call decided an outcome other than the script's own (divergence, mismatch) or failed. */
private ended: ToolExecutionResult | undefined;
private failure: unknown;
/** Resolves once the VM's fingerprint is checked or recorded; nothing is delivered or admitted before. */
private gate!: Promise<void>;
private openGate!: () => void;
constructor(options: CodemodeToolOptions, api: ToolExecutionApi, context: Context) {
this.options = options;
this.api = api;
this.context = context;
this.gate = new Promise((resolve) => {
this.openGate = resolve;
});
}
async execute(source: string): Promise<ToolExecutionResult> {
const { code, options: sourceOptions } = parseCodemodeSource(source);
const { api, context } = this;
const agent = await api.agent(context);
const tools: DeclaredTool[] = agent.callable.map((tool) => ({
name: tool.name,
jsName: toCodemodeIdentifier(tool.name),
description: tool.description,
}));
const globals = (this.options.globals ?? []).map((global) => ({
name: global.name,
spread: global.spread === true,
}));
const now = Date.now();
const seed = crypto.getRandomValues(new Uint32Array(1))[0]!;
type Found =
| { done: ToolExecutionResult }
| { start: Start; journal: JournalEntry[]; firstIssued: number | undefined; rerun: boolean };
const found = await api.commit(async (tx): Promise<Found> => {
const run = await tx.doc(CodemodeRunDoc, api.taskId);
if (run.result !== undefined) return { done: copyJson(run.result as JsonValue) as ToolExecutionResult };
const rerun = run.start !== undefined;
if (run.start === undefined) {
const store = await tx.doc(CodemodeStoreDoc, api.conversationId);
const timeout = sourceOptions.timeoutMs;
run.start = {
version: CODEMODE_MODULE_VERSION,
startedAt: now,
seed,
...(timeout === undefined ? {} : { deadline: now + timeout }),
tools,
globals,
store: copyJson(store.values as JsonValue) as Record<string, JsonValue>,
};
}
const journal = await tx.doc(CodemodeJournalDoc, api.taskId);
return {
start: copyJson(run.start as unknown as JsonValue) as unknown as Start,
journal: copyJson(journal.entries as unknown as JsonValue) as unknown as JournalEntry[],
firstIssued: journal.firstIssued,
rerun,
};
}, context);
if ("done" in found) return found.done;
this.start = found.start;
this.replayed = found.journal;
this.replayIndex = new Map(found.journal.map((entry, index) => [entry.id, index]));
this.journalLength = found.journal.length;
this.replaying = found.journal.length > 0;
this.rerun = found.rerun;
this.firstIssued = found.firstIssued;
// A loop, not spread arguments: a journal can hold more entries than a call takes arguments.
this.durableIssued = found.journal.reduce(
(max, entry) => Math.max(max, entry.issuedAfter ?? 0),
found.firstIssued ?? 0,
);
if (this.start.version !== CODEMODE_MODULE_VERSION) {
return this.decide(
errorResult(
`The script cannot resume: it started under code mode version ${this.start.version}, this is ${CODEMODE_MODULE_VERSION}.`,
),
);
}
const remaining = this.start.deadline === undefined ? undefined : this.start.deadline - Date.now();
if (remaining !== undefined && remaining <= 0) {
return this.decide(errorResult(`Script timed out: its deadline passed while it was interrupted.`));
}
const onAbort = () => this.controller.abort(context.abortSignal?.reason);
context.abortSignal?.addEventListener("abort", onAbort, { once: true });
if (context.abortSignal?.aborted) onAbort();
try {
this.run = this.options.sandbox.start(code, {
startedAt: this.start.startedAt,
seed: this.start.seed,
tools: this.start.tools,
globals: this.start.globals,
store: this.start.store,
signal: this.controller.signal,
...(remaining === undefined ? {} : { timeoutMs: remaining }),
onStarted: (fingerprint) => void this.checkFingerprint(fingerprint),
onCall: (call) => this.onCall(call),
onIdle: (seq, issued) => this.onIdle(seq, issued),
});
const result = await this.run.result;
if (this.failure !== undefined) throw this.failure;
if (this.ended !== undefined) return this.ended;
context.abortSignal?.throwIfAborted();
// A replay that ends where the live run went on is a divergence (design §4.3).
const during = result.during;
// Within the replayed part, the first run included: a rerun without journal entries replays that much too.
if (this.rerun && during !== undefined && during.seq <= this.replayed.length) {
// The live run went on: it acknowledged that result, or it made a call that later results answer, which the
// replay never made.
const continued =
(during.seq === 0
? this.replayed.length > 0 || this.firstIssued !== undefined
: this.replayed[during.seq - 1]?.issuedAfter !== undefined) ||
this.replayed.slice(during.seq).some((entry) => entry.id > during.issued);
if (continued) {
return this.decide(
errorResult(
`The script took a different path when resumed after a restart: it ended after result ${during.seq}, where it went on before.`,
),
);
}
}
return this.complete(result);
} finally {
context.abortSignal?.removeEventListener("abort", onAbort);
}
}
/** Store an outcome the call decided, and end the VM if it runs; returns the outcome. */
private async decide(result: ToolExecutionResult): Promise<ToolExecutionResult> {
await this.api.commit(async (tx) => {
const run = await tx.doc(CodemodeRunDoc, this.api.taskId);
run.result ??= copyJson(result as JsonValue, { omitUndefinedProperties: true }) as JsonObject;
}, this.context);
return result;
}
/** End the call with `message`: store it as the outcome, then stop the VM with an error the script cannot catch. */
private diverge(message: string): void {
if (this.ended !== undefined || this.failure !== undefined) return;
const result = errorResult(`The script took a different path when resumed after a restart: ${message}`);
this.ended = result;
this.decide(result).then(
() => this.controller.abort(new Error(message)),
(error: unknown) => this.fail(error),
);
}
/** Whether nothing more may happen: the run has its outcome, or the call decided or failed. */
private get stopped(): boolean {
return this.ended !== undefined || this.failure !== undefined || this.run?.finished === true;
}
private fail(error: unknown): void {
if (this.failure !== undefined) return;
this.failure = error;
this.controller.abort(error);
}
/** The script's own outcome, stored with its store writes in one transaction. */
private async complete(result: CodemodeResult): Promise<ToolExecutionResult> {
const tool = toolResult(result);
const writes = result.ok ? result.storeWrites : undefined;
return this.api.commit(async (tx) => {
const run = await tx.doc(CodemodeRunDoc, this.api.taskId);
if (run.result !== undefined) return run.result as ToolExecutionResult;
run.result = copyJson(tool as JsonValue, { omitUndefinedProperties: true }) as JsonObject;
if (writes !== undefined && (Object.keys(writes.set).length > 0 || writes.delete.length > 0)) {
const store = await tx.doc(CodemodeStoreDoc, this.api.conversationId);
for (const key of writes.delete) delete store.values[key];
for (const [key, value] of Object.entries(writes.set)) store.values[key] = value as JsonValue;
}
return tool;
}, this.context);
}
private async checkFingerprint(fingerprint: string): Promise<void> {
const recorded = this.start.fingerprint;
if (recorded !== undefined) {
if (recorded !== fingerprint) {
this.ended = errorResult(
`The script cannot resume: the sandbox changed since it started (${recorded} -> ${fingerprint}).`,
);
await this.decide(this.ended).catch((error: unknown) => this.fail(error));
this.controller.abort(new Error("Sandbox fingerprint changed"));
return;
}
} else {
try {
await this.api.commit(async (tx) => {
const run = await tx.doc(CodemodeRunDoc, this.api.taskId);
if (run.start !== undefined) run.start.fingerprint = fingerprint;
}, this.context);
} catch (error) {
this.fail(error);
return;
}
}
this.openGate();
// Nothing was delivered before the gate: start now. A rerun without a journal releases its calls at the
// script's first acknowledgment instead.
if (this.replaying) this.deliverReplayed();
else if (!this.rerun) void this.release();
}
private onCall(call: CodemodeHostCall): void {
if (this.stopped) return;
this.issued = Math.max(this.issued, call.id);
const index = this.replaying ? (this.replayIndex.get(call.id) ?? -1) : -1;
if (index === -1) {
if (this.released) {
this.waiting.push(call);
this.launchCovered();
} else this.held.push(call);
return;
}
const entry = this.replayed[index]!;
if (entry.global !== undefined) {
if (call.target !== "global" || call.name !== entry.global.name || !sameJson(call.args, entry.global.args)) {
this.diverge(`call ${call.id} is ${describe(call)}, but was a call to ${entry.global.name}`);
return;
}
this.ready.set(call.id, entry.global.outcome as CodemodeOutcome);
void this.gate.then(() => this.deliverReplayed());
return;
}
if (call.target !== "tool") {
this.diverge(`call ${call.id} is ${describe(call)}, but was a tool call`);
return;
}
void this.gate
.then(() => this.callTool(call))
.then(
(outcome) => {
this.ready.set(call.id, outcome);
this.deliverReplayed();
},
(error: unknown) => {
if (this.context.abortSignal?.aborted) this.fail(error);
// The key names another tool or other arguments.
else this.diverge(`call ${call.id} is ${describe(call)}: ${errorMessage(error)}`);
},
);
}
/** Replay: deliver journaled results in journal order, each once the VM has issued its call. */
private deliverReplayed(): void {
while (this.nextReplayed < this.replayed.length && !this.stopped) {
const entry = this.replayed[this.nextReplayed]!;
const outcome = this.ready.get(entry.id);
if (entry.id > this.issued || outcome === undefined) return;
this.ready.delete(entry.id);
this.nextReplayed++;
this.run.deliver(this.nextReplayed, entry.at, entry.id, outcome);
}
}
private onIdle(seq: number, issued: number): void {
if (this.stopped) return;
// A count an earlier attempt recorded must match; a missing one is recorded now.
if (this.rerun && seq <= this.replayed.length) {
const recorded = seq === 0 ? this.firstIssued : this.replayed[seq - 1]!.issuedAfter;
if (recorded !== undefined && recorded !== issued) {
this.diverge(
seq === 0
? `its first run made ${issued} calls, where it had made ${recorded}`
: `after result ${seq} it had made ${issued} calls, where it had made ${recorded}`,
);
return;
}
}
this.acks.set(seq, issued);
if (!this.replaying) {
// A rerun without a journal: every call an earlier attempt could have admitted is made by now.
if (seq === 0 && this.rerun) void this.release();
// Committed at once when the script has processed everything delivered: calls it made wait for it, and a script
// waiting on a long call is where a restart usually finds it. Otherwise with the next commit; the VM catches up
// without waiting on anything.
if (seq === this.journalLength) void this.pump();
return;
}
// Caught up: the VM processed everything delivered and waits.
if (seq !== this.nextReplayed) return;
const next = this.replayed[seq];
if (next !== undefined) {
if (next.id > issued) this.diverge(`it waits without making call ${next.id}, which it made before`);
return;
}
// The last replayed result is acknowledged: continue live, admitting the calls the journal did not have once their
// count is committed.
this.replaying = false;
void this.pump();
void this.release();
}
/**
* Let the held calls launch, and later live calls, each once its issued count is committed. On a rerun, an earlier
* attempt may have admitted some of them under their keys, as other calls: when the replay releases (idle after the
* last journaled result, or after its first run without a journal) its counts matched every recorded one, so it has
* made every call an earlier attempt could have admitted. All held calls are checked first, whatever their target,
* and a divergence launches none. Calls made after this are new.
*/
private async release(): Promise<void> {
await this.gate;
if (this.stopped || this.released) return;
const calls = this.held.splice(0);
if (this.rerun) {
for (const call of calls) {
let other: string | undefined;
try {
other = await this.earlier(call);
} catch (error) {
this.fail(error);
return;
}
if (this.stopped) return;
if (other !== undefined) {
this.diverge(`call ${call.id} is ${describe(call)}, but was ${other}`);
return;
}
}
}
this.released = true;
this.waiting.push(...calls, ...this.held.splice(0));
this.launchCovered();
}
/** Launch the waiting calls whose issued count is committed. */
private launchCovered(): void {
if (!this.released || this.stopped) return;
const covered = this.waiting.filter((call) => call.id <= this.durableIssued);
if (covered.length === 0) return;
this.waiting = this.waiting.filter((call) => call.id > this.durableIssued);
for (const call of covered) this.launch(call);
}
/** The call an earlier attempt made under this call's key, when it is another one: another target, tool, or arguments. */
private async earlier(call: CodemodeHostCall): Promise<string | undefined> {
const { api, context } = this;
const taskId = (await api.snapshot(NestedCallDoc, api.taskId, `c${call.id}`, context))?.taskId;
if (taskId === undefined) return undefined;
const input = (await api.getTask(taskId, context))?.input as ToolTaskInput | undefined;
if (input?.kind !== "nested") return undefined;
const made = input.call;
if (call.target === "tool" && made.name === call.name && sameJson(made.arguments, call.args ?? {}))
return undefined;
return `tools.${made.name}(${JSON.stringify(made.arguments)})`;
}
/** Run one live call and journal its result. */
private launch(call: CodemodeHostCall): void {
if (this.stopped) return;
const global = call.target === "global" ? this.options.globals?.find((g) => g.name === call.name) : undefined;
const running: Promise<{ outcome: CodemodeOutcome; global?: JournalEntry["global"] }> =
call.target === "tool"
? this.callTool(call).then((outcome) => ({ outcome }))
: this.callGlobal(call, global).then((outcome) => ({
outcome,
global: {
name: call.name,
...(call.args === undefined ? {} : { args: call.args as JsonValue }),
outcome: outcome as JsonObject,
},
}));
running.then(
(done) => {
this.queue.push({ id: call.id, at: Date.now(), ...done });
void this.pump();
},
(error: unknown) => {
if (this.context.abortSignal?.aborted) this.fail(error);
// A key an earlier attempt admitted with another tool or other arguments: this attempt took another path.
else this.diverge(`call ${call.id} is ${describe(call)}: ${errorMessage(error)}`);
},
);
}
private async callTool(call: CodemodeHostCall): Promise<CodemodeOutcome> {
// Passed as the script gave them; validation reports arguments that are not an object.
const args = (call.args ?? {}) as JsonObject;
const result = await this.api.executeTool(call.name, args, this.context, { key: `c${call.id}` });
return outcomeOf(call.name, result);
}
private async callGlobal(call: CodemodeHostCall, global: CodemodeGlobal | undefined): Promise<CodemodeOutcome> {
if (global === undefined) return { ok: false, message: `Unknown global ${call.name}` };
try {
// `undefined` stays undefined: the journal omits the value, and a replay resolves with undefined again.
return { ok: true, value: await global.execute(call.args, this.api, this.context) };
} catch (error) {
if (this.context.abortSignal?.aborted) throw error;
return { ok: false, message: errorMessage(error) };
}
}
/**
* Journal finished calls and acknowledged counts in one commit, then deliver the calls and launch the calls the
* counts cover; one commit in flight at a time, so what arrives meanwhile goes into the next.
*/
private async pump(): Promise<void> {
if (this.committing || this.stopped) return;
if (this.queue.length === 0 && this.acks.size === 0) return;
this.committing = true;
const batch = this.queue.splice(0);
const acks = [...this.acks];
this.acks.clear();
try {
await this.api.commit(async (tx) => {
const journal = await tx.doc(CodemodeJournalDoc, this.api.taskId);
for (const [seq, issued] of acks) {
if (seq === 0) journal.firstIssued ??= issued;
else {
const entry = journal.entries[seq - 1];
if (entry !== undefined && entry.issuedAfter === undefined) entry.issuedAfter = issued;
}
}
for (const item of batch) {
const entry = {
id: item.id,
at: item.at,
...(item.global === undefined ? {} : { global: item.global }),
};
journal.entries.push(
copyJson(entry as unknown as JsonValue, { omitUndefinedProperties: true }) as unknown as JournalEntry,
);
}
}, this.context);
} catch (error) {
this.fail(error);
return;
}
for (const [, issued] of acks) this.durableIssued = Math.max(this.durableIssued, issued);
for (const item of batch) {
this.journalLength++;
if (!this.run.finished) this.run.deliver(this.journalLength, item.at, item.id, item.outcome);
}
this.launchCovered();
this.committing = false;
void this.pump();
}
}
function describe(call: CodemodeHostCall): string {
return `${call.target === "tool" ? "tools." : ""}${call.name}(${JSON.stringify(call.args) ?? ""})`;
}
/** Structural JSON equality; key order does not matter. */
function sameJson(a: unknown, b: unknown): boolean {
if (a === b) return true;
if (typeof a !== "object" || typeof b !== "object" || a === null || b === null) return false;
if (Array.isArray(a) || Array.isArray(b)) {
return (
Array.isArray(a) && Array.isArray(b) && a.length === b.length && a.every((item, i) => sameJson(item, b[i]))
);
}
const keys = Object.keys(a);
return (
keys.length === Object.keys(b).length &&
keys.every(
(key) =>
Object.hasOwn(b, key) && sameJson((a as Record<string, unknown>)[key], (b as Record<string, unknown>)[key]),
)
);
}