@boardwalk-labs/runner 0.2.17 → 0.3.0
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 +1 -1
- package/dist/runtime/agent/budget.d.ts +43 -24
- package/dist/runtime/agent/budget.js +108 -57
- package/dist/runtime/agent/events.d.ts +1 -1
- package/dist/runtime/broker_child_dispatcher.d.ts +5 -2
- package/dist/runtime/broker_child_dispatcher.js +24 -6
- package/dist/runtime/budget_gate.d.ts +58 -21
- package/dist/runtime/budget_gate.js +227 -46
- package/dist/runtime/host_capabilities.d.ts +4 -0
- package/dist/runtime/host_capabilities.js +25 -0
- package/dist/runtime/host_server.d.ts +137 -0
- package/dist/runtime/host_server.js +562 -0
- package/dist/runtime/index.js +45 -12
- package/dist/runtime/leaf_executor.d.ts +7 -4
- package/dist/runtime/leaf_executor.js +11 -6
- package/dist/runtime/program_runner.d.ts +62 -42
- package/dist/runtime/program_runner.js +156 -101
- package/dist/runtime/program_worker.d.ts +22 -11
- package/dist/runtime/program_worker.js +26 -48
- package/dist/runtime/python_program.d.ts +68 -0
- package/dist/runtime/python_program.js +270 -0
- package/dist/runtime/run_context.d.ts +13 -0
- package/dist/runtime/run_context.js +77 -0
- package/dist/runtime/runner_control_client.d.ts +23 -0
- package/dist/runtime/runner_control_client.js +69 -147
- package/dist/runtime/shell_exec.d.ts +17 -0
- package/dist/runtime/shell_exec.js +144 -0
- package/dist/runtime/support/index.d.ts +6 -0
- package/dist/runtime/support/index.js +14 -0
- package/dist/runtime/wire/inference_proxy.js +1 -1
- package/dist/runtime/wire/run.d.ts +5 -2
- package/dist/runtime/workflow_host.d.ts +53 -7
- package/dist/runtime/workflow_host.js +82 -42
- package/package.json +4 -3
package/README.md
CHANGED
|
@@ -86,7 +86,7 @@ pnpm lint && pnpm typecheck && pnpm build
|
|
|
86
86
|
## The Boardwalk repos
|
|
87
87
|
|
|
88
88
|
- [`boardwalk`](https://github.com/boardwalk-labs/boardwalk) — the open-source single-node engine: cron scheduling, webhooks, durable runs, run history
|
|
89
|
-
- [`sdk`](https://github.com/boardwalk-labs/sdk) — `@boardwalk-labs/workflow`, the TypeScript API a workflow program imports
|
|
89
|
+
- [`sdk-typescript`](https://github.com/boardwalk-labs/sdk-typescript) — `@boardwalk-labs/workflow`, the TypeScript API a workflow program imports
|
|
90
90
|
- [`cli`](https://github.com/boardwalk-labs/cli) — `boardwalk`: scaffold, validate, run locally, deploy
|
|
91
91
|
- [`examples`](https://github.com/boardwalk-labs/examples) — copyable workflow templates (`boardwalk init --template`)
|
|
92
92
|
- [`plugins`](https://github.com/boardwalk-labs/plugins) — coding-agent skills (Claude Code, Codex, Cursor, OpenClaw, OpenCode) + a control-plane MCP server
|
|
@@ -1,4 +1,8 @@
|
|
|
1
|
+
import type { UsageSnapshot } from "@boardwalk-labs/workflow/runtime";
|
|
1
2
|
import type { Budget } from "../wire/manifest.js";
|
|
3
|
+
/** The three capped budget dimensions. Each parks at the budget gate on breach (SUSPEND_POLICY
|
|
4
|
+
* Decision 3); `capBreachReason()` reports the first breached one. */
|
|
5
|
+
export type BudgetDimension = "usd" | "tokens" | "compute";
|
|
2
6
|
/** Per-million-token rates. */
|
|
3
7
|
export interface ModelRate {
|
|
4
8
|
/** USD per million input tokens. */
|
|
@@ -30,8 +34,8 @@ export interface PriorUsage {
|
|
|
30
34
|
totalUsd: number;
|
|
31
35
|
/**
|
|
32
36
|
* Active execution time from prior sessions (ms). Only on-CPU turn time counts toward the
|
|
33
|
-
*
|
|
34
|
-
* not blow its
|
|
37
|
+
* compute cap — sleep/wait pauses don't burn it (a run intentionally parked for a day must
|
|
38
|
+
* not blow its `max_compute_seconds` budget on resume).
|
|
35
39
|
*/
|
|
36
40
|
activeMs: number;
|
|
37
41
|
}
|
|
@@ -41,10 +45,6 @@ export interface BudgetMeterOptions {
|
|
|
41
45
|
rate: ModelRate;
|
|
42
46
|
/** This SESSION's start time (ms). Per-session active duration is measured from here. */
|
|
43
47
|
startedAt: number;
|
|
44
|
-
/** The RUN's original start time (ms) — the basis for `deadline_seconds`, a WALL-CLOCK cap that
|
|
45
|
-
* INCLUDES suspended idle (unlike max_duration_seconds, which is active compute). Omit to leave
|
|
46
|
-
* the deadline cap unenforced (e.g. a run that hasn't started yet). */
|
|
47
|
-
deadlineStartedAt?: number;
|
|
48
48
|
/** Cumulative usage from prior sessions of this run (resume). Defaults to zero. */
|
|
49
49
|
priorUsage?: PriorUsage;
|
|
50
50
|
/** Injected clock for tests. */
|
|
@@ -60,13 +60,16 @@ export interface BudgetSnapshot {
|
|
|
60
60
|
elapsedMs: number;
|
|
61
61
|
}
|
|
62
62
|
export declare class BudgetMeter {
|
|
63
|
-
/** NOT readonly:
|
|
64
|
-
* responder approves more spend. The meter lives in the frozen
|
|
65
|
-
* instance and the blocked
|
|
63
|
+
/** NOT readonly: the budget gate (docs/SUSPEND_POLICY.md Decision 3) raises a breached cap in
|
|
64
|
+
* place when a responder approves more spend — on any dimension. The meter lives in the frozen
|
|
65
|
+
* heap, so the wake mutates this instance and the blocked call proceeds against the new cap. */
|
|
66
66
|
private budget;
|
|
67
|
+
/** Wall-clock ms excluded from the compute cap — time the run spent PARKED at the budget gate
|
|
68
|
+
* (frozen or held). Parked time burns no `max_compute_seconds` (SUSPEND_POLICY Decision 3.4);
|
|
69
|
+
* without this exclusion a compute park would instantly re-breach on wake, forever. */
|
|
70
|
+
private excludedIdleMs;
|
|
67
71
|
private readonly rate;
|
|
68
72
|
private readonly startedAt;
|
|
69
|
-
private readonly deadlineStartedAt;
|
|
70
73
|
private readonly prior;
|
|
71
74
|
private readonly now;
|
|
72
75
|
private inputTokens;
|
|
@@ -90,6 +93,11 @@ export declare class BudgetMeter {
|
|
|
90
93
|
* loop) — and by audit/post-run cost rollups. Cap enforcement uses {@link cumulative} instead.
|
|
91
94
|
*/
|
|
92
95
|
snapshot(): BudgetSnapshot;
|
|
96
|
+
/** Exclude `ms` of wall-clock from the compute cap — the budget gate's park window (mirrors
|
|
97
|
+
* RuntimeFlusher.excludeIdle for billing). Called by the gate after every park, whichever
|
|
98
|
+
* substrate: on the snapshot fleet the guest clock resyncs on wake so the measured park spans
|
|
99
|
+
* the frozen window; on a hold substrate it spans the register-and-poll wait. */
|
|
100
|
+
excludeIdle(ms: number): void;
|
|
93
101
|
/**
|
|
94
102
|
* RUN-CUMULATIVE snapshot: this session's usage PLUS the prior sessions' usage seeded at
|
|
95
103
|
* construction. Cap enforcement ({@link assertWithinCaps}) and checkpoint persistence use
|
|
@@ -98,6 +106,12 @@ export declare class BudgetMeter {
|
|
|
98
106
|
* per-session token metering reports token deltas; seeding it would double-report usage.
|
|
99
107
|
*/
|
|
100
108
|
cumulative(): BudgetSnapshot;
|
|
109
|
+
/**
|
|
110
|
+
* The first breached cap (in the tokens → usd → compute order the assert has always used) with
|
|
111
|
+
* the numbers + message the `BUDGET_EXCEEDED` error carries, or null while within every cap.
|
|
112
|
+
* The one probe both {@link assertWithinCaps} and {@link capBreachReason} read from.
|
|
113
|
+
*/
|
|
114
|
+
private firstBreach;
|
|
101
115
|
/**
|
|
102
116
|
* Throws `AppError(BUDGET_EXCEEDED)` when any cap is breached. Call BETWEEN
|
|
103
117
|
* turns (after `addUsage` reflects the latest delta) — that way the meter
|
|
@@ -105,27 +119,32 @@ export declare class BudgetMeter {
|
|
|
105
119
|
*/
|
|
106
120
|
assertWithinCaps(): void;
|
|
107
121
|
/**
|
|
108
|
-
*
|
|
109
|
-
* inline (e.g., the sleep tool rejects a sleep that would breach the
|
|
110
|
-
* duration cap). Returns the first breach reason or null.
|
|
111
|
-
*/
|
|
112
|
-
/**
|
|
113
|
-
* Raise the `max_usd` cap to `usd` — the budget gate's approval path (docs/SUSPEND_POLICY.md
|
|
122
|
+
* Raise `dimension`'s cap to `value` — the budget gate's approval path (docs/SUSPEND_POLICY.md
|
|
114
123
|
* Decision 3). Only ever RAISES: a value at or below the current cap is ignored, so an approval
|
|
115
124
|
* can't silently tighten a cap and re-park the run on the very next call. A no-op when the
|
|
116
125
|
* workflow declared no budget (nothing to breach). Returns the cap now in force, or null when
|
|
117
126
|
* there is no budget.
|
|
118
127
|
*/
|
|
119
|
-
|
|
120
|
-
/** The
|
|
121
|
-
|
|
122
|
-
|
|
128
|
+
raiseCap(dimension: BudgetDimension, value: number): number | null;
|
|
129
|
+
/** The cap in force for `dimension`, or null when unset. The gate prompt reports it beside spend. */
|
|
130
|
+
cap(dimension: BudgetDimension): number | null;
|
|
131
|
+
/** What `dimension` has consumed so far, on the SAME run-cumulative basis cap enforcement uses
|
|
132
|
+
* (usd = real-cost dollars, tokens = conversation tokens, compute = active seconds). */
|
|
133
|
+
spent(dimension: BudgetDimension): number;
|
|
134
|
+
/**
|
|
135
|
+
* Predicate variant of {@link assertWithinCaps} for callers that handle the cap-hit path inline
|
|
136
|
+
* (the budget gate parks on it; the legacy no-gate path throws on it). Returns the first breach
|
|
137
|
+
* dimension or null.
|
|
138
|
+
*/
|
|
139
|
+
capBreachReason(): BudgetDimension | null;
|
|
123
140
|
/**
|
|
124
|
-
*
|
|
125
|
-
*
|
|
126
|
-
*
|
|
141
|
+
* Live budget state in the host protocol's `usage.get` shape: every dimension always present
|
|
142
|
+
* as `{spent, cap, remaining}`, with `cap`/`remaining` null when uncapped. RUN-CUMULATIVE (the
|
|
143
|
+
* same basis cap enforcement uses), so a program polling it sees the numbers the platform's
|
|
144
|
+
* budget pause would act on. `tokens.spent` is conversation tokens (input + output) — the same
|
|
145
|
+
* basis as the `max_tokens` cap.
|
|
127
146
|
*/
|
|
128
|
-
|
|
147
|
+
usageSnapshot(): UsageSnapshot;
|
|
129
148
|
/**
|
|
130
149
|
* USD cost for a single delta — the representative-rate ESTIMATE used only when the broker reports
|
|
131
150
|
* no real upstream cost for the turn (a BYO-provider turn, or a managed turn whose cost tap missed).
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// BudgetMeter — enforces token / USD /
|
|
1
|
+
// BudgetMeter — enforces token / USD / compute-time caps declared in the manifest.
|
|
2
2
|
//
|
|
3
3
|
// One meter per run. The agent loop calls `addUsage()` after each LLM turn and
|
|
4
4
|
// `assertWithinCaps()` between turns. When a cap is exceeded the meter throws
|
|
@@ -13,6 +13,12 @@
|
|
|
13
13
|
// billing is metered by the platform, not by the runner. The meter takes
|
|
14
14
|
// the fallback rate as a constructor arg so tests inject a fixed rate.
|
|
15
15
|
import { AppError, ErrorCode } from "../support/index.js";
|
|
16
|
+
/** The manifest budget field each dimension caps. */
|
|
17
|
+
const CAP_FIELD = {
|
|
18
|
+
usd: "max_usd",
|
|
19
|
+
tokens: "max_tokens",
|
|
20
|
+
compute: "max_compute_seconds",
|
|
21
|
+
};
|
|
16
22
|
const ZERO_PRIOR = {
|
|
17
23
|
inputTokens: 0,
|
|
18
24
|
outputTokens: 0,
|
|
@@ -22,13 +28,16 @@ const ZERO_PRIOR = {
|
|
|
22
28
|
activeMs: 0,
|
|
23
29
|
};
|
|
24
30
|
export class BudgetMeter {
|
|
25
|
-
/** NOT readonly:
|
|
26
|
-
* responder approves more spend. The meter lives in the frozen
|
|
27
|
-
* instance and the blocked
|
|
31
|
+
/** NOT readonly: the budget gate (docs/SUSPEND_POLICY.md Decision 3) raises a breached cap in
|
|
32
|
+
* place when a responder approves more spend — on any dimension. The meter lives in the frozen
|
|
33
|
+
* heap, so the wake mutates this instance and the blocked call proceeds against the new cap. */
|
|
28
34
|
budget;
|
|
35
|
+
/** Wall-clock ms excluded from the compute cap — time the run spent PARKED at the budget gate
|
|
36
|
+
* (frozen or held). Parked time burns no `max_compute_seconds` (SUSPEND_POLICY Decision 3.4);
|
|
37
|
+
* without this exclusion a compute park would instantly re-breach on wake, forever. */
|
|
38
|
+
excludedIdleMs = 0;
|
|
29
39
|
rate;
|
|
30
40
|
startedAt;
|
|
31
|
-
deadlineStartedAt;
|
|
32
41
|
prior;
|
|
33
42
|
now;
|
|
34
43
|
inputTokens = 0;
|
|
@@ -41,7 +50,6 @@ export class BudgetMeter {
|
|
|
41
50
|
this.budget = opts.budget;
|
|
42
51
|
this.rate = opts.rate;
|
|
43
52
|
this.startedAt = opts.startedAt;
|
|
44
|
-
this.deadlineStartedAt = opts.deadlineStartedAt ?? null;
|
|
45
53
|
this.prior = opts.priorUsage ?? ZERO_PRIOR;
|
|
46
54
|
this.now = opts.now ?? Date.now;
|
|
47
55
|
}
|
|
@@ -73,9 +81,17 @@ export class BudgetMeter {
|
|
|
73
81
|
cacheWriteTokens: this.cacheWriteTokens,
|
|
74
82
|
totalTokens: this.inputTokens + this.outputTokens,
|
|
75
83
|
totalUsd: this.totalUsd,
|
|
76
|
-
elapsedMs: this.now() - this.startedAt,
|
|
84
|
+
elapsedMs: Math.max(0, this.now() - this.startedAt - this.excludedIdleMs),
|
|
77
85
|
};
|
|
78
86
|
}
|
|
87
|
+
/** Exclude `ms` of wall-clock from the compute cap — the budget gate's park window (mirrors
|
|
88
|
+
* RuntimeFlusher.excludeIdle for billing). Called by the gate after every park, whichever
|
|
89
|
+
* substrate: on the snapshot fleet the guest clock resyncs on wake so the measured park spans
|
|
90
|
+
* the frozen window; on a hold substrate it spans the register-and-poll wait. */
|
|
91
|
+
excludeIdle(ms) {
|
|
92
|
+
if (ms > 0)
|
|
93
|
+
this.excludedIdleMs += ms;
|
|
94
|
+
}
|
|
79
95
|
/**
|
|
80
96
|
* RUN-CUMULATIVE snapshot: this session's usage PLUS the prior sessions' usage seeded at
|
|
81
97
|
* construction. Cap enforcement ({@link assertWithinCaps}) and checkpoint persistence use
|
|
@@ -96,88 +112,123 @@ export class BudgetMeter {
|
|
|
96
112
|
};
|
|
97
113
|
}
|
|
98
114
|
/**
|
|
99
|
-
*
|
|
100
|
-
*
|
|
101
|
-
*
|
|
115
|
+
* The first breached cap (in the tokens → usd → compute order the assert has always used) with
|
|
116
|
+
* the numbers + message the `BUDGET_EXCEEDED` error carries, or null while within every cap.
|
|
117
|
+
* The one probe both {@link assertWithinCaps} and {@link capBreachReason} read from.
|
|
102
118
|
*/
|
|
103
|
-
|
|
119
|
+
firstBreach() {
|
|
104
120
|
if (this.budget === undefined)
|
|
105
|
-
return;
|
|
121
|
+
return null;
|
|
106
122
|
// Cumulative across resume sessions — caps bound the whole run, not each session.
|
|
107
123
|
const snap = this.cumulative();
|
|
108
124
|
// `max_tokens` deliberately bounds CONVERSATION tokens (input + output) only; cache-read /
|
|
109
125
|
// cache-write tokens are tracked + fully billed via costFor() and are bounded by `max_usd`,
|
|
110
126
|
// not by this cap. totalTokens (snapshot/cumulative) reflects that choice.
|
|
111
127
|
if (this.budget.max_tokens !== undefined && snap.totalTokens > this.budget.max_tokens) {
|
|
112
|
-
|
|
128
|
+
return {
|
|
129
|
+
kind: "tokens",
|
|
130
|
+
used: snap.totalTokens,
|
|
131
|
+
cap: this.budget.max_tokens,
|
|
132
|
+
message: `Token cap exceeded: ${snap.totalTokens.toString()} > ${this.budget.max_tokens.toString()}`,
|
|
133
|
+
};
|
|
113
134
|
}
|
|
114
135
|
if (this.budget.max_usd !== undefined && snap.totalUsd > this.budget.max_usd) {
|
|
115
|
-
|
|
136
|
+
return {
|
|
137
|
+
kind: "usd",
|
|
138
|
+
used: snap.totalUsd,
|
|
139
|
+
cap: this.budget.max_usd,
|
|
140
|
+
message: `USD cap exceeded: $${snap.totalUsd.toFixed(4)} > $${this.budget.max_usd.toFixed(4)}`,
|
|
141
|
+
};
|
|
116
142
|
}
|
|
117
|
-
|
|
143
|
+
// max_compute_seconds bounds ACTIVE compute (turn time) — a long sleep / human-input wait
|
|
144
|
+
// doesn't burn it. There is deliberately no wall-clock deadline cap (deadline_seconds was
|
|
145
|
+
// deleted with the workflow-format redesign; nothing replaces it).
|
|
146
|
+
if (this.budget.max_compute_seconds !== undefined) {
|
|
118
147
|
const elapsedSeconds = Math.floor(snap.elapsedMs / 1000);
|
|
119
|
-
if (elapsedSeconds > this.budget.
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
// burn that, but DOES count here.
|
|
127
|
-
if (this.budget.deadline_seconds !== undefined && this.deadlineStartedAt !== null) {
|
|
128
|
-
const wallSeconds = Math.floor((this.now() - this.deadlineStartedAt) / 1000);
|
|
129
|
-
if (wallSeconds > this.budget.deadline_seconds) {
|
|
130
|
-
throw new AppError(ErrorCode.BUDGET_EXCEEDED, `Deadline exceeded: ${wallSeconds.toString()}s > ${this.budget.deadline_seconds.toString()}s (wall-clock)`, { kind: "deadline", used: wallSeconds, cap: this.budget.deadline_seconds });
|
|
148
|
+
if (elapsedSeconds > this.budget.max_compute_seconds) {
|
|
149
|
+
return {
|
|
150
|
+
kind: "compute",
|
|
151
|
+
used: elapsedSeconds,
|
|
152
|
+
cap: this.budget.max_compute_seconds,
|
|
153
|
+
message: `Compute cap exceeded: ${elapsedSeconds.toString()}s > ${this.budget.max_compute_seconds.toString()}s`,
|
|
154
|
+
};
|
|
131
155
|
}
|
|
132
156
|
}
|
|
157
|
+
return null;
|
|
133
158
|
}
|
|
134
159
|
/**
|
|
135
|
-
*
|
|
136
|
-
*
|
|
137
|
-
*
|
|
160
|
+
* Throws `AppError(BUDGET_EXCEEDED)` when any cap is breached. Call BETWEEN
|
|
161
|
+
* turns (after `addUsage` reflects the latest delta) — that way the meter
|
|
162
|
+
* tears the loop down before another LLM call is dispatched.
|
|
138
163
|
*/
|
|
164
|
+
assertWithinCaps() {
|
|
165
|
+
const breach = this.firstBreach();
|
|
166
|
+
if (breach === null)
|
|
167
|
+
return;
|
|
168
|
+
throw new AppError(ErrorCode.BUDGET_EXCEEDED, breach.message, {
|
|
169
|
+
kind: breach.kind,
|
|
170
|
+
used: breach.used,
|
|
171
|
+
cap: breach.cap,
|
|
172
|
+
});
|
|
173
|
+
}
|
|
139
174
|
/**
|
|
140
|
-
* Raise
|
|
175
|
+
* Raise `dimension`'s cap to `value` — the budget gate's approval path (docs/SUSPEND_POLICY.md
|
|
141
176
|
* Decision 3). Only ever RAISES: a value at or below the current cap is ignored, so an approval
|
|
142
177
|
* can't silently tighten a cap and re-park the run on the very next call. A no-op when the
|
|
143
178
|
* workflow declared no budget (nothing to breach). Returns the cap now in force, or null when
|
|
144
179
|
* there is no budget.
|
|
145
180
|
*/
|
|
146
|
-
|
|
181
|
+
raiseCap(dimension, value) {
|
|
147
182
|
if (this.budget === undefined)
|
|
148
183
|
return null;
|
|
149
|
-
const
|
|
150
|
-
|
|
184
|
+
const field = CAP_FIELD[dimension];
|
|
185
|
+
const current = this.budget[field];
|
|
186
|
+
if (current !== undefined && value <= current)
|
|
151
187
|
return current;
|
|
152
|
-
this.budget = { ...this.budget,
|
|
153
|
-
return
|
|
188
|
+
this.budget = { ...this.budget, [field]: value };
|
|
189
|
+
return value;
|
|
154
190
|
}
|
|
155
|
-
/** The
|
|
156
|
-
|
|
157
|
-
return this.budget?.
|
|
191
|
+
/** The cap in force for `dimension`, or null when unset. The gate prompt reports it beside spend. */
|
|
192
|
+
cap(dimension) {
|
|
193
|
+
return this.budget?.[CAP_FIELD[dimension]] ?? null;
|
|
194
|
+
}
|
|
195
|
+
/** What `dimension` has consumed so far, on the SAME run-cumulative basis cap enforcement uses
|
|
196
|
+
* (usd = real-cost dollars, tokens = conversation tokens, compute = active seconds). */
|
|
197
|
+
spent(dimension) {
|
|
198
|
+
const snap = this.cumulative();
|
|
199
|
+
if (dimension === "usd")
|
|
200
|
+
return snap.totalUsd;
|
|
201
|
+
if (dimension === "tokens")
|
|
202
|
+
return snap.totalTokens;
|
|
203
|
+
return Math.floor(snap.elapsedMs / 1000);
|
|
158
204
|
}
|
|
205
|
+
/**
|
|
206
|
+
* Predicate variant of {@link assertWithinCaps} for callers that handle the cap-hit path inline
|
|
207
|
+
* (the budget gate parks on it; the legacy no-gate path throws on it). Returns the first breach
|
|
208
|
+
* dimension or null.
|
|
209
|
+
*/
|
|
159
210
|
capBreachReason() {
|
|
160
|
-
|
|
161
|
-
this.assertWithinCaps();
|
|
162
|
-
return null;
|
|
163
|
-
}
|
|
164
|
-
catch (err) {
|
|
165
|
-
if (err instanceof AppError &&
|
|
166
|
-
typeof err.detail === "object" &&
|
|
167
|
-
err.detail !== null &&
|
|
168
|
-
"kind" in err.detail) {
|
|
169
|
-
return err.detail.kind;
|
|
170
|
-
}
|
|
171
|
-
throw err;
|
|
172
|
-
}
|
|
211
|
+
return this.firstBreach()?.kind ?? null;
|
|
173
212
|
}
|
|
174
213
|
/**
|
|
175
|
-
*
|
|
176
|
-
*
|
|
177
|
-
*
|
|
214
|
+
* Live budget state in the host protocol's `usage.get` shape: every dimension always present
|
|
215
|
+
* as `{spent, cap, remaining}`, with `cap`/`remaining` null when uncapped. RUN-CUMULATIVE (the
|
|
216
|
+
* same basis cap enforcement uses), so a program polling it sees the numbers the platform's
|
|
217
|
+
* budget pause would act on. `tokens.spent` is conversation tokens (input + output) — the same
|
|
218
|
+
* basis as the `max_tokens` cap.
|
|
178
219
|
*/
|
|
179
|
-
|
|
180
|
-
|
|
220
|
+
usageSnapshot() {
|
|
221
|
+
const snap = this.cumulative();
|
|
222
|
+
const dimension = (spent, cap) => ({
|
|
223
|
+
spent,
|
|
224
|
+
cap: cap ?? null,
|
|
225
|
+
remaining: cap === undefined ? null : Math.max(0, cap - spent),
|
|
226
|
+
});
|
|
227
|
+
return {
|
|
228
|
+
usd: dimension(snap.totalUsd, this.budget?.max_usd),
|
|
229
|
+
tokens: dimension(snap.totalTokens, this.budget?.max_tokens),
|
|
230
|
+
compute_seconds: dimension(Math.floor(snap.elapsedMs / 1000), this.budget?.max_compute_seconds),
|
|
231
|
+
};
|
|
181
232
|
}
|
|
182
233
|
/**
|
|
183
234
|
* USD cost for a single delta — the representative-rate ESTIMATE used only when the broker reports
|
|
@@ -38,7 +38,7 @@ export interface TurnEventSink {
|
|
|
38
38
|
* Identity of one agent() leaf — stamped on its `turn_started`/`turn_ended` frames so a stream
|
|
39
39
|
* consumer can tell concurrent agents apart. `agentId` is stable + run-unique (worker-assigned);
|
|
40
40
|
* `agentName` is the author's `AgentOptions.name`, present only when set. Mirrors the engine's
|
|
41
|
-
* `AgentIdentity` shape so the platform's wire matches
|
|
41
|
+
* `AgentIdentity` shape so the platform's wire matches the self-hosted server.
|
|
42
42
|
*/
|
|
43
43
|
export interface AgentIdentity {
|
|
44
44
|
agentId: string;
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { CallOptions } from "@boardwalk-labs/workflow/runtime";
|
|
2
|
-
import type { ChildDispatcher, ChildResult, ScheduleOptions } from "./workflow_host.js";
|
|
2
|
+
import type { ChildCallOutput, ChildDispatcher, ChildResult, ScheduleOptions } from "./workflow_host.js";
|
|
3
3
|
import type { RunnerControlClient } from "./runner_control_client.js";
|
|
4
4
|
export interface BrokerChildDispatcherDeps {
|
|
5
5
|
client: RunnerControlClient;
|
|
@@ -14,7 +14,10 @@ export declare class BrokerChildDispatcher implements ChildDispatcher {
|
|
|
14
14
|
constructor(deps: BrokerChildDispatcherDeps);
|
|
15
15
|
/** Create (or re-attach to) the child, then hold + poll to terminal and return its output. An
|
|
16
16
|
* abort (`signal`) stops the hold within one poll interval and throws RunAbortedError. */
|
|
17
|
-
call(slug: string, input: unknown, _opts: CallOptions | undefined, signal?: AbortSignal): Promise<
|
|
17
|
+
call(slug: string, input: unknown, _opts: CallOptions | undefined, signal?: AbortSignal): Promise<ChildCallOutput>;
|
|
18
|
+
/** Poll a child's current state (the freeze-wake path uses this to fetch the callee's output
|
|
19
|
+
* schema); null = not this run's child. */
|
|
20
|
+
poll(childRunId: string): Promise<ChildResult | null>;
|
|
18
21
|
/** Start (or idempotently re-attach to) the child and resolve its CURRENT state — no hold. The
|
|
19
22
|
* durable callWorkflow seam decides whether to suspend (non-terminal) or return (terminal). */
|
|
20
23
|
start(slug: string, input: unknown, _opts: CallOptions | undefined, signal?: AbortSignal): Promise<ChildResult>;
|
|
@@ -24,16 +24,34 @@ export class BrokerChildDispatcher {
|
|
|
24
24
|
throwIfAborted(signal);
|
|
25
25
|
const child = await this.deps.client.startChild(slug, input);
|
|
26
26
|
if (TERMINAL_CHILD_STATUSES.has(child.status)) {
|
|
27
|
-
return this.childOutput(slug, child.childRunId, child.status, child.output);
|
|
27
|
+
return this.childOutput(slug, child.childRunId, child.status, child.output, child.outputSchema);
|
|
28
28
|
}
|
|
29
29
|
return this.pollToCompletion(slug, child.childRunId, signal);
|
|
30
30
|
}
|
|
31
|
+
/** Poll a child's current state (the freeze-wake path uses this to fetch the callee's output
|
|
32
|
+
* schema); null = not this run's child. */
|
|
33
|
+
async poll(childRunId) {
|
|
34
|
+
const child = await this.deps.client.getChild(childRunId);
|
|
35
|
+
if (child === null)
|
|
36
|
+
return null;
|
|
37
|
+
return {
|
|
38
|
+
childRunId: child.id,
|
|
39
|
+
status: child.status,
|
|
40
|
+
output: child.output,
|
|
41
|
+
outputSchema: child.outputSchema ?? null,
|
|
42
|
+
};
|
|
43
|
+
}
|
|
31
44
|
/** Start (or idempotently re-attach to) the child and resolve its CURRENT state — no hold. The
|
|
32
45
|
* durable callWorkflow seam decides whether to suspend (non-terminal) or return (terminal). */
|
|
33
46
|
async start(slug, input, _opts, signal) {
|
|
34
47
|
throwIfAborted(signal);
|
|
35
48
|
const child = await this.deps.client.startChild(slug, input);
|
|
36
|
-
return {
|
|
49
|
+
return {
|
|
50
|
+
childRunId: child.childRunId,
|
|
51
|
+
status: child.status,
|
|
52
|
+
output: child.output,
|
|
53
|
+
outputSchema: child.outputSchema ?? null,
|
|
54
|
+
};
|
|
37
55
|
}
|
|
38
56
|
/** Fire-and-forget: create (or re-attach to) the child and return its id without holding. */
|
|
39
57
|
async run(slug, input, _opts) {
|
|
@@ -64,7 +82,7 @@ export class BrokerChildDispatcher {
|
|
|
64
82
|
throw new AppError(ErrorCode.INTERNAL_ERROR, `Called workflow's child run ${childRunId} vanished`);
|
|
65
83
|
}
|
|
66
84
|
if (TERMINAL_CHILD_STATUSES.has(child.status)) {
|
|
67
|
-
return this.childOutput(slug, child.id, child.status, child.output);
|
|
85
|
+
return this.childOutput(slug, child.id, child.status, child.output, child.outputSchema);
|
|
68
86
|
}
|
|
69
87
|
await this.sleep(this.pollIntervalMs);
|
|
70
88
|
// Re-check after the wait so an abort during the inter-poll sleep stops within one interval.
|
|
@@ -72,10 +90,10 @@ export class BrokerChildDispatcher {
|
|
|
72
90
|
}
|
|
73
91
|
}
|
|
74
92
|
/** A completed child returns its output; a failed/cancelled child rejects the parent's await. */
|
|
75
|
-
childOutput(slug, childRunId, status, output) {
|
|
93
|
+
childOutput(slug, childRunId, status, output, outputSchema) {
|
|
76
94
|
if (status === "completed")
|
|
77
|
-
return output;
|
|
78
|
-
log.warn("
|
|
95
|
+
return { output, outputSchema: outputSchema ?? null };
|
|
96
|
+
log.warn("child_workflow_did_not_complete", { slug, childRunId, status });
|
|
79
97
|
throw new AppError(ErrorCode.INTERNAL_ERROR, `Called workflow "${slug}" ${status} (run ${childRunId})`, { childRunId, status });
|
|
80
98
|
}
|
|
81
99
|
}
|
|
@@ -1,11 +1,10 @@
|
|
|
1
|
-
import type { BudgetMeter } from "./agent/budget.js";
|
|
1
|
+
import type { BudgetDimension, BudgetMeter } from "./agent/budget.js";
|
|
2
2
|
import type { HumanInputResult } from "@boardwalk-labs/workflow";
|
|
3
3
|
/** The stable key a responder answers a budget gate by (`boardwalk respond <runId> budget …`). */
|
|
4
4
|
export declare const BUDGET_GATE_KEY = "budget";
|
|
5
|
-
/** Preset approvals, plus the
|
|
6
|
-
|
|
7
|
-
export declare const
|
|
8
|
-
export declare const BUDGET_CHOICE_ADD_100 = "+$100";
|
|
5
|
+
/** Preset approvals per dimension, plus the open-ended `other` answer. Kept small: the point is a
|
|
6
|
+
* fast decision. These strings ARE the wire options — backend/web render them verbatim. */
|
|
7
|
+
export declare const BUDGET_PRESETS: Record<BudgetDimension, readonly [string, string, string]>;
|
|
9
8
|
export declare const BUDGET_CHOICE_CANCEL = "cancel";
|
|
10
9
|
/** The port the gate needs from the host — narrowed so tests don't build a whole WorkflowHost. */
|
|
11
10
|
export interface BudgetClearancePort {
|
|
@@ -20,32 +19,70 @@ export declare class BudgetGateCancelled extends Error {
|
|
|
20
19
|
}
|
|
21
20
|
/**
|
|
22
21
|
* 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
|
|
24
|
-
* was hit (not the org's credit balance — a different failure with a different fix).
|
|
22
|
+
* decide without opening the dashboard, and names the run's own manifest cap field so it's obvious
|
|
23
|
+
* WHICH cap was hit (not the org's credit balance — a different failure with a different fix).
|
|
25
24
|
*/
|
|
26
|
-
export declare function budgetGatePrompt(
|
|
25
|
+
export declare function budgetGatePrompt(dimension: BudgetDimension, spent: number, cap: number): string;
|
|
27
26
|
/** The gate's response form — a choice, so the common answers are one click / one word. */
|
|
28
|
-
export declare function budgetGateInputSpec(): unknown;
|
|
27
|
+
export declare function budgetGateInputSpec(dimension: BudgetDimension): unknown;
|
|
29
28
|
/**
|
|
30
|
-
* Interpret an answer as the NEW absolute `
|
|
29
|
+
* Interpret an answer as the NEW absolute cap for `dimension`, or null to cancel the run.
|
|
31
30
|
*
|
|
32
|
-
*
|
|
33
|
-
* "set a cap" escape hatch, via the choice's `other` entry). Anything unrecognized is
|
|
34
|
-
* cancel rather than guessed at — silently resuming a run on a misread answer spends
|
|
31
|
+
* `+<amount>` is an INCREMENT on the current cap (the presets); a bare amount is an ABSOLUTE new
|
|
32
|
+
* cap (the "set a cap" escape hatch, via the choice's `other` entry). Anything unrecognized is
|
|
33
|
+
* treated as cancel rather than guessed at — silently resuming a run on a misread answer spends
|
|
34
|
+
* real money. See the module header for the full per-dimension grammar.
|
|
35
35
|
*/
|
|
36
|
-
export declare function resolveBudgetAnswer(answer: string,
|
|
36
|
+
export declare function resolveBudgetAnswer(dimension: BudgetDimension, answer: string, currentCap: number): number | null;
|
|
37
37
|
/**
|
|
38
|
-
* Budget clearance, awaited
|
|
39
|
-
* overwhelming majority of calls). On a
|
|
40
|
-
* live meter and let the call proceed.
|
|
38
|
+
* Budget clearance, awaited at every park point (see the module header). Fast path: no breach ⇒
|
|
39
|
+
* resolve immediately (the overwhelming majority of calls). On a breach of ANY dimension: park at
|
|
40
|
+
* a gate, then apply the answer to the live meter and let the call proceed.
|
|
41
41
|
*
|
|
42
|
-
*
|
|
43
|
-
*
|
|
44
|
-
*
|
|
42
|
+
* The park's own wall-clock is EXCLUDED from the compute cap (`meter.excludeIdle`): parked time is
|
|
43
|
+
* not compute (SUSPEND_POLICY Decision 3.4), and on the snapshot fleet the guest clock resyncs
|
|
44
|
+
* across the frozen window — without the exclusion a compute park would re-breach the instant it
|
|
45
|
+
* woke, forever. `usage.get()` polled DURING a park may transiently show the parked wall-clock as
|
|
46
|
+
* compute spend; the exclusion lands when the park resolves.
|
|
45
47
|
*/
|
|
46
48
|
export declare class BudgetGate {
|
|
47
49
|
private readonly meter;
|
|
48
50
|
private readonly host;
|
|
49
|
-
|
|
51
|
+
private readonly now;
|
|
52
|
+
constructor(meter: BudgetMeter, host: BudgetClearancePort, now?: () => number);
|
|
50
53
|
clear(): Promise<void>;
|
|
51
54
|
}
|
|
55
|
+
/** How often the watcher samples the meter. Coarse is fine: compute presets are minutes. */
|
|
56
|
+
export declare const DEFAULT_COMPUTE_WATCH_INTERVAL_MS = 15000;
|
|
57
|
+
/**
|
|
58
|
+
* Detects a `max_compute_seconds` breach BETWEEN park points. `usd`/`tokens` only move at the
|
|
59
|
+
* `streamModel` seam, but compute burns continuously — a breach can land mid-shell, mid-turn, or
|
|
60
|
+
* mid-program-compute, far from any model call.
|
|
61
|
+
*
|
|
62
|
+
* WHY THIS ONLY DETECTS AND DOES NOT PARK — true park-anywhere needs fleet-host work. The freeze
|
|
63
|
+
* machinery is quiescence-gated: `budgetClearance` may only be entered from a work-tracked seam
|
|
64
|
+
* (its `freezeWait` steps the CALLER out of the work count; from a timer context that corrupts the
|
|
65
|
+
* count and could freeze around live in-flight work, which SUSPEND_POLICY Decision 1 forbids). A
|
|
66
|
+
* timer-initiated park would need (a) a host-agent-initiated out-of-band VM pause that does not
|
|
67
|
+
* require runner quiescence, plus a wake path that re-arms the runner-side gate and applies the
|
|
68
|
+
* answer to the meter, and (b) on hold-only substrates a program-process stop (SIGSTOP) in the
|
|
69
|
+
* program runner. Until that lands, the honest contract is: the watcher logs the breach the moment
|
|
70
|
+
* it happens, and the run PARKS AT ITS NEXT SEAM (`streamModel`, `sleep`, `shell`,
|
|
71
|
+
* `workflows.call`) via the same {@link BudgetGate.clear} every park point awaits.
|
|
72
|
+
*/
|
|
73
|
+
export declare class ComputeBreachWatcher {
|
|
74
|
+
private readonly meter;
|
|
75
|
+
private readonly opts;
|
|
76
|
+
private timer;
|
|
77
|
+
private announced;
|
|
78
|
+
constructor(meter: BudgetMeter, opts?: {
|
|
79
|
+
runId: string;
|
|
80
|
+
intervalMs?: number;
|
|
81
|
+
});
|
|
82
|
+
/** True while a compute breach is standing (sampled; resets when an approval clears it). */
|
|
83
|
+
get breachDetected(): boolean;
|
|
84
|
+
start(): void;
|
|
85
|
+
stop(): void;
|
|
86
|
+
/** One sample tick (exposed for tests — real ticks come from the interval). */
|
|
87
|
+
sample(): void;
|
|
88
|
+
}
|