@boardwalk-labs/runner 0.2.0 → 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.
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
@@ -34,6 +34,7 @@ import { BUDGET_GUARDRAIL_RATE } from "./agent/model_rates.js";
34
34
  import { SecretRedactor } from "./agent/secret_redactor.js";
35
35
  import { RecordingSecretResolver } from "./recording_secret_resolver.js";
36
36
  import { EngineLeafExecutor } from "./leaf_executor.js";
37
+ import { BudgetGate } from "./budget_gate.js";
37
38
  import { parseByoProviders } from "./direct_inference.js";
38
39
  import { applyIdentityToEnv, connectIdentityRelayFd, relayFdFromEnv, workerDiagnostics, } from "./identity_relay.js";
39
40
  import { FreezeCoordinator } from "./freeze_coordinator.js";
@@ -56,10 +57,12 @@ import { CreditWatcher } from "./credit_watcher.js";
56
57
  import { CancelWatcher } from "./cancel_watcher.js";
57
58
  import { LeaseRenewer } from "./lease_renewer.js";
58
59
  import { RuntimeFlusher } from "./runtime_flusher.js";
59
- import { WorkspaceStore, TarWorkspaceArchiver, NodeWorkspaceFs } from "./workspace_store.js";
60
+ import { WorkspaceStore, TarWorkspaceArchiver, NodeWorkspaceFs, resolvePersistSelection, } from "./workspace_store.js";
61
+ import { LocalWorkspaceStore } from "./local_workspace_store.js";
60
62
  import { PhaseTracker } from "./phase_tracker.js";
61
63
  import { mkdir } from "node:fs/promises";
62
64
  import { join } from "node:path";
65
+ import { tmpdir } from "node:os";
63
66
  const log = createLogger("worker_entrypoint");
64
67
  /** Durable events are deferred (durable event storage) — live fan-out via the broker only. */
