Skip to content

Commit c2c7ff5

Browse files
committed
Phase 2 (local loop): config + real runContainer
config.mjs: the worker's env config, validated and fail-loud (configError-tagged, like the runner). Conservative money defaults -- PI_DAILY_CAP=25 bounds container starts/day, cap 0 rejected (would fail closed, likelier a typo), PI_MAX_TURNS=30 (pi has none of its own), concurrency 3. Model default is the DATED claude-sonnet-4-5-20250929 for determinism per CONST-PI-VERSION-PINNED, not the floating alias. run-container.mjs: the real runContainer the processor injects. spawn (not execFile) because a non-zero exit is NORMAL -- exit 1/2 are outcomes, not errors to reject on; the code comes from the close event. It reuses buildContainerEnv (closed provider-key allowlist) and buildDockerRunArgs (isolation flags). An already-aborted signal returns 137 and never spawns a container (the timeout can fire during a slow prepare). Output streams to the operator's console so they watch the agent work on their own folder. async so a sync throw (unconfigured provider) surfaces as an awaitable rejection. Verified against the REAL image end-to-end: with a fake key forwarded, runContainer launched the container, the runner read the mounted prompt, found the model, called Anthropic's real API, got a genuine 401, and the pi-never-throws -> stopReason -> exit-code chain classified it as error -> exit 1 (retryable) with turns:1. That is the entire local execution path proven against a real provider round-trip, with a fake key so no spend. The already-aborted path returns 137, no container. CI unit tests use an injected spawn (no docker needed): argv, mounts, exit-code passthrough, and the pre-spend refusal when the provider is unconfigured.
1 parent ab02b42 commit c2c7ff5

4 files changed

Lines changed: 223 additions & 0 deletions

File tree

worker/src/config.mjs

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
/**
2+
* Worker configuration, from the environment. Validated and fail-loud: a misconfigured worker
3+
* should refuse to start with a clear message, not launch and fail per-job.
4+
*
5+
* Errors are tagged `piDispatchConfig` so the CLI/entry can print them cleanly and exit non-zero.
6+
*/
7+
8+
export function configError(message) {
9+
const error = new Error(message);
10+
error.piDispatchConfig = true;
11+
return error;
12+
}
13+
14+
function positiveInt(env, name, fallback) {
15+
const raw = env[name];
16+
if (raw === undefined || raw === "") {
17+
if (fallback !== undefined) return fallback;
18+
throw configError(`missing required env: ${name}`);
19+
}
20+
const n = Number.parseInt(raw, 10);
21+
if (!Number.isInteger(n) || n < 1 || String(n) !== String(raw).trim()) {
22+
throw configError(`invalid ${name}: ${JSON.stringify(raw)} (want a positive integer)`);
23+
}
24+
return n;
25+
}
26+
27+
/**
28+
* Parse the worker's config from `env` (default process.env). All defaults are conservative:
29+
* spend controls (`PI_DAILY_CAP`, `PI_MAX_TURNS`) exist to bound money, so they default low, and a
30+
* cap of 0 would fail closed (budget.mjs refuses every job) rather than mean "unlimited".
31+
*/
32+
export function loadConfig(env = process.env) {
33+
const model = env.PI_MODEL ?? "claude-sonnet-4-5-20250929"; // dated snapshot; deterministic per CONST-PI-VERSION-PINNED
34+
return {
35+
valkeyUrl: env.VALKEY_URL ?? "redis://127.0.0.1:6379",
36+
concurrency: positiveInt(env, "PI_CONCURRENCY", 3), // DES-CONCURRENCY-3
37+
dailyCap: positiveInt(env, "PI_DAILY_CAP", 25), // bounds container STARTS per day (money)
38+
provider: env.PI_PROVIDER ?? "anthropic",
39+
model,
40+
maxTurns: positiveInt(env, "PI_MAX_TURNS", 30), // pi has no turn limit; we impose one
41+
jobImage: env.PI_JOB_IMAGE ?? "pi-job:latest",
42+
jobsDir: env.PI_JOBS_DIR ?? defaultJobsDir(),
43+
};
44+
}
45+
46+
function defaultJobsDir() {
47+
// Under the OS temp dir by default. Holds only the read-only /job inputs (prompt + .pi/); the
48+
// workspace for a local job is the operator's own folder, not here.
49+
return `${process.env.TMPDIR ?? process.env.TEMP ?? "/tmp"}/pi-dispatch/jobs`.replace(/\\/g, "/");
50+
}

