@boardwalk-labs/runner 0.2.17 → 0.3.0-alpha.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. package/README.md +1 -1
  2. package/dist/runtime/agent/budget.d.ts +43 -24
  3. package/dist/runtime/agent/budget.js +108 -57
  4. package/dist/runtime/agent/events.d.ts +1 -1
  5. package/dist/runtime/broker_child_dispatcher.d.ts +5 -2
  6. package/dist/runtime/broker_child_dispatcher.js +24 -6
  7. package/dist/runtime/budget_gate.d.ts +58 -21
  8. package/dist/runtime/budget_gate.js +227 -46
  9. package/dist/runtime/host_capabilities.d.ts +4 -0
  10. package/dist/runtime/host_capabilities.js +25 -0
  11. package/dist/runtime/host_server.d.ts +137 -0
  12. package/dist/runtime/host_server.js +562 -0
  13. package/dist/runtime/index.js +45 -12
  14. package/dist/runtime/leaf_executor.d.ts +7 -4
  15. package/dist/runtime/leaf_executor.js +11 -6
  16. package/dist/runtime/program_runner.d.ts +62 -42
  17. package/dist/runtime/program_runner.js +156 -101
  18. package/dist/runtime/program_worker.d.ts +22 -11
  19. package/dist/runtime/program_worker.js +26 -48
  20. package/dist/runtime/python_program.d.ts +68 -0
  21. package/dist/runtime/python_program.js +270 -0
  22. package/dist/runtime/run_context.d.ts +13 -0
  23. package/dist/runtime/run_context.js +77 -0
  24. package/dist/runtime/runner_control_client.d.ts +23 -0
  25. package/dist/runtime/runner_control_client.js +69 -147
  26. package/dist/runtime/shell_exec.d.ts +17 -0
  27. package/dist/runtime/shell_exec.js +144 -0
  28. package/dist/runtime/support/index.d.ts +6 -0
  29. package/dist/runtime/support/index.js +14 -0
  30. package/dist/runtime/wire/inference_proxy.js +1 -1
  31. package/dist/runtime/wire/run.d.ts +5 -2
  32. package/dist/runtime/workflow_host.d.ts +53 -7
  33. package/dist/runtime/workflow_host.js +82 -42
  34. 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
- * duration cap — sleep/wait pauses don't burn it (a run intentionally parked for a day must
34
- * not blow its duration budget on resume).
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: 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. */
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
- * Predicate variant for callers that prefer to handle the cap-hit path
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
- 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;
122
- capBreachReason(): "tokens" | "usd" | "duration" | "deadline" | null;
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
- * The declared `max_duration_seconds` cap, or null when unset. Lets callers that
125
- * reason about wall-clock ahead of time (e.g. the sleep tool rejecting a sleep that
126
- * would overrun the run) read the cap without reaching into the meter's internals.
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
- durationCapSeconds(): number | null;
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 / duration caps declared in the manifest.
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: 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. */
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
- * Throws `AppError(BUDGET_EXCEEDED)` when any cap is breached. Call BETWEEN
100
- * turns (after `addUsage` reflects the latest delta) that way the meter
101
- * tears the loop down before another LLM call is dispatched.
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
- assertWithinCaps() {
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
- throw new AppError(ErrorCode.BUDGET_EXCEEDED, `Token cap exceeded: ${snap.totalTokens.toString()} > ${this.budget.max_tokens.toString()}`, { kind: "tokens", used: snap.totalTokens, cap: this.budget.max_tokens });
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
- throw new AppError(ErrorCode.BUDGET_EXCEEDED, `USD cap exceeded: $${snap.totalUsd.toFixed(4)} > $${this.budget.max_usd.toFixed(4)}`, { kind: "usd", used: snap.totalUsd, cap: this.budget.max_usd });
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
- if (this.budget.max_duration_seconds !== undefined) {
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.max_duration_seconds) {
120
- throw new AppError(ErrorCode.BUDGET_EXCEEDED, `Duration cap exceeded: ${elapsedSeconds.toString()}s > ${this.budget.max_duration_seconds.toString()}s`, { kind: "duration", used: elapsedSeconds, cap: this.budget.max_duration_seconds });
121
- }
122
- }
123
- // deadline_seconds: WALL-CLOCK from the run's original start, INCLUDING suspended idle (a run
124
- // resumed past its deadline trips on its first cap check; a long-running one trips per turn).
125
- // Distinct from max_duration_seconds (active compute) — a long sleep / human-input wait does not
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
- * Predicate variant for callers that prefer to handle the cap-hit path
136
- * inline (e.g., the sleep tool rejects a sleep that would breach the
137
- * duration cap). Returns the first breach reason or null.
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 the `max_usd` cap to `usd` — the budget gate's approval path (docs/SUSPEND_POLICY.md
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
- raiseUsdCap(usd) {
181
+ raiseCap(dimension, value) {
147
182
  if (this.budget === undefined)
148
183
  return null;
149
- const current = this.budget.max_usd;
150
- if (current !== undefined && usd <= current)
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, max_usd: usd };
153
- return usd;
188
+ this.budget = { ...this.budget, [field]: value };
189
+ return value;
154
190
  }
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;
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
- try {
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
- * The declared `max_duration_seconds` cap, or null when unset. Lets callers that
176
- * reason about wall-clock ahead of time (e.g. the sleep tool rejecting a sleep that
177
- * would overrun the run) read the cap without reaching into the meter's internals.
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
- durationCapSeconds() {
180
- return this.budget?.max_duration_seconds ?? null;
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 `boardwalk dev` and the self-hosted server.
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<unknown>;
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 { childRunId: child.childRunId, status: child.status, output: child.output };
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("child_workflow_nonterminal_output", { slug, childRunId, status });
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 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";
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 `max_usd` so it's obvious WHICH cap
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(spentUsd: number, capUsd: number): string;
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 `max_usd`, or null to cancel the run.
29
+ * Interpret an answer as the NEW absolute cap for `dimension`, or null to cancel the run.
31
30
  *
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.
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, currentCapUsd: number): number | null;
36
+ export declare function resolveBudgetAnswer(dimension: BudgetDimension, answer: string, currentCap: number): number | null;
37
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.
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
- * 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.
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
- constructor(meter: BudgetMeter, host: BudgetClearancePort);
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
+ }