diff --git a/CHANGELOG.md b/CHANGELOG.md index 1459dca..4612bb2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,12 @@ surface is governed by [`COMPATIBILITY.md`](COMPATIBILITY.md). ## [Unreleased] ### Added +- **TypeScript SDK: crash-resume.** `Runtime.resumeRun(runId, fn)` re-attaches a + Node/TypeScript agent to an existing governed run after a crash — it neither + creates a new run nor cancels on error, so the daemon's restored budget and usage + continue enforcing without re-spending. Mirrors the Python SDK's `resume_run`; read + `run.latestCheckpoint()` to resume from where the agent left off. See + [`docs/RESUME.md`](docs/RESUME.md) and [`sdks/typescript`](sdks/typescript). - **Point a provider at a custom upstream.** Set `RISKKERNEL_OPENAI_BASE_URL` or `RISKKERNEL_ANTHROPIC_BASE_URL` to route that provider through an OpenAI-compatible gateway, a corporate proxy, or a local mock (e.g. for benchmarking) instead of its diff --git a/sdks/typescript/README.md b/sdks/typescript/README.md index 3faf7ed..0900808 100644 --- a/sdks/typescript/README.md +++ b/sdks/typescript/README.md @@ -6,9 +6,9 @@ deterministic decision (budgets, halts, approval policy); this package just make governed runs ergonomic from Node/TypeScript. **No runtime dependencies** — it uses the global `fetch` (Node 18+), the same stdlib-only ethos as the Python SDK. -> **Status:** core client — run control, budgets, the governing proxy, and approval -> gates. Crash-resume (`resumeRun`), framework adapters (Vercel AI SDK), and npm -> publishing are tracked in the repo issues (**#80–#82**) — contributions welcome. +> **Status:** core client — run control, budgets, crash-resume (`resumeRun`), the +> governing proxy, and approval gates. Framework adapters (Vercel AI SDK) and npm +> publishing are tracked in the repo issues (**#81–#82**) — contributions welcome. ## Use @@ -37,10 +37,34 @@ A budget halt surfaces as `BudgetExceeded` (`reason` is the machine-readable HaltReason, e.g. `dollar_budget_exceeded`). The run is cancelled automatically if the body throws — pass `cancelOnError: false` to opt out. +## Resume after a crash + +The daemon reloads non-terminal runs on restart with the budget and usage they had +already spent, so a `SIGKILL`'d run keeps enforcing without re-spending. Reattach to +it by id with `resumeRun` and pick your work back up from the last checkpoint: + +```ts +await rt.resumeRun(runId, async (run) => { // attaches; never creates or cancels + const cp = await run.latestCheckpoint(); // the state you saved before the crash + const start = (cp?.payload?.cursor as number) ?? 0; + for (let i = start; i < total; i++) { // skip the steps you already paid for + await run.step(); // counts against the SAME budget + // ... your work ... + await run.checkpoint("step", { cursor: i + 1 }); + } +}); +``` + +The run resumes against whatever budget it had left, so it can't overspend by +restarting — `run.step()` still throws `BudgetExceeded` at the original ceiling. The +run id is the only thing to keep across a restart (a file, your job queue, a DB row); +see [`docs/RESUME.md`](../../docs/RESUME.md) for the full model. + ## API - `new Runtime(opts)` — `{ baseUrl, token, approvalPollIntervalMs, approvalTimeoutMs }`. - `rt.governedRun({ name?, budget?, metadata?, cancelOnError? }, async (run) => …)`. +- `rt.resumeRun(runId, async (run) => …)` — re-attach to an existing run after a crash. - `run.step()` · `run.checkpoint(name, payload)` · `run.latestCheckpoint()` · `run.cancel(reason)` · `run.status()` · `run.proxyConfig()` · `run.approve(tool, opts)`. - `RiskKernel` — the low-level `/v1` client, for manual control. diff --git a/sdks/typescript/src/runtime.ts b/sdks/typescript/src/runtime.ts index 35ff169..e356ab1 100644 --- a/sdks/typescript/src/runtime.ts +++ b/sdks/typescript/src/runtime.ts @@ -157,6 +157,35 @@ export class Runtime { throw e; } } + + /** + * Re-attach to an existing governed run by id — the resume path after a crash. + * + * Unlike {@link governedRun}, this neither creates a new run nor cancels on + * error: the daemon reloads non-terminal runs on restart with the budget and + * usage they had already spent, so enforcement continues without re-spending. + * Read {@link Run.latestCheckpoint} to pick the work back up where it left off: + * + * ```ts + * await rt.resumeRun(runId, async (run) => { + * const cp = await run.latestCheckpoint(); + * const start = (cp?.payload?.cursor as number) ?? 0; + * for (let i = start; i < total; i++) { + * await run.step(); // counts against the SAME budget + * // ... your agent's work ... + * await run.checkpoint("progress", { cursor: i + 1 }); + * } + * }); + * ``` + * + * The run id is the only thing the agent must keep across a restart. Throws + * {@link APIError} (404) if the run id is unknown. + */ + async resumeRun(runId: string, fn: (run: Run) => Promise): Promise { + const data = await this.client.getRun(runId); + const run = new Run(this.client, data, this.pollMs, this.timeoutMs); + return fn(run); + } } function toBudgetDict(b?: Budget): Record | undefined { diff --git a/sdks/typescript/test/sdk.test.ts b/sdks/typescript/test/sdk.test.ts index a83acb0..f9071d6 100644 --- a/sdks/typescript/test/sdk.test.ts +++ b/sdks/typescript/test/sdk.test.ts @@ -4,7 +4,12 @@ import { Runtime } from "../src/index"; // A tiny in-process mock of the daemon's /v1 API, so the SDK is exercised over // real HTTP with no daemon, no keys. -const state = { steps: 0, haltAfter: Number.POSITIVE_INFINITY, cancelled: false }; +const state = { + steps: 0, + haltAfter: Number.POSITIVE_INFINITY, + cancelled: false, + checkpoint: null as { name: string; payload: Record } | null, +}; let server: Server; let baseUrl = ""; @@ -27,11 +32,25 @@ beforeAll(async () => { } return send(res, 200, { stepIndex: state.steps++ }); } + if (method === "POST" && url.endsWith("/checkpoints")) { + const body = buf ? JSON.parse(buf) : {}; + state.checkpoint = { name: body.name ?? "", payload: body.payload ?? {} }; + return send(res, 200, {}); + } if (method === "POST" && url.endsWith("/cancel")) { state.cancelled = true; return send(res, 200, { id: "run-test", status: "cancelled" }); } + if (method === "GET" && url.startsWith("/v1/runs/")) { + // Resume path: GET /v1/runs/{id} reloads a run with the usage it already + // spent. Unknown ids 404 (mirrors the daemon). + if (url === "/v1/runs/run-test") { + return send(res, 200, { id: "run-test", status: "running", usage: { loops: state.steps } }); + } + return send(res, 404, { code: "not_found", message: "unknown run" }); + } if (method === "GET" && url.startsWith("/v1/checkpoints/")) { + if (state.checkpoint) return send(res, 200, state.checkpoint); return send(res, 404, { code: "not_found", message: "no checkpoint" }); } return send(res, 404, { code: "not_found", message: "unhandled" }); @@ -48,6 +67,7 @@ function reset(): void { state.steps = 0; state.haltAfter = Number.POSITIVE_INFINITY; state.cancelled = false; + state.checkpoint = null; } describe("Runtime", () => { @@ -100,4 +120,49 @@ describe("Runtime", () => { expect(await run.latestCheckpoint()).toBeNull(); }); }); + + it("resumeRun re-attaches to a crashed run and continues from its checkpoint without re-spending", async () => { + reset(); + // Simulate a run that crashed after 3 paid steps, having checkpointed cursor 3. + state.steps = 3; + state.checkpoint = { name: "progress", payload: { cursor: 3 } }; + const rt = new Runtime({ baseUrl }); + + const seen: number[] = []; + await rt.resumeRun("run-test", async (run) => { + expect(run.id).toBe("run-test"); + const cp = await run.latestCheckpoint(); + const start = (cp?.payload?.cursor as number) ?? 0; + expect(start).toBe(3); // skip the work already paid for + for (let i = start; i < 6; i++) { + seen.push(await run.step()); // steps count against the SAME budget — no reset + await run.checkpoint("progress", { cursor: i + 1 }); + } + }); + + // The step counter continued at 3 rather than restarting at 0. + expect(seen).toEqual([3, 4, 5]); + expect(state.steps).toBe(6); + expect(state.cancelled).toBe(false); + }); + + it("resumeRun does NOT cancel the run when the body throws", async () => { + reset(); + state.steps = 1; + const rt = new Runtime({ baseUrl }); + await expect( + rt.resumeRun("run-test", async () => { + throw new Error("boom"); + }), + ).rejects.toThrow("boom"); + expect(state.cancelled).toBe(false); // resume must never cancel a run it didn't start + }); + + it("resumeRun surfaces APIError(404) for an unknown run id", async () => { + reset(); + const rt = new Runtime({ baseUrl }); + await expect( + rt.resumeRun("does-not-exist", async () => "unreachable"), + ).rejects.toMatchObject({ name: "APIError", status: 404 }); + }); });