@edgehero/pi-dispatch 1.9.0 → 1.10.0

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/.env.example CHANGED
@@ -130,6 +130,15 @@ PI_JOB_IMAGE=pi-job:latest # the DEFAULT job image. Any trigger may nam
130
130
 
131
131
  PI_SCHEDULER_STALL_MAX=2 # tear down a scheduler after N consecutive stalls (money backstop)
132
132
 
133
+ # --- Container backend: where a job's container is built, and what that place guarantees (docs/backends.md) ---
134
+ # PI_BACKENDS= # which backends this deployment blesses, comma separated (default: local, the docker daemon on this host).
135
+ # # A trigger's run.backend selects among these; one that names none runs on the first, so the list must include local. An unknown name refuses to boot.
136
+ # PI_BACKEND_FLOOR= # the minimum every backend above must declare, as property=word pairs: e.g. egress=enforced,nonRoot=asserted
137
+ # # Words are enforced (this worker builds it and a test reads it back), asserted (something outside the worker provides it, unverifiable from here) or absent.
138
+ # # A floor naming a switched-off control refuses to boot: asking for egress=enforced while PI_EGRESS=0 is a bound you would believe in and not have.
139
+ # # `pi-dispatch doctor` prints enforced quietly, asserted as a warning naming who asserts it, and absent as a failure.
140
+ # # Env-only, deliberately: a bound that can be widened from the surface it bounds is not a bound.
141
+
133
142
  # --- Egress policy: what a job container may reach on the network (docs/egress.md) ---
134
143
  # ON by default. Every job runs on its own --internal Docker network with no route anywhere except an
135
144
  # allowlist proxy, and a job whose policy cannot serve it is refused BEFORE it spends a budget slot.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@edgehero/pi-dispatch",
3
- "version": "1.9.0",
3
+ "version": "1.10.0",
4
4
  "type": "module",
5
5
  "description": "Self-hosted job harness for the pi coding agent: a BullMQ worker that drains the queue, mints scoped forge tokens, and runs one container per job — plus the pi-dispatch CLI (init, up, doctor, service).",
