@edgehero/pi-dispatch 1.10.3 → 2.1.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.
Files changed (101) hide show
  1. package/.env.example +303 -150
  2. package/README.md +52 -0
  3. package/deploy/com.pi-dispatch.worker.plist +10 -4
  4. package/deploy/docker-compose.yml +49 -16
  5. package/deploy/egress-proxy.conf +32 -2
  6. package/deploy/nssm-install.cmd +12 -6
  7. package/deploy/pi-dispatch-egress-out.network +10 -0
  8. package/deploy/pi-dispatch-egress-proxy.container +50 -0
  9. package/deploy/pi-dispatch-netns-keeper.container +80 -0
  10. package/deploy/pi-dispatch-netns-keeper.network +18 -0
  11. package/deploy/pi-dispatch-valkey.container +51 -0
  12. package/deploy/pi-dispatch-valkey.network +16 -0
  13. package/deploy/receiver.service +6 -0
  14. package/deploy/worker-env-wrapper.cmd +12 -1
  15. package/deploy/worker-env-wrapper.sh +63 -37
  16. package/deploy/worker.service +18 -8
  17. package/package.json +15 -5
  18. package/src/azure-host.mjs +19 -0
  19. package/src/azure-identity.mjs +18 -2
  20. package/src/backend-conformance.mjs +71 -18
  21. package/src/backend-local.mjs +637 -21
  22. package/src/backend-podman.mjs +1168 -0
  23. package/src/backend-registry.mjs +86 -3
  24. package/src/backends.mjs +489 -37
  25. package/src/branch.mjs +7 -2
  26. package/src/cancel-cli.mjs +174 -0
  27. package/src/cancel-state.mjs +125 -0
  28. package/src/cli.mjs +188 -90
  29. package/src/config.mjs +503 -43
  30. package/src/connection.mjs +374 -8
  31. package/src/container-spec.mjs +102 -7
  32. package/src/daemon-facts.mjs +167 -0
  33. package/src/deployment-venue.mjs +158 -0
  34. package/src/docker-run.mjs +146 -15
  35. package/src/doctor.mjs +4756 -394
  36. package/src/egress-conf-copy.mjs +166 -0
  37. package/src/egress-proxy-state.mjs +151 -0
  38. package/src/egress.mjs +456 -25
  39. package/src/entry.mjs +27 -0
  40. package/src/env-allowlist.mjs +245 -40
  41. package/src/env-file.mjs +1869 -33
  42. package/src/exit-code.mjs +15 -0
  43. package/src/flow-gate.mjs +5 -3
  44. package/src/forgejo-host.mjs +19 -0
  45. package/src/forgejo-identity.mjs +21 -2
  46. package/src/get-token.mjs +67 -18
  47. package/src/git-dirty.mjs +9 -1
  48. package/src/git-hardening.mjs +33 -0
  49. package/src/github-app-setup.mjs +29 -12
  50. package/src/github-prompt.mjs +4 -1
  51. package/src/gitlab-host.mjs +19 -0
  52. package/src/gitlab-identity.mjs +19 -2
  53. package/src/host-pi.mjs +19 -3
  54. package/src/host-registry.mjs +29 -2
  55. package/src/identity.mjs +29 -4
  56. package/src/image-preflight.mjs +46 -11
  57. package/src/image-ref.mjs +21 -0
  58. package/src/index.mjs +363 -13
  59. package/src/init.mjs +197 -38
  60. package/src/job-user.mjs +252 -0
  61. package/src/json-duplicates.mjs +204 -0
  62. package/src/live-probes.mjs +1020 -0
  63. package/src/materialize.mjs +4 -11
  64. package/src/netns-keeper.mjs +264 -0
  65. package/src/on-failure.mjs +119 -0
  66. package/src/outbox.mjs +7 -0
  67. package/src/packages.mjs +2 -2
  68. package/src/podman-stack.mjs +1304 -0
  69. package/src/prepare-github.mjs +6 -6
  70. package/src/prepare-local.mjs +51 -17
  71. package/src/prepare.mjs +27 -6
  72. package/src/pricing.mjs +9 -5
  73. package/src/processor.mjs +506 -26
  74. package/src/provider-key.mjs +66 -0
  75. package/src/provider-steering.mjs +185 -0
  76. package/src/queue.mjs +35 -8
  77. package/src/redact.mjs +84 -0
  78. package/src/reserved-env.mjs +7 -3
  79. package/src/retention-sweep.mjs +178 -0
  80. package/src/run-container.mjs +181 -14
  81. package/src/run-history.mjs +105 -16
  82. package/src/runtime-observations.mjs +1152 -0
  83. package/src/runtime-settings.mjs +13 -8
  84. package/src/sandbox-cli.mjs +100 -95
  85. package/src/sandbox-store.mjs +612 -45
  86. package/src/sandbox.mjs +1459 -37
  87. package/src/schedules.mjs +16 -3
  88. package/src/secret-profiles.mjs +2 -1
  89. package/src/secrets.mjs +24 -6
  90. package/src/service-env.mjs +247 -0
  91. package/src/service.mjs +618 -28
  92. package/src/session-store.mjs +678 -53
  93. package/src/start.mjs +1348 -326
  94. package/src/subscriptions.mjs +7 -3
  95. package/src/transient.mjs +240 -0
  96. package/src/triggers-file.mjs +71 -15
  97. package/src/triggers.mjs +179 -19
  98. package/src/up.mjs +1399 -85
  99. package/src/valkey-auth.mjs +529 -0
  100. package/src/valkey-endpoint.mjs +367 -0
  101. package/src/watch-closer.mjs +158 -0
