@boardwalk-labs/runner 0.1.21 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. package/README.md +11 -0
  2. package/dist/contract.d.ts +2 -0
  3. package/dist/contract.js +14 -0
  4. package/dist/daemon/container.d.ts +3 -2
  5. package/dist/daemon/container.js +15 -2
  6. package/dist/daemon/daemon.d.ts +1 -0
  7. package/dist/daemon/daemon.js +16 -1
  8. package/dist/runtime/agent/budget.d.ts +14 -1
  9. package/dist/runtime/agent/budget.js +23 -0
  10. package/dist/runtime/broker_child_dispatcher.d.ts +0 -2
  11. package/dist/runtime/broker_child_dispatcher.js +0 -7
  12. package/dist/runtime/budget_gate.d.ts +51 -0
  13. package/dist/runtime/budget_gate.js +114 -0
  14. package/dist/runtime/freeze_coordinator.js +8 -0
  15. package/dist/runtime/index.d.ts +15 -2
  16. package/dist/runtime/index.js +80 -57
  17. package/dist/runtime/leaf_executor.d.ts +20 -0
  18. package/dist/runtime/leaf_executor.js +13 -4
  19. package/dist/runtime/local_workspace_store.d.ts +22 -0
  20. package/dist/runtime/local_workspace_store.js +103 -0
  21. package/dist/runtime/program_runner.d.ts +32 -28
  22. package/dist/runtime/program_runner.js +75 -44
  23. package/dist/runtime/program_worker.d.ts +5 -18
  24. package/dist/runtime/program_worker.js +3 -20
  25. package/dist/runtime/runner_control_client.d.ts +2 -22
  26. package/dist/runtime/runner_control_client.js +2 -49
  27. package/dist/runtime/suspension.d.ts +20 -109
  28. package/dist/runtime/suspension.js +18 -111
  29. package/dist/runtime/wire/human_input.d.ts +1 -1
  30. package/dist/runtime/wire/human_input.js +2 -2
  31. package/dist/runtime/workflow_host.d.ts +65 -62
  32. package/dist/runtime/workflow_host.js +124 -199
  33. package/dist/runtime/workspace_store.d.ts +31 -3
  34. package/dist/runtime/workspace_store.js +43 -4
  35. package/package.json +3 -3
package/README.md CHANGED
@@ -81,6 +81,17 @@ pnpm test
81
81
  pnpm lint && pnpm typecheck && pnpm build