6
6
  "keywords": [
@@ -52,7 +52,12 @@
52
52
  "./connection": "./src/connection.mjs",
53
53
  "./job-id": "./src/job-id.mjs",
54
54
  "./forges": "./src/forges.mjs",
55
- "./triggers": "./src/triggers.mjs",
55
+ "./backend-local": "./src/backend-local.mjs",
56
+ "./backends": "./src/backends.mjs",
57
+ "./backend-registry": "./src/backend-registry.mjs",
58
+ "./container-spec": "./src/container-spec.mjs",
59
+ "./backend-conformance": "./src/backend-conformance.mjs",
60
+ "./triggers": "./src/triggers.mjs",
56
61
  "./triggers-file": "./src/triggers-file.mjs",
57
62
  "./packages": "./src/packages.mjs",
58
63
  "./pause-windows": "./src/pause-windows.mjs",
@@ -0,0 +1,262 @@
1
+ /**
2
+ * THE CONFORMANCE SUITE a backend runs against itself (issue #227).
3
+ *
4
+ * Every earlier slice of this issue ends with the same sentence: the table's words are a CONTRACT and not a
5
+ * FINDING, because nothing verifies a declaration against behaviour. `backends.mjs` says so in its own
6
+ * header, `backend-local.mjs` says its completeness check "proves ARITY AND NOTHING ELSE", and
7
+ * `container-spec.mjs` says the transfer abstraction is an adapter-facing contract awaiting a consumer.
8
+ * This module is that consumer. It is what turns `enforced` from a word into something that can be wrong.
9
+ *
10
+ * IT SHIPS IN `src/`, NOT IN `test/`, and that is the whole point. A suite that lived beside this repo's own
11
+ * tests could only ever check this repo's own backend. An adapter is written elsewhere, by someone who will
12
+ * never run `npm test` here, and the question they need answered is "does MY backend hold the contract" --
13
+ * so the checks have to be importable. `docs/backends.md` is the page that tells them to import it.
14
+ *
15
+ * WHAT IT CAN AND CANNOT DO, stated at its true size. It verifies the SHAPE of a bundle, the INTERNAL
16
+ * CONSISTENCY of its declaration, and the behaviour of whatever the caller's PROBES produce. It cannot
17
+ * start a real container, reach a real daemon, or prove a kernel enforced anything.
18
+ *
19
+ * THE PROBES ARE THE ADAPTER'S OWN CODE, and that is a real limit rather than a detail. How you make a
20
+ * container exit 2, or make an enumeration fail, is the runtime's business and cannot be written
21
+ * generically -- so `checkExitCodes` and `checkAbortFlag` verify what your probe REPORTS, and a probe that
22
+ * fabricates its answer instead of routing through your `runContainer` will pass while proving nothing.
23
+ * The suite cannot detect that, which is why it is written here rather than left for a reader to discover.
24
+ * `docs/backends.md` says the same thing where an adapter author will actually read it.
25
+ *
26
+ * `OQ-012`'s line applies to the harness itself: a check that proves intent is not a check that proves
27
+ * conformance, and the difference has to be stated rather than blurred.
28
+ */
29
+
30
+ import { BACKENDS, PROPERTY_NAMES, isDeclaration } from "./backends.mjs";
31
+ import { containerSpec, copyDowngrades } from "./container-spec.mjs";
32
+
33
+ /** A finding. `ok: false` is a contract violation; `unverifiable` is a property this harness cannot reach. */
34
+ const pass = (check, detail) => ({ check, ok: true, detail });
35
+ const fail = (check, detail) => ({ check, ok: false, detail });
36
+ const abstain = (check, detail) => ({ check, ok: true, unverifiable: true, detail });
37
+
38
+ /**
39
+ * EXIT-CODE FIDELITY. The runner's integer must reach the processor unmodified.
40
+ *
41
+ * `INT-RUNNER-EXIT-CODE-PROTOCOL` rests on this and so does every retry decision: 0 is completed, 1 is
42
+ * infra (retry), 2 is policy (no retry). A backend that clamped, remapped or swallowed a code would turn a
43
+ * policy refusal into a paid retry loop, or a real failure into a clean success. The zero case matters most
44
+ * -- a backend that returns a falsy code as `null` reads as "unknown exit" downstream.
45
+ */
46
+ async function checkExitCodes(backend, { probe }) {
47
+ const out = [];
48
+ for (const code of [0, 1, 2, 3, 137, 255]) {
49
+ const got = await probe(backend, { exitCode: code });
50
+ if (got?.code !== code) {
51
+ out.push(fail("exitCodes", `a runner exit of ${code} arrived as ${JSON.stringify(got?.code)}; every retry decision reads this integer`));
52
+ return out;
53
+ }
54
+ }
55
+ out.push(pass("exitCodes", "0, 1, 2, 3, 137 and 255 all arrive unmodified"));
56
+ return out;
57
+ }
58
+
59
+ /**
60
+ * THE ABORT FLAG must be distinguishable from the exit code.
61
+ *
62
+ * `INT-RUNNER-EXIT-CODE-PROTOCOL`: a worker SIGKILL and a kernel OOM BOTH surface as 137, so the code alone
63
+ * cannot say which happened. The processor classifies an abort as POLICY (no retry) and an OOM as INFRA
64
+ * (retry), so a backend that reports only the integer makes a hung job retry forever and an OOM look like a
65
+ * deliberate stop. This is the single most transferable clause in the contract and the easiest to drop.
66
+ */
67
+ async function checkAbortFlag(backend, { probe }) {
68
+ const stopped = await probe(backend, { exitCode: 137, aborted: true });
69
+ const oomed = await probe(backend, { exitCode: 137, aborted: false });
70
+ if (stopped?.aborted !== true) return [fail("abortable", "a container stopped by the worker did not report aborted: true; 137 alone cannot be told from a kernel OOM")];
71
+ if (oomed?.aborted === true) return [fail("abortable", "a container that exited 137 on its own reported aborted: true; an OOM would be classified as a deliberate stop and never retried")];
72
+ return [pass("abortable", "the abort flag is reported independently of the exit code")];
73
+ }
74
+
75
+ /**
76
+ * THE REAPER'S TRI-STATE. `{reaped: true}` means "this host has ESTABLISHED it holds no job containers".
77
+ *
78
+ * `makeScopeClaimSweeper` gates a money decision on it: it may only delete a scope claim naming this host
79
+ * once the host has proven it holds nothing. A backend whose reap returns true after a FAILED enumeration
80
+ * frees slots for containers that may still be running and lets another host start more alongside them --
81
+ * a spend overrun rather than a tidy-up. Returning `[]` on a failed listing is the specific shape that gets
82
+ * this wrong, because an empty list and an unanswerable question look identical.
83
+ */
84
+ async function checkReaperTriState(backend, { withBrokenEnumeration }) {
85
+ if (typeof backend?.reap !== "function") return []; // the shape check already said so; do not say it twice
86
+ if (typeof withBrokenEnumeration !== "function") {
87
+ return [abstain("reap", "no `withBrokenEnumeration` probe supplied, so a failed enumeration could not be simulated")];
88
+ }
89
+ const broken = await withBrokenEnumeration(backend);
90
+ if (broken?.reaped !== false) {
91
+ return [fail("reap", `a reap whose enumeration failed returned ${JSON.stringify(broken)}; it must be {reaped:false}, or a scope sweep frees slots for containers that may still be running`)];
92
+ }
93
+ const clean = await backend.reap();
94
+ if (clean?.reaped !== true) return [fail("reap", `a successful reap returned ${JSON.stringify(clean)}; it must be {reaped:true} or no host can ever sweep its own stale claims`)];
95
+ return [pass("reap", "the tri-state distinguishes a failed enumeration from an empty one")];
96
+ }
97
+
98
+ /**
99
+ * THE BUNDLE'S SHAPE, and the name it answers to.
100
+ *
101
+ * Cheap, and it catches the mistake an adapter author actually makes: a bundle whose `name` does not match
102
+ * the table entry it declares against. The registry keys on `name` and the table keys on the same string,
103
+ * so a mismatch silently gives the adapter a different backend's declaration.
104
+ */
105
+ function checkShape(backend) {
106
+ const out = [];
107
+ // `containerName` is here because the REGISTRY calls it -- `registry.containerName(job)` builds the
108
+ // name the abort then stops. A bundle without it built at boot and threw at the first pickup, which is
109
+ // exactly the property the registry claims to have.
110
+ for (const fn of ["runContainer", "imagePreflight", "egressPreflight", "stopContainer", "reap", "containerName"]) {
111
+ if (typeof backend?.[fn] !== "function") out.push(fail("shape", `the bundle has no ${fn}()`));
112
+ }
113
+ if (!Array.isArray(backend?.neverStartedExits)) {
114
+ out.push(fail("shape", "neverStartedExits must be an array -- [] if this runtime normalises to container-never-started itself"));
115
+ } else {
116
+ // The set must not CLAIM a code the protocol already means. 137 is the OOM/SIGKILL code, so claiming
117
+ // it turns every kernel OOM into a refunded "never started" -- the container ran, spent its slot, and
118
+ // the refund hands the slot back. 0/1/2 are the runner's own completed/infra/policy codes, so claiming
119
+ // one of those converts a real outcome into an infra retry that can never resolve. Both are money.
120
+ const reserved = backend.neverStartedExits.filter((c) => [0, 1, 2, 137].includes(c));
121
+ if (reserved.length > 0) {
122
+ out.push(fail("shape", `neverStartedExits claims ${reserved.join(", ")}, which INT-RUNNER-EXIT-CODE-PROTOCOL already means (0 completed, 1 infra, 2 policy, 137 OOM/SIGKILL); claiming one refunds a slot the container actually spent`));
123
+ }
124
+ }
125
+ const entry = BACKENDS[backend?.name];
126
+ if (!entry) out.push(fail("shape", `the bundle's name ${JSON.stringify(backend?.name)} has no entry in the backend table, so nothing declares what it guarantees`));
127
+ else {
128
+ // BY VALUE, not by reference. An adapter written outside this repo cannot hold the table's own frozen
129
+ // object, and refusing a faithful copy made `checkDeclaration` below unreachable -- every bundle that
130
+ // passed shape held the table's own words, so the declaration gate could only ever restate a shape
131
+ // failure. What matters is that the words AGREE, since disagreement is what lets an adapter say one
132
+ // thing while doctor prints another.
133
+ const drift = PROPERTY_NAMES.filter((p) => backend.declares?.[p] !== entry.declares[p]);
134
+ if (drift.length > 0) out.push(fail("shape", `the bundle declares ${drift.join(", ")} differently from the table, so doctor would print one answer and the adapter believe another`));
135
+ }
136
+ return out.length > 0 ? out : [pass("shape", "six functions, a declared exit set, and a name the table knows")];
137
+ }
138
+
139
+ /**
140
+ * THE DECLARATION ITSELF, against the closed list and the three words.
141
+ *
142
+ * A backend that omits a property is not admitted for it (`backends.mjs`'s rule), and a word outside the
143
+ * vocabulary ranks with `absent`, so a typo would silently downgrade rather than fail.
144
+ */
145
+ function checkDeclaration(backend) {
146
+ const declares = backend?.declares ?? {};
147
+ const out = [];
148
+ for (const property of PROPERTY_NAMES) {
149
+ if (!isDeclaration(declares[property])) out.push(fail("declaration", `${property} is declared ${JSON.stringify(declares[property])}, which is not one of enforced/asserted/absent`));
150
+ }
151
+ for (const property of Object.keys(declares)) {
152
+ if (!PROPERTY_NAMES.includes(property)) out.push(fail("declaration", `${property} is not a property of the closed list, so nothing reads it`));
153
+ }
154
+ // An `asserted` word with no source is not actionable: it says the worker is not providing the property
155
+ // without saying who is, so an operator cannot go and check.
156
+ for (const property of PROPERTY_NAMES) {
157
+ if (declares[property] === "asserted" && !BACKENDS[backend?.name]?.asserts?.[property]) {
158
+ out.push(fail("declaration", `${property} is asserted but nothing names WHO asserts it; "not us" without "them" leaves an operator nothing to check`));
159
+ }
160
+ }
161
+ return out.length > 0 ? out : [pass("declaration", "every property carries one of the three words, and every asserted one names its source")];
162
+ }
163
+
164
+ /**
165
+ * THE TRANSFER CONTRACT, and the check that gives it teeth.
166
+ *
167
+ * This is the consumer `container-spec.mjs` was written for. A backend that COPIES rather than bind-mounts
168
+ * loses the kernel's enforcement of `/job`'s read-only mount -- `DES-JOB-FILES-VIA-VOLUME-SUBPATH` says
169
+ * `docker cp` "cannot give /job a kernel-enforced read-only mount, which INT-CONTAINER-JOB-INPUTS depends
170
+ * on" -- so a backend declaring `binds: false` and `readOnlyJobInputs: enforced` is making exactly the
171
+ * claim `CONST-EGRESS-POLICY-IN-THE-ARGV` calls worse than no claim at all.
172
+ */
173
+ function checkTransfers(backend) {
174
+ const declares = backend?.declares ?? {};
175
+ // `binds` is the adapter's own statement about how it moves files. Absent means "bind-mounts", which is
176
+ // what the local backend does and what every reader assumed before this field existed.
177
+ // FAIL-CLOSED on an unstated `binds`. An earlier draft treated "not false" as "binds", so a copying
178
+ // adapter that never set the field got a positive assertion about a property nothing had examined --
179
+ // the believed-in control, produced by the harness written to prevent it. A backend that does not say
180
+ // how it moves files has not earned the word, and this abstains rather than failing because not saying
181
+ // is a documentation gap rather than a false claim.
182
+ if (backend?.binds === undefined) {
183
+ return [abstain("readOnlyJobInputs", "this backend does not declare `binds`, so whether /job's read-only is the kernel's or a convention could not be determined; set binds: true (bind-mounts) or false (copies)")];
184
+ }
185
+ if (backend.binds === true) {
186
+ return [pass("readOnlyJobInputs", "this backend bind-mounts, so /job's read-only is the kernel's")];
187
+ }
188
+ const spec = containerSpec({ image: "i", name: "n", jobDir: "/j", workspace: "/w" });
189
+ const lost = copyDowngrades(spec).map((d) => d.container);
190
+ if (lost.length > 0 && declares.readOnlyJobInputs === "enforced") {
191
+ return [fail("readOnlyJobInputs", `this backend copies, so ${lost.join(", ")} is read-only by convention rather than by the kernel -- declaring it enforced is a control an operator would believe in and not have`)];
192
+ }
193
+ return [pass("readOnlyJobInputs", `this backend copies and declares ${JSON.stringify(declares.readOnlyJobInputs)}, which matches what a copy can hold`)];
194
+ }
195
+
196
+ /**
197
+ * Run every check against one backend bundle.
198
+ *
199
+ * `probe` is the one thing an adapter must supply: `(backend, { exitCode, aborted }) => result`, arranging
200
+ * for the backend's own `runContainer` to produce a container that exits that way. It cannot be written
201
+ * generically -- how you make a container exit 2 is the runtime's business -- and it is the reason this is a
202
+ * function taking probes rather than a fixed suite.
203
+ *
204
+ * Returns `{ ok, findings }`. `ok` is false if ANY check failed; a check that abstained does not fail the
205
+ * run but is reported, because a property nobody could verify is not a property that was verified.
206
+ */
207
+ export async function runBackendConformance(backend, probes = {}) {
208
+ try {
209
+ return await conformance(backend, probes);
210
+ } catch (error) {
211
+ // A backend whose own function THREW is a conformance failure, not a harness crash. An adapter
212
+ // author running this for the first time is the person most likely to hit it, and they need the
213
+ // message rather than a stack trace from someone else's code.
214
+ return { ok: false, findings: [fail("harness", `a backend function threw while being driven: ${error?.message ?? error}`)] };
215
+ }
216
+ }
217
+
218
+ async function conformance(backend, probes) {
219
+ const findings = [
220
+ ...checkShape(backend),
221
+ ...checkDeclaration(backend),
222
+ ...checkTransfers(backend),
223
+ ];
224
+ // A bundle that is missing a function cannot be DRIVEN, and a harness that threw here would be useless
225
+ // in exactly the case it exists for: an adapter author's first run, against a bundle they have not
226
+ // finished. The shape findings above already name what is missing.
227
+ const drivable = ["runContainer", "stopContainer", "reap"].every((fn) => typeof backend?.[fn] === "function");
228
+ if (typeof probes.probe === "function" && drivable) {
229
+ findings.push(...(await checkExitCodes(backend, probes)));
230
+ findings.push(...(await checkAbortFlag(backend, probes)));
231
+ } else if (!drivable) {
232
+ findings.push(abstain("exitCodes", "the bundle is incomplete, so nothing could be driven to an exit"));
233
+ findings.push(abstain("abortable", "the bundle is incomplete, so an abort could not be told from an OOM"));
234
+ } else {
235
+ findings.push(abstain("exitCodes", "no `probe` supplied, so no container was driven to an exit"));
236
+ findings.push(abstain("abortable", "no `probe` supplied, so an abort could not be told from an OOM"));
237
+ }
238
+ findings.push(...(await checkReaperTriState(backend, probes)));
239
+ return { ok: findings.every((f) => f.ok), findings };
240
+ }
241
+
242
+ /**
243
+ * The properties this harness DOES NOT verify, and why -- printed alongside the findings so a green run is
244
+ * never mistaken for a conformant backend.
245
+ *
246
+ * Every one of these needs a live container on the target runtime, which is exactly what this repo's own
247
+ * offline CI cannot have. Naming them is the difference between a suite that is honest about its reach and
248
+ * one that lets a green tick stand for something it never checked. `verify-image.sh` is the shape the
249
+ * missing half would take, and it "runs ON THE HOST THAT HOLDS THE IMAGE, which is the only place it can".
250
+ */
251
+ export const UNVERIFIED_BY_THIS_HARNESS = Object.freeze({
252
+ isolation: "needs a live container: read the capability set and no-new-privileges back from inside it",
253
+ ephemeral: "needs two runs of the same job id and a check that no container survived the first",
254
+ mountSet: "needs a live container: enumerate its mounts and assert nothing beyond the declared set",
255
+ egress: "needs a live container and a blocked destination",
256
+ jobToJobIsolation: "needs two live containers and an attempted connection between them",
257
+ imagePinning: "needs a run against an image absent from the target runtime",
258
+ nonRoot: "needs `id -u` inside a live container (`verify-image.sh` is the shape)",
259
+ secretsCustody: "needs a canary secret and an audit of every log, record and forge comment the run produced",
260
+ credentialTransit: "needs to observe what actually crossed the network to the runtime",
261
+ localFolders: "needs a bind-mounted host folder and a write read back outside the container",
262
+ });
@@ -0,0 +1,222 @@
1
+ /**
2
+ * THE `local` BACKEND: the Docker daemon on this worker's own host (issue #227).
3
+ *
4
+ * `backends.mjs` says WHAT each backend guarantees; this module is the first thing that has to be true.
5
+ * It bundles the functions that actually build and run a container into one value, so that "a backend" is
6
+ * a thing the worker holds rather than a shape spread across four `deps` keys nobody names together.
7
+ *
8
+ * WHY THE TABLE DOES NOT HOLD A `make()`. The obvious design is one entry per backend carrying its own
9
+ * factory, and it cannot work here: `backends.mjs` imports NOTHING on purpose, because `doctor`, the config
10
+ * loader and the receiver all have to read a declaration without pulling the Docker implementation into
11
+ * their graph. A `make()` in the table is an import edge from the leaf to every adapter, which is the leaf
12
+ * property gone. So the table declares and this module implements, and the two are joined by NAME -- the
13
+ * same split `forges.mjs` uses against the forge hosts.
14
+ *
15
+ * FIVE FUNCTIONS, and the last two arrived late on purpose. `stopContainer` was a one-line literal inside
16
+ * `index.mjs`'s `createWorker` and unreachable from `startWorker`; `reap` lived in `start.mjs` and returns a
17
+ * TRI-STATE that `makeScopeClaimSweeper` gates a money decision on. Neither could move without its reasoning
18
+ * moving too, so an earlier slice declared `abortable` in the table, named both as deferred, and REFUSED a
19
+ * bundle that tried to supply them -- because an adapter author who passes `stopContainer` and has it
20
+ * silently dropped believes a runaway job can be stopped through their backend when the abort path still
21
+ * calls docker directly. That refusal is now gone because the seam is real.
22
+ */
23
+
24
+ import { execFile } from "node:child_process";
25
+ import { promisify } from "node:util";
26
+ import { BACKENDS, DEFAULT_BACKEND, DOCKER_NEVER_STARTED_EXITS } from "./backends.mjs";
27
+
28
+ const execDocker = promisify(execFile);
29
+
30
+ /**
31
+ * `pi-job-` -- the container-name namespace, and a LOAD-BEARING string rather than a prefix chosen for
32
+ * readability. TWO sweeps match it as a SUBSTRING, both at boot in `start.mjs`: the container reaper's
33
+ * `docker ps` filter and the network reaper's `docker network ls` filter. The sandbox tooling is the
34
+ * counterpart rather than a third sweep -- it names itself `pi-sandbox-` precisely to stay OUTSIDE this
35
+ * namespace, so a worker restart cannot tear down the shell an operator is sitting in, and it reaps its own
36
+ * by job id rather than by name.
37
+ *
38
+ * Exported and imported rather than re-typed at each site for the reason `CONTAINER_GLOBAL_PI_DIR` is: the
39
+ * namespace and the filters that sweep it are ONE fact, and two literals in two modules is how a rename
40
+ * lands in the producer and not in the reaper, leaving every crashed worker's containers behind forever with
41
+ * both test suites green.
42
+ */
43
+ export const JOB_NAME_PREFIX = "pi-job-";
44
+
45
+ /**
46
+ * `pi-job-<jobId>`. The name a running job answers to, for `docker stop` on the 30-minute timeout, for the
47
+ * per-job egress network derived from it, and for the reaper's filter.
48
+ *
49
+ * Not sanitised here: BullMQ ids are already `[A-Za-z0-9._-]`, and the one place a job id comes from
50
+ * anywhere else (a sandbox) goes through `sanitizeJobId` under its own prefix.
51
+ */
52
+ export function jobContainerName(jobId) {
53
+ return `${JOB_NAME_PREFIX}${jobId}`;
54
+ }
55
+
56
+ /**
57
+ * Bundle the local backend's already-built functions into one checked value.
58
+ *
59
+ * Takes them BUILT rather than building them from config, because each is constructed in `start.mjs` from a
60
+ * different slice of the deployment and behind its own injectable factory that the wiring tests drive. This
61
+ * function's job is not to own that construction; it is to be the one place that says these functions
62
+ * together are a backend, and to REFUSE a bundle that is missing one.
63
+ *
64
+ * The refusal is worth having and worth not overselling. It catches a bundle assembled with a key MISSING or
65
+ * not callable, which is a wiring mistake that would otherwise surface as an unhelpful `undefined is not a
66
+ * function` deep inside a paid job. It proves ARITY AND NOTHING ELSE: `makeEgressPreflight({ armed: false })`
67
+ * returns a function that answers `{ ok: true }` and spawns nothing, so a bundle can pass this check with a
68
+ * gate that does no gating. `backend-conformance.mjs` does not close that either: it never invokes
69
+ * `imagePreflight` or `egressPreflight`, so a stubbed gate remains exactly what neither check can see. What
70
+ * the harness does verify is listed in its own header, and what it cannot is listed beside it.
71
+ */
72
+ /** The functions a bundle carries today. Deferred members are refused BY NAME below, never ignored. */
73
+ export const BACKEND_FUNCTIONS = ["runContainer", "imagePreflight", "egressPreflight", "stopContainer", "reap"];
74
+
75
+ /**
76
+ * Members `makeLocalBackend` SETS rather than takes. An adapter written elsewhere supplies them itself; the
77
+ * local bundle knows its own, so passing them here is refused as an unknown member like any other.
78
+ */
79
+ export const BACKEND_PROVIDED = ["name", "declares", "namePrefix", "containerName", "neverStartedExits", "binds"];
80
+
81
+ /**
82
+ * Non-function members every bundle must also carry. Separate from the list above because the completeness
83
+ * check tests callability, and these are values -- but they are just as required: `neverStartedExits` gates
84
+ * a budget REFUND, so a bundle that omitted it would keep the slot and let BullMQ retry, burning a second
85
+ * one per never-started job. That is the exact bug the explicit `case 125/126/127` was added to fix,
86
+ * reachable again by an adapter simply not setting a property.
87
+ */
88
+ export const BACKEND_VALUES = ["neverStartedExits"];
89
+
90
+ export function makeLocalBackend(parts = {}) {
91
+ const { runContainer, imagePreflight, egressPreflight, stopContainer, reap } = parts ?? {};
92
+ const missing = Object.entries({ runContainer, imagePreflight, egressPreflight, stopContainer, reap })
93
+ .filter(([, fn]) => typeof fn !== "function")
94
+ .map(([k]) => k);
95
+ if (missing.length > 0) {
96
+ throw new Error(`backend "${DEFAULT_BACKEND}": cannot build a backend missing ${missing.join(", ")}`);
97
+ }
98
+
99
+ // An unknown key is REFUSED rather than dropped, and the two this slice defers are named as deferred.
100
+ // An adapter author who supplies `stopContainer` has read the issue and reasonably expects it to be
101
+ // wired; silently ignoring it would leave them believing a runaway job can be stopped through their
102
+ // backend while the abort path still goes straight to the local docker CLI. That is the believed-in
103
+ // control again, arriving through a dropped argument.
104
+ for (const key of Object.keys(parts ?? {})) {
105
+ if (BACKEND_FUNCTIONS.includes(key) || BACKEND_VALUES.includes(key)) continue;
106
+ throw new Error(`backend "${DEFAULT_BACKEND}": unknown bundle member ${JSON.stringify(key)} (this factory takes ${BACKEND_FUNCTIONS.join(", ")} and sets ${BACKEND_PROVIDED.join(", ")} itself)`);
107
+ }
108
+
109
+ return {
110
+ name: DEFAULT_BACKEND,
111
+ // The declaration is READ from the table, never re-typed here. An adapter that stated its own
112
+ // guarantees inline could drift from what `doctor` prints and what the boot refusal checks, and an
113
+ // operator would then be told one thing by the thing that decides and another by the thing that ran.
114
+ // This is the SAME object the table holds, and that is safe only because the table is deeply FROZEN:
115
+ // an unfrozen alias would let any holder of a bundle rewrite what `doctor`, the boot refusal and the
116
+ // receiver are all told about this backend, process-wide and invisibly, while the source still read
117
+ // `enforced`. A defensive copy would hide such a mutation rather than prevent it.
118
+ declares: BACKENDS[DEFAULT_BACKEND].declares,
119
+ namePrefix: JOB_NAME_PREFIX,
120
+ containerName: jobContainerName,
121
+ runContainer,
122
+ imagePreflight,
123
+ egressPreflight,
124
+ stopContainer,
125
+ reap,
126
+ // The integers this runtime uses for "the runner never ran". The processor asks the BACKEND rather
127
+ // than assuming docker's triple, because those numbers collide with the runner's own exit channel.
128
+ neverStartedExits: LOCAL_NEVER_STARTED_EXITS,
129
+ // This runtime BIND-MOUNTS, so `/job`'s read-only is the kernel's. The conformance harness abstains
130
+ // rather than passing when a bundle does not say, because a copy downgrades that to a convention.
131
+ binds: true,
132
+ };
133
+ }
134
+
135
+ /**
136
+ * The exit codes that mean THE RUNNER NEVER RAN, as this runtime spells them.
137
+ *
138
+ * Docker's own convention, and defined in `backends.mjs` rather than here so the processor can default to
139
+ * it without importing this module. Re-exported under the local backend's name because that is what the
140
+ * bundle carries and what an adapter author reads.
141
+ */
142
+ export const LOCAL_NEVER_STARTED_EXITS = DOCKER_NEVER_STARTED_EXITS;
143
+
144
+ /**
145
+ * `docker stop` on the job's container name, fired by the abort (the 30-minute timeout or a shutdown).
146
+ *
147
+ * MOVED HERE from `index.mjs`'s `createWorker`, where it was a one-line literal inside the processor's
148
+ * construction and could not be reached from `startWorker` at all. That is why `abortable` was declared in
149
+ * the table two slices before this function existed: a second backend could have passed every other check
150
+ * with no way to stop a runaway container, and nothing in the table would have moved.
151
+ *
152
+ * `-t 5` is SIGTERM then SIGKILL after five seconds. The runner exits, `docker run` returns, and
153
+ * `runContainer`'s promise resolves -- so the abort's effect reaches the processor through the container's
154
+ * own exit rather than through this call's return value, which is why nothing awaits it.
155
+ *
156
+ * `INT-RUNNER-EXIT-CODE-PROTOCOL` is what makes this transferable: the discriminator is the abort FLAG the
157
+ * processor already holds, not the exit code, because a worker SIGKILL and a kernel OOM both surface as
158
+ * 137. An adapter implements "stop this job" however its runtime spells it and the classification is
159
+ * unchanged.
160
+ */
161
+ export function makeStopContainer({ exec = execDocker } = {}) {
162
+ return async function stopContainer(name) {
163
+ return exec("docker", ["stop", "-t", "5", name]);
164
+ };
165
+ }
166
+
167
+ /**
168
+ * Boot-time reaper: clear stray `pi-job-*` containers a previous worker crash left behind.
169
+ *
170
+ * MOVED HERE from `start.mjs` (issue #227). It belongs to the backend because the containers it sweeps are
171
+ * that backend's, and a second backend's crashed containers are unreachable by this one's `docker ps`.
172
+ *
173
+ * THE TRI-STATE IS THE POINT and moved with it: `{ reaped: true }` means this host has ESTABLISHED that it
174
+ * holds no job containers, `{ reaped: false }` means it could not establish that. `makeScopeClaimSweeper`
175
+ * gates a money decision on the difference -- it may only delete a scope claim naming this host once the
176
+ * host has proven it holds nothing -- so returning `[]` or `true` on a failed enumeration would free slots
177
+ * for containers that may still be running and let another host start more alongside them. That is a spend
178
+ * overrun rather than a tidy-up, which is why the catch below returns false rather than swallowing.
179
+ */
180
+ export function makeReaper({ log, exec = execDocker }) {
181
+ return async function reap() {
182
+ try {
183
+ const { stdout } = await exec("docker", ["ps", "--filter", `name=${JOB_NAME_PREFIX}`, "--format", "{{.Names}}"]);
184
+ const names = stdout
185
+ .split("\n")
186
+ .map((n) => n.trim())
187
+ .filter(Boolean);
188
+ for (const name of names) {
189
+ await exec("docker", ["rm", "-f", name]);
190
+ log("reaped_container", { name });
191
+ }
192
+ // REQ-EGRESS-ALLOWLIST: the per-job networks those containers were on. Swept AFTER the containers,
193
+ // because a network with a member still attached cannot be removed -- and swept by the SAME
194
+ // `pi-job-` filter, so the namespace rule that keeps an operator's live sandbox safe from the
195
+ // container reaper keeps their sandbox NETWORK safe too, with no second rule to remember.
196
+ //
197
+ // A crashed worker is the case this exists for: `runContainer`'s own finally removes the network
198
+ // on every ordinary path, so anything still here outlived a process that did not get to run it.
199
+ // A network still in use by something else fails to remove and is skipped, which is correct: this
200
+ // is a best-effort sweep and never a reason not to boot.
201
+ const { stdout: nets } = await exec("docker", ["network", "ls", "--filter", `name=${JOB_NAME_PREFIX}`, "--format", "{{.Name}}"]);
202
+ for (const net of nets.split("\n").map((n) => n.trim()).filter(Boolean)) {
203
+ try {
204
+ await exec("docker", ["network", "rm", net]);
205
+ log("reaped_network", { network: net });
206
+ } catch {} // still in use, or already gone -- either way not this boot's problem
207
+ }
208
+ // Whether the enumeration HAPPENED, which the scope-claim sweep depends on: it may only delete a
209
+ // claim naming this host once this host has actually established that it holds no containers.
210
+ return { reaped: true };
211
+ } catch (err) {
212
+ // The `docker ps` is inside this try, so this path CANNOT establish that this host holds no
213
+ // containers -- whether it failed before listing anything or after reaping some and then losing
214
+ // the daemon. Either way the claim "I hold nothing" is unproven, and sweeping on it would free
215
+ // slots for containers that may STILL BE RUNNING, letting another host start more alongside
216
+ // them: a money overrun rather than a tidy-up. Conservative in the only safe direction.
217
+ log("reaper_skipped", { reason: err?.message });
218
+ return { reaped: false };
219
+ }
220
+ };
221
+ }
222
+