@@ -1,5 +1,8 @@
1
1
  import { spawn } from "node:child_process";
2
- import { buildDockerRunArgs, CONTAINER_SESSION_FILE } from "./docker-run.mjs";
2
+ import { readFileSync, rmSync } from "node:fs";
3
+ import { DOCKER_NEVER_STARTED_EXITS } from "./backends.mjs";
4
+ import { CONTAINER_HOME } from "./container-spec.mjs";
5
+ import { buildDockerRunArgs, CONTAINER_SESSION_FILE, insideDir } from "./docker-run.mjs";
3
6
  import { createJobNetwork, networkNameFor, removeJobNetwork } from "./egress.mjs";
4
7
  import { buildContainerEnv } from "./env-allowlist.mjs";
5
8
  import { resolveJobImage } from "./image-preflight.mjs";
@@ -7,7 +10,7 @@ import { InfraRetry } from "./processor.mjs";
7
10
 
8
11
  /**
9
12
  * The real `runContainer` the processor injects. Launches one job container and returns
10
- * `{ code, aborted, turns, tokens, session, usage, context }`, where `aborted` records whether the WORKER initiated the stop (docker stop on
13
+ * `{ code, aborted, turns, tokens, session, usage, context, exitReason }`, where `aborted` records whether the WORKER initiated the stop (docker stop on
11
14
  * the 30-min timeout or graceful shutdown), which the processor classifies as POLICY (no retry) per
12
15
  * INT-RUNNER-EXIT-CODE-PROTOCOL. The numeric `code` alone cannot say this: a worker SIGKILL and a
13
16
  * kernel OOM both surface as 137, so the abort FLAG -- not the code -- is the discriminator.
@@ -30,7 +33,7 @@ export function makeRunContainer({
30
33
  image, // the DEPLOYMENT default (PI_JOB_IMAGE); a trigger's own run.image overrides it per job
31
34
  hostEnv = process.env,
32
35
  onOutput = (c) => process.stdout.write(c),
33
- openJobLog = () => ({ write() {}, close: async () => ({ turns: null, tokens: null, session: null, usage: null, context: null }) }),
36
+ openJobLog = () => ({ write() {}, close: async () => ({ turns: null, tokens: null, session: null, usage: null, context: null, exitReason: null }) }),
34
37
  spawnFn = spawn,
35
38
  globalPiDir = null, // REQ-GLOBAL-PI-OVERLAY: operator's global pi overlay dir, mounted :ro; null = off
36
39
  allowGlobalExtensions = true, // REQ-GLOBAL-PI-OVERLAY: the staged overlay's extensions load unless PI_GLOBAL_ALLOW_EXTENSIONS=0
@@ -43,14 +46,42 @@ export function makeRunContainer({
43
46
  forgeHosts = {}, // per-forge self-hosted instance URLs, so a forge CLI in the container talks to the right one
44
47
  egress = false, // REQ-EGRESS-ALLOWLIST: put this job on its own --internal network behind the allowlist proxy
45
48
  egressProxy, // the proxy component attached to that network; undefined = egress.mjs's default name
49
+ // Issue #345: the exits this runtime spells "never started", after which the cidfile says whether a container was
50
+ // created anyway; and the host fs the cidfile is read and removed through. Seams for the tests.
51
+ neverStartedExits = DOCKER_NEVER_STARTED_EXITS,
52
+ fs = { readFileSync, rmSync },
53
+ detachedCheck = {}, // the clock and bounds `stopDetached` takes, a seam so a test does not wait out a real 10 s
54
+ // Issue #354: the runtime's CLI and its argv builder, a PAIR. Every spawn below names `bin` -- the run, the job
55
+ // network's create and teardown, the detached check -- so a podman venue cannot run its container under one binary
56
+ // and look for it (or remove its network) under another. The defaults are the local venue's, byte-identical.
57
+ bin = "docker",
58
+ buildArgs = buildDockerRunArgs,
59
+ // Issue #452, gate round 4: `(job) => runtime | undefined`, the runtime THIS job was ADMITTED on (recorded per job),
60
+ // handed to the teardown's detach gate so the teardown never reads the daemon again; `undefined` falls back to a read.
61
+ // `log` names a teardown the gate refused, which leaves the job's network for the boot reaper.
62
+ teardownRuntime = null,
63
+ log = () => {},
46
64
  }) {
47
65
  // async so a synchronous throw (e.g. buildContainerEnv on an unconfigured provider) surfaces as
48
66
  // a rejection, uniformly awaitable by the processor and by tests.
49
- return async function runContainer({ job, token, prepared, secrets = {}, name, signal }) {
50
- if (signal?.aborted) return { code: 137, aborted: true, turns: null, tokens: null, session: null, usage: null, context: null }; // killed before it could start
67
+ // `relabel` (issue #355) is the processor's, off the same job-user answer as `user`: true where the daemon confines
68
+ // containers with SELinux, so the worker's own per-job mounts carry `:Z`. Defaults off, so a caller that predates it
69
+ // builds exactly the argv it always did.
70
+ return async function runContainer({ job, token, prepared, secrets = {}, name, signal, user = null, home = null, relabel = false }) {
71
+ if (signal?.aborted) return { code: 137, aborted: true, turns: null, tokens: null, session: null, usage: null, context: null, exitReason: null }; // killed before it could start
72
+ // Issue #341. `user` and `home` travel as a PAIR: a uid with no passwd entry in the image gets `HOME=/` from
73
+ // Docker and `HOME=/workspace` from Podman (measured), so a `--user` without this HOME is refused here rather
74
+ // than started. The builder does not insist, because `doctor --live`'s probes run `--user` with no environment.
75
+ if (user !== null && home !== CONTAINER_HOME) {
76
+ throw new Error(`runContainer: a job user (${user}) must be paired with HOME=${CONTAINER_HOME}`);
77
+ }
51
78
 
52
79
  // Closed env allowlist: only the provider key + the declared PI_* vars. Throws (config) if
53
- // the provider is unconfigured -- the processor turns that into a pre-spend refusal.
80
+ // the provider is unconfigured, which the processor turns into a policy refusal that refunds the
81
+ // reserve. That sentence was aspirational until issue #310: the processor did not read the
82
+ // `piDispatchConfig` tag at all, so the throw fell through to a bare rethrow with the budget kept.
83
+ // The common case no longer reaches here either, because the processor probes the same resolution
84
+ // among its free gates, ahead of the mint and the clone.
54
85
  const env = buildContainerEnv({
55
86
  provider: job.provider,
56
87
  model: job.model,
@@ -86,18 +117,33 @@ export function makeRunContainer({
86
117
  // before any spend (command-unregistered). Same guard shape as `flow` directly above, and
87
118
  // mutually exclusive with it by parse -- a job carries one or the other, never both.
88
119
  command: typeof job.command === "string" && job.command.trim() !== "" ? job.command : undefined,
120
+ // Issue #291: the trigger's tool denylist, off `job` like command/flow. The loader guarantees a
121
+ // non-empty validated array; the guard is the same defensive shape `flow` above wears, so a
122
+ // hand-built job with junk in the field emits no variable rather than an empty one.
123
+ excludeTools: Array.isArray(job.excludeTools) && job.excludeTools.length > 0 ? job.excludeTools : undefined,
89
124
  authFromPi, // source the provider key from pi's auth.json when the env has none
90
125
  // REQ-TRIGGER-SECRETS: this trigger's resolved secrets, fetched by the processor BEFORE anything
91
126
  // spent. Off the call bag rather than off `job` or the closure: it is neither a per-job fact the
92
127
  // record may carry nor a deployment setting, it is a live credential, and `token` is its precedent.
93
128
  secrets,
129
+ home: user !== null ? home : null, // issue #341: HOME only beside --user, assigned after the forward loops
94
130
  });
95
131
 
96
132
  // `-net` on this container's own name (egress.mjs). null when no policy is armed, and docker-run's
97
133
  // guard then omits the flag entirely, so the argv is byte-identical to one built before this feature.
98
134
  const network = egress ? networkNameFor(name) : null;
99
135
 
100
- const args = buildDockerRunArgs({
136
+ // Issue #345: BESIDE the job directory, never inside the `/job:ro` mount. Removed first because the docker CLI refuses
137
+ // to start with an existing cidfile; every attempt's job dir is a fresh mkdtemp, so a leftover is not expected, and
138
+ // this keeps one from turning into a start failure if a caller ever reuses a directory.
139
+ const cidFile = `${prepared.jobDir}.cid`;
140
+ try {
141
+ fs.rmSync(cidFile, { force: true });
142
+ } catch {
143
+ // an unremovable stale file makes the run itself fail to start, which is reported as that
144
+ }
145
+
146
+ const args = buildArgs({
101
147
  // Same split as packagePaths above: the per-job value off `job`, the deployment value off the closure,
102
148
  // so a trigger can name its own toolchain (INT-TRIGGERS-FILE-CONTRACT). Resolved through the SAME
103
149
  // function the pre-spend preflight uses (image-preflight.mjs), so the tag that was checked is the tag
@@ -113,6 +159,15 @@ export function makeRunContainer({
113
159
  globalPiDir, // undefined/null -> docker-run's guard skips the /opt/pi-global mount
114
160
  name,
115
161
  network, // REQ-EGRESS-ALLOWLIST: null when no policy is armed, and the flag is then absent
162
+ user, // issue #341: the worker's own "<uid>:<gid>" on a daemon that enforces bind-mount ownership, else null
163
+ cidFile, // issue #345: where the CLI writes this attempt's container ID, read below when the run exits "never started"
164
+ // Issue #355. `=== true`, so only the processor's boolean re-owns anything. The workspace is relabelled only when the
165
+ // worker made it: a forge job's is its own clone under the job dir, a local job's IS the operator's folder, which a
166
+ // private label would take from every other container and from the operator's own labelling.
167
+ relabel: relabel === true,
168
+ // Both facts, the kind the preparers branch on AND containment in this job's own directory, so a kind added later
169
+ // that works on a folder in place fails closed rather than relabelling it.
170
+ workspaceOwned: job?.kind !== "local" && insideDir(prepared.jobDir, prepared.workspace),
116
171
  });
117
172
 
118
173
  // REQ-EGRESS-ALLOWLIST. This job's own --internal network, created here rather than at boot because
@@ -123,7 +178,7 @@ export function makeRunContainer({
123
178
  //
124
179
  // A failure to build it is INFRA, not policy: nothing has been spent, a retry may well succeed, and
125
180
  // `container-never-started` is literally true, so the reservation is given back (processor.mjs).
126
- if (network && !(await createJobNetwork(spawnFn, { network, proxy: egressProxy }))) {
181
+ if (network && !(await createJobNetwork(spawnFn, { network, proxy: egressProxy, bin }))) {
127
182
  throw new InfraRetry("container-never-started", { reason: "container-never-started" });
128
183
  }
129
184
 
@@ -132,7 +187,7 @@ export function makeRunContainer({
132
187
  const sink = openJobLog(name);
133
188
 
134
189
  const run = new Promise((resolve, reject) => {
135
- const child = spawnFn("docker", args, { stdio: ["ignore", "pipe", "pipe"] });
190
+ const child = spawnFn(bin, args, { stdio: ["ignore", "pipe", "pipe"] });
136
191
  // A throwing sink.write is swallowed so a misbehaving sink cannot break the tee or hang the run.
137
192
  const tee = (chunk) => {
138
193
  onOutput(chunk);
@@ -151,25 +206,31 @@ export function makeRunContainer({
151
206
  });
152
207
  child.on("close", async (code) => {
153
208
  const aborted = signal?.aborted === true; // capture BEFORE the await
154
- // A rejecting sink.close is swallowed so a misbehaving sink cannot hang the run; turns/tokens/session/usage/context fall back to null.
209
+ // A rejecting sink.close is swallowed so a misbehaving sink cannot hang the run; turns/tokens/session/usage/context/exitReason fall back to null.
155
210
  let turns = null;
156
211
  let tokens = null;
157
212
  let session = null;
158
213
  let usage = null;
159
214
  let context = null;
215
+ // Issue #437: the runner's exit-2 reason, already filtered by parseExitReason to the closed
216
+ // RUNNER_POLICY_REASONS set and to a line that itself said code 2. The processor still decides
217
+ // the retry class from `code` alone; this only picks the label inside exit 2.
218
+ let exitReason = null;
160
219
  try {
161
220
  // `context = null` is a DEFAULT rather than a plain destructure: an injected sink that
162
221
  // predates the field returns no such key, and `undefined` would then reach the record's
163
222
  // shape where every other absence is spelled `null`.
164
- ({ turns, tokens, session, usage, context = null } = await sink.close());
223
+ // `exitReason` defaults the same way, for the same reason.
224
+ ({ turns, tokens, session, usage, context = null, exitReason = null } = await sink.close());
165
225
  } catch {
166
226
  turns = null;
167
227
  tokens = null;
168
228
  session = null;
169
229
  usage = null;
170
230
  context = null;
231
+ exitReason = null;
171
232
  }
172
- resolve(aborted ? { code: code ?? 137, aborted: true, turns, tokens, session, usage, context } : { code: code ?? 1, aborted: false, turns, tokens, session, usage, context });
233
+ resolve(aborted ? { code: code ?? 137, aborted: true, turns, tokens, session, usage, context, exitReason } : { code: code ?? 1, aborted: false, turns, tokens, session, usage, context, exitReason });
173
234
  });
174
235
  });
175
236
 
@@ -177,9 +238,115 @@ export function makeRunContainer({
177
238
  // container has already exited, its code is the job's answer, and a teardown fault must not rewrite
178
239
  // that answer. What a failure leaves behind is a memberless network, which the boot reaper sweeps.
179
240
  try {
180
- return await run;
241
+ const result = await run;
242
+ // Issue #345: an exit that says "never started" is checked against the cidfile BEFORE the network goes, so a
243
+ // container found running is stopped while its network still exists. Only when the worker did not abort it.
244
+ if (!result.aborted && (neverStartedExits ?? []).includes(result.code) && (await stopDetached({ spawnFn, cidFile, fs, bin, ...detachedCheck }))) {
245
+ return { ...result, detached: true };
246
+ }
247
+ return result;
181
248
  } finally {
182
- if (network) await removeJobNetwork(spawnFn, { network, proxy: egressProxy });
249
+ if (network) {
250
+ await removeJobNetwork(spawnFn, {
251
+ network,
252
+ proxy: egressProxy,
253
+ bin,
254
+ ...(typeof teardownRuntime === "function" ? { readRuntime: async () => teardownRuntime(job) } : {}),
255
+ onRefused: (reason) => log("job_network_not_removed", { network, reason }),
256
+ });
257
+ }
258
+ try {
259
+ fs.rmSync(cidFile, { force: true });
260
+ } catch {
261
+ // best effort: a leftover file is removed before this job's next attempt starts
262
+ }
183
263
  }
184
264
  };
185
265
  }
266
+
267
+ /** The detached check's whole bound, retries included, so a daemon that stopped answering cannot hold the slot for long. */
268
+ export const DETACHED_CHECK_TIMEOUT_MS = 10_000;
269
+ /** The least time any single step of it is given, so the last one tried at the deadline is not killed before it is sent. */
270
+ export const DETACHED_MIN_STEP_MS = 5_000;
271
+
272
+ /**
273
+ * Whether THIS attempt created a container that is still there after `docker run` exited "never started" (issue #345), and
274
+ * if so stop and remove it by ID. Measured on rootful Podman 5.8.2: killing the API service mid-job made the docker CLI
275
+ * exit 125 while the container kept running, outside every abort and every refund.
276
+ *
277
+ * The cidfile is the evidence it was THIS attempt's: the docker CLI writes the ID right after a successful create and
278
+ * removes the file when nothing was created, so a plain name conflict (another attempt's live container) leaves no ID and
279
+ * is never touched. On Podman the service also deletes the file when it removes the container (measured), which reads
280
+ * as nothing to check, the same as a container already gone. With an ID, `ps -a --no-trunc --filter id=` answers
281
+ * (`--no-trunc`, because `{{.ID}}` prints 12 characters and the cidfile holds 64):
282
+ * - nothing at all: nothing runs on, so never started, as before;
283
+ * - this ID listed `created`: created and never started, so it is removed and still counts as never started;
284
+ * - this ID in any other state, any other output (a daemon that ignored `--no-trunc`), or `ps` failing or timing out:
285
+ * this attempt's container may be running, so it is stopped and removed best effort, and the run is DETACHED (it
286
+ * did start, so its slot is not refunded).
287
+ *
288
+ * A `ps`, `stop` or `rm` that fails is ASKED AGAIN until `timeoutMs` has passed since the check began: measured with
289
+ * Podman's service SIGKILLed, the CLI exits 125 within tens of milliseconds and a single `ps` meets a refused connection,
290
+ * so a restarting service would never be asked to stop the container. Each try is bounded by what is left of the deadline,
291
+ * but never below `minStepMs`, so the step that runs at the deadline (a `stop` that waited out its grace period, then the
292
+ * `rm -f`) still has time to reach the daemon. Worst case: the deadline plus two of those.
293
+ */
294
+ export async function stopDetached({ spawnFn, cidFile, fs, timeoutMs = DETACHED_CHECK_TIMEOUT_MS, minStepMs = DETACHED_MIN_STEP_MS, now = () => Date.now(), delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms)), retryMs = 500, run = dockerStep, bin = "docker" }) {
295
+ let id;
296
+ try {
297
+ id = String(fs.readFileSync(cidFile, "utf8")).trim();
298
+ } catch {
299
+ return false;
300
+ }
301
+ if (!/^[0-9a-f]{64}$/.test(id)) return false;
302
+ const deadline = now() + timeoutMs;
303
+ // One step, asked again while it fails and the check's own deadline has not passed; each try is bounded by what is left.
304
+ const step = async (args) => {
305
+ for (;;) {
306
+ const result = await run(spawnFn, args, Math.max(minStepMs, deadline - now()), bin);
307
+ if (result.code === 0 || now() + retryMs >= deadline) return result;
308
+ await delay(retryMs);
309
+ }
310
+ };
311
+ const listed = await step(["ps", "-a", "--no-trunc", "--filter", `id=${id}`, "--format", "{{.ID}} {{.State}}"]);
312
+ const lines = listed.stdout.split(/\r?\n/).map((l) => l.trim()).filter(Boolean);
313
+ if (listed.code === 0 && lines.length === 0) return false;
314
+ const line = lines.find((l) => l.startsWith(`${id} `));
315
+ if (listed.code === 0 && lines.length === 1 && line && /^created$/i.test(line.slice(id.length + 1).trim())) {
316
+ await step(["rm", "-f", id]);
317
+ return false;
318
+ }
319
+ await step(["stop", id]);
320
+ await step(["rm", "-f", id]);
321
+ return true;
322
+ }
323
+
324
+ /** One bounded CLI step under `bin`: `{ code, stdout }`, `code: null` when it could not run or overran. Never throws. */
325
+ function dockerStep(spawnFn, args, timeoutMs, bin = "docker") {
326
+ return new Promise((resolve) => {
327
+ let child;
328
+ try {
329
+ child = spawnFn(bin, args, { stdio: ["ignore", "pipe", "ignore"] });
330
+ } catch {
331
+ resolve({ code: null, stdout: "" });
332
+ return;
333
+ }
334
+ let stdout = "";
335
+ let done = false;
336
+ const finish = (code) => {
337
+ if (done) return;
338
+ done = true;
339
+ clearTimeout(timer);
340
+ resolve({ code, stdout });
341
+ };
342
+ const timer = setTimeout(() => {
343
+ try {
344
+ child.kill("SIGKILL");
345
+ } catch {}
346
+ finish(null);
347
+ }, timeoutMs);
348
+ child.stdout?.on("data", (d) => (stdout += d));
349
+ child.on("error", () => finish(null));
350
+ child.on("close", (code) => finish(code));
351
+ });
352
+ }
@@ -1,5 +1,7 @@
1
1
  import * as nodeFs from "node:fs";