82
82
  ```
83
83
 
84
+ ## The Boardwalk repos
85
+
86
+ - [`boardwalk`](https://github.com/boardwalk-labs/boardwalk) — the open-source single-node engine: cron scheduling, webhooks, durable runs, run history
87
+ - [`sdk`](https://github.com/boardwalk-labs/sdk) — `@boardwalk-labs/workflow`, the TypeScript API a workflow program imports
88
+ - [`cli`](https://github.com/boardwalk-labs/cli) — `boardwalk`: scaffold, validate, run locally, deploy
89
+ - [`examples`](https://github.com/boardwalk-labs/examples) — copyable workflow templates (`boardwalk init --template`)
90
+ - [`plugins`](https://github.com/boardwalk-labs/plugins) — skills + MCP server for Claude Code, Codex, Cursor, OpenClaw, OpenCode
91
+ - [`runner-images`](https://github.com/boardwalk-labs/runner-images) — reproducible base images hosted runners execute in
92
+
93
+ Hosted platform and docs: [boardwalk.sh](https://boardwalk.sh).
94
+
84
95
  ## License
85
96
 
86
97
  Apache-2.0
@@ -102,6 +102,8 @@ export type ByoInferenceProvider = z.infer<typeof byoInferenceProviderSchema>;
102
102
  export declare const claimResponseSchema: z.ZodObject<{
103
103
  lease_id: z.ZodString;
104
104
  run_id: z.ZodString;
105
+ workflow_id: z.ZodString;
106
+ environment_id: z.ZodNullable<z.ZodString>;
105
107
  lease_expires_at: z.ZodNumber;
106
108
  control_plane: z.ZodObject<{
107
109
  base_url: z.ZodString;
package/dist/contract.js CHANGED
@@ -127,6 +127,20 @@ export const byoInferenceProviderSchema = z.strictObject({
127
127
  export const claimResponseSchema = z.strictObject({
128
128
  lease_id: id,
129
129
  run_id: id,
130
+ /**
131
+ * The run's persistence SCOPE — the daemon needs it to lay out its own disk BEFORE the run starts.
132
+ *
133
+ * A self-hosted runner keeps persistent workspaces on its own disk (never our S3), keyed per
134
+ * (workflow, environment) exactly like the hosted S3 key — one workflow program runs against N
135
+ * environments (docs/WORKSPACE_PERSISTENCE.md §4/I3). The daemon must know the scope up front,
136
+ * because in container isolation the scope dir is a BIND MOUNT chosen at `docker run` time: mount
137
+ * only this run's scope, and a program can't read another workflow's state; mount the whole persist
138
+ * root and it can. `run_id` alone can't express that, which is why these ride the claim.
139
+ *
140
+ * `environment_id` is null for a run with no environment — the BASE scope.
141
+ */
142
+ workflow_id: id,
143
+ environment_id: id.nullable(),
130
144
  /** Heartbeat before this or the lease expires and the run is recovered elsewhere. */
131
145
  lease_expires_at: epochMs,
132
146
  /**
@@ -24,8 +24,9 @@ export declare function containerEnv(runEnv: Record<string, string>): Record<str
24
24
  export declare function runIdFromCwd(cwd: string): string;
25
25
  /**
26
26
  * Build the `docker run` argv. PURE + exported so the isolation guarantees are unit-asserted:
27
- * - the ONLY bind mount is the per-run workspace (+ any explicit user mounts) the identity dir,
28
- * the user's home, and the rest of the host FS are never mounted;
27
+ * - the only bind mounts are the per-run workspace, this run's OWN persistence scope (+ any explicit
28
+ * user mounts) — the identity dir, the user's home, OTHER workflows' persisted workspaces, and the
29
+ * rest of the host FS are never mounted;
29
30
  * - per-run credentials are passed by NAME (`-e BOARDWALK_RUN_TOKEN`), so their VALUES come from the
30
31
  * docker client's env and never appear in the argv (which `ps` exposes);
31
32
  * - `--rm` (no leftover container), `--init` (proper signal handling / zombie reaping).
@@ -39,6 +39,9 @@ export function containerEnv(runEnv) {
39
39
  WORKSPACE_ROOT: "/workspace",
40
40
  HOME: "/workspace",
41
41
  TMPDIR: "/tmp",
42
+ // The host's per-scope durable dir is bind-mounted at /persist (buildContainerArgs), so the
43
+ // in-container path replaces the host one — which is meaningless inside the container.
44
+ ...(runEnv.PERSIST_SCOPE_DIR !== undefined ? { PERSIST_SCOPE_DIR: "/persist" } : {}),
42
45
  };
43
46
  return env;
44
47
  }
@@ -59,8 +62,9 @@ export function runIdFromCwd(cwd) {
59
62
  }
60
63
  /**
61
64
  * Build the `docker run` argv. PURE + exported so the isolation guarantees are unit-asserted:
62
- * - the ONLY bind mount is the per-run workspace (+ any explicit user mounts) the identity dir,
63
- * the user's home, and the rest of the host FS are never mounted;
65
+ * - the only bind mounts are the per-run workspace, this run's OWN persistence scope (+ any explicit
66
+ * user mounts) — the identity dir, the user's home, OTHER workflows' persisted workspaces, and the
67
+ * rest of the host FS are never mounted;
64
68
  * - per-run credentials are passed by NAME (`-e BOARDWALK_RUN_TOKEN`), so their VALUES come from the
65
69
  * docker client's env and never appear in the argv (which `ps` exposes);
66
70
  * - `--rm` (no leftover container), `--init` (proper signal handling / zombie reaping).
@@ -80,6 +84,15 @@ export function buildContainerArgs(cfg, opts) {
80
84
  "-w",
81
85
  "/workspace",
82
86
  ];
87
+ // This run's durable workspace, when the daemon resolved one. THIS RUN'S SCOPE ONLY — never the
88
+ // whole persist root: the run's program is arbitrary code with raw fs access, so mounting the root
89
+ // would let any workflow read every other workflow's persisted state on this machine. Hosted
90
+ // isolates per workflow via the token-derived S3 key; self-hosted must not be weaker
91
+ // (docs/WORKSPACE_PERSISTENCE.md I3). The daemon pre-creates it, so docker can't make it root-owned.
92
+ const persistScopeDir = opts.env.PERSIST_SCOPE_DIR;
93
+ if (persistScopeDir !== undefined) {
94
+ args.push("-v", `${persistScopeDir}:/persist`);
95
+ }
83
96
  // Run as the invoking host user (Linux) so the bind-mounted workspace is writable — the image's
84
97
  // `node` (uid 1000) otherwise can't write a host dir it doesn't own.
85
98
  if (cfg.user !== undefined) {
@@ -41,5 +41,6 @@ export interface DaemonController {
41
41
  export declare function runProcessEnv(claim: ClaimResponse, opts: {
42
42
  runnerId: string;
43
43
  workspaceRoot: string;
44
+ persistScopeDir: string;
44
45
  }): Record<string, string>;
45
46
  export declare function startDaemon(deps: DaemonDeps): DaemonController;
@@ -7,6 +7,7 @@
7
7
  // per-run workspace, then tears the workspace down (contract: cleanup "always").
8
8
  import { mkdir, rm } from "node:fs/promises";
9
9
  import * as path from "node:path";
10
+ import { localScopeDir } from "../runtime/local_workspace_store.js";
10
11
  import { createLogger } from "../runtime/support/index.js";
11
12
  /** Assignment-lease heartbeat cadence — comfortably beats the 300s lease. */
12
13
  const HEARTBEAT_MS = 60_000;
@@ -31,6 +32,15 @@ export function runProcessEnv(claim, opts) {
31
32
  BOARDWALK_BYO_PROVIDERS: JSON.stringify(claim.byo_providers),
32
33
  WORKER_ID: opts.runnerId,
33
34
  WORKSPACE_ROOT: opts.workspaceRoot,
35
+ // This run's durable workspace directory — its (workflow, environment) SCOPE, already resolved
36
+ // (docs/WORKSPACE_PERSISTENCE.md I3). A self-hosted runner owns a disk that outlives a run, so
37
+ // `workspace.persist` and `agent({ memory })` compound HERE, never in our S3 (which never sees a
38
+ // self-hosted workspace). Without it the runtime has no durable root and falls back to the
39
+ // broker, which correctly refuses self-hosted runs — so persistence was a silent no-op.
40
+ //
41
+ // The DAEMON resolves the scope, not the runtime: it owns this disk, and in container isolation
42
+ // the scope dir is a bind mount chosen before the container starts. One owner, one layout.
43
+ PERSIST_SCOPE_DIR: opts.persistScopeDir,
34
44
  // The machine's own PATH/HOME etc. deliberately do NOT flow here: the run gets exactly the
35
45
  // resolved env + platform contract, mirroring the hosted container. The spawner overlays the
36
46
  // minimal process necessities (PATH, HOME=workspace).
@@ -44,9 +54,14 @@ export function startDaemon(deps) {
44
54
  const runDir = path.join(deps.workDir, "runs", claim.run_id);
45
55
  const workspaceRoot = path.join(runDir, "workspace");
46
56
  await mkdir(workspaceRoot, { recursive: true });
57
+ // This run's durable workspace, keyed per (workflow, environment) and living OUTSIDE the per-run
58
+ // dir (which is scratch, removed with the run). Created up front because the container lane binds
59
+ // it as a mount, and docker would otherwise create it root-owned.
60
+ const persistScopeDir = localScopeDir(path.join(deps.workDir, "persist"), claim.workflow_id, claim.environment_id);
61
+ await mkdir(persistScopeDir, { recursive: true });
47
62
  const child = deps.spawn({
48
63
  entry: deps.runtimeEntry,
49
- env: runProcessEnv(claim, { runnerId: deps.runnerId, workspaceRoot }),
64
+ env: runProcessEnv(claim, { runnerId: deps.runnerId, workspaceRoot, persistScopeDir }),
50
65
  cwd: workspaceRoot,
51
66
  });
52
67
  log.info("run_started", { runId: claim.run_id, assignmentId: offer.assignment_id });
@@ -60,7 +60,10 @@ export interface BudgetSnapshot {
60
60
  elapsedMs: number;
61
61
  }
62
62
  export declare class BudgetMeter {
63
- private readonly budget;
63
+ /** NOT readonly: a budget gate (docs/SUSPEND_POLICY.md Decision 3) raises `max_usd` in place when a
64
+ * responder approves more spend. The meter lives in the frozen heap, so the wake mutates this
65
+ * instance and the blocked model call proceeds against the new cap. */
66
+ private budget;
64
67
  private readonly rate;
65
68
  private readonly startedAt;
66
69
  private readonly deadlineStartedAt;
@@ -106,6 +109,16 @@ export declare class BudgetMeter {
106
109
  * inline (e.g., the sleep tool rejects a sleep that would breach the
107
110
  * duration cap). Returns the first breach reason or null.
108
111
  */
112
+ /**
113
+ * Raise the `max_usd` cap to `usd` — the budget gate's approval path (docs/SUSPEND_POLICY.md
114
+ * Decision 3). Only ever RAISES: a value at or below the current cap is ignored, so an approval
115
+ * can't silently tighten a cap and re-park the run on the very next call. A no-op when the
116
+ * workflow declared no budget (nothing to breach). Returns the cap now in force, or null when
117
+ * there is no budget.
118
+ */
119
+ raiseUsdCap(usd: number): number | null;
120
+ /** The `max_usd` cap in force, or null when unset. The gate prompt reports it alongside spend. */
121
+ usdCap(): number | null;
109
122
  capBreachReason(): "tokens" | "usd" | "duration" | "deadline" | null;
110
123
  /**
111
124
  * The declared `max_duration_seconds` cap, or null when unset. Lets callers that
@@ -22,6 +22,9 @@ const ZERO_PRIOR = {
22
22
  activeMs: 0,
23
23
  };
24
24
  export class BudgetMeter {
25
+ /** NOT readonly: a budget gate (docs/SUSPEND_POLICY.md Decision 3) raises `max_usd` in place when a
26
+ * responder approves more spend. The meter lives in the frozen heap, so the wake mutates this
27
+ * instance and the blocked model call proceeds against the new cap. */
25
28
  budget;
26
29
  rate;
27
30
  startedAt;
@@ -133,6 +136,26 @@ export class BudgetMeter {
133
136
  * inline (e.g., the sleep tool rejects a sleep that would breach the
134
137
  * duration cap). Returns the first breach reason or null.
135
138
  */
139
+ /**
140
+ * Raise the `max_usd` cap to `usd` — the budget gate's approval path (docs/SUSPEND_POLICY.md
141
+ * Decision 3). Only ever RAISES: a value at or below the current cap is ignored, so an approval
142
+ * can't silently tighten a cap and re-park the run on the very next call. A no-op when the
143
+ * workflow declared no budget (nothing to breach). Returns the cap now in force, or null when
144
+ * there is no budget.
145
+ */
146
+ raiseUsdCap(usd) {
147
+ if (this.budget === undefined)
148
+ return null;
149
+ const current = this.budget.max_usd;
150
+ if (current !== undefined && usd <= current)
151
+ return current;
152
+ this.budget = { ...this.budget, max_usd: usd };
153
+ return usd;
154
+ }
155
+ /** The `max_usd` cap in force, or null when unset. The gate prompt reports it alongside spend. */
156
+ usdCap() {
157
+ return this.budget?.max_usd ?? null;
158
+ }
136
159
  capBreachReason() {
137
160
  try {
138
161
  this.assertWithinCaps();
@@ -18,8 +18,6 @@ export declare class BrokerChildDispatcher implements ChildDispatcher {
18
18
  /** Start (or idempotently re-attach to) the child and resolve its CURRENT state — no hold. The
19
19
  * durable callWorkflow seam decides whether to suspend (non-terminal) or return (terminal). */
20
20
  start(slug: string, input: unknown, _opts: CallOptions | undefined, signal?: AbortSignal): Promise<ChildResult>;
21
- /** Poll a child's current state by id (the resume path), or null when it isn't this run's child. */
22
- poll(childRunId: string): Promise<ChildResult | null>;
23
21
  /** Fire-and-forget: create (or re-attach to) the child and return its id without holding. */
24
22
  run(slug: string, input: unknown, _opts: CallOptions | undefined): Promise<string>;
25
23
  /** Provision a durable schedule via the broker; returns the new schedule's id. A `Date` `at` is
@@ -35,13 +35,6 @@ export class BrokerChildDispatcher {
35
35
  const child = await this.deps.client.startChild(slug, input);
36
36
  return { childRunId: child.childRunId, status: child.status, output: child.output };
37
37
  }
38
- /** Poll a child's current state by id (the resume path), or null when it isn't this run's child. */
39
- async poll(childRunId) {
40
- const child = await this.deps.client.getChild(childRunId);
41
- return child === null
42
- ? null
43
- : { childRunId: child.id, status: child.status, output: child.output };
44
- }
45
38
  /** Fire-and-forget: create (or re-attach to) the child and return its id without holding. */
46
39
  async run(slug, input, _opts) {
47
40
  const child = await this.deps.client.startChild(slug, input);
@@ -0,0 +1,51 @@
1
+ import type { BudgetMeter } from "./agent/budget.js";
2
+ import type { HumanInputResult } from "@boardwalk-labs/workflow";
3
+ /** The stable key a responder answers a budget gate by (`boardwalk respond <runId> budget …`). */
4
+ export declare const BUDGET_GATE_KEY = "budget";
5
+ /** Preset approvals, plus the two open-ended answers. Kept small: the point is a fast decision. */
6
+ export declare const BUDGET_CHOICE_ADD_10 = "+$10";
7
+ export declare const BUDGET_CHOICE_ADD_25 = "+$25";
8
+ export declare const BUDGET_CHOICE_ADD_100 = "+$100";
9
+ export declare const BUDGET_CHOICE_CANCEL = "cancel";
10
+ /** The port the gate needs from the host — narrowed so tests don't build a whole WorkflowHost. */
11
+ export interface BudgetClearancePort {
12
+ budgetClearance(gate: {
13
+ prompt: string;
14
+ inputSpec: unknown;
15
+ }): Promise<HumanInputResult>;
16
+ }
17
+ /** Raised when a responder answers `cancel`: the run stops, deliberately, at the user's word. */
18
+ export declare class BudgetGateCancelled extends Error {
19
+ constructor();
20
+ }
21
+ /**
22
+ * The gate's question. It states the two numbers that matter (spent vs. cap) so the responder can
23
+ * decide without opening the dashboard, and names the run's own `max_usd` so it's obvious WHICH cap
24
+ * was hit (not the org's credit balance — a different failure with a different fix).
25
+ */
26
+ export declare function budgetGatePrompt(spentUsd: number, capUsd: number): string;
27
+ /** The gate's response form — a choice, so the common answers are one click / one word. */
28
+ export declare function budgetGateInputSpec(): unknown;
29
+ /**
30
+ * Interpret an answer as the NEW absolute `max_usd`, or null to cancel the run.
31
+ *
32
+ * `+$N` is an INCREMENT on the current cap (the presets); a bare number is an ABSOLUTE new cap (the
33
+ * "set a cap" escape hatch, via the choice's `other` entry). Anything unrecognized is treated as
34
+ * cancel rather than guessed at — silently resuming a run on a misread answer spends real money.
35
+ */
36
+ export declare function resolveBudgetAnswer(answer: string, currentCapUsd: number): number | null;
37
+ /**
38
+ * Budget clearance, awaited before every model call. Fast path: no breach ⇒ resolve immediately (the
39
+ * overwhelming majority of calls). On a `usd` breach: park at a gate, then apply the answer to the
40
+ * live meter and let the call proceed.
41
+ *
42
+ * Scope (slice 1): ONLY the `usd` cap parks. A `tokens` / `duration` / `deadline` breach still fails
43
+ * the run through the leaf executor's existing throw — those dimensions have no "approve more" story
44
+ * yet, and `deadline` is wall-clock, which a park cannot pause by definition.
45
+ */
46
+ export declare class BudgetGate {
47
+ private readonly meter;
48
+ private readonly host;
49
+ constructor(meter: BudgetMeter, host: BudgetClearancePort);
50
+ clear(): Promise<void>;
51
+ }
@@ -0,0 +1,114 @@
1
+ // SPDX-License-Identifier: MIT
2
+ /** The stable key a responder answers a budget gate by (`boardwalk respond <runId> budget …`). */
3
+ export const BUDGET_GATE_KEY = "budget";
4
+ /** Preset approvals, plus the two open-ended answers. Kept small: the point is a fast decision. */
5
+ export const BUDGET_CHOICE_ADD_10 = "+$10";
6
+ export const BUDGET_CHOICE_ADD_25 = "+$25";
7
+ export const BUDGET_CHOICE_ADD_100 = "+$100";
8
+ export const BUDGET_CHOICE_CANCEL = "cancel";
9
+ /** Raised when a responder answers `cancel`: the run stops, deliberately, at the user's word. */
10
+ export class BudgetGateCancelled extends Error {
11
+ constructor() {
12
+ super("Run cancelled at the budget gate.");
13
+ this.name = "BudgetGateCancelled";
14
+ }
15
+ }
16
+ /** Money in a prompt: 2dp is right for a cap, which is always a human-chosen round-ish number. */
17
+ function usd(n) {
18
+ return `$${n.toFixed(2)}`;
19
+ }
20
+ /**
21
+ * The gate's question. It states the two numbers that matter (spent vs. cap) so the responder can
22
+ * decide without opening the dashboard, and names the run's own `max_usd` so it's obvious WHICH cap
23
+ * was hit (not the org's credit balance — a different failure with a different fix).
24
+ */
25
+ export function budgetGatePrompt(spentUsd, capUsd) {
26
+ return (`Budget cap reached: this run has spent ${usd(spentUsd)} of its ${usd(capUsd)} ` +
27
+ `max_usd cap. Approve more spend to continue, or cancel the run.`);
28
+ }
29
+ /** The gate's response form — a choice, so the common answers are one click / one word. */
30
+ export function budgetGateInputSpec() {
31
+ return {
32
+ kind: "choice",
33
+ options: [
34
+ BUDGET_CHOICE_ADD_10,
35
+ BUDGET_CHOICE_ADD_25,
36
+ BUDGET_CHOICE_ADD_100,
37
+ BUDGET_CHOICE_CANCEL,
38
+ ],
39
+ // `other` lets a responder type an absolute cap ("50") instead of taking a preset increment.
40
+ other: true,
41
+ };
42
+ }
43
+ /**
44
+ * Interpret an answer as the NEW absolute `max_usd`, or null to cancel the run.
45
+ *
46
+ * `+$N` is an INCREMENT on the current cap (the presets); a bare number is an ABSOLUTE new cap (the
47
+ * "set a cap" escape hatch, via the choice's `other` entry). Anything unrecognized is treated as
48
+ * cancel rather than guessed at — silently resuming a run on a misread answer spends real money.
49
+ */
50
+ export function resolveBudgetAnswer(answer, currentCapUsd) {
51
+ const raw = answer.trim();
52
+ if (raw === "" || raw.toLowerCase() === BUDGET_CHOICE_CANCEL)
53
+ return null;
54
+ const increment = /^\+\s*\$?\s*(\d+(?:\.\d+)?)$/.exec(raw);
55
+ if (increment?.[1] !== undefined)
56
+ return currentCapUsd + Number(increment[1]);
57
+ const absolute = /^\$?\s*(\d+(?:\.\d+)?)$/.exec(raw);
58
+ if (absolute?.[1] !== undefined) {
59
+ const value = Number(absolute[1]);
60
+ // An "absolute" cap at or below what's already spent would re-park instantly — read it as a
61
+ // refusal to fund more, which is a cancel.
62
+ return value > currentCapUsd ? value : null;
63
+ }
64
+ return null;
65
+ }
66
+ /** Pull the answer's text out of the SDK's result union (text | choice | multiselect). */
67
+ function answerText(result) {
68
+ if (typeof result === "string")
69
+ return result;
70
+ if (typeof result === "object" && result !== null && "value" in result) {
71
+ const value = result.value;
72
+ if (typeof value === "string")
73
+ return value;
74
+ }
75
+ return "";
76
+ }
77
+ /**
78
+ * Budget clearance, awaited before every model call. Fast path: no breach ⇒ resolve immediately (the
79
+ * overwhelming majority of calls). On a `usd` breach: park at a gate, then apply the answer to the
80
+ * live meter and let the call proceed.
81
+ *
82
+ * Scope (slice 1): ONLY the `usd` cap parks. A `tokens` / `duration` / `deadline` breach still fails
83
+ * the run through the leaf executor's existing throw — those dimensions have no "approve more" story
84
+ * yet, and `deadline` is wall-clock, which a park cannot pause by definition.
85
+ */
86
+ export class BudgetGate {
87
+ meter;
88
+ host;
89
+ constructor(meter, host) {
90
+ this.meter = meter;
91
+ this.host = host;
92
+ }
93
+ async clear() {
94
+ // Loop, don't `if`: a responder can approve an increment too small to clear the breach (spend
95
+ // already exceeds it), in which case we ask again rather than resume into an instant re-park.
96
+ while (this.meter.capBreachReason() === "usd") {
97
+ const cap = this.meter.usdCap();
98
+ if (cap === null)
99
+ return; // no cap ⇒ nothing to breach (defensive; capBreachReason implies one)
100
+ const spent = this.meter.snapshot().totalUsd;
101
+ // The park needs no bespoke event: registering the gate moves the run to `awaiting_input` and
102
+ // the prompt below rides the gate row, so the live tail + `boardwalk inputs` explain the pause
103
+ // through exactly the same path a humanInput() gate uses.
104
+ const answer = await this.host.budgetClearance({
105
+ prompt: budgetGatePrompt(spent, cap),
106
+ inputSpec: budgetGateInputSpec(),
107
+ });
108
+ const newCap = resolveBudgetAnswer(answerText(answer), cap);
109
+ if (newCap === null)
110
+ throw new BudgetGateCancelled();
111
+ this.meter.raiseUsdCap(newCap);
112
+ }
113
+ }
114
+ }
@@ -269,6 +269,14 @@ export class FreezeCoordinator {
269
269
  kind: "human_input",
270
270
  ...(signal.humanInput !== undefined ? { request_keys: [signal.humanInput.key] } : {}),
271
271
  };
272
+ // A budget park waits on the same gate machinery as human_input, but reports its own kind so
273
+ // the control plane can say "paused on budget" rather than "waiting on a human" — a different
274
+ // thing to a person triaging the run list (SUSPEND_POLICY Decision 3).
275
+ case "budget":
276
+ return {
277
+ kind: "budget",
278
+ ...(signal.humanInput !== undefined ? { request_keys: [signal.humanInput.key] } : {}),
279
+ };
272
280
  case "workflow_call":
273
281
  return {
274
282
  kind: "workflow_call",
@@ -11,8 +11,21 @@ import { type ProgramWorkerDeps } from "./program_worker.js";
11
11
  export interface WorkerRuntime {
12
12
  /** Stable worker identity stamped onto the lease (task ARN / hostname / run-derived). */
13
13
  workerId: string;
14
- /** Sandbox workspace root for filesystem/shell/git tools. */
14
+ /** Sandbox workspace root for filesystem/shell/git tools — and the working directory + HOME for
15
+ * author code (docs/WORKSPACE_PERSISTENCE.md I1). */
15
16
  workspaceRoot: string;
17
+ /** Where the program artifact is extracted. Deliberately OUTSIDE {@link workspaceRoot} (I2): a
18
+ * bundle inside the workspace rides into every pre-sleep snapshot and, because each run's dir is
19
+ * uniquely named, accumulates there forever. */
20
+ programRoot: string;
21
+ /** This run's durable workspace directory — its (workflow, environment) scope, ALREADY RESOLVED by
22
+ * the daemon (PERSIST_SCOPE_DIR). Set only by a SELF-HOSTED runner, which owns a disk that
23
+ * outlives a run. Present ⇒ persistence is a plain directory tree here (never our S3: a
24
+ * self-hosted workspace is the customer's data on the customer's disk). Absent ⇒ the hosted lane,
25
+ * which has no disk outliving the VM and persists through the broker. The daemon resolves the
26
+ * scope because it owns the disk and, under container isolation, binds this exact dir as a mount.
27
+ * See docs/WORKSPACE_PERSISTENCE.md I3. */
28
+ persistScopeDir?: string;
16
29
  /** The run this worker task is executing (RUN_ID) — binds the Runner Control API client. */
17
30
  runId: string;
18
31
  /** The org's BYO inference providers (claim-delivered, BOARDWALK_BYO_PROVIDERS) for the
@@ -32,7 +45,7 @@ export interface WorkerRuntime {
32
45
  vcpus: number;
33
46
  /** The in-guest identity relay, present ONLY on the snapshot-based microVM substrate (relay-mode
34
47
  * boot). When set, the worker suspends by FREEZING — the FreezeCoordinator parks seams over this
35
- * relay's suspend/wake channel — instead of the exit-and-replay path. */
48
+ * relay's suspend/wake channel — the whole VM freezes and the wake resolves seams in place. */
36
49
  freezeRelay?: IdentityRelay;
37
50
  /** The browser tier's process backend, present ONLY when the runner IMAGE ships the browser stack
38
51
  * (Chromium + a pre-installed Playwright MCP + an X display; gated by BOARDWALK_BROWSER_TIER).