Skip to content

Commit 293f45f

Browse files
committed
Phase 2 (local loop): connection, queue, prepare dispatcher, entry, CLI -- loop CLOSED
The glue that turns the tested modules into a running system. The full local loop is now verified end-to-end against real Valkey + the real image + the real provider API. - connection.mjs: BullMQ connection opts + a raw ioredis client (for the budget), both from one VALKEY_URL. maxRetriesPerRequest: null (required by BullMQ's blocking connections). - job-id.mjs: the local dedup key (content hash), kept free of any bullmq import so the dedup logic tests everywhere. NUL-delimited so field-concatenation cannot collide. - queue.mjs: makeQueue + enqueueLocalJob. Verified against real Valkey: enqueue, dedup (a same-minute double-invoke is ignored), and the exact data shape runJob consumes. - prepare.mjs: the prepareWorkspace dispatcher (local -> prepareLocalWorkspace; github throws "not in this slice"); the flow becomes a prompt hint, the skill comes from the project's materialised .pi/skills. cleanup removes the per-job dir, never the operator's folder. - start.mjs: the runnable worker -- reads config, connects, wires every REAL dep, calls createWorker (which already has the timeout, abort->docker-stop, and SIGTERM shutdown). - cli.mjs (bin: pi-dispatch): `run <folder> --task ... [--flow --provider --model --max-turns --force]` and `worker`. Refuses a dirty git working tree unless --force, because a local job edits in place with no undo (SECURITY.md). Validation returns before any queue contact. Verified: `pi-dispatch run <clean git folder> --task tidy` enqueued (exit 0); the worker picked it up (ACTIVE), materialised .pi/, RESERVED BUDGET (counter=1, proving CONST-BUDGET-BEFORE-TOKENS in the assembled system), launched the real container; the runner read the mounted prompt, found the model, called Anthropic's real API, got a genuine 401, mapped it to exit 1 (error), and the worker classified it InfraRetry -> retry. A valid key turns that 401 into a real edit. Fake key throughout -- no spend. ioredis 5.11.1 pinned (bullmq's version). CLI validation + pure dedup run everywhere; the Valkey enqueue integration test runs when VALKEY_TEST_URL is set (CI service, next commit).
1 parent c2c7ff5 commit 293f45f

10 files changed

Lines changed: 379 additions & 3 deletions

File tree

package-lock.json

Lines changed: 5 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

worker/package.json

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,15 +6,20 @@
66
"description": "BullMQ worker: drains the queue, mints scoped tokens, runs one container per job.",
77
"license": "MIT",
88
"main": "src/index.mjs",
9+
"bin": {
10+
"pi-dispatch": "src/cli.mjs"
11+
},
912
"engines": {
1013
"node": ">=22.19.0"
1114
},
1215
"scripts": {
13-
"test": "node --test \"test/*.test.mjs\""
16+
"test": "node --test \"test/*.test.mjs\"",
17+
"start": "node src/cli.mjs worker"
1418
},
1519
"dependencies": {
1620
"@earendil-works/pi-ai": "0.80.7",
1721
"@octokit/auth-app": "8.2.0",
18-
"bullmq": "5.80.4"
22+
"bullmq": "5.80.4",
23+
"ioredis": "5.11.1"
1924
}
2025
}

worker/src/cli.mjs

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
#!/usr/bin/env node
2+
import { execFileSync } from "node:child_process";
3+
import { existsSync } from "node:fs";
4+
import { resolve } from "node:path";
5+
import { parseArgs } from "node:util";
6+
import { loadConfig } from "./config.mjs";
7+
8+
const USAGE = `pi-dispatch — run pi coding-agent flows on your own folders
9+
10+
pi-dispatch run <folder> --task "<what to do>" [--flow <name>]
11+
[--provider <p>] [--model <m>] [--max-turns <n>] [--force]
12+
pi-dispatch worker drain the queue (run this in another terminal, or as a service)
13+
14+
Config comes from the environment (see .env.example); flags override it per run.`;
15+
16+
export async function main(argv = process.argv.slice(2), env = process.env) {
17+
const cmd = argv[0];
18+
19+
if (cmd === "worker") {
20+
const { startWorker } = await import("./start.mjs");
21+
startWorker(env);
22+
return 0; // the worker keeps the process alive until SIGTERM
23+
}
24+
25+
if (cmd === "run") {
26+
const { values, positionals } = parseArgs({
27+
args: argv.slice(1),
28+
allowPositionals: true,
29+
options: {
30+
task: { type: "string" },
31+
flow: { type: "string" },
32+
provider: { type: "string" },
33+
model: { type: "string" },
34+
"max-turns": { type: "string" },
35+
force: { type: "boolean", default: false },
36+
},
37+
});
38+
const folder = positionals[0] && resolve(positionals[0]);
39+
if (!folder || !existsSync(folder)) return fail(`folder not found: ${positionals[0] ?? "(none given)"}`);
40+
if (!values.task) return fail("a --task is required");
41+
42+
// A local job edits the folder IN PLACE with no undo (SECURITY.md). Refuse a dirty working
43+
// tree unless --force, so a bad run cannot mix with uncommitted work the operator can't
44+
// cleanly separate. A non-git folder is caught later by prepare (v1 requires a git repo).
45+
if (existsSync(`${folder}/.git`) && !values.force) {
46+
const dirty = gitDirty(folder);
47+
if (dirty === null) return fail(`${folder} is not a usable git repository`);
48+
if (dirty) return fail(`${folder} has uncommitted changes. Commit or stash them, or pass --force.`);
49+
}
50+
51+
const config = loadConfig(env);
52+
const { parseConnection } = await import("./connection.mjs");
53+
const { makeQueue, enqueueLocalJob } = await import("./queue.mjs");
54+
const queue = makeQueue(parseConnection(config.valkeyUrl));
55+
try {
56+
const jobId = await enqueueLocalJob(queue, {
57+
folder,
58+
task: values.task,
59+
flow: values.flow,
60+
provider: values.provider ?? config.provider,
61+
model: values.model ?? config.model,
62+
maxTurns: values["max-turns"] ? Number(values["max-turns"]) : config.maxTurns,
63+
});
64+
process.stdout.write(`queued ${jobId} — folder ${folder}\nrun \`pi-dispatch worker\` to process it.\n`);
65+
} finally {
66+
await queue.close();
67+
}
68+
return 0;
69+
}
70+
71+
process.stdout.write(`${USAGE}\n`);
72+
return cmd ? 1 : 0;
73+
}
74+
75+
function fail(message) {
76+
process.stderr.write(`error: ${message}\n`);
77+
return 1;
78+
}
79+
80+
/** true = dirty, false = clean, null = not a working git repo. */
81+
function gitDirty(folder) {
82+
try {
83+
const out = execFileSync("git", ["-C", folder, "status", "--porcelain"], { encoding: "utf8" });
84+
return out.trim().length > 0;
85+
} catch {
86+
return null;
87+
}
88+
}
89+
90+
// Entry point when run as a bin. Kept out of the exported main so tests can call main() directly.
91+
if (import.meta.url === `file://${process.argv[1]}` || process.argv[1]?.endsWith("cli.mjs")) {
92+
main().then((code) => {
93+
if (code) process.exitCode = code;
94+
});
95+
}

worker/src/connection.mjs

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import { Redis } from "ioredis";
2+
3+
/**
4+
* Connection helpers for BullMQ and the budget's raw Redis client, both from one VALKEY_URL.
5+
*
6+
* `maxRetriesPerRequest: null` is REQUIRED by BullMQ for its blocking connections (the Worker
7+
* uses BRPOPLPUSH); without it BullMQ throws at construction. It is harmless on the Queue and the
8+
* budget client, so it is set consistently.
9+
*/
10+
11+
/** BullMQ connection options parsed from a redis:// URL. */
12+
export function parseConnection(url) {
13+
const u = new URL(url);
14+
return {
15+
host: u.hostname || "127.0.0.1",
16+
port: Number(u.port || 6379),
17+
...(u.password ? { password: u.password } : {}),
18+
...(u.username ? { username: u.username } : {}),
19+
...(u.pathname && u.pathname !== "/" ? { db: Number(u.pathname.slice(1)) } : {}),
20+
maxRetriesPerRequest: null,
21+
};
22+
}
23+
24+
/** A raw ioredis client for the budget's INCR/EXPIRE. */
25+
export function makeRedisClient(url) {
26+
return new Redis(url, { maxRetriesPerRequest: null });
27+
}

worker/src/job-id.mjs

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import { createHash } from "node:crypto";
2+
3+
/**
4+
* A deterministic jobId for a local job. BullMQ's dedup is `EXISTS jobId`, so a double-invoke of
5+
* the same task within the same minute produces the same id and the duplicate is ignored -- the
6+
* local equivalent of REQ-DEDUP-BY-DELIVERY-GUID, guarding against a hasty second Enter
7+
* double-spending, without blocking a deliberate re-run a minute later.
8+
*
9+
* Kept free of any bullmq import so the dedup logic is testable everywhere, not only where the
10+
* queue's dependencies are installed.
11+
*/
12+
export function localJobId({ folder, flow, task, minute }) {
13+
// NUL-delimited so {folder:'a',task:'bc'} and {folder:'ab',task:'c'} cannot collide.
14+
const digest = createHash("sha256").update([folder, flow ?? "", task ?? "", minute].join("\0")).digest("hex");
15+
return `local-${digest.slice(0, 16)}`;
16+
}

worker/src/prepare.mjs

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import { mkdirSync, mkdtempSync } from "node:fs";
2+
import { rm } from "node:fs/promises";
3+
import { join } from "node:path";
4+
import { prepareLocalWorkspace } from "./prepare-local.mjs";
5+
6+
/**
7+
* The `prepareWorkspace` dispatcher the processor injects. Creates a per-job dir under `jobsDir`
8+
* (holding the read-only /job inputs) and routes by job kind.
9+
*
10+
* The `flow` becomes a prompt hint; the actual skill is provided by the project's materialised
11+
* .pi/skills. GitHub jobs are not implemented in this slice.
12+
*/
13+
export function makePrepareWorkspace({ jobsDir }) {
14+
mkdirSync(jobsDir, { recursive: true });
15+
return async function prepareWorkspace(job) {
16+
const jobDir = mkdtempSync(join(jobsDir, "job-"));
17+
if (job.kind === "local") {
18+
const task = job.flow ? `Use the "${job.flow}" skill for this task.\n\n${job.task ?? ""}` : (job.task ?? "");
19+
return await prepareLocalWorkspace({ folder: job.folder, task, jobDir });
20+
}
21+
if (job.kind === "github") {
22+
throw new Error("github jobs are not implemented in this slice (needs a GitHub App)");
23+
}
24+
throw new Error(`unknown job kind: ${job.kind}`);
25+
};
26+
}
27+
28+
/** Remove a per-job dir after the run. The workspace (the operator's folder) is never touched here. */
29+
export async function cleanup(prepared) {
30+
if (prepared?.jobDir) await rm(prepared.jobDir, { recursive: true, force: true });
31+
}

worker/src/queue.mjs

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import { Queue } from "bullmq";
2+
import { localJobId } from "./job-id.mjs";
3+
4+
export const QUEUE = "pi-jobs";
5+
export { localJobId };
6+
7+
export function makeQueue(connection) {
8+
return new Queue(QUEUE, { connection });
9+
}
10+
11+
/**
12+
* Enqueue a local-folder job. Returns the jobId. The data shape is what the processor's runJob
13+
* consumes (kind/folder/flow/task/provider/model/maxTurns).
14+
*
15+
* removeOnComplete keeps the dedup window ~= the retention. Unlike webhooks, local jobs are not
16+
* redelivered, so a modest window is enough.
17+
*/
18+
export async function enqueueLocalJob(queue, { folder, flow, task, provider, model, maxTurns, now = new Date() }) {
19+
const minute = now.toISOString().slice(0, 16); // YYYY-MM-DDTHH:MM -- the dedup window
20+
const jobId = localJobId({ folder, flow, task, minute });
21+
const data = { kind: "local", folder, flow, task, provider, model, maxTurns };
22+
await queue.add("local", data, {
23+
jobId,
24+
attempts: 2,
25+
backoff: { type: "exponential", delay: 60_000 },
26+
removeOnComplete: { age: 24 * 3600 },
27+
removeOnFail: { age: 7 * 24 * 3600 },
28+
});
29+
return jobId;
30+
}

worker/src/start.mjs

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
import { loadConfig } from "./config.mjs";
2+
import { makeRedisClient, parseConnection } from "./connection.mjs";
3+
import { createWorker } from "./index.mjs";
4+
import { cleanup, makePrepareWorkspace } from "./prepare.mjs";
5+
import { makeRunContainer } from "./run-container.mjs";
6+
7+
/**
8+
* The runnable worker. Reads config, connects to Valkey, wires every REAL dependency the processor
9+
* needs, and starts draining the queue. `createWorker` already installs the timeout, the
10+
* abort->docker-stop, and the SIGTERM/SIGINT graceful shutdown.
11+
*
12+
* GitHub deps (mintToken, isDefaultBranchProtected) are wired to throw: a github job reaching this
13+
* worker in this slice is a clear error, not a silent no-op.
14+
*/
15+
export function startWorker(env = process.env) {
16+
const config = loadConfig(env);
17+
const log = (event, fields = {}) => process.stdout.write(`${JSON.stringify({ event, ...fields })}\n`);
18+
19+
const worker = createWorker({
20+
connection: parseConnection(config.valkeyUrl),
21+
concurrency: config.concurrency,
22+
cap: config.dailyCap,
23+
redis: makeRedisClient(config.valkeyUrl),
24+
deps: {
25+
runContainer: makeRunContainer({ image: config.jobImage, hostEnv: env }),
26+
prepareWorkspace: makePrepareWorkspace({ jobsDir: config.jobsDir }),
27+
cleanup,
28+
comment: (job, text) => log("comment", { jobId: job?.id, text }),
29+
log,
30+
mintToken: async () => {
31+
throw new Error("github jobs are not implemented in this slice");
32+
},
33+
isDefaultBranchProtected: async () => {
34+
throw new Error("github jobs are not implemented in this slice");
35+
},
36+
},
37+
});
38+
39+
log("worker_started", {
40+
queue: "pi-jobs",
41+
concurrency: config.concurrency,
42+
dailyCap: config.dailyCap,
43+
image: config.jobImage,
44+
valkey: config.valkeyUrl,
45+
});
46+
return worker;
47+
}

worker/test/cli.test.mjs

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
import assert from "node:assert/strict";
2+
import { execFileSync } from "node:child_process";
3+
import { mkdtempSync, writeFileSync } from "node:fs";
4+
import { tmpdir } from "node:os";
5+
import { join } from "node:path";
6+
import { test } from "node:test";
7+
import { main } from "../src/cli.mjs";
8+
9+
// cli.mjs dynamic-imports bullmq (in the `run` enqueue and `worker` paths), so the VALIDATION
10+
// paths -- which return before any enqueue -- run everywhere. That is exactly the safety surface
11+
// worth testing: nothing should reach the queue if the inputs are bad.
12+
13+
const env = { VALKEY_URL: "redis://127.0.0.1:6399" };
14+
15+
test("no args prints usage and exits 0", async () => {
16+
assert.equal(await main([], env), 0);
17+
});
18+
19+
test("an unknown command exits 1", async () => {
20+
assert.equal(await main(["frobnicate"], env), 1);
21+
});
22+
23+
test("run with no folder fails", async () => {
24+
assert.equal(await main(["run"], env), 1);
25+
});
26+
27+
test("run with a missing folder fails before touching the queue", async () => {
28+
assert.equal(await main(["run", "/no/such/folder", "--task", "x"], env), 1);
29+
});
30+
31+
test("run with no --task fails", async () => {
32+
const dir = mkdtempSync(join(tmpdir(), "cli-"));
33+
assert.equal(await main(["run", dir, "--flow", "tidy"], env), 1);
34+
});
35+
36+
function gitRepo({ dirty }) {
37+
const dir = mkdtempSync(join(tmpdir(), "cli-git-"));
38+
const g = (args) =>
39+
execFileSync("git", ["-C", dir, ...args], {
40+
env: { ...process.env, GIT_AUTHOR_NAME: "t", GIT_AUTHOR_EMAIL: "t@t", GIT_COMMITTER_NAME: "t", GIT_COMMITTER_EMAIL: "t@t" },
41+
});
42+
g(["init", "-q"]);
43+
g(["config", "core.autocrlf", "false"]);
44+
writeFileSync(join(dir, "f.txt"), "one\n");
45+
g(["add", "-A"]);
46+
g(["commit", "-qm", "init"]);
47+
if (dirty) writeFileSync(join(dir, "f.txt"), "one\ntwo\n"); // uncommitted change
48+
return dir;
49+
}
50+
51+
test("run refuses a dirty git working tree (edits are in place, no undo)", async () => {
52+
const dir = gitRepo({ dirty: true });
53+
assert.equal(await main(["run", dir, "--task", "x"], env), 1);
54+
});
55+
56+
test("--force overrides the dirty-tree refusal (reaches the enqueue, which needs Valkey)", async () => {
57+
// Without Valkey the enqueue will throw; we only assert the guard did NOT stop it (it got past
58+
// validation). If a Valkey is present it returns 0. Either way it must not return 1-from-guard.
59+
const dir = gitRepo({ dirty: true });
60+
let reachedEnqueue = false;
61+
try {
62+
const code = await main(["run", dir, "--task", "x", "--force"], env);
63+
reachedEnqueue = code === 0; // Valkey present
64+
} catch {
65+
reachedEnqueue = true; // tried to connect => passed the guard
66+
}
67+
assert.ok(reachedEnqueue, "--force must let a dirty tree through to the enqueue");
68+
});

0 commit comments

Comments
 (0)