@boardwalk-labs/runner 0.2.16 → 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.
- package/README.md +1 -1
- package/dist/bin.js +5 -0
- 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/identity_relay.js +19 -9
- package/dist/runtime/index.d.ts +38 -0
- package/dist/runtime/index.js +109 -35
- 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/screen_capture_backend.d.ts +3 -0
- package/dist/runtime/screen_capture_backend.js +17 -2
- package/dist/runtime/shell_exec.d.ts +17 -0
- package/dist/runtime/shell_exec.js +144 -0
- package/dist/runtime/support/index.d.ts +18 -0
- package/dist/runtime/support/index.js +38 -6
- 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/dist/runtime/index.js
CHANGED
|
@@ -26,7 +26,7 @@
|
|
|
26
26
|
//
|
|
27
27
|
// Documented v0 deferral (a clear seam): durable events — a no-op AgentEventStore (live fan-out
|
|
28
28
|
// via the broker only) until durable event storage lands.
|
|
29
|
-
import { createLogger, newId, AppError, ErrorCode, DEFAULT_LEASE_MS } from "./support/index.js";
|
|
29
|
+
import { createLogger, configureLogging, newId, AppError, ErrorCode, DEFAULT_LEASE_MS, } from "./support/index.js";
|
|
30
30
|
import { LspService } from "@boardwalk-labs/engine/core";
|
|
31
31
|
import { BudgetMeter } from "./agent/budget.js";
|
|
32
32
|
import { WorkerRunEventEmitter } from "./run_event_emitter.js";
|
|
@@ -34,17 +34,19 @@ import { BUDGET_GUARDRAIL_RATE } from "./agent/model_rates.js";
|
|
|
34
34
|
import { SecretRedactor } from "./agent/secret_redactor.js";
|
|
35
35
|
import { RecordingSecretResolver } from "./recording_secret_resolver.js";
|
|
36
36
|
import { EngineLeafExecutor } from "./leaf_executor.js";
|
|
37
|
-
import { BudgetGate } from "./budget_gate.js";
|
|
37
|
+
import { BudgetGate, ComputeBreachWatcher } from "./budget_gate.js";
|
|
38
38
|
import { parseByoProviders } from "./direct_inference.js";
|
|
39
39
|
import { applyIdentityToEnv, connectIdentityRelayFd, relayFdFromEnv, workerDiagnostics, } from "./identity_relay.js";
|
|
40
40
|
import { FreezeCoordinator } from "./freeze_coordinator.js";
|
|
41
41
|
import { reseedUserspaceCsprng } from "./uniqueness_reseed.js";
|
|
42
42
|
import { resetHttpConnectionPool } from "./http_pool_reset.js";
|
|
43
43
|
import { WorkerWorkflowHost } from "./workflow_host.js";
|
|
44
|
+
import { buildHostCapabilities } from "./host_capabilities.js";
|
|
45
|
+
import { runShell } from "./shell_exec.js";
|
|
44
46
|
import { BrowserSessionManager } from "./browser_session.js";
|
|
45
47
|
import { loadGuestBrowserConfig, makeGuestBrowserBackend, connectSessionMcp, } from "./browser_session_backend.js";
|
|
46
48
|
import { ScreenCapture } from "./screen_capture.js";
|
|
47
|
-
import { loadCaptureConfig, makeCaptureBackend } from "./screen_capture_backend.js";
|
|
49
|
+
import { loadCaptureConfig, makeCaptureBackend, } from "./screen_capture_backend.js";
|
|
48
50
|
import { runProgramWorker, } from "./program_worker.js";
|
|
49
51
|
import { RunnerControlClient } from "./runner_control_client.js";
|
|
50
52
|
import { BrokerChildDispatcher } from "./broker_child_dispatcher.js";
|
|
@@ -131,9 +133,10 @@ export function assembleWorkerDeps(runtime) {
|
|
|
131
133
|
// monotonic with no separate program band. Frames publish as `{cursor, event}` rows via the
|
|
132
134
|
// broker telemetry path. The claim wrapper below bumps it past a previous session's frames.
|
|
133
135
|
// Optional on-screen mirror: append the (already-redacted) event stream to a local file an xterm in
|
|
134
|
-
// the ambient desktop tails, so the live-view/recording shows the run working.
|
|
135
|
-
//
|
|
136
|
-
|
|
136
|
+
// the ambient desktop tails, so the live-view/recording shows the run working. The path is injected
|
|
137
|
+
// (resolved by `main` from the trusted platform BOOT env), so env-reading stays out of this pure
|
|
138
|
+
// wiring and a run's author env can't repoint the mirror. Absent off the desktop tier ⇒ no sink.
|
|
139
|
+
const runLogFilePath = runtime.runLogFilePath?.trim();
|
|
137
140
|
const runLogLocalSink = runLogFilePath !== undefined && runLogFilePath !== ""
|
|
138
141
|
? makeRunLogFileSink(runLogFilePath)
|
|
139
142
|
: undefined;
|
|
@@ -164,10 +167,6 @@ export function assembleWorkerDeps(runtime) {
|
|
|
164
167
|
// the bill — the platform meters the actual per-leaf usage.
|
|
165
168
|
rate: BUDGET_GUARDRAIL_RATE,
|
|
166
169
|
startedAt: Date.now(),
|
|
167
|
-
// deadline_seconds is WALL-CLOCK from the run's ORIGINAL start (incl. suspended idle), so a run
|
|
168
|
-
// resumed past its deadline trips on its first cap check. Falls back to now on the first session
|
|
169
|
-
// (startedAt is being set this session anyway).
|
|
170
|
-
deadlineStartedAt: run.startedAt ?? Date.now(),
|
|
171
170
|
});
|
|
172
171
|
// Per-session id for per-turn metering identifiers (`<runId>:<sessionId>:<leafIndex>:<turnSeq>`) —
|
|
173
172
|
// a fresh value per worker session keeps a resumed run's events from colliding with a prior
|
|
@@ -238,10 +237,11 @@ export function assembleWorkerDeps(runtime) {
|
|
|
238
237
|
// it). Workspace-rooted at the same `/workspace` the leaf + tools use. Closed on the run's teardown
|
|
239
238
|
// (returned below as `lsp` → program_worker's finally) so no language-server process leaks.
|
|
240
239
|
const lspService = new LspService({ workspaceDir: runtime.workspaceRoot });
|
|
241
|
-
// Budget gate (docs/SUSPEND_POLICY.md Decision 3): a
|
|
242
|
-
//
|
|
243
|
-
//
|
|
244
|
-
//
|
|
240
|
+
// Budget gate (docs/SUSPEND_POLICY.md Decision 3): a breach of ANY budget cap (`max_usd` /
|
|
241
|
+
// `max_tokens` / `max_compute_seconds`) PARKS the run for approval instead of failing it. The
|
|
242
|
+
// gate needs the HOST (to park) but the host is constructed below with `leaf` — a genuine
|
|
243
|
+
// cycle, broken with a late-bound ref: `clear()` only ever runs mid-run, long after both exist.
|
|
244
|
+
// Wired to `budgetHost` immediately after the host is built.
|
|
245
245
|
let budgetHost = null;
|
|
246
246
|
const budgetGate = new BudgetGate(budget, {
|
|
247
247
|
budgetClearance: (gate) => {
|
|
@@ -252,6 +252,13 @@ export function assembleWorkerDeps(runtime) {
|
|
|
252
252
|
return budgetHost.budgetClearance(gate);
|
|
253
253
|
},
|
|
254
254
|
});
|
|
255
|
+
// Compute burns continuously (unlike usd/tokens, which only move at model calls), so a
|
|
256
|
+
// `max_compute_seconds` breach between seams is DETECTED on this timer and logged; the park
|
|
257
|
+
// itself lands at the run's next capability seam (see the watcher's doc block for why a
|
|
258
|
+
// timer-initiated park needs fleet-host coordination that doesn't exist yet). Stopped by the
|
|
259
|
+
// orchestrator on every terminal path via the returned `budgetWatch` handle.
|
|
260
|
+
const computeWatch = new ComputeBreachWatcher(budget, { runId: run.id });
|
|
261
|
+
computeWatch.start();
|
|
255
262
|
const leaf = new EngineLeafExecutor({
|
|
256
263
|
inference: broker,
|
|
257
264
|
// Direct BYO (D7): the claim's registry + the run's RECORDING resolver, so a provider key
|
|
@@ -403,9 +410,20 @@ export function assembleWorkerDeps(runtime) {
|
|
|
403
410
|
// (absent ⇒ the host throws "not available on this runner image").
|
|
404
411
|
...(browserSessions !== undefined ? { browserSessions } : {}),
|
|
405
412
|
phases: phaseTracker,
|
|
413
|
+
// The protocol's `shell` capability: host-side execution in the run's workspace (the old
|
|
414
|
+
// in-process execSync moved behind the wire with the redesign).
|
|
415
|
+
shell: (cmd, opts) => runShell(cmd, opts, { workspaceRoot: runtime.workspaceRoot, signal }),
|
|
416
|
+
// Live budget state for `usage.get` — read off the run-level meter (run-cumulative).
|
|
417
|
+
usage: () => budget.usageSnapshot(),
|
|
418
|
+
// Budget clearance at the host's blocking/spending seams (sleep/shell/workflows.call): the
|
|
419
|
+
// park points for a compute breach that lands between model calls.
|
|
420
|
+
budgetGate,
|
|
406
421
|
});
|
|
407
422
|
// Close the budget gate's cycle: the leaf (built above) parks THROUGH the host (built just now).
|
|
408
423
|
budgetHost = host;
|
|
424
|
+
// The protocol server's dispatch seam — a thin adapter over the host (workflows.call gains
|
|
425
|
+
// its output_schema slot, null until the broker delivers the callee's schema).
|
|
426
|
+
const capabilities = buildHostCapabilities(host);
|
|
409
427
|
// Late-bind the coordinator's per-run hooks now that the run-scoped objects exist.
|
|
410
428
|
if (freeze !== undefined) {
|
|
411
429
|
const coordinator = freeze;
|
|
@@ -451,6 +469,11 @@ export function assembleWorkerDeps(runtime) {
|
|
|
451
469
|
redactor.record(wake.api_token);
|
|
452
470
|
}
|
|
453
471
|
activeFlusher?.excludeIdle(wake.wall_clock_ms - freezeWallMs);
|
|
472
|
+
// The budget meter excludes the same frozen window: `max_compute_seconds` is ACTIVE
|
|
473
|
+
// on-CPU compute by definition (it "EXCLUDES sleep/wait" — the manifest schema's own
|
|
474
|
+
// words), so a sleep/humanInput/child-wait freeze must not count toward the cap any
|
|
475
|
+
// more than it counts toward billing.
|
|
476
|
+
budget.excludeIdle(wake.wall_clock_ms - freezeWallMs);
|
|
454
477
|
// Only now that the frozen window is excluded may the flush timer run again.
|
|
455
478
|
activeFlusher?.resume();
|
|
456
479
|
// Resume capture in a fresh segment epoch (a suspend/resume boundary is a segment boundary).
|
|
@@ -461,7 +484,7 @@ export function assembleWorkerDeps(runtime) {
|
|
|
461
484
|
// Hand the redactor back too: the worker scrubs a terminal error's message with it;
|
|
462
485
|
// workspace (when opted in) hydrates at start + persists at terminal.
|
|
463
486
|
return Promise.resolve({
|
|
464
|
-
|
|
487
|
+
capabilities,
|
|
465
488
|
redactor,
|
|
466
489
|
phases: phaseTracker,
|
|
467
490
|
// The orchestrator closes the per-run LSP on every terminal path (close() is idempotent + never
|
|
@@ -478,6 +501,9 @@ export function assembleWorkerDeps(runtime) {
|
|
|
478
501
|
setProgramDir: (dir) => {
|
|
479
502
|
programDir = dir;
|
|
480
503
|
},
|
|
504
|
+
// The orchestrator stops the compute-breach watcher on every terminal path (the interval is
|
|
505
|
+
// unref'd, so this is hygiene, not liveness).
|
|
506
|
+
budgetWatch: { stop: () => computeWatch.stop() },
|
|
481
507
|
// Always present now: hydrate must run BEFORE the program, but whether anything compounds
|
|
482
508
|
// isn't known until the run's agent() calls have registered their memory dirs. A run that
|
|
483
509
|
// selects nothing persists nothing and pays for nothing (WorkspaceStore.persist returns early).
|
|
@@ -507,7 +533,15 @@ export function assembleWorkerDeps(runtime) {
|
|
|
507
533
|
// lifecycle transition the claim IS — the wire's `running` frame.
|
|
508
534
|
runEvents.resumeAfter(claimed.lastEventCursor);
|
|
509
535
|
runEvents.emit({ kind: "run_status", status: "running" });
|
|
510
|
-
return
|
|
536
|
+
return {
|
|
537
|
+
run: claimed.run,
|
|
538
|
+
context: {
|
|
539
|
+
...(claimed.workflowVersion !== undefined
|
|
540
|
+
? { workflowVersion: claimed.workflowVersion }
|
|
541
|
+
: {}),
|
|
542
|
+
...(claimed.environment !== undefined ? { environment: claimed.environment } : {}),
|
|
543
|
+
},
|
|
544
|
+
};
|
|
511
545
|
},
|
|
512
546
|
},
|
|
513
547
|
versions: {
|
|
@@ -527,7 +561,7 @@ export function assembleWorkerDeps(runtime) {
|
|
|
527
561
|
// Extract the verified artifact tarball via system `tar` (same impl as workspace snapshots).
|
|
528
562
|
extractArchive: (tgzPath, destDir) => new TarWorkspaceArchiver().extract(tgzPath, destDir),
|
|
529
563
|
// Guarantee /workspace exists before the program runs (override-safe; the image also pre-creates
|
|
530
|
-
// it). Lets authors write to /workspace without a defensive mkdir — see
|
|
564
|
+
// it). Lets authors write to /workspace without a defensive mkdir — see the descriptor's `workspace` field.
|
|
531
565
|
ensureWorkspace: async () => {
|
|
532
566
|
await mkdir(runtime.workspaceRoot, { recursive: true });
|
|
533
567
|
},
|
|
@@ -660,7 +694,44 @@ export function capturePlatformContext(env) {
|
|
|
660
694
|
Reflect.deleteProperty(env, key);
|
|
661
695
|
return { runId, controlPlane, vcpus };
|
|
662
696
|
}
|
|
697
|
+
/**
|
|
698
|
+
* Resolve {@link PlatformConfig} from the trusted BOOT env. This is the ONE place env is read for the
|
|
699
|
+
* worker's own platform config — every other module consumes the typed result — so platform behavior
|
|
700
|
+
* never depends on the author-mutable process.env. Pure (unit-tested). MUST be called on the boot-env
|
|
701
|
+
* snapshot taken BEFORE the identity relay overlays the author env (see `main`).
|
|
702
|
+
*/
|
|
703
|
+
export function capturePlatformConfig(bootEnv) {
|
|
704
|
+
return {
|
|
705
|
+
browser: loadGuestBrowserConfig(bootEnv),
|
|
706
|
+
capture: loadCaptureConfig(bootEnv),
|
|
707
|
+
...(bootEnv.WORKER_ID !== undefined ? { workerId: bootEnv.WORKER_ID } : {}),
|
|
708
|
+
workspaceRoot: bootEnv.WORKSPACE_ROOT ?? "/workspace",
|
|
709
|
+
// Never inside the workspace, and never `process.cwd()` (WORKSPACE_PERSISTENCE.md I2). `tmpdir()`
|
|
710
|
+
// honors TMPDIR, which the self-hosted daemon points at the per-run dir — so concurrent daemons on
|
|
711
|
+
// one machine don't collide, and the hosted lanes get the VM's own /tmp.
|
|
712
|
+
programRoot: bootEnv.PROGRAM_ROOT ?? join(tmpdir(), "bw-programs"),
|
|
713
|
+
...(bootEnv.PERSIST_SCOPE_DIR !== undefined
|
|
714
|
+
? { persistScopeDir: bootEnv.PERSIST_SCOPE_DIR }
|
|
715
|
+
: {}),
|
|
716
|
+
...(bootEnv.BOARDWALK_RUN_LOG_FILE !== undefined
|
|
717
|
+
? { runLogFilePath: bootEnv.BOARDWALK_RUN_LOG_FILE }
|
|
718
|
+
: {}),
|
|
719
|
+
};
|
|
720
|
+
}
|
|
663
721
|
export async function main() {
|
|
722
|
+
// Snapshot the platform-owned BOOT env (image-baked `/etc/bwimage.env`, plus anything the
|
|
723
|
+
// launcher/daemon set) BEFORE the identity relay overlays the run's AUTHOR env onto process.env below,
|
|
724
|
+
// and resolve the worker's OWN config from it up front. The worker reads platform behavior only from
|
|
725
|
+
// these trusted values — never the author-mutable process.env — so a workflow's `meta.env` can't
|
|
726
|
+
// shadow it (`BOARDWALK_RECORDING_ENABLED=0`, `BOARDWALK_BROWSER_TIER=0`, `BOARDWALK_RUNNER_LOG_LEVEL`,
|
|
727
|
+
// repointing `WORKSPACE_ROOT`, ...). The author still owns process.env outright — no reserved keys
|
|
728
|
+
// (docs/RUN_ENV_AND_CREDS.md); an eslint rule bans `process.env.BOARDWALK_*` reads to keep it that way.
|
|
729
|
+
// Per-run values the relay ITSELF delivers (run token, api token, task size, BYO providers) are a
|
|
730
|
+
// separate channel: `applyIdentityToEnv` set-or-clears them over the author env, so they stay trusted
|
|
731
|
+
// in process.env. On env-boot substrates (Fargate/self-hosted, no relay) this equals the boot env.
|
|
732
|
+
const platformBootEnv = { ...process.env };
|
|
733
|
+
configureLogging(platformBootEnv);
|
|
734
|
+
const platformConfig = capturePlatformConfig(platformBootEnv);
|
|
664
735
|
// Relay-mode bootstrap (the snapshot-based microVM substrate): when the guest init handed
|
|
665
736
|
// us an identity relay fd, park here — warm, generic, pre-identity; this await is what the
|
|
666
737
|
// base snapshot freezes — until the run's identity arrives, then map it onto process.env
|
|
@@ -690,27 +761,30 @@ export async function main() {
|
|
|
690
761
|
// API token (the run env/credential rules). WORKER_ID / WORKSPACE_ROOT are non-secret infra knobs.
|
|
691
762
|
const platform = capturePlatformContext(process.env);
|
|
692
763
|
const runId = platform.runId;
|
|
764
|
+
// BYO providers are a per-run value the relay delivers, NOT image-baked config — read post-overlay.
|
|
765
|
+
// `applyIdentityToEnv` set-or-clears this key over the author env, so the value here is the org's
|
|
766
|
+
// registry (or empty), never an author's `meta.env`. (Sanctioned platform-key read; the general ban
|
|
767
|
+
// on `process.env.BOARDWALK_*` is what routes image config through `platformBootEnv` above.)
|
|
768
|
+
// eslint-disable-next-line no-restricted-syntax -- relay-asserted per-run key, trusted post-overlay
|
|
693
769
|
const byoProviders = parseByoProviders(process.env.BOARDWALK_BYO_PROVIDERS);
|
|
694
770
|
Reflect.deleteProperty(process.env, "BOARDWALK_BYO_PROVIDERS");
|
|
695
|
-
//
|
|
696
|
-
//
|
|
697
|
-
// absent ⇒ `computer.openBrowser()` fails clearly.
|
|
698
|
-
|
|
699
|
-
const browserBackend =
|
|
700
|
-
|
|
701
|
-
// (ffmpeg + a display) and recording isn't disabled. Read here so env-reading stays out of the wiring.
|
|
702
|
-
const captureConfig = loadCaptureConfig(process.env);
|
|
703
|
-
const captureBackend = captureConfig !== null ? makeCaptureBackend(captureConfig) : undefined;
|
|
771
|
+
// Construct the image-tier backends from the typed platform config (env already read once, above).
|
|
772
|
+
// Browser tier: present only when the image declares the browser stack (BOARDWALK_BROWSER_TIER=1 + a
|
|
773
|
+
// Chrome path); absent ⇒ `computer.openBrowser()` fails clearly. Screen capture: present only when the
|
|
774
|
+
// image ships the desktop stack (ffmpeg + a display) and recording isn't disabled.
|
|
775
|
+
const browserBackend = platformConfig.browser !== null ? makeGuestBrowserBackend(platformConfig.browser) : undefined;
|
|
776
|
+
const captureBackend = platformConfig.capture !== null ? makeCaptureBackend(platformConfig.capture) : undefined;
|
|
704
777
|
const deps = assembleWorkerDeps({
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
778
|
+
// Worker-self config from the typed platform config (trusted boot env), never process.env — so a
|
|
779
|
+
// run's `meta.env` can't repoint its own workspace/program roots, worker id, or run-log mirror.
|
|
780
|
+
workerId: platformConfig.workerId ?? `worker-${runId}`,
|
|
781
|
+
workspaceRoot: platformConfig.workspaceRoot,
|
|
782
|
+
programRoot: platformConfig.programRoot,
|
|
783
|
+
...(platformConfig.persistScopeDir !== undefined
|
|
784
|
+
? { persistScopeDir: platformConfig.persistScopeDir }
|
|
785
|
+
: {}),
|
|
786
|
+
...(platformConfig.runLogFilePath !== undefined
|
|
787
|
+
? { runLogFilePath: platformConfig.runLogFilePath }
|
|
714
788
|
: {}),
|
|
715
789
|
runId,
|
|
716
790
|
controlPlane: platform.controlPlane,
|
|
@@ -31,14 +31,17 @@ export interface EngineLeafExecutorDeps {
|
|
|
31
31
|
* breach so the loop terminates mid-flight (the budget authority). */
|
|
32
32
|
budget: BudgetMeter;
|
|
33
33
|
/**
|
|
34
|
-
* Budget clearance, awaited before EVERY model call (docs/SUSPEND_POLICY.md Decision 3). When
|
|
35
|
-
* run's `max_usd`
|
|
36
|
-
*
|
|
37
|
-
*
|
|
34
|
+
* Budget clearance, awaited before EVERY model call (docs/SUSPEND_POLICY.md Decision 3). When ANY
|
|
35
|
+
* of the run's budget caps (`max_usd` / `max_tokens` / `max_compute_seconds`) is breached this
|
|
36
|
+
* PARKS the run at a gate — the engine just sees a model call that took a long time, because the
|
|
37
|
+
* VM froze and resumed underneath it — and resolves once a responder raises the breached cap.
|
|
38
|
+
* Rejects with `BudgetGateCancelled` if they decline.
|
|
38
39
|
*
|
|
39
40
|
* Why here and not at the `reportUsage` breach check below: that callback is synchronous and fires
|
|
40
41
|
* mid-turn, and per SUSPEND_POLICY Decision 1 a partial turn is never discarded. The model-call
|
|
41
42
|
* boundary is the first safe place to park, which bounds overrun at one in-flight turn per leaf.
|
|
43
|
+
* (The workflow host adds further park points at its `sleep`/`shell`/`workflows.call` seams for
|
|
44
|
+
* the continuously-burning compute cap.)
|
|
42
45
|
*
|
|
43
46
|
* Absent ⇒ the legacy behavior: a breach fails the run at `reportUsage`.
|
|
44
47
|
*/
|
|
@@ -3,8 +3,8 @@
|
|
|
3
3
|
// In the JS-body model the run is the workflow PROGRAM; the agent loop is no longer "the run" — it
|
|
4
4
|
// is an ephemeral leaf the program invokes via `agent(prompt, opts)`. This is the LeafExecutor the
|
|
5
5
|
// worker's WorkflowHost delegates `agent()` to. It runs ONE leaf to completion via the engine's
|
|
6
|
-
// `runAgentLeaf` (`@boardwalk-labs/engine/core`) — the SAME loop
|
|
7
|
-
// server
|
|
6
|
+
// `runAgentLeaf` (`@boardwalk-labs/engine/core`) — the SAME loop the self-hosted
|
|
7
|
+
// server runs — supplying a broker-backed `LeafIo`: the model call routes through the Runner Control
|
|
8
8
|
// API (the worker holds no model creds), events flow onto the run's v1 event stream, usage meters
|
|
9
9
|
// per-leaf + per-model through the broker, and secrets are redacted out of all model-bound content.
|
|
10
10
|
//
|
|
@@ -138,9 +138,14 @@ export class EngineLeafExecutor {
|
|
|
138
138
|
currentTurnId = turnId;
|
|
139
139
|
sink.emit(toRunEventBody(body, identity), turnId);
|
|
140
140
|
},
|
|
141
|
-
// Usage flows to the budget authority after EVERY model call. Feed the run-level meter
|
|
142
|
-
//
|
|
143
|
-
//
|
|
141
|
+
// Usage flows to the budget authority after EVERY model call. Feed the run-level meter; what a
|
|
142
|
+
// breach then does depends on whether the budget gate is wired. With a gate (production): do
|
|
143
|
+
// NOT throw — the in-flight turn completes (its tools run; SUSPEND_POLICY Decision 1 never
|
|
144
|
+
// discards a partial turn) and the run PARKS at its next park point (the next `streamModel`
|
|
145
|
+
// clearance, or a host capability seam). Without a gate (legacy/test paths): THROW — the
|
|
146
|
+
// engine loop propagates it and the run fails BUDGET_EXCEEDED before another model call is
|
|
147
|
+
// dispatched. On the gate path the breach turn ALSO meters to the broker below — its tokens
|
|
148
|
+
// were genuinely spent and must be billed (the old always-throw path silently skipped that).
|
|
144
149
|
reportUsage: (modelRefForUsage, usage) => {
|
|
145
150
|
const delta = toUsageDelta(usage);
|
|
146
151
|
// Cap on the turn's REAL upstream cost when the broker reported one (the result frame's
|
|
@@ -151,7 +156,7 @@ export class EngineLeafExecutor {
|
|
|
151
156
|
pendingCostMicros = null;
|
|
152
157
|
this.deps.budget.addUsage(delta, realCostUsd);
|
|
153
158
|
const breach = this.deps.budget.capBreachReason();
|
|
154
|
-
if (breach !== null) {
|
|
159
|
+
if (breach !== null && this.deps.budgetGate === undefined) {
|
|
155
160
|
throw new EngineError("BUDGET_EXCEEDED", `agent() leaf exceeded the run budget cap (${breach})`);
|
|
156
161
|
}
|
|
157
162
|
this.deps.meterUsage?.({
|
|
@@ -1,14 +1,12 @@
|
|
|
1
|
-
import type
|
|
1
|
+
import { type ContextData } from "@boardwalk-labs/workflow/runtime";
|
|
2
|
+
import { type HostCapabilities } from "./host_server.js";
|
|
2
3
|
/**
|
|
3
4
|
* Link THIS runtime's `@boardwalk-labs/workflow` into the exec dir's `node_modules` so the program's
|
|
4
5
|
* bare import resolves from ANY program root — this link, not an ancestor `node_modules`, is what
|
|
5
|
-
* makes resolution work.
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
* resolves it to the REAL path, so the program gets the same module instance the host adapter was
|
|
10
|
-
* installed on (the singleton contract). `junction` covers Windows without elevation; a failed link
|
|
11
|
-
* is only logged, since a resolvable ancestor may still exist.
|
|
6
|
+
* makes resolution work. A symlink — not a copy — is load-bearing: Node resolves it to the REAL
|
|
7
|
+
* path, so the program gets the same module instance the loader's protocol client was installed
|
|
8
|
+
* on (the active-host singleton in the SDK's host_client). `junction` covers Windows without
|
|
9
|
+
* elevation; a failed link is only logged, since a resolvable ancestor may still exist.
|
|
12
10
|
*/
|
|
13
11
|
export declare function ensureSdkLink(execDir: string): Promise<void>;
|
|
14
12
|
/** Resolve the program's entry module inside the extraction dir, refusing any path that escapes it.
|
|
@@ -17,10 +15,18 @@ export declare function ensureSdkLink(execDir: string): Promise<void>;
|
|
|
17
15
|
* outside `dir` throws rather than importing code from elsewhere on the machine. */
|
|
18
16
|
export declare function resolveEntryPath(dir: string, entry: string): string;
|
|
19
17
|
/**
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
18
|
+
* Resolve a program entry by LANGUAGE, binding the ratified artifact layout: a TS/JS entry is a
|
|
19
|
+
* BUILT module at the artifact root (`index.mjs` — the CLI bundles, the api-server type-strips);
|
|
20
|
+
* a `.py` entry is a SOURCE path under `.bw-src/` (`main.py` ⇒ `<dir>/.bw-src/main.py` — Python
|
|
21
|
+
* has no bundle step, the shipped source is what runs). A leading `./` on the stored entry is
|
|
22
|
+
* tolerated (`join` collapses it). Both lanes keep the containment guard; for Python the guard's
|
|
23
|
+
* base is the `.bw-src` tree itself, so an escaping entry (`../evil.py`) is rejected even when
|
|
24
|
+
* it would still land inside the extract dir.
|
|
25
|
+
*/
|
|
26
|
+
export declare function resolveProgramEntryPath(dir: string, entry: string): string;
|
|
27
|
+
/**
|
|
28
|
+
* Enforce I2: the extracted program must not live inside the run's workspace. Compares RESOLVED
|
|
29
|
+
* paths, and treats "the workspace itself" as inside.
|
|
24
30
|
*/
|
|
25
31
|
export declare function assertProgramRootOutsideWorkspace(programRoot: string, workspaceRoot: string): void;
|
|
26
32
|
export interface RunProgramArgs {
|
|
@@ -28,17 +34,24 @@ export interface RunProgramArgs {
|
|
|
28
34
|
runId: string;
|
|
29
35
|
/** The VERIFIED program artifact tarball (sha256 already checked against the pinned digest). */
|
|
30
36
|
tarball: Uint8Array;
|
|
31
|
-
/** Entry module to import after extraction (a safe relative POSIX path
|
|
37
|
+
/** Entry module to import after extraction (a safe relative POSIX path). TS/JS: the BUILT
|
|
38
|
+
* module at the artifact root (`index.mjs`). Python: the SOURCE path relative to the
|
|
39
|
+
* artifact's `.bw-src/` tree (`main.py` ⇒ `.bw-src/main.py` on disk). */
|
|
32
40
|
entry: string;
|
|
33
|
-
/**
|
|
41
|
+
/** The run's RAW JSON input (trigger payload / inline input). The revival pass is CLIENT-side. */
|
|
34
42
|
input: unknown;
|
|
35
|
-
/**
|
|
36
|
-
*
|
|
37
|
-
|
|
43
|
+
/** The stored derived input schema (`null` for an untyped workflow) — carried on `bootstrap`
|
|
44
|
+
* so the SDK revives rich fields (`date-time` → `Date`, base64 → `Uint8Array`, …). */
|
|
45
|
+
inputSchema: Record<string, unknown> | null;
|
|
46
|
+
/** The stored derived output schema (`null` ⇒ the return persists unvalidated). */
|
|
47
|
+
outputSchema: Record<string, unknown> | null;
|
|
48
|
+
/** The context DATA for `bootstrap` (P3.3) — the client builds the live `Context` from it. */
|
|
49
|
+
context: ContextData;
|
|
38
50
|
}
|
|
39
51
|
export interface ProgramRunnerDeps {
|
|
40
|
-
/** The
|
|
41
|
-
|
|
52
|
+
/** The capability seam the host server dispatches onto (agent leaf, sleep hold, child calls,
|
|
53
|
+
* secrets, shell, usage, auth, browser, phase). */
|
|
54
|
+
capabilities: HostCapabilities;
|
|
42
55
|
/**
|
|
43
56
|
* The run's `/workspace` — the working directory AND `HOME` for author code (I1). Must already
|
|
44
57
|
* exist (the orchestrator's `ensureWorkspace` guarantees it); a missing workspace fails the run
|
|
@@ -46,42 +59,49 @@ export interface ProgramRunnerDeps {
|
|
|
46
59
|
*/
|
|
47
60
|
workspaceRoot: string;
|
|
48
61
|
/**
|
|
49
|
-
* Root the program artifact extracts under. MUST be outside {@link workspaceRoot} (I2) —
|
|
50
|
-
* because a bundle inside the workspace is tarred into every pre-sleep snapshot
|
|
51
|
-
* run's dir name is unique, accumulates there forever.
|
|
62
|
+
* Root the program artifact extracts under. MUST be outside {@link workspaceRoot} (I2) —
|
|
63
|
+
* enforced, because a bundle inside the workspace is tarred into every pre-sleep snapshot.
|
|
52
64
|
*/
|
|
53
65
|
programRoot: string;
|
|
54
66
|
/**
|
|
55
67
|
* Extract a gzipped tar file into a directory (created already). System `tar` in production
|
|
56
|
-
* (matches WorkspaceArchiver); injected in tests.
|
|
57
|
-
* on-disk assets (markdown skills, templates) sit where the program expects them.
|
|
68
|
+
* (matches WorkspaceArchiver); injected in tests.
|
|
58
69
|
*/
|
|
59
70
|
extract: (tgzPath: string, destDir: string) => Promise<void>;
|
|
60
71
|
/**
|
|
61
|
-
* Called once with the extracted program directory, right after the artifact is unpacked
|
|
62
|
-
*
|
|
63
|
-
* (`<dir>/skills/<name>.md`).
|
|
72
|
+
* Called once with the extracted program directory, right after the artifact is unpacked
|
|
73
|
+
* (before `run()` is invoked). The worker uses it to point the `agent()` leaf at the run's
|
|
74
|
+
* bundled files (`<dir>/skills/<name>.md`).
|
|
64
75
|
*/
|
|
65
76
|
onExtracted?: (programDir: string) => void;
|
|
66
77
|
/**
|
|
67
|
-
* Scrubs known secret values out of a string (the run's `SecretRedactor.redactText`). Applied
|
|
68
|
-
*
|
|
69
|
-
* that resolves a secret and then throws it in an error message must NOT land that secret raw in the
|
|
70
|
-
* logs or the finalized run output. Defaults to identity (tests/local).
|
|
78
|
+
* Scrubs known secret values out of a string (the run's `SecretRedactor.redactText`). Applied
|
|
79
|
+
* to a thrown error's message/code/hint before logging + finalize. Defaults to identity.
|
|
71
80
|
*/
|
|
72
81
|
redactText?: (text: string) => string;
|
|
73
82
|
/**
|
|
74
|
-
* Called once with the
|
|
75
|
-
*
|
|
76
|
-
*
|
|
77
|
-
* throw (a telemetry hiccup can't change the run's result). Absent ⇒ no output entry.
|
|
83
|
+
* Called once with the run's reported return IFF it is non-null (a void return sends null,
|
|
84
|
+
* which is not an author-declared output). The worker wires this to emit an `output` activity
|
|
85
|
+
* entry into the run's event log. Best-effort: it must not throw.
|
|
78
86
|
*/
|
|
79
87
|
onOutput?: (value: unknown) => void;
|
|
88
|
+
/**
|
|
89
|
+
* The run's cooperative-cancellation signal. The host server pushes the `cancel` notification
|
|
90
|
+
* to the program when it fires (the SDK aborts `context.signal`); the capability layer already
|
|
91
|
+
* honors it server-side at every hook.
|
|
92
|
+
*/
|
|
93
|
+
signal?: AbortSignal | undefined;
|
|
94
|
+
/** Override the socket directory (tests). Default `os.tmpdir()` (short paths — sun_path cap). */
|
|
95
|
+
sockDir?: string | undefined;
|
|
96
|
+
/** Interpreter a `.py` entry is launched with (P5.5). Default `python3`, resolved on PATH —
|
|
97
|
+
* the base image bakes one CPython (P5.3). May be an absolute path. */
|
|
98
|
+
pythonInterpreter?: string | undefined;
|
|
99
|
+
/** How long after SIGTERM an aborted Python child gets before SIGKILL. Default 5s. */
|
|
100
|
+
pythonKillGraceMs?: number | undefined;
|
|
80
101
|
}
|
|
81
|
-
/** Terminal result of running a workflow program. `output` is
|
|
82
|
-
*
|
|
83
|
-
*
|
|
84
|
-
* holds the process, and the body simply continues when the wait is over. */
|
|
102
|
+
/** Terminal result of running a workflow program. `output` is the validated value `run()`
|
|
103
|
+
* returned (`null` for void); for a failure it's null and `error` is set. A waiting seam never
|
|
104
|
+
* surfaces here — it freezes with the VM or holds the process, and `run()` simply continues. */
|
|
85
105
|
export type ProgramResult = {
|
|
86
106
|
kind: "completed";
|
|
87
107
|
output: unknown;
|
|
@@ -95,8 +115,8 @@ export type ProgramResult = {
|
|
|
95
115
|
};
|
|
96
116
|
};
|
|
97
117
|
/**
|
|
98
|
-
* Run a workflow program to completion
|
|
99
|
-
*
|
|
100
|
-
*
|
|
118
|
+
* Run a workflow program to completion: start the host-protocol server, extract the VERIFIED
|
|
119
|
+
* artifact, drive the loader (`bootstrap` → import entry → `run(input, context)` →
|
|
120
|
+
* `reportReturn`), and return the terminal result. Always tears the server + temp tree down.
|
|
101
121
|
*/
|
|
102
122
|
export declare function runWorkflowProgram(args: RunProgramArgs, deps: ProgramRunnerDeps): Promise<ProgramResult>;
|