2
+ import { scrubCredentials } from "./redact.mjs";
2
3
  import { basename, join } from "node:path";
4
+ import { resolveBackendName } from "./backend-registry.mjs";
3
5
  import { isForgeKind, targetSeparator } from "./forges.mjs";
4
6
 
5
7
  /**
@@ -15,7 +17,7 @@ import { isForgeKind, targetSeparator } from "./forges.mjs";
15
17
  * with a fake and no disk. `makeLogSink` streams a job's raw output to a per-job `.log` and recovers the
16
18
  * turn count from a bounded tail; `makeRecordWriter` serialises a finished run to a JSON sidecar;
17
19
  * `makeFindPreviousRun` reads a scheduler's most recent prior sidecar back; `makeLogReaper` sweeps aged
18
- * `.log`/`.json` files at boot.
20
+ * `.log`/`.json` files at boot and on the retention timer (issue #292).
19
21
  */
20
22
 
21
23
  /**
@@ -59,7 +61,7 @@ export function sanitizeJobId(id) {
59
61
  * END (a mid-write death) has no complete object and is skipped too. A fragment is never MISREAD as a
60
62
  * value: a broken one does not parse and an anchorless one is not repaired.
61
63
  *
62
- * NEVER throws, like the five scanners that call it.
64
+ * NEVER throws, like the six scanners that call it.
63
65
  */