worker/src/run-container.mjs

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
import { spawn } from "node:child_process";
2+
import { buildDockerRunArgs } from "./docker-run.mjs";
3+
import { buildContainerEnv } from "./env-allowlist.mjs";
4+
5+
/**
6+
* The real `runContainer` the processor injects. Launches one job container and returns its exit
7+
* code, which INT-RUNNER-EXIT-CODE-PROTOCOL turns into retry-vs-success.
8+
*
9+
* `spawn` (not execFile) because a non-zero exit is NORMAL here: exit 1 (infra) and 2 (policy) are
10+
* expected outcomes, not errors to reject on. The exit code comes from the `close` event.
11+
*
12+
* The container is stopped on abort by the worker wiring (index.mjs onAbort -> docker stop), which
13+
* causes `docker run` to exit and this promise to resolve. We only handle the entry case here: if
14+
* the signal is ALREADY aborted (the 30-min timeout fired during a slow prepare), do not start a
15+
* container at all.
16+
*
17+
* Output is streamed to `onOutput` (default: the worker's stdout) so the operator watches the agent
18+
* work on their own machine -- the natural local UX. This is the operator's own console for their
19+
* own folder; it is not a persistent PII log.
20+
*/
21+
export function makeRunContainer({ image, hostEnv = process.env, onOutput = (c) => process.stdout.write(c), spawnFn = spawn }) {
22+
// async so a synchronous throw (e.g. buildContainerEnv on an unconfigured provider) surfaces as
23+
// a rejection, uniformly awaitable by the processor and by tests.
24+
return async function runContainer({ job, token, prepared, name, signal }) {
25+
if (signal?.aborted) return 137; // killed before it could start
26+
27+
// Closed env allowlist: only the provider key + the declared PI_* vars. Throws (config) if
28+
// the provider is unconfigured -- the processor turns that into a pre-spend refusal.
29+
const env = buildContainerEnv({
30+
provider: job.provider,
31+
model: job.model,
32+
maxTurns: job.maxTurns,
33+
jobId: name,
34+
githubToken: token ?? undefined,
35+
hostEnv,
36+
});
37+
38+
const args = buildDockerRunArgs({
39+
image,
40+
env,
41+
jobDir: prepared.jobDir,
42+
workspace: prepared.workspace,
43+
name,
44+
});
45+
46+
return await new Promise((resolve, reject) => {
47+
const child = spawnFn("docker", args, { stdio: ["ignore", "pipe", "pipe"] });
48+
child.stdout?.on("data", onOutput);
49+
child.stderr?.on("data", onOutput);
50+
child.on("error", reject); // docker not found / cannot spawn -- a real infra failure
51+
child.on("close", (code) => resolve(code ?? 1));
52+
});
53+
};
54+
}

worker/test/config.test.mjs

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
import assert from "node:assert/strict";
2+
import { test } from "node:test";
3+
import { configError, loadConfig } from "../src/config.mjs";
4+
5+
test("loads conservative defaults with an empty-ish env", () => {
6+
const c = loadConfig({});
7+
assert.equal(c.concurrency, 3);
8+
assert.equal(c.dailyCap, 25);
9+
assert.equal(c.provider, "anthropic");
10+
assert.equal(c.model, "claude-sonnet-4-5-20250929"); // dated, deterministic
11+
assert.equal(c.maxTurns, 30);
12+
assert.equal(c.valkeyUrl, "redis://127.0.0.1:6379");
13+
assert.equal(c.jobImage, "pi-job:latest");
14+
assert.ok(c.jobsDir.length > 0);
15+
});
16+
17+
test("env overrides every field", () => {
18+
const c = loadConfig({
19+
VALKEY_URL: "redis://valkey:6379",
20+
PI_CONCURRENCY: "6",
21+
PI_DAILY_CAP: "100",
22+
PI_PROVIDER: "openai",
23+
PI_MODEL: "gpt-x",
24+
PI_MAX_TURNS: "50",
25+
PI_JOB_IMAGE: "pi-job:0.1.0",
26+
PI_JOBS_DIR: "/srv/jobs",
27+
});
28+
assert.equal(c.concurrency, 6);
29+
assert.equal(c.dailyCap, 100);
30+
assert.equal(c.provider, "openai");
31+
assert.equal(c.model, "gpt-x");
32+
assert.equal(c.jobsDir, "/srv/jobs");
33+
});
34+
35+
test("a malformed integer is a config error, not a silent NaN", () => {
36+
for (const bad of ["0", "-1", "3.5", "abc", "3x"]) {
37+
assert.throws(() => loadConfig({ PI_CONCURRENCY: bad }), (e) => e.piDispatchConfig === true, `PI_CONCURRENCY=${bad}`);
38+
}
39+
});
40+
41+
test("cap 0 is rejected -- it would fail closed, and is more likely a typo than intent", () => {
42+
assert.throws(() => loadConfig({ PI_DAILY_CAP: "0" }), (e) => e.piDispatchConfig === true);
43+
});
44+
45+
test("configError is tagged for clean CLI reporting", () => {
46+
assert.equal(configError("x").piDispatchConfig, true);
47+
});

