@boardwalk-labs/runner 0.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +202 -0
- package/README.md +68 -0
- package/dist/bin.d.ts +2 -0
- package/dist/bin.js +214 -0
- package/dist/contract.d.ts +154 -0
- package/dist/contract.js +196 -0
- package/dist/daemon/daemon.d.ts +45 -0
- package/dist/daemon/daemon.js +129 -0
- package/dist/daemon/identity.d.ts +15 -0
- package/dist/daemon/identity.js +44 -0
- package/dist/daemon/index.d.ts +3 -0
- package/dist/daemon/index.js +4 -0
- package/dist/daemon/pool_client.d.ts +31 -0
- package/dist/daemon/pool_client.js +101 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +8 -0
- package/dist/runtime/agent/budget.d.ts +123 -0
- package/dist/runtime/agent/budget.js +174 -0
- package/dist/runtime/agent/events.d.ts +46 -0
- package/dist/runtime/agent/events.js +8 -0
- package/dist/runtime/agent/model_rates.d.ts +7 -0
- package/dist/runtime/agent/model_rates.js +21 -0
- package/dist/runtime/agent/secret_redactor.d.ts +42 -0
- package/dist/runtime/agent/secret_redactor.js +98 -0
- package/dist/runtime/broker_artifact_store.d.ts +17 -0
- package/dist/runtime/broker_artifact_store.js +46 -0
- package/dist/runtime/broker_child_dispatcher.d.ts +31 -0
- package/dist/runtime/broker_child_dispatcher.js +88 -0
- package/dist/runtime/broker_event_publisher.d.ts +25 -0
- package/dist/runtime/broker_event_publisher.js +69 -0
- package/dist/runtime/broker_tool_host.d.ts +49 -0
- package/dist/runtime/broker_tool_host.js +240 -0
- package/dist/runtime/cancel_watcher.d.ts +31 -0
- package/dist/runtime/cancel_watcher.js +77 -0
- package/dist/runtime/credit_watcher.d.ts +30 -0
- package/dist/runtime/credit_watcher.js +78 -0
- package/dist/runtime/direct_inference.d.ts +29 -0
- package/dist/runtime/direct_inference.js +78 -0
- package/dist/runtime/index.d.ts +85 -0
- package/dist/runtime/index.js +525 -0
- package/dist/runtime/inference_transport.d.ts +5 -0
- package/dist/runtime/inference_transport.js +9 -0
- package/dist/runtime/leaf_executor.d.ts +104 -0
- package/dist/runtime/leaf_executor.js +333 -0
- package/dist/runtime/lease_renewer.d.ts +31 -0
- package/dist/runtime/lease_renewer.js +80 -0
- package/dist/runtime/main.d.ts +1 -0
- package/dist/runtime/main.js +7 -0
- package/dist/runtime/phase_tracker.d.ts +26 -0
- package/dist/runtime/phase_tracker.js +45 -0
- package/dist/runtime/program_log_capture.d.ts +20 -0
- package/dist/runtime/program_log_capture.js +79 -0
- package/dist/runtime/program_runner.d.ts +82 -0
- package/dist/runtime/program_runner.js +116 -0
- package/dist/runtime/program_worker.d.ts +177 -0
- package/dist/runtime/program_worker.js +272 -0
- package/dist/runtime/recording_secret_resolver.d.ts +9 -0
- package/dist/runtime/recording_secret_resolver.js +22 -0
- package/dist/runtime/run_abort.d.ts +16 -0
- package/dist/runtime/run_abort.js +38 -0
- package/dist/runtime/run_event_emitter.d.ts +24 -0
- package/dist/runtime/run_event_emitter.js +63 -0
- package/dist/runtime/runner_control_client.d.ts +184 -0
- package/dist/runtime/runner_control_client.js +501 -0
- package/dist/runtime/runtime_flusher.d.ts +41 -0
- package/dist/runtime/runtime_flusher.js +90 -0
- package/dist/runtime/sandbox_config.d.ts +21 -0
- package/dist/runtime/sandbox_config.js +34 -0
- package/dist/runtime/support/index.d.ts +53 -0
- package/dist/runtime/support/index.js +114 -0
- package/dist/runtime/suspension.d.ts +141 -0
- package/dist/runtime/suspension.js +120 -0
- package/dist/runtime/testing_artifact_build.d.ts +27 -0
- package/dist/runtime/testing_artifact_build.js +96 -0
- package/dist/runtime/tools/artifacts.d.ts +146 -0
- package/dist/runtime/tools/artifacts.js +125 -0
- package/dist/runtime/tools/types.d.ts +114 -0
- package/dist/runtime/tools/types.js +61 -0
- package/dist/runtime/tools/web_search.d.ts +66 -0
- package/dist/runtime/tools/web_search.js +165 -0
- package/dist/runtime/wire/artifact_storage.d.ts +37 -0
- package/dist/runtime/wire/artifact_storage.js +93 -0
- package/dist/runtime/wire/artifact_verify.d.ts +1 -0
- package/dist/runtime/wire/artifact_verify.js +8 -0
- package/dist/runtime/wire/human_input.d.ts +62 -0
- package/dist/runtime/wire/human_input.js +156 -0
- package/dist/runtime/wire/inference_proxy.d.ts +70 -0
- package/dist/runtime/wire/inference_proxy.js +157 -0
- package/dist/runtime/wire/manifest.d.ts +16 -0
- package/dist/runtime/wire/manifest.js +18 -0
- package/dist/runtime/wire/run.d.ts +54 -0
- package/dist/runtime/wire/run.js +2 -0
- package/dist/runtime/workflow_host.d.ts +194 -0
- package/dist/runtime/workflow_host.js +382 -0
- package/dist/runtime/workspace_store.d.ts +73 -0
- package/dist/runtime/workspace_store.js +118 -0
- package/package.json +70 -0
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import type { WorkflowHost } from "@boardwalk-labs/workflow/runtime";
|
|
2
|
+
import { type SuspendSignal } from "./suspension.js";
|
|
3
|
+
export interface RunProgramArgs {
|
|
4
|
+
/** Run id — used for the temp dir path + correlation. */
|
|
5
|
+
runId: string;
|
|
6
|
+
/** The VERIFIED program artifact tarball (sha256 already checked against the pinned digest). */
|
|
7
|
+
tarball: Uint8Array;
|
|
8
|
+
/** Entry module to import after extraction (a safe relative POSIX path, e.g. `index.mjs`). */
|
|
9
|
+
entry: string;
|
|
10
|
+
/** Trigger payload exposed to the program as `import { input } from "@boardwalk-labs/workflow"`. */
|
|
11
|
+
input: unknown;
|
|
12
|
+
/** Experiment config exposed as `import { config } from "@boardwalk-labs/workflow"` ({} for non-eval).
|
|
13
|
+
* Arbitrary JSON from the run row; narrowed to the SDK's JsonValue at the installConfig boundary. */
|
|
14
|
+
config: Record<string, unknown>;
|
|
15
|
+
}
|
|
16
|
+
export interface ProgramRunnerDeps {
|
|
17
|
+
/** The host adapter the program's hooks delegate to (agent leaf, sleep hold, child calls, secrets). */
|
|
18
|
+
host: WorkflowHost;
|
|
19
|
+
/**
|
|
20
|
+
* Extract a gzipped tar file into a directory (created already). System `tar` in production
|
|
21
|
+
* (matches WorkspaceArchiver); injected in tests. The artifact's relative layout is preserved so
|
|
22
|
+
* on-disk assets (markdown skills, templates) sit where the program expects them.
|
|
23
|
+
*/
|
|
24
|
+
extract: (tgzPath: string, destDir: string) => Promise<void>;
|
|
25
|
+
/**
|
|
26
|
+
* Called once with the extracted program directory, right after the artifact is unpacked (before the
|
|
27
|
+
* program body runs). The worker uses it to point the `agent()` leaf at the run's bundled files
|
|
28
|
+
* (`<dir>/skills/<name>.md`). Optional — the local/test path may omit it.
|
|
29
|
+
*/
|
|
30
|
+
onExtracted?: (programDir: string) => void;
|
|
31
|
+
/**
|
|
32
|
+
* Root whose tree can resolve `@boardwalk-labs/workflow` (has `node_modules` reachable). The extracted
|
|
33
|
+
* program lives under here so its bare SDK import resolves to the same package instance the host
|
|
34
|
+
* was installed on. Defaults to `process.cwd()`.
|
|
35
|
+
*/
|
|
36
|
+
workRoot?: string;
|
|
37
|
+
/**
|
|
38
|
+
* Scrubs known secret values out of a string (the run's `SecretRedactor.redactText`). Applied to a
|
|
39
|
+
* top-level throw's message before it is logged AND before it is returned to the worker — a program
|
|
40
|
+
* that resolves a secret and then throws it in an error message must NOT land that secret raw in the
|
|
41
|
+
* logs or the finalized run output (MASTER_SPEC §12, review #5). Defaults to identity (tests/local).
|
|
42
|
+
*/
|
|
43
|
+
redactText?: (text: string) => string;
|
|
44
|
+
/**
|
|
45
|
+
* Called once with the program's declared output IFF the program called `output(value)` (so an
|
|
46
|
+
* explicit `output(null)` still fires, but a program that never declared one does NOT). The worker
|
|
47
|
+
* wires this to emit an `output` activity entry into the run's event log. Best-effort: it must not
|
|
48
|
+
* throw (a telemetry hiccup can't change the run's result). Absent ⇒ no output entry.
|
|
49
|
+
*/
|
|
50
|
+
onOutput?: (value: unknown) => void;
|
|
51
|
+
/**
|
|
52
|
+
* Resolves when a host seam SUSPENDS the run (docs/SUSPENSION.md) — the worker wires it to the
|
|
53
|
+
* host's `onSuspend` so a suspend is surfaced OUT OF BAND, racing the program body. The program's
|
|
54
|
+
* own `try/catch` can't swallow a suspend this way (the suspending seam never resolves; this signal
|
|
55
|
+
* short-circuits at the runner). Absent ⇒ no suspension wired (a seam that suspends throws
|
|
56
|
+
* {@link SuspendError} instead, which is caught here just the same).
|
|
57
|
+
*/
|
|
58
|
+
suspendSignal?: Promise<SuspendSignal>;
|
|
59
|
+
}
|
|
60
|
+
/** Terminal (or suspended) result of running a workflow program. `output` is what the program
|
|
61
|
+
* declared via `output(value)` (null when it never did); for a failure it's null and `error` is set;
|
|
62
|
+
* for a suspension the `signal` carries the wake condition (the worker persists it, no finalize). */
|
|
63
|
+
export type ProgramResult = {
|
|
64
|
+
kind: "completed";
|
|
65
|
+
output: unknown;
|
|
66
|
+
} | {
|
|
67
|
+
kind: "failed";
|
|
68
|
+
output: null;
|
|
69
|
+
error: {
|
|
70
|
+
code: string;
|
|
71
|
+
message: string;
|
|
72
|
+
};
|
|
73
|
+
} | {
|
|
74
|
+
kind: "suspended";
|
|
75
|
+
signal: SuspendSignal;
|
|
76
|
+
};
|
|
77
|
+
/**
|
|
78
|
+
* Run a workflow program to completion. Installs the host + input, extracts the VERIFIED artifact +
|
|
79
|
+
* dynamic-imports its entry (which runs the body), and returns the terminal result. Always tears the
|
|
80
|
+
* runtime state down and removes the temp tree afterward.
|
|
81
|
+
*/
|
|
82
|
+
export declare function runWorkflowProgram(args: RunProgramArgs, deps: ProgramRunnerDeps): Promise<ProgramResult>;
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
// WorkflowProgramRunner — executes a workflow program (the JS-body model, docs/WORKFLOW_RUNTIME.md).
|
|
2
|
+
//
|
|
3
|
+
// A run is the execution of a built program ARTIFACT (§3.9): the worker is handed the VERIFIED tarball
|
|
4
|
+
// (its sha256 already checked against the pinned digest by the orchestrator) plus the entry module
|
|
5
|
+
// name. This module is the mechanism: it installs the host adapter + trigger payload onto the
|
|
6
|
+
// `@boardwalk-labs/workflow` singleton, extracts the artifact into a unique temp dir under a
|
|
7
|
+
// node_modules-reachable root, and dynamic-imports the entry so the program's body runs. The
|
|
8
|
+
// program's `import { agent, sleep, … } from "@boardwalk-labs/workflow"` resolves to the SAME package
|
|
9
|
+
// instance the host was installed on (one instance per process), so the hooks reach our adapter.
|
|
10
|
+
//
|
|
11
|
+
// The artifact is already BUILT JS (the CLI esbuild-bundles packages; the api-server type-strips
|
|
12
|
+
// single files) — so the worker NEVER transpiles and NEVER installs. It only extracts + imports.
|
|
13
|
+
// `@boardwalk-labs/workflow` is left external in the bundle, so the imported program resolves it to the SDK
|
|
14
|
+
// package present in the worker image (giving up its own copy would break the dual-adapter).
|
|
15
|
+
//
|
|
16
|
+
// Why a temp dir under a node_modules-reachable root (not os.tmpdir): the program imports the bare
|
|
17
|
+
// specifier `@boardwalk-labs/workflow`, which Node resolves by walking up from the module's location to
|
|
18
|
+
// `node_modules`. The extracted tree must therefore live inside the app tree (/app/.bw-runs/*).
|
|
19
|
+
//
|
|
20
|
+
// Durability: the body runs once, in-process. `sleep`/`workflows.call` hold in-process via the host
|
|
21
|
+
// (no checkpoint, no exit). A crash mid-run restarts the run from the top (handled by the
|
|
22
|
+
// worker/scheduler-sweep, not here). Output capture is deferred (v0 returns null).
|
|
23
|
+
import { mkdir, writeFile, rm } from "node:fs/promises";
|
|
24
|
+
import { join } from "node:path";
|
|
25
|
+
import { pathToFileURL } from "node:url";
|
|
26
|
+
import { randomUUID } from "node:crypto";
|
|
27
|
+
import { installHost, installInput, installConfig, takeDeclaredOutput, resetRuntime, } from "@boardwalk-labs/workflow/runtime";
|
|
28
|
+
import { createLogger } from "./support/index.js";
|
|
29
|
+
import { SuspendError } from "./suspension.js";
|
|
30
|
+
const log = createLogger("ProgramRunner");
|
|
31
|
+
/** Subdirectory (under the work root) that holds transient extracted program trees. */
|
|
32
|
+
const RUN_DIR = ".bw-runs";
|
|
33
|
+
/** Scratch filename for the in-flight artifact tarball inside a run's dir. */
|
|
34
|
+
const ARTIFACT_FILE = "__program.tgz";
|
|
35
|
+
/**
|
|
36
|
+
* Run a workflow program to completion. Installs the host + input, extracts the VERIFIED artifact +
|
|
37
|
+
* dynamic-imports its entry (which runs the body), and returns the terminal result. Always tears the
|
|
38
|
+
* runtime state down and removes the temp tree afterward.
|
|
39
|
+
*/
|
|
40
|
+
export async function runWorkflowProgram(args, deps) {
|
|
41
|
+
const workRoot = deps.workRoot ?? process.cwd();
|
|
42
|
+
const dir = join(workRoot, RUN_DIR, `${args.runId}-${randomUUID()}`);
|
|
43
|
+
installHost(deps.host);
|
|
44
|
+
installInput(args.input);
|
|
45
|
+
// The run row's config is arbitrary JSON (jsonb); it IS valid JSON, so narrow to the SDK's JsonValue.
|
|
46
|
+
installConfig(args.config);
|
|
47
|
+
try {
|
|
48
|
+
await mkdir(dir, { recursive: true });
|
|
49
|
+
const tgzPath = join(dir, ARTIFACT_FILE);
|
|
50
|
+
await writeFile(tgzPath, args.tarball);
|
|
51
|
+
// Extract the built tree (entry + sourcemap + assets) preserving relative layout, then drop the
|
|
52
|
+
// tarball so it isn't visible to the program.
|
|
53
|
+
await deps.extract(tgzPath, dir);
|
|
54
|
+
await rm(tgzPath, { force: true });
|
|
55
|
+
// The bundled tree is now on disk — let the worker point the agent() leaf at `<dir>/skills/*.md`.
|
|
56
|
+
deps.onExtracted?.(dir);
|
|
57
|
+
const entryPath = join(dir, ...args.entry.split("/"));
|
|
58
|
+
// Run the program body. A SUSPEND is surfaced two ways and both land here as a `suspended`
|
|
59
|
+
// result: (a) out of band via `suspendSignal` — racing the body, immune to a program's own
|
|
60
|
+
// try/catch (the suspending seam never resolves); (b) a thrown {@link SuspendError} on the
|
|
61
|
+
// no-`onSuspend` path. Everything else is the program's natural completion / failure.
|
|
62
|
+
const body = runProgramBody(entryPath, deps);
|
|
63
|
+
const result = deps.suspendSignal === undefined
|
|
64
|
+
? await body
|
|
65
|
+
: await Promise.race([
|
|
66
|
+
body,
|
|
67
|
+
deps.suspendSignal.then((signal) => ({ kind: "suspended", signal })),
|
|
68
|
+
]);
|
|
69
|
+
// If the suspend won the race, the body promise is abandoned (its suspending seam never settles);
|
|
70
|
+
// swallow any late settle so it can't surface as an unhandled rejection before the process exits.
|
|
71
|
+
void body.catch(() => undefined);
|
|
72
|
+
return result;
|
|
73
|
+
}
|
|
74
|
+
catch (err) {
|
|
75
|
+
if (err instanceof SuspendError)
|
|
76
|
+
return { kind: "suspended", signal: err.signal };
|
|
77
|
+
const redactText = deps.redactText ?? ((s) => s);
|
|
78
|
+
// Redact BEFORE both sinks: the message can carry a secret the program resolved then threw.
|
|
79
|
+
const message = redactText(err instanceof Error ? err.message : String(err));
|
|
80
|
+
log.error("program_failed", { runId: args.runId, error: message });
|
|
81
|
+
return {
|
|
82
|
+
kind: "failed",
|
|
83
|
+
output: null,
|
|
84
|
+
error: {
|
|
85
|
+
// `code` is the error class name (e.g. "Error", "AppError"), not user content — left as-is.
|
|
86
|
+
code: err instanceof Error ? err.name : "PROGRAM_ERROR",
|
|
87
|
+
message,
|
|
88
|
+
},
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
finally {
|
|
92
|
+
resetRuntime();
|
|
93
|
+
await rm(dir, { recursive: true, force: true }).catch((rmErr) => {
|
|
94
|
+
log.warn("program_cleanup_failed", {
|
|
95
|
+
runId: args.runId,
|
|
96
|
+
error: rmErr instanceof Error ? rmErr.message : String(rmErr),
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* Import (= run) the program entry and capture its declared output. Resolves to a `completed`
|
|
103
|
+
* result; a program failure (or a {@link SuspendError} on the no-`onSuspend` path) THROWS and is
|
|
104
|
+
* handled by the caller. A unique dir per run gives a fresh URL so the module cache never returns an
|
|
105
|
+
* already-run program; `@vite-ignore` keeps vitest/vite from statically analyzing the runtime URL.
|
|
106
|
+
*/
|
|
107
|
+
async function runProgramBody(entryPath, deps) {
|
|
108
|
+
await import(/* @vite-ignore */ pathToFileURL(entryPath).href);
|
|
109
|
+
// The program declares its result via `output(value)` (top-level code can't `return`); null when it
|
|
110
|
+
// never called it. This becomes the run's persisted output + a `workflows.call` parent's value, and
|
|
111
|
+
// (when actually declared) an `output` entry in the run's activity log.
|
|
112
|
+
const declared = takeDeclaredOutput();
|
|
113
|
+
if (declared !== null)
|
|
114
|
+
deps.onOutput?.(declared.value);
|
|
115
|
+
return { kind: "completed", output: declared !== null ? declared.value : null };
|
|
116
|
+
}
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
import type { Run } from "./wire/run.js";
|
|
2
|
+
import { type WorkflowManifest } from "./wire/manifest.js";
|
|
3
|
+
import type { WorkflowHost } from "@boardwalk-labs/workflow/runtime";
|
|
4
|
+
import type { SecretRedactor } from "./agent/secret_redactor.js";
|
|
5
|
+
import { type LogStream } from "./program_log_capture.js";
|
|
6
|
+
import type { SuspendSignal } from "./suspension.js";
|
|
7
|
+
/** Default 5-minute lease (matches the engine spec). */
|
|
8
|
+
export declare const DEFAULT_LEASE_MS: number;
|
|
9
|
+
/** Race-safe claim surface — RunRepository satisfies it. */
|
|
10
|
+
export interface RunClaimer {
|
|
11
|
+
claimForWorker(runId: string, workerId: string, leaseUntil: number, nowMs: number): Promise<Run | null>;
|
|
12
|
+
}
|
|
13
|
+
/** The pinned program's download reference (the worker fetches + verifies + extracts it). */
|
|
14
|
+
export interface ProgramRef {
|
|
15
|
+
entry: string;
|
|
16
|
+
digest: string;
|
|
17
|
+
sdkVersion: string;
|
|
18
|
+
downloadUrl: string;
|
|
19
|
+
}
|
|
20
|
+
/** Reads the pinned version's manifest + program artifact reference. */
|
|
21
|
+
export interface ProgramVersionReader {
|
|
22
|
+
getById(id: string): Promise<{
|
|
23
|
+
manifest: unknown;
|
|
24
|
+
program: ProgramRef;
|
|
25
|
+
} | null>;
|
|
26
|
+
}
|
|
27
|
+
/** Books the run's RUNTIME usage as periodic deltas (the worker's RuntimeFlusher; §10.7 + §15). The
|
|
28
|
+
* orchestrator drives the lifecycle: the timer flushes mid-run, `stop()` halts it at the body's end,
|
|
29
|
+
* and `flushFinal()` books the tail on a clean terminal (skipped on a `lease_lost` handoff — the new
|
|
30
|
+
* owner books its own runtime). Replaces the old single terminal runtime charge. */
|
|
31
|
+
export interface RuntimeFlushHandle {
|
|
32
|
+
/** Stop the periodic flush timer (does NOT book the tail). */
|
|
33
|
+
stop(): Promise<void>;
|
|
34
|
+
/** Book the remaining runtime since the last flush (the terminal tail). */
|
|
35
|
+
flushFinal(): Promise<void>;
|
|
36
|
+
}
|
|
37
|
+
/** Starts periodic runtime metering for a claimed run. `startedAtMs` is this session's claim time (the
|
|
38
|
+
* point runtime begins accruing). Optional: absent disables runtime metering (the local/test path). */
|
|
39
|
+
export type RuntimeMeterStarter = (args: {
|
|
40
|
+
run: Run;
|
|
41
|
+
startedAtMs: number;
|
|
42
|
+
}) => RuntimeFlushHandle;
|
|
43
|
+
/** Marks a run terminal (status + output/error + completedAt + lease release). */
|
|
44
|
+
export interface RunFinalizer {
|
|
45
|
+
finalize(runId: string, status: "completed" | "failed", output: unknown): Promise<void>;
|
|
46
|
+
}
|
|
47
|
+
/** Persists a durable SUSPENSION (docs/SUSPENSION.md): the broker records the wake condition (a
|
|
48
|
+
* pending/suspended journal entry + a human-input request row for HITL, or the wake time for a long
|
|
49
|
+
* sleep), flips the run to its suspended status, and releases the lease — all transactionally. The
|
|
50
|
+
* run is NOT finalized; a wake (an answer, a child finalize, or a timer) re-dispatches it. */
|
|
51
|
+
export interface RunSuspender {
|
|
52
|
+
suspend(signal: SuspendSignal, workerId: string): Promise<void>;
|
|
53
|
+
}
|
|
54
|
+
/** Restores/snapshots the workflow's persistent `/workspace` (§5). Best-effort — both no-op when the
|
|
55
|
+
* run isn't eligible (not opted-in / self-hosted), and neither throws. */
|
|
56
|
+
export interface WorkspaceHandle {
|
|
57
|
+
hydrate(): Promise<void>;
|
|
58
|
+
/** Returns the snapshot byte size (0 on no-op) for the orchestrator's logging. */
|
|
59
|
+
persist(): Promise<number>;
|
|
60
|
+
}
|
|
61
|
+
export interface PhaseLifecycleHandle {
|
|
62
|
+
close(status: "completed" | "failed" | "cancelled"): void;
|
|
63
|
+
}
|
|
64
|
+
/** The run's engine-native LSP service (the engine's `LspService` satisfies it). Constructed once per
|
|
65
|
+
* run (not per leaf) so the language server stays warm across the run's edits/leaves; the orchestrator
|
|
66
|
+
* closes it on teardown so no language-server process leaks. `close()` is idempotent + never throws. */
|
|
67
|
+
export interface LspLifecycleHandle {
|
|
68
|
+
close(): Promise<void>;
|
|
69
|
+
}
|
|
70
|
+
/** Emits the program's declared `output` onto the run's event stream (v1 `output` kind). */
|
|
71
|
+
export interface RunOutputHandle {
|
|
72
|
+
output(value: unknown): void;
|
|
73
|
+
}
|
|
74
|
+
/** Builds the per-run host (leaf + sleep + children + secrets) for a claimed run. Receives the run's
|
|
75
|
+
* cooperative-cancellation `signal` so every host hook honors it (credit exhaustion / cancel).
|
|
76
|
+
* Returns the run's `SecretRedactor` alongside the host so the orchestrator can scrub a terminal
|
|
77
|
+
* error with the SAME instance every resolved secret was recorded into (review #5); `readUsage` — a
|
|
78
|
+
* sampler over the run-level BudgetMeter the token flusher meters from; and an optional `workspace`
|
|
79
|
+
* handle (when the workflow opted into persistence) the orchestrator hydrates at start + persists at
|
|
80
|
+
* terminal (the host's `sleep` also persists, wired inside buildHost). */
|
|
81
|
+
export type ProgramHostBuilder = (run: Run, manifest: WorkflowManifest, signal: AbortSignal) => Promise<{
|
|
82
|
+
host: WorkflowHost;
|
|
83
|
+
redactor: SecretRedactor;
|
|
84
|
+
workspace?: WorkspaceHandle;
|
|
85
|
+
phases?: PhaseLifecycleHandle;
|
|
86
|
+
/** The run's engine-native LSP service (constructed per run; held by the `agent()` leaf). The
|
|
87
|
+
* orchestrator closes it on terminal — success AND failure — so no language-server process leaks.
|
|
88
|
+
* Optional — absent on paths with no LSP (the local/test path). */
|
|
89
|
+
lsp?: LspLifecycleHandle;
|
|
90
|
+
/** Emits the program's declared output onto the run's event stream. */
|
|
91
|
+
activity?: RunOutputHandle;
|
|
92
|
+
/** Records the extracted program directory once the runner unpacks the artifact, so the `agent()`
|
|
93
|
+
* leaf can resolve this run's bundled skill files (`<dir>/skills/<name>.md`). The orchestrator wires
|
|
94
|
+
* it to the runner's `onExtracted`. Optional — absent on paths that don't surface bundled files. */
|
|
95
|
+
setProgramDir?: (dir: string) => void;
|
|
96
|
+
/** Resolves when a host seam SUSPENDS the run — wired to the host's `onSuspend`, threaded into the
|
|
97
|
+
* program runner so a suspend short-circuits the body out of band (docs/SUSPENSION.md). Absent ⇒
|
|
98
|
+
* no durable suspension on this path (a suspend then surfaces as a thrown SuspendError). */
|
|
99
|
+
suspendSignal?: Promise<SuspendSignal>;
|
|
100
|
+
}>;
|
|
101
|
+
/** Handle to a running per-session loop (metering or credit watch); `stop()` ends + drains it. */
|
|
102
|
+
export interface RunSessionHandle {
|
|
103
|
+
stop(): Promise<void>;
|
|
104
|
+
}
|
|
105
|
+
/** Starts mid-run credit watching for a claimed run (the worker wires it to a CreditWatcher → broker
|
|
106
|
+
* `/credit`). `onExhausted` fires once when the org runs out of credit — the orchestrator aborts the
|
|
107
|
+
* run. Optional: absent disables credit watching. */
|
|
108
|
+
export type CreditWatchStarter = (args: {
|
|
109
|
+
run: Run;
|
|
110
|
+
onExhausted: () => void;
|
|
111
|
+
}) => RunSessionHandle;
|
|
112
|
+
/** Starts mid-run user-cancel watching for a claimed run (the worker wires it to a CancelWatcher →
|
|
113
|
+
* broker `/cancel`). `onCancelled` fires once when the user cancels — the orchestrator aborts the run.
|
|
114
|
+
* Optional: absent disables cancel watching (e.g. the local/pre-broker path). */
|
|
115
|
+
export type CancelWatchStarter = (args: {
|
|
116
|
+
run: Run;
|
|
117
|
+
onCancelled: () => void;
|
|
118
|
+
}) => RunSessionHandle;
|
|
119
|
+
/** Starts periodic lease renewal for a claimed run (the worker wires it to a LeaseRenewer → broker
|
|
120
|
+
* `/renew`), so a run longer than the lease isn't reclaimed mid-flight. `onLost` fires once if the
|
|
121
|
+
* lease is definitively lost (another worker reclaimed it) — the orchestrator aborts `lease_lost`,
|
|
122
|
+
* and the run stops WITHOUT finalizing. Optional: absent disables renewal (the local/pre-broker path). */
|
|
123
|
+
export type LeaseWatchStarter = (args: {
|
|
124
|
+
run: Run;
|
|
125
|
+
onLost: () => void;
|
|
126
|
+
}) => RunSessionHandle;
|
|
127
|
+
export interface ProgramWorkerDeps {
|
|
128
|
+
runs: RunClaimer;
|
|
129
|
+
versions: ProgramVersionReader;
|
|
130
|
+
/** Download the program artifact bytes from the broker's presigned URL (broker.downloadBytes). */
|
|
131
|
+
fetchProgram: (downloadUrl: string) => Promise<Uint8Array>;
|
|
132
|
+
/** Extract a gzipped tar into a dir (system `tar`); passed through to the program runner. */
|
|
133
|
+
extractArchive: (tgzPath: string, destDir: string) => Promise<void>;
|
|
134
|
+
/** Ensure the run's `/workspace` sandbox dir exists BEFORE the program runs — on EVERY run
|
|
135
|
+
* (persist or not, snapshot or not). This makes "`/workspace` always exists" a guaranteed
|
|
136
|
+
* contract a program can rely on, so authors write to `/workspace` WITHOUT a defensive `mkdir`.
|
|
137
|
+
* Wired by the entrypoint to `mkdir(workspaceRoot, { recursive: true })`. Optional (the
|
|
138
|
+
* local/test path may omit it); best-effort — a failure is logged, not thrown (the program's
|
|
139
|
+
* own write would surface the real error, and the image already pre-creates the dir). */
|
|
140
|
+
ensureWorkspace?: () => Promise<void>;
|
|
141
|
+
/** Periodic runtime metering (optional — absent disables it, e.g. the local/test path). */
|
|
142
|
+
startRuntimeFlush?: RuntimeMeterStarter;
|
|
143
|
+
finalizer: RunFinalizer;
|
|
144
|
+
/** Persists a durable suspension (docs/SUSPENSION.md). Absent ⇒ no suspension support: a run that
|
|
145
|
+
* reaches a suspend fails cleanly rather than stranding (the brokered worker always wires it). */
|
|
146
|
+
suspender?: RunSuspender;
|
|
147
|
+
buildHost: ProgramHostBuilder;
|
|
148
|
+
/** Starts mid-run credit watching for the session (optional — absent disables it). */
|
|
149
|
+
startCreditWatch?: CreditWatchStarter;
|
|
150
|
+
/** Starts mid-run user-cancel watching for the session (optional — absent disables it). */
|
|
151
|
+
startCancelWatch?: CancelWatchStarter;
|
|
152
|
+
/** Starts periodic lease renewal for the session, so a long run isn't spuriously reclaimed
|
|
153
|
+
* (optional — absent disables renewal). */
|
|
154
|
+
startLeaseRenew?: LeaseWatchStarter;
|
|
155
|
+
/** Emit the program's `console.*` output as `log` run-events while the body runs (optional —
|
|
156
|
+
* absent disables capture). Wired by the entrypoint to the batched telemetry publisher. */
|
|
157
|
+
onProgramLog?: (stream: LogStream, text: string) => void;
|
|
158
|
+
/** ECS task ARN (or any stable worker identity). */
|
|
159
|
+
workerId: string;
|
|
160
|
+
/** Drain any buffered telemetry before the worker exits (brokered path's BrokerEventPublisher).
|
|
161
|
+
* Called by the worker entrypoint's cleanup; the orchestrator itself never invokes it. */
|
|
162
|
+
flushTelemetry?: () => Promise<void>;
|
|
163
|
+
now?: () => number;
|
|
164
|
+
leaseMs?: number;
|
|
165
|
+
}
|
|
166
|
+
export type ProgramWorkerOutcome = {
|
|
167
|
+
kind: "claim_lost";
|
|
168
|
+
} | {
|
|
169
|
+
kind: "completed";
|
|
170
|
+
} | {
|
|
171
|
+
kind: "failed";
|
|
172
|
+
reason: string;
|
|
173
|
+
} | {
|
|
174
|
+
kind: "suspended";
|
|
175
|
+
reason: string;
|
|
176
|
+
};
|
|
177
|
+
export declare function runProgramWorker(runId: string, deps: ProgramWorkerDeps): Promise<ProgramWorkerOutcome>;
|
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
// program_worker — the JS-body worker orchestration (docs/WORKFLOW_RUNTIME.md).
|
|
2
|
+
//
|
|
3
|
+
// Replaces the checkpoint-and-resume run_worker. One run, claim to terminal:
|
|
4
|
+
// 1. Race-safe claim (pending → running). Lose the race → exit.
|
|
5
|
+
// 2. Load the pinned version (manifest + program source). Bad/missing → fail pre-flight (no charge).
|
|
6
|
+
// 3. Build the per-run WorkflowHost (agent() leaf, sleep-hold, workflows.call, secrets).
|
|
7
|
+
// 4. Run the program to completion (it HOLDS in-process on sleep / child waits — no exit, no
|
|
8
|
+
// checkpoint). A crash restarts the run from the top (Lambda/GHA semantics), handled by the
|
|
9
|
+
// scheduler-sweep, not here.
|
|
10
|
+
// 5. Charge the runtime this session consumed, then finalize completed/failed (or, if the run was
|
|
11
|
+
// aborted mid-flight — credit exhaustion — finalize failed with the abort reason).
|
|
12
|
+
//
|
|
13
|
+
// While the program runs, two per-session loops watch it (both brokered): a UsageFlusher meters token
|
|
14
|
+
// deltas (§10.7) and a CreditWatcher polls the org's funding (§15). When credit hits zero the watcher
|
|
15
|
+
// aborts the run's AbortSignal, which the WorkflowHost honors at every hook (cooperative
|
|
16
|
+
// cancellation, run_abort.ts) — `signal.aborted` is authoritative for the terminal status.
|
|
17
|
+
//
|
|
18
|
+
// What's gone vs. the old worker: no checkpoint load/pause, no resume, no sleep/wait_for_child
|
|
19
|
+
// pause outcomes (the program holds), no per-turn transcript. The Strands loop now lives one
|
|
20
|
+
// level down, behind the host's agent() leaf.
|
|
21
|
+
//
|
|
22
|
+
// v0 deferral (a clear seam): run-level outcome validation (needs program output capture).
|
|
23
|
+
import { createLogger } from "./support/index.js";
|
|
24
|
+
import { workflowManifestSchema } from "./wire/manifest.js";
|
|
25
|
+
// Import from the pure file (NOT the domain/workflow barrel, which pulls in `typescript`) so the
|
|
26
|
+
// worker bundle stays free of the TS compiler — the worker never transpiles.
|
|
27
|
+
import { verifyArtifactDigest } from "./wire/artifact_verify.js";
|
|
28
|
+
import { runWorkflowProgram } from "./program_runner.js";
|
|
29
|
+
import { captureConsole } from "./program_log_capture.js";
|
|
30
|
+
import { RunAbortedError, abortReason } from "./run_abort.js";
|
|
31
|
+
const log = createLogger("ProgramWorker");
|
|
32
|
+
/** Default 5-minute lease (matches the engine spec). */
|
|
33
|
+
export const DEFAULT_LEASE_MS = 5 * 60 * 1000;
|
|
34
|
+
export async function runProgramWorker(runId, deps) {
|
|
35
|
+
const now = deps.now ?? Date.now;
|
|
36
|
+
const leaseMs = deps.leaseMs ?? DEFAULT_LEASE_MS;
|
|
37
|
+
// Runtime-billing baseline: when THIS worker session began.
|
|
38
|
+
const sessionStartMs = now();
|
|
39
|
+
const claimed = await deps.runs.claimForWorker(runId, deps.workerId, sessionStartMs + leaseMs, sessionStartMs);
|
|
40
|
+
if (claimed === null) {
|
|
41
|
+
log.info("worker_claim_lost", { runId, workerId: deps.workerId });
|
|
42
|
+
return { kind: "claim_lost" };
|
|
43
|
+
}
|
|
44
|
+
log.info("worker_claimed", { runId, workerId: deps.workerId });
|
|
45
|
+
const loaded = await loadVersion(deps, claimed);
|
|
46
|
+
if (loaded === null) {
|
|
47
|
+
// Pre-flight integrity failure — no program ran, so no charge.
|
|
48
|
+
await deps.finalizer.finalize(runId, "failed", {
|
|
49
|
+
error: { code: "INTERNAL_ERROR", message: "Run version missing, or manifest invalid" },
|
|
50
|
+
});
|
|
51
|
+
log.error("worker_version_invalid", { runId, workflowVersionId: claimed.workflowVersionId });
|
|
52
|
+
return { kind: "failed", reason: "version_invalid" };
|
|
53
|
+
}
|
|
54
|
+
// Fetch + verify the program ARTIFACT before any work (pre-flight; an integrity failure = no charge,
|
|
55
|
+
// no run). The bytes are the EXACT artifact the manifest was derived from at deploy; verifying the
|
|
56
|
+
// sha256 here means a tampered/corrupted object can never be imported (docs/WORKFLOW_RUNTIME.md §3.9).
|
|
57
|
+
let tarball;
|
|
58
|
+
try {
|
|
59
|
+
tarball = await deps.fetchProgram(loaded.program.downloadUrl);
|
|
60
|
+
}
|
|
61
|
+
catch (err) {
|
|
62
|
+
await deps.finalizer.finalize(runId, "failed", {
|
|
63
|
+
error: { code: "PROGRAM_FETCH_FAILED", message: "Could not download the program artifact" },
|
|
64
|
+
});
|
|
65
|
+
log.error("worker_program_fetch_failed", {
|
|
66
|
+
runId,
|
|
67
|
+
error: err instanceof Error ? err.message : String(err),
|
|
68
|
+
});
|
|
69
|
+
return { kind: "failed", reason: "program_fetch_failed" };
|
|
70
|
+
}
|
|
71
|
+
if (!verifyArtifactDigest(tarball, loaded.program.digest)) {
|
|
72
|
+
await deps.finalizer.finalize(runId, "failed", {
|
|
73
|
+
error: { code: "PROGRAM_INTEGRITY", message: "Program artifact digest mismatch" },
|
|
74
|
+
});
|
|
75
|
+
log.error("worker_program_integrity", { runId, workflowVersionId: claimed.workflowVersionId });
|
|
76
|
+
return { kind: "failed", reason: "program_integrity" };
|
|
77
|
+
}
|
|
78
|
+
// deadline_seconds: a WALL-CLOCK cap from the run's ORIGINAL start (incl. suspended idle). Enforced
|
|
79
|
+
// HERE, before running — so a run RESUMED past its deadline (e.g. it slept/awaited longer than the
|
|
80
|
+
// cap) fails even when its program has NO agent() turn (the BudgetMeter's per-turn check would never
|
|
81
|
+
// fire for those). Orthogonal to max_duration_seconds (active compute, the BudgetMeter's job).
|
|
82
|
+
const deadlineSeconds = loaded.manifest.budget?.deadline_seconds;
|
|
83
|
+
if (deadlineSeconds !== undefined &&
|
|
84
|
+
claimed.startedAt !== null &&
|
|
85
|
+
Date.now() - claimed.startedAt > deadlineSeconds * 1000) {
|
|
86
|
+
await deps.finalizer.finalize(runId, "failed", {
|
|
87
|
+
error: {
|
|
88
|
+
code: "BUDGET_EXCEEDED",
|
|
89
|
+
message: `Run exceeded budget.deadline_seconds (${deadlineSeconds.toString()}s wall-clock) and was terminated.`,
|
|
90
|
+
},
|
|
91
|
+
});
|
|
92
|
+
log.info("worker_deadline_exceeded", { runId, deadlineSeconds });
|
|
93
|
+
return { kind: "failed", reason: "deadline_exceeded" };
|
|
94
|
+
}
|
|
95
|
+
// Cooperative-cancellation signal for the run. The credit watcher (and, later, user-initiated
|
|
96
|
+
// cancel) aborts it; the WorkflowHost honors it at every hook so the program unwinds.
|
|
97
|
+
const controller = new AbortController();
|
|
98
|
+
// buildHost is async (MCP-server tool discovery connects out at run start) and can FAIL — e.g. a
|
|
99
|
+
// granted MCP connection is unreachable. Finalize the run failed rather than crash the task: an
|
|
100
|
+
// escaped throw here would exit the worker → reclaim-crashed sweep → restart → same throw, a loop.
|
|
101
|
+
let built;
|
|
102
|
+
try {
|
|
103
|
+
built = await deps.buildHost(claimed, loaded.manifest, controller.signal);
|
|
104
|
+
}
|
|
105
|
+
catch (err) {
|
|
106
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
107
|
+
await deps.finalizer.finalize(runId, "failed", {
|
|
108
|
+
error: { code: "HOST_BUILD_FAILED", message },
|
|
109
|
+
});
|
|
110
|
+
log.error("worker_host_build_failed", { runId, error: message });
|
|
111
|
+
return { kind: "failed", reason: "host_build_failed" };
|
|
112
|
+
}
|
|
113
|
+
const { host, redactor, workspace, phases, activity, setProgramDir, lsp, suspendSignal } = built;
|
|
114
|
+
// Guarantee the /workspace sandbox dir exists for EVERY run (persist or not) so a program can write
|
|
115
|
+
// to /workspace without a defensive mkdir. Runs before hydrate (whose extract targets the dir).
|
|
116
|
+
// Best-effort — the image pre-creates the dir; a failure here would resurface at the program's write.
|
|
117
|
+
if (deps.ensureWorkspace !== undefined) {
|
|
118
|
+
try {
|
|
119
|
+
await deps.ensureWorkspace();
|
|
120
|
+
}
|
|
121
|
+
catch (err) {
|
|
122
|
+
log.warn("workspace_ensure_failed", {
|
|
123
|
+
runId,
|
|
124
|
+
error: err instanceof Error ? err.message : String(err),
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
// Restore the workflow's persistent /workspace before the program runs (no-op when not opted-in /
|
|
129
|
+
// self-hosted, or on a first run). Best-effort — never fails the run.
|
|
130
|
+
if (workspace !== undefined)
|
|
131
|
+
await workspace.hydrate();
|
|
132
|
+
// Token metering is PER-LEAF: each agent() leaf reports its own tokens + model to the broker, which
|
|
133
|
+
// decides `billed_by_boardwalk` per model + meters to Stripe (see leaf_executor `meterUsage`). A
|
|
134
|
+
// workflow has no run-level model, so there is no run-level token metering here.
|
|
135
|
+
// Mid-run credit watching (§15): when the org runs out of credit, abort the run cooperatively.
|
|
136
|
+
const credit = deps.startCreditWatch?.({
|
|
137
|
+
run: claimed,
|
|
138
|
+
onExhausted: () => {
|
|
139
|
+
controller.abort(new RunAbortedError("credit_exhausted"));
|
|
140
|
+
},
|
|
141
|
+
});
|
|
142
|
+
// Mid-run user-cancel watching (§6): when the user cancels, abort the run cooperatively. The host
|
|
143
|
+
// honors the abort at the next hook boundary (a `sleep` hold wakes immediately); the broker then
|
|
144
|
+
// upgrades the terminal write to `cancelled` because the run was flipped to `cancelling`.
|
|
145
|
+
const cancel = deps.startCancelWatch?.({
|
|
146
|
+
run: claimed,
|
|
147
|
+
onCancelled: () => {
|
|
148
|
+
controller.abort(new RunAbortedError("cancelled"));
|
|
149
|
+
},
|
|
150
|
+
});
|
|
151
|
+
// Lease renewal (§6): heartbeat the lease so a long run isn't reclaimed mid-flight. If the lease is
|
|
152
|
+
// definitively lost (another worker reclaimed it), abort `lease_lost` — the run stops without
|
|
153
|
+
// finalizing (the new owner owns the terminal write; see the lease_lost guard after the body).
|
|
154
|
+
const lease = deps.startLeaseRenew?.({
|
|
155
|
+
run: claimed,
|
|
156
|
+
onLost: () => {
|
|
157
|
+
controller.abort(new RunAbortedError("lease_lost"));
|
|
158
|
+
},
|
|
159
|
+
});
|
|
160
|
+
// Runtime metering (§15): flush runtime as periodic deltas from the claim, so a long/perpetual run
|
|
161
|
+
// bills as it burns (and the credit watcher sees it) instead of only at terminal. The tail is booked
|
|
162
|
+
// by `flushFinal()` after the body (on every path except a lease_lost handoff).
|
|
163
|
+
const runtimeFlush = deps.startRuntimeFlush?.({ run: claimed, startedAtMs: sessionStartMs });
|
|
164
|
+
// Capture the program's console.* as `log` run-events for the duration of the body (best-effort).
|
|
165
|
+
const restoreConsole = deps.onProgramLog !== undefined ? captureConsole(deps.onProgramLog) : () => undefined;
|
|
166
|
+
let result;
|
|
167
|
+
try {
|
|
168
|
+
result = await runWorkflowProgram({
|
|
169
|
+
runId,
|
|
170
|
+
tarball,
|
|
171
|
+
entry: loaded.program.entry,
|
|
172
|
+
input: claimed.input ?? claimed.triggerPayload,
|
|
173
|
+
config: claimed.config ?? {},
|
|
174
|
+
},
|
|
175
|
+
// redactText scrubs a thrown error's message before it is logged + finalized into run output.
|
|
176
|
+
// onOutput emits the `output` activity entry into the run's log when the program declared one.
|
|
177
|
+
{
|
|
178
|
+
host,
|
|
179
|
+
redactText: (text) => redactor.redactText(text),
|
|
180
|
+
extract: deps.extractArchive,
|
|
181
|
+
...(setProgramDir !== undefined ? { onExtracted: setProgramDir } : {}),
|
|
182
|
+
...(suspendSignal !== undefined ? { suspendSignal } : {}),
|
|
183
|
+
...(activity !== undefined
|
|
184
|
+
? {
|
|
185
|
+
onOutput: (value) => {
|
|
186
|
+
activity.output(value);
|
|
187
|
+
},
|
|
188
|
+
}
|
|
189
|
+
: {}),
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
finally {
|
|
193
|
+
restoreConsole();
|
|
194
|
+
// Stop the per-session watchers, however the program ended (success/failure/throw). Stopping the
|
|
195
|
+
// runtime flusher halts the timer only; the tail is booked by flushFinal() below (post-lease guard).
|
|
196
|
+
if (credit !== undefined)
|
|
197
|
+
await credit.stop();
|
|
198
|
+
if (cancel !== undefined)
|
|
199
|
+
await cancel.stop();
|
|
200
|
+
if (lease !== undefined)
|
|
201
|
+
await lease.stop();
|
|
202
|
+
if (runtimeFlush !== undefined)
|
|
203
|
+
await runtimeFlush.stop();
|
|
204
|
+
// Shut down the run's language server(s) on EVERY terminal path (success/failure/throw) so no
|
|
205
|
+
// language-server process leaks. `close()` is idempotent + never throws, so this best-effort call
|
|
206
|
+
// is safe (it runs before the workspace snapshot, which is the slowest step).
|
|
207
|
+
if (lsp !== undefined)
|
|
208
|
+
await lsp.close();
|
|
209
|
+
// Snapshot the final /workspace so the workflow's NEXT run hydrates it. Best-effort.
|
|
210
|
+
if (workspace !== undefined) {
|
|
211
|
+
await workspace.persist();
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
// Lease lost: another worker reclaimed this run and now owns it. Do NOT charge or finalize — the
|
|
215
|
+
// new owner books its own runtime and writes the terminal status; our work here was superseded.
|
|
216
|
+
// Exit quietly (like a lost claim), so we can't double-charge or clobber the live run.
|
|
217
|
+
if (abortReason(controller.signal) === "lease_lost") {
|
|
218
|
+
log.info("worker_lease_lost", { runId, workerId: deps.workerId });
|
|
219
|
+
return { kind: "claim_lost" };
|
|
220
|
+
}
|
|
221
|
+
// Book the runtime TAIL since the last periodic flush — every path except the lease_lost handoff
|
|
222
|
+
// above (which returns before this, so the new owner books its own runtime). The periodic flushes
|
|
223
|
+
// already booked the bulk; this captures the final partial interval.
|
|
224
|
+
if (runtimeFlush !== undefined)
|
|
225
|
+
await runtimeFlush.flushFinal();
|
|
226
|
+
// An abort is AUTHORITATIVE: even if the program swallowed RunAbortedError and returned normally,
|
|
227
|
+
// the run stops here, terminal `failed` — so the crash-sweep never restarts a credit-exhausted run.
|
|
228
|
+
if (controller.signal.aborted) {
|
|
229
|
+
const reason = abortReason(controller.signal) ?? "cancelled";
|
|
230
|
+
phases?.close("failed");
|
|
231
|
+
await deps.finalizer.finalize(runId, "failed", {
|
|
232
|
+
error: { code: "RUN_ABORTED", reason, message: `Run stopped: ${reason}` },
|
|
233
|
+
});
|
|
234
|
+
log.info("worker_run_aborted", { runId, reason });
|
|
235
|
+
return { kind: "failed", reason };
|
|
236
|
+
}
|
|
237
|
+
// Suspended: a host seam released the task (a long `sleep`, a `humanInput()` gate, or the in-leaf
|
|
238
|
+
// `human_input` tool). Persist the wake condition through the broker — NO finalize — and exit
|
|
239
|
+
// cleanly; a wake (an answer or a timer) re-dispatches the run, which restarts from the top and
|
|
240
|
+
// replays the journal past the already-done seams. The runtime tail for THIS session was booked
|
|
241
|
+
// above, so idle time while suspended is not billed. Phases stay open (the run is non-terminal).
|
|
242
|
+
if (result.kind === "suspended") {
|
|
243
|
+
if (deps.suspender === undefined) {
|
|
244
|
+
phases?.close("failed");
|
|
245
|
+
await deps.finalizer.finalize(runId, "failed", {
|
|
246
|
+
error: { code: "SUSPEND_UNSUPPORTED", message: "This runtime cannot suspend a run." },
|
|
247
|
+
});
|
|
248
|
+
log.error("worker_suspend_unsupported", { runId });
|
|
249
|
+
return { kind: "failed", reason: "suspend_unsupported" };
|
|
250
|
+
}
|
|
251
|
+
await deps.suspender.suspend(result.signal, deps.workerId);
|
|
252
|
+
log.info("worker_suspended", { runId, reason: result.signal.reason });
|
|
253
|
+
return { kind: "suspended", reason: result.signal.reason };
|
|
254
|
+
}
|
|
255
|
+
if (result.kind === "completed") {
|
|
256
|
+
phases?.close("completed");
|
|
257
|
+
await deps.finalizer.finalize(runId, "completed", result.output);
|
|
258
|
+
return { kind: "completed" };
|
|
259
|
+
}
|
|
260
|
+
phases?.close("failed");
|
|
261
|
+
await deps.finalizer.finalize(runId, "failed", { error: result.error });
|
|
262
|
+
return { kind: "failed", reason: result.error.code };
|
|
263
|
+
}
|
|
264
|
+
async function loadVersion(deps, run) {
|
|
265
|
+
const version = await deps.versions.getById(run.workflowVersionId);
|
|
266
|
+
if (version === null)
|
|
267
|
+
return null;
|
|
268
|
+
const parsed = workflowManifestSchema.safeParse(version.manifest);
|
|
269
|
+
if (!parsed.success)
|
|
270
|
+
return null;
|
|
271
|
+
return { manifest: parsed.data, program: version.program };
|
|
272
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { SecretRefManifest } from "./wire/manifest.js";
|
|
2
|
+
import type { SecretResolver } from "./tools/types.js";
|
|
3
|
+
import type { SecretRedactor } from "./agent/secret_redactor.js";
|
|
4
|
+
export declare class RecordingSecretResolver implements SecretResolver {
|
|
5
|
+
private readonly inner;
|
|
6
|
+
private readonly redactor;
|
|
7
|
+
constructor(inner: SecretResolver, redactor: SecretRedactor);
|
|
8
|
+
resolve(ref: SecretRefManifest): Promise<string>;
|
|
9
|
+
}
|