64
66
  function parseTailLine(line) {
65
67
  try {
@@ -82,13 +84,18 @@ function parseTailLine(line) {
82
84
  * Recover the agent's turn count from buffered container stdout, or `null` if it is not reported.
83
85
  *
84
86
  * The stream interleaves docker/agent noise and other JSON events (`pi_auto_retry`) with the runner's
85
- * own lines. Only the success exit line carries `turns` (`image/runner/run-job.mjs:263`); the
86
- * catch-path exit line (`:277`) omits it. Scan from the end and return the turns of the last `exit`
87
- * event that reports an integer count, repairing a glued line on the way (`parseTailLine`).
87
+ * own lines. Only the decided-outcome exit line carries `turns` (`image/runner/run-job.mjs:431`); the
88
+ * catch-path exit line (`:446`) omits it. The decided line's `retryTurns` (issue #449, pi's own
89
+ * auto-retry turns, which the turn budget does not count) is not recovered here: it is diagnostic, read
90
+ * from the container log, and the record's `turns` stays the budgeted count. Scan from the end and
91
+ * return the turns of the last `exit` event that reports an integer count, repairing a glued line on
92
+ * the way (`parseTailLine`).
88
93
  *
89
94
  * This is read-only telemetry: it MUST NEVER throw and MUST NOT feed exit-code or retry
90
95
  * classification -- that is the container exit code's job (INT-RUNNER-EXIT-CODE-PROTOCOL). Every parse
91
- * is guarded; a truncated or non-JSON line is skipped.
96
+ * is guarded; a truncated or non-JSON line is skipped. The one exit-line field that reaches the
97
+ * terminal outcome at all is `parseExitReason`'s, and it cannot move a job between retry classes: the
98
+ * container exit code alone decides the class, and that parsed reason only picks a label INSIDE exit 2.
92
99
  */
93
100
  export function parseExitTurns(text) {
94
101
  if (typeof text !== "string") return null;
@@ -103,6 +110,43 @@ export function parseExitTurns(text) {
103
110
  return null;
104
111
  }
105
112
 
113
+ /**
114
+ * The runner's exit-2 reasons the worker names in the record and the status comment (issue #437). CLOSED
115
+ * and EXPORTED because three lookups key off it and each would fail quietly on a member it lacks: the
116
+ * processor comments `TERMINAL_COMMENTS[reason]` (a missing row posts `undefined`), start.mjs builds
117
+ * `HOOK_POLICY_REASONS` by spreading this set (so a member cannot be left unpaged), and parseExitReason
118
+ * below admits nothing else. A test requires a TERMINAL_COMMENTS row per member. The runner writes the
119
+ * literal in `image/runner/src/outcome.mjs`, which the shipped worker cannot import, so a test reads that
120
+ * source and requires every member here to appear there verbatim.
121
+ */
122
+ export const RUNNER_POLICY_REASONS = new Set(["provider-auth-refused"]);
123
+
124
+ /**
125
+ * The reason off the LAST runner exit line, or `null`: a member of `RUNNER_POLICY_REASONS` and only when
126
+ * that same line says `code: 2`. Scanned from the end exactly as `parseExitTurns` is, repairing a glued
127
+ * line through `parseTailLine`, and NEVER throws.
128
+ *
129
+ * Why this may reach the outcome when its five siblings may not: it cannot change the retry class. The
130
+ * processor consults it only inside its container-exit-2 branch, so a container that exits 1 while
131
+ * printing `{"event":"exit","code":2,"reason":"provider-auth-refused"}` is still InfraRetry, and the
132
+ * worst a forged line can do on a real exit 2 is swap one not-retried label for another from a closed
133
+ * set. The `code === 2` check on the line itself is what keeps a stale or mismatched line (a runner
134
+ * that said 1 in its own words) from naming the outcome. Only the LAST exit event counts, because an
135
+ * earlier one in the tail is not the one the runner exited on. A fixed enum, so the PII-free record stays so.
136
+ */
137
+ export function parseExitReason(text) {
138
+ if (typeof text !== "string") return null;
139
+ const lines = text.split("\n");
140
+ for (let i = lines.length - 1; i >= 0; i--) {
141
+ const line = lines[i].trim();
142
+ if (line === "") continue;
143
+ const parsed = parseTailLine(line);
144
+ if (parsed?.event !== "exit") continue;
145
+ return parsed?.code === 2 && RUNNER_POLICY_REASONS.has(parsed?.reason) ? parsed.reason : null;
146
+ }
147
+ return null;
148
+ }
149
+
106
150
  /**
107
151
  * Recover the agent's token usage from buffered container stdout, or `null` if it is not reported.
108
152
  *
@@ -126,7 +170,16 @@ export function parseExitTurns(text) {
126
170
  * beside the store because this module is where the container's copy is admitted, and an enum that lives
127
171
  * anywhere but the admission point is a comment, not a check.
128
172
  */
129
- const SESSION_REASONS = new Set([
173
+ /**
174
+ * EXPORTED so the enum has ONE home. It is written out in five places -- here, the record shape and the
175
+ * producer rows in `INT-RUN-HISTORY-FILE-CONTRACT`, and the two tables in `docs/sessions.md` -- and until
176
+ * issue #375's review rounds nothing compared them: an invented token added here survived the whole suite,
177
+ * and a token removed was caught only because a test restated the list by hand. `session-reasons.test.mjs`
178
+ * derives the record-shape enum and both operator tables from this set. THE PRODUCER ROWS ARE NOT DERIVED
179
+ * and nothing checks them, which is said here rather than left to be assumed: they say which PATH can emit
180
+ * each token, and this set knows only the vocabulary.
181
+ */
182
+ export const SESSION_REASONS = new Set([
130
183
  "resumed",
131
184
  "absent",
132
185
  "expired",
@@ -136,7 +189,11 @@ const SESSION_REASONS = new Set([
136
189
  "too-large",
137
190
  "unparseable",
138
191
  "not-a-regular-file",
192
+ "key-not-a-directory",
193
+ "transcript-diverted",
194
+ "venue-changed",
139
195
  "pi-version-changed",
196
+ "transcript-replaced",
140
197
  "locked",
141
198
  "promote-failed",
142
199
  "disabled",
@@ -374,11 +431,11 @@ function rebuildUsage(u) {
374
431
  * path embeds the operator's OS account name.
375
432
  *
376
433
  * `reason` is a fixed enum passthrough (worker-abort | over-budget | unprotected-branch |
377
- * runner-policy | job-image-missing | egress-proxy-missing | ...), never free-form or payload text. `exitCode`, `turns`, and `budgetReserved`
434
+ * runner-policy | provider-auth-refused | job-image-missing | egress-proxy-missing | ...), never free-form or payload text. `exitCode`, `turns`, and `budgetReserved`
378
435
  * default to `null` when the outcome does not carry them, so the record shape is stable whether or not
379
436
  * the source reports those fields.
380
437
  */
381
- export function buildRecord({ job, result, error, startedAt, endedAt, host = null }) {
438
+ export function buildRecord({ job, result, error, startedAt, endedAt, host = null, defaultBackend = null }) {
382
439
  const data = job.data ?? {};
383
440
  const kind = data.kind ?? job.name;
384
441
  const source = result ?? error ?? {};
@@ -408,7 +465,12 @@ export function buildRecord({ job, result, error, startedAt, endedAt, host = nul
408
465
  provider: source.provider ?? null,
409
466
  model: source.model ?? null,
410
467
  budgetReserved: source.budgetReserved ?? null,
411
- attempt: job.attemptsMade ?? 0,
468
+ // The ATTEMPT NUMBER, 1-based: the first run of a job is 1, its retry 2 (ledger item, issue #464 round). Every
469
+ // record is written while the job is still processing, where BullMQ's `attemptsMade` counts the attempts FINISHED
470
+ // before this one (it increments in moveToFinished/moveToFailed, measured against bullmq 5.80.4), so it read 0 on
471
+ // a first attempt and 1 on the last of two, and the panel's "attempt 1" named the retry. +1 is the number an
472
+ // operator reads, and what the worker's own `job_failed` log line already shows (logged after the increment).
473
+ attempt: (Number.isInteger(job.attemptsMade) && job.attemptsMade >= 0 ? job.attemptsMade : 0) + 1,
412
474
  // Chain telemetry (INT-RUN-HISTORY-FILE-CONTRACT): additive and nullable, explicit literals, no spread.
413
475
  // parentJobId/chainDepth come from a chained child's own job.data; chainRefused counts a PARENT's
414
476
  // /outbox requests that were refused. A chain refusal is pre-enqueue of the child, so the `reason` enum
@@ -463,6 +525,26 @@ export function buildRecord({ job, result, error, startedAt, endedAt, host = nul
463
525
  // Passed in rather than read from a module-level value, so `buildRecord` stays pure and every
464
526
  // existing caller keeps getting `null` without knowing this field exists.
465
527
  host,
528
+ // Which VENUE it resolved to (issue #277). Additive, an explicit literal, TAIL position after `host`,
529
+ // on host's own argument: field order is the serialisation order, so the tail leaves twenty-five
530
+ // existing positions untouched. Unconditional, on the same `tokens`/`usage`/`session` precedent.
531
+ //
532
+ // THE RESOLVED NAME, NEVER THE RAW FIELD. `data.backend` is ABSENT for every trigger that names no
533
+ // venue, which is nearly all of them, and an absent key would mean "whatever the default was that
534
+ // day" -- exactly what a record exists not to mean. `resolveBackendName` is the registry's own
535
+ // expression, so what this records is what dispatch chose rather than a second opinion of it.
536
+ //
537
+ // ON A JOB REFUSED BEFORE ANY CONTAINER, it is still the venue the job RESOLVED to, not a claim that a
538
+ // container ran there; `outcome` and `reason` say whether one did, the way `provider`/`model` above
539
+ // still attribute a catch-path death. A `backend-unblessed` refusal therefore records the unblessed
540
+ // name -- the default would be false (the registry never falls back) and a null would discard the
541
+ // one fact that refusal is about.
542
+ //
543
+ // ADMISSIBLE for the reason `processor.mjs` gives where it logs the same name: a backend name is
544
+ // operator-authored config checked against a charset at load, never payload. `null` only where no
545
+ // default was passed (a dependency-injection seam; `recordRun` in start.mjs always passes one) or
546
+ // where the job data carries an explicit null, which no producer writes and the blessed gate refuses.
547
+ backend: resolveBackendName(data, defaultBackend),
466
548
  };
467
549
  }
468
550
 
@@ -544,7 +626,11 @@ export function makeLogSink({ logsDir, enabled, fs = nodeFs, log = () => {} }) {
544
626
  }
545
627
 
546
628
  async function close({ timeoutMs = 2000 } = {}) {
547
- // Capture turns/tokens/session/usage/context from the tail first, so they survive even if the flush errors or times out.
629
+ // Capture turns/tokens/session/usage/context/exitReason from the tail first, so they survive even if the flush errors or times out.
630
+ // The tail accumulates whether or not `enabled` is set, which is what keeps exitReason in the record
631
+ // when raw logs are off: a label that vanished with log capture would make the record depend on an
632
+ // opt-in PII switch.
633
+ const exitReason = parseExitReason(tail);
548
634
  const turns = parseExitTurns(tail);
549
635
  const tokens = parseExitTokens(tail);
550
636
  const session = parseExitSession(tail);
@@ -573,7 +659,7 @@ export function makeLogSink({ logsDir, enabled, fs = nodeFs, log = () => {} }) {
573
659
  } catch (err) {
574
660
  log("log_sink_error", { jobId, reason: err?.message });
575
661
  }
576
- return { turns, tokens, session, usage, context };
662
+ return { turns, tokens, session, usage, context, exitReason };
577
663
  }
578
664
 
579
665
  return { write, close };
@@ -664,8 +750,11 @@ export function makeFindPreviousRun({ logsDir, fs = nodeFs }) {
664
750
  }
665
751
 
666
752
  /**
667
- * The durable log reaper: a boot-time sweep that deletes `.log` and `.json` history files older than
668
- * the retention window, keeping the logs directory bounded across restarts.
753
+ * The durable log reaper: an age sweep that deletes `.log` and `.json` history files older than the
754
+ * retention window, keeping the logs directory bounded. Runs at boot AND on the retention timer since
755
+ * issue #292 (`PI_SWEEP_INTERVAL_HOURS`), because a worker that never restarts never re-swept. It holds
756
+ * no state between calls, so it is safe to re-run; deletes are per FILE, so no single call is unbounded
757
+ * the way the sandbox reaper's tree deletes are.
669
758
  *
670
759
  * Fault isolation is the contract, mirroring `makeReaper` in `start.mjs`: `reapLogs` NEVER throws under
671
760
  * any input. A missing logs directory on first boot (`readdirSync` ENOENT), an unreadable entry, or an
@@ -693,11 +782,11 @@ export function makeLogReaper({ logsDir, retentionDays, fs = nodeFs, log = () =>
693
782
  log("reaped_log", { file: name });
694
783
  }
695
784
  } catch (err) {
696
- log("log_reaper_skipped", { file: name, reason: err?.message });
785
+ log("log_reaper_skipped", { file: name, reason: scrubCredentials(err?.message) });
697
786
  }
698
787
  }
699
788
  } catch (err) {
700
- log("log_reaper_skipped", { reason: err?.message });
789
+ log("log_reaper_skipped", { reason: scrubCredentials(err?.message) });
701
790
  }
702
791
  };
703
792
  }