worker/test/run-container.test.mjs

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
import assert from "node:assert/strict";
2+
import { EventEmitter } from "node:events";
3+
import { test } from "node:test";
4+
5+
// run-container imports env-allowlist -> @earendil-works/pi-ai, so this skips below the node floor
6+
// and runs in CI (PI_DISPATCH_REQUIRE_WORKER_TESTS=1 makes a skip a hard failure).
7+
let mod;
8+
let importError;
9+
try {
10+
mod = await import("../src/run-container.mjs");
11+
} catch (error) {
12+
importError = error;
13+
}
14+
if (!mod && process.env.PI_DISPATCH_REQUIRE_WORKER_TESTS === "1") {
15+
throw new Error(`run-container tests are REQUIRED here but pi-ai could not import.\n${importError}`);
16+
}
17+
const skip = mod ? false : `pi-ai not installed (node ${process.version} < 22.19.0); CI runs these`;
18+
19+
const HOST = { ANTHROPIC_API_KEY: "sk-real" };
20+
const JOB = { kind: "local", provider: "anthropic", model: "m", maxTurns: 5 };
21+
const PREPARED = { workspace: "/host/folder", jobDir: "/host/jobs/j1" };
22+
23+
/** A fake `docker` child: records argv, lets the test drive its exit. */
24+
function fakeSpawn(recorder, exitCode = 0) {
25+
return (cmd, args) => {
26+
recorder.cmd = cmd;
27+
recorder.args = args;
28+
const child = new EventEmitter();
29+
child.stdout = new EventEmitter();
30+
child.stderr = new EventEmitter();
31+
queueMicrotask(() => child.emit("close", exitCode));
32+
return child;
33+
};
34+
}
35+
36+
test("an already-aborted signal returns 137 and NEVER spawns docker", { skip }, async () => {
37+
const rec = {};
38+
const runContainer = mod.makeRunContainer({ image: "pi-job:x", hostEnv: HOST, spawnFn: fakeSpawn(rec) });
39+
const ac = new AbortController();
40+
ac.abort();
41+
const code = await runContainer({ job: JOB, prepared: PREPARED, name: "j1", signal: ac.signal });
42+
assert.equal(code, 137);
43+
assert.equal(rec.cmd, undefined, "no container may start once the timeout has fired");
44+
});
45+
46+
test("launches docker with the isolation argv and returns the container's exit code", { skip }, async () => {
47+
const rec = {};
48+
const runContainer = mod.makeRunContainer({ image: "pi-job:x", hostEnv: HOST, spawnFn: fakeSpawn(rec, 2) });
49+
const code = await runContainer({ job: JOB, prepared: PREPARED, name: "j1", signal: new AbortController().signal });
50+
assert.equal(code, 2, "exit 2 (policy) is a normal outcome, not an error to reject on");
51+
assert.equal(rec.cmd, "docker");
52+
assert.ok(rec.args.includes("--cap-drop=ALL"), "isolation flags present");
53+
assert.ok(rec.args.includes("/host/jobs/j1:/job:ro"), "whole /job mounted read-only");
54+
assert.ok(rec.args.includes("/host/folder:/workspace"), "the folder is the workspace");
55+
assert.ok(rec.args.includes("ANTHROPIC_API_KEY=sk-real"), "the provider key is forwarded");
56+
});
57+
58+
test("exit 1 (infra) is returned, not thrown -- it is retryable, not a spawn error", { skip }, async () => {
59+
const runContainer = mod.makeRunContainer({ image: "pi-job:x", hostEnv: HOST, spawnFn: fakeSpawn({}, 1) });
60+
const code = await runContainer({ job: JOB, prepared: PREPARED, name: "j1", signal: new AbortController().signal });
61+
assert.equal(code, 1);
62+
});
63+
64+
test("refuses before spawning if the provider is unconfigured (pre-spend guard)", { skip }, async () => {
65+
const rec = {};
66+
const runContainer = mod.makeRunContainer({ image: "pi-job:x", hostEnv: {}, spawnFn: fakeSpawn(rec) });
67+
await assert.rejects(
68+
() => runContainer({ job: JOB, prepared: PREPARED, name: "j1", signal: new AbortController().signal }),
69+
(e) => e.piDispatchConfig === true,
70+
);
71+
assert.equal(rec.cmd, undefined, "no container for an unconfigured provider");
72+
});

0 commit comments

Comments
 (0)