@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.
- 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 +15 -2
- package/dist/runtime/index.js +80 -57
- 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 +32 -28
- package/dist/runtime/program_runner.js +75 -44
- package/dist/runtime/program_worker.d.ts +5 -18
- package/dist/runtime/program_worker.js +3 -20
- package/dist/runtime/runner_control_client.d.ts +2 -22
- package/dist/runtime/runner_control_client.js +2 -49
- package/dist/runtime/suspension.d.ts +20 -109
- package/dist/runtime/suspension.js +18 -111
- package/dist/runtime/wire/human_input.d.ts +1 -1
- package/dist/runtime/wire/human_input.js +2 -2
- package/dist/runtime/workflow_host.d.ts +65 -62
- package/dist/runtime/workflow_host.js +124 -199
- 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
|
|
@@ -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 —
|
|
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).
|