@boardwalk-labs/runner 0.2.0 → 0.2.2
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 +11 -0
- package/dist/contract.d.ts +2 -0
- package/dist/contract.js +14 -0
- package/dist/daemon/container.d.ts +3 -2
- package/dist/daemon/container.js +15 -2
- package/dist/daemon/daemon.d.ts +1 -0
- package/dist/daemon/daemon.js +16 -1
- package/dist/runtime/agent/budget.d.ts +14 -1
- package/dist/runtime/agent/budget.js +23 -0
- package/dist/runtime/broker_child_dispatcher.d.ts +0 -2
- package/dist/runtime/broker_child_dispatcher.js +0 -7
- package/dist/runtime/budget_gate.d.ts +51 -0
- package/dist/runtime/budget_gate.js +114 -0
- package/dist/runtime/freeze_coordinator.js +8 -0
- package/dist/runtime/index.d.ts +14 -1
- package/dist/runtime/index.js +72 -17
- package/dist/runtime/leaf_executor.d.ts +20 -0
- package/dist/runtime/leaf_executor.js +13 -4
- package/dist/runtime/local_workspace_store.d.ts +22 -0
- package/dist/runtime/local_workspace_store.js +103 -0
- package/dist/runtime/program_runner.d.ts +28 -13
- package/dist/runtime/program_runner.js +69 -23
- package/dist/runtime/program_worker.d.ts +5 -0
- package/dist/runtime/program_worker.js +2 -0
- package/dist/runtime/suspension.d.ts +8 -2
- package/dist/runtime/workflow_host.d.ts +23 -2
- package/dist/runtime/workflow_host.js +35 -0
- package/dist/runtime/workspace_store.d.ts +31 -3
- package/dist/runtime/workspace_store.js +43 -4
- 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
|
package/dist/contract.d.ts
CHANGED
|
@@ -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
|
|
28
|
-
* the user's home,
|
|
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).
|
package/dist/daemon/container.js
CHANGED
|
@@ -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
|
|
63
|
-
* the user's home,
|
|
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) {
|
package/dist/daemon/daemon.d.ts
CHANGED
|
@@ -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;
|
package/dist/daemon/daemon.js
CHANGED
|
@@ -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
|
-
|
|
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",
|
package/dist/runtime/index.d.ts
CHANGED
|
@@ -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
|
package/dist/runtime/index.js
CHANGED
|
@@ -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
|
|
293
|
-
//
|
|
294
|
-
//
|
|
295
|
-
|
|
296
|
-
|
|
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
|
-
|
|
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
|
-
|
|
352
|
-
|
|
353
|
-
|
|
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
|
|
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
|
-
|
|
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,
|