65
68
  /** Synthesize the run's AuthContext. The run was already authorized at trigger time; the
@@ -235,6 +238,20 @@ export function assembleWorkerDeps(runtime) {
235
238
  // it). Workspace-rooted at the same `/workspace` the leaf + tools use. Closed on the run's teardown
236
239
  // (returned below as `lsp` → program_worker's finally) so no language-server process leaks.
237
240
  const lspService = new LspService({ workspaceDir: runtime.workspaceRoot });
241
+ // Budget gate (docs/SUSPEND_POLICY.md Decision 3): a `max_usd` breach PARKS the run for approval
242
+ // instead of failing it. The gate needs the HOST (to park) but the host is constructed below with
243
+ // `leaf` — a genuine cycle, broken with a late-bound ref: `clear()` only ever runs mid-run, long
244
+ // after both exist. Wired to `budgetHost` immediately after the host is built.
245
+ let budgetHost = null;
246
+ const budgetGate = new BudgetGate(budget, {
247
+ budgetClearance: (gate) => {
248
+ if (budgetHost === null) {
249
+ // Unreachable in a real run; a loud error beats parking against a null host.
250
+ return Promise.reject(new Error("budget gate reached before the workflow host was wired"));
251
+ }
252
+ return budgetHost.budgetClearance(gate);
253
+ },
254
+ });
238
255
  const leaf = new EngineLeafExecutor({
239
256
  inference: broker,
240
257
  // Direct BYO (D7): the claim's registry + the run's RECORDING resolver, so a provider key
@@ -248,10 +265,14 @@ export function assembleWorkerDeps(runtime) {
248
265
  }
249
266
  : {}),
250
267
  budget,
268
+ budgetGate,
251
269
  redactor,
252
270
  toolHost,
253
271
  lspService,
254
272
  workspaceRoot: runtime.workspaceRoot,
273
+ // Register every memory dir the run uses, so the workspace store persists it with no manifest
274
+ // declaration (WORKSPACE_PERSISTENCE.md §3). Read back by the store's `selection` thunk.
275
+ onMemoryUsed: (dir) => memoryDirs.add(dir),
255
276
  // Resolve this run's deployed `skills/` dir for `agent({ skills })` — the engine reads
256
277
  // `<skillsDir>/<name>.md`, so it's the `skills` subdir of the extracted program tree (filled
257
278
  // once the artifact extracts). Null until then / when no program dir is known.
@@ -289,17 +310,35 @@ export function assembleWorkerDeps(runtime) {
289
310
  // comes from the org's connection vault via the Runner Control API — never stored on the worker.
290
311
  brokerMcpToken: (serverUrl, invalidateToken) => broker.mcpToken(serverUrl, invalidateToken),
291
312
  });
292
- // Per-workflow persistent /workspace: only when the manifest opts in. The BROKER additionally
293
- // gates on hosted (self-hosted runs get null URLs), and snapshots are keyed per-workflow + scoped
294
- // by the run token so even the untrusted in-process program can't reach another tenant's data.
295
- const workspaceStore = manifest.workspace?.persist === true
296
- ? new WorkspaceStore({
313
+ // Per-workflow persistent /workspace (docs/WORKSPACE_PERSISTENCE.md §3). Constructed on EVERY
314
+ // run, NOT gated on the manifest: `agent({ memory })` persists with no declaration at all, and a
315
+ // memory dir is only known once the run has made the call so a construction-time manifest gate
316
+ // (the old `persist === true`) silently dropped BOTH the list form and every memory dir. What to
317
+ // write is decided at persist time by `resolvePersistSelection`; a run that selects nothing does
318
+ // no fs or broker work. The BROKER still gates eligibility (hosted-only; self-hosted gets null
319
+ // URLs), and snapshots are keyed per workflow + environment and scoped by the run token — so even
320
+ // the untrusted in-process program can't reach another tenant's, or another environment's, data.
321
+ const memoryDirs = new Set();
322
+ const selection = () => resolvePersistSelection(manifest.workspace?.persist, memoryDirs);
323
+ // TWO stores, ONE contract (WORKSPACE_PERSISTENCE.md I3): `runs_on` decides WHERE the bytes live,
324
+ // never WHETHER persistence happens. A runner with its OWN durable root (a self-hosted daemon,
325
+ // which sets PERSIST_ROOT) keeps state on the customer's disk and never touches our S3 — the
326
+ // broker returns null URLs for self-hosted runs by design. Hosted runners have no local disk that
327
+ // outlives the VM, so they push a tarball through the broker. Before this, "don't upload it" was
328
+ // implemented as "don't persist it", so self-hosted runs silently forgot everything.
329
+ const workspaceStore = runtime.persistScopeDir !== undefined
330
+ ? new LocalWorkspaceStore({
331
+ scopeDir: runtime.persistScopeDir,
332
+ workspaceRoot: runtime.workspaceRoot,
333
+ selection,
334
+ })
335
+ : new WorkspaceStore({
297
336
  broker,
298
337
  archiver: new TarWorkspaceArchiver(),
299
338
  fs: new NodeWorkspaceFs(),
300
339
  workspaceRoot: runtime.workspaceRoot,
301
- })
302
- : undefined;
340
+ selection,
341
+ });
303
342
  // The run's identity + on-demand public-API bearer, surfaced to the program via `import { runtime }`.
304
343
  // ids come from the claimed run; the bearer is the captured (scrubbed-from-env) api token, already
305
344
  // recorded in this run's redactor above — so threading it into an MCP header keeps it out of LLM
@@ -348,13 +387,9 @@ export function assembleWorkerDeps(runtime) {
348
387
  signal,
349
388
  // Snapshot the workspace before a long sleep (crash-during-hold recovery). The byte count
350
389
  // is only logged by the store itself.
351
- ...(workspaceStore !== undefined
352
- ? {
353
- onBeforeSleep: async () => {
354
- await workspaceStore.persist();
355
- },
356
- }
357
- : {}),
390
+ onBeforeSleep: async () => {
391
+ await workspaceStore.persist();
392
+ },
358
393
  // Snapshot-substrate suspension: seams freeze in place under the quiescence gate. Absent
359
394
  // (a self-hosted daemon / the Fargate break-glass), waiting seams HOLD the live process.
360
395
  ...(freeze !== undefined ? { freeze } : {}),
@@ -369,6 +404,8 @@ export function assembleWorkerDeps(runtime) {
369
404
  ...(browserSessions !== undefined ? { browserSessions } : {}),
370
405
  phases: phaseTracker,
371
406
  });
407
+ // Close the budget gate's cycle: the leaf (built above) parks THROUGH the host (built just now).
408
+ budgetHost = host;
372
409
  // Late-bind the coordinator's per-run hooks now that the run-scoped objects exist.
373
410
  if (freeze !== undefined) {
374
411
  const coordinator = freeze;
@@ -380,7 +417,7 @@ export function assembleWorkerDeps(runtime) {
380
417
  onBeforeFreeze: async () => {
381
418
  freezeWallMs = Date.now();
382
419
  await activeFlusher?.flushNow();
383
- await workspaceStore?.persist();
420
+ await workspaceStore.persist();
384
421
  // The recorder never spans a snapshot: finalize + upload the in-flight segment before the
385
422
  // freeze (SCREEN_CAPTURE §4.3). Bounded internally, so a slow upload delays, not blocks, it.
386
423
  await capture?.stopAndFlush();
@@ -441,7 +478,10 @@ export function assembleWorkerDeps(runtime) {
441
478
  setProgramDir: (dir) => {
442
479
  programDir = dir;
443
480
  },
444
- ...(workspaceStore !== undefined ? { workspace: workspaceStore } : {}),
481
+ // Always present now: hydrate must run BEFORE the program, but whether anything compounds
482
+ // isn't known until the run's agent() calls have registered their memory dirs. A run that
483
+ // selects nothing persists nothing and pays for nothing (WorkspaceStore.persist returns early).
484
+ workspace: workspaceStore,
445
485
  // The orchestrator reaps every still-open browser session on terminal (kill Chromium + its
446
486
  // Playwright MCP) so no browser process leaks past the run.
447
487
  ...(browserSessions !== undefined ? { browserSessions } : {}),
@@ -450,6 +490,13 @@ export function assembleWorkerDeps(runtime) {
450
490
  });
451
491
  };
452
492
  return {
493
+ // The two filesystem coordinates, both explicit (docs/WORKSPACE_PERSISTENCE.md I1/I2). The
494
+ // workspace is cwd + HOME for author code; the program extracts OUTSIDE it, so no snapshot ever
495
+ // captures the bundle. Neither is `process.cwd()` — deriving them from it is exactly how the
496
+ // hosted lanes drifted (the fleet boots bwinit as PID 1 with cwd `/`; Fargate's image left cwd
497
+ // at `/app`), and how a bundle would land inside a persisted workspace on the lanes that hadn't.
498
+ workspaceRoot: runtime.workspaceRoot,
499
+ programRoot: runtime.programRoot,
453
500
  runs: {
454
501
  // The broker owns the lease duration server-side; convert the absolute lease back to seconds.
455
502
  claimForWorker: async (_runId, workerId, leaseUntil, nowMs) => {
@@ -657,6 +704,14 @@ export async function main() {
657
704
  const deps = assembleWorkerDeps({
658
705
  workerId: process.env.WORKER_ID ?? `worker-${runId}`,
659
706
  workspaceRoot: process.env.WORKSPACE_ROOT ?? "/workspace",
707
+ // Never inside the workspace, and never `process.cwd()` (WORKSPACE_PERSISTENCE.md I2). `tmpdir()`
708
+ // honors TMPDIR, which the self-hosted daemon points at the per-run dir — so concurrent daemons on
709
+ // one machine don't collide, and the hosted lanes get the VM's own /tmp.
710
+ programRoot: process.env.PROGRAM_ROOT ?? join(tmpdir(), "bw-programs"),
711
+ // Set by the self-hosted daemon only (both isolation lanes); hosted images never set it.
712
+ ...(process.env.PERSIST_SCOPE_DIR !== undefined
713
+ ? { persistScopeDir: process.env.PERSIST_SCOPE_DIR }
714
+ : {}),
660
715
  runId,
661
716
  controlPlane: platform.controlPlane,
662
717
  vcpus: platform.vcpus,