@edgehero/pi-dispatch 0.1.1 → 0.2.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/src/service.mjs CHANGED
@@ -3,12 +3,27 @@
3
3
  *
4
4
  * Durable running used to mean hand-editing the per-OS examples in deploy/. This module reads those
5
5
  * SAME files from the package and substitutes a documented table of their known literals —
6
- * `/usr/bin/node` → `process.execPath`, `/opt/pi-dispatch` → the real repo root — rather than
6
+ * `/usr/bin/node` → `process.execPath`, `/opt/pi-dispatch` → the deployment folder — rather than
7
7
  * introducing a `{{placeholder}}` dialect. That keeps the deploy/ files byte-usable examples (and
8
8
  * deploy-lint keeps parsing exactly what ships); TEMPLATE_PINS below is the table's enforcement — the
9
9
  * test suite asserts every literal is still present in every template, so template drift breaks the
10
10
  * build loudly instead of breaking the render silently.
11
11
  *
12
+ * Path doctrine (issue #96): this module used to derive a REPO_ROOT from its own location ("../..").
13
+ * Right in a checkout; WRONG under `npm install`, where src/ lives at
14
+ * node_modules/@edgehero/pi-dispatch/src and "../.." is the @edgehero SCOPE directory — every rendered
15
+ * unit pointed at files that do not exist. Three anchors replace it, each correct in BOTH layouts:
16
+ * - deployDir the deployment folder = the cwd `service` is invoked from. Owns everything
17
+ * host-side: WorkingDirectory, EnvironmentFile (<deployDir>/.env) and the daemon
18
+ * logs (<deployDir>/logs/, created at install time) — never the package dir, which
19
+ * npm may replace wholesale on update.
20
+ * - cliPath join(moduleDir, "cli.mjs"): cli.mjs sits beside this module in src/ in both
21
+ * layouts, so the worker ExecStart needs no repo root at all.
22
+ * - receiverStart import.meta.resolve("@edgehero/pi-dispatch-receiver/start"): the receiver
23
+ * package's own exported entry, wherever npm (or the workspace symlink) put it.
24
+ * null when the package is not installed — receiver renders refuse loudly instead
25
+ * of writing a unit that would crash-loop at boot.
26
+ *
12
27
  * Scope doctrine:
13
28
  * - User-level by default, everywhere. macOS REFUSES root outright (a LaunchAgent is per-user, and a
14
29
  * root agent could not see the login session's Docker Desktop anyway — the svc.sh precedent).
@@ -32,12 +47,26 @@ import { dirname, join, resolve } from "node:path";
32
47
  import { fileURLToPath } from "node:url";
33
48
  import { parseArgs } from "node:util";
34
49
 
35
- // Deploy templates resolved relative to this module (the init.mjs pattern): worker/deploy is SHIPPED
36
- // in the npm tarball and kept byte-identical to the repo-root deploy/ (the documented source) by
37
- // worker/test/publish.test.mjs — so `service` renders the same templates from a checkout and from an
38
- // npm install, no matter where the CLI is invoked from.
39
- const DEPLOY_DIR = resolve(dirname(fileURLToPath(import.meta.url)), "..", "deploy");
40
- const REPO_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..", "..");
50
+ // src/ is where this module lives in BOTH layouts (worker/src in a checkout,
51
+ // node_modules/@edgehero/pi-dispatch/src under npm). Deploy templates resolve one level up from it
52
+ // (the init.mjs pattern): worker/deploy is SHIPPED in the npm tarball and kept byte-identical to the
53
+ // repo-root deploy/ (the documented source) by worker/test/publish.test.mjs — so `service` renders
54
+ // the same templates from a checkout and from an npm install, no matter where the CLI is invoked from.
55
+ const MODULE_DIR = dirname(fileURLToPath(import.meta.url));
56
+
57
+ /**
58
+ * The receiver's entry point comes from the receiver PACKAGE (its ./start export), wherever module
59
+ * resolution finds it from here — the workspace symlink in a checkout, the sibling install under the
60
+ * deployment folder's node_modules in production. Never a path guessed off this module: that guess is
61
+ * exactly what issue #96 is about. null = not installed, and the receiver renders refuse on it.
62
+ */
63
+ function resolveReceiverStart() {
64
+ try {
65
+ return fileURLToPath(import.meta.resolve("@edgehero/pi-dispatch-receiver/start"));
66
+ } catch {
67
+ return null;
68
+ }
69
+ }
41
70
 
42
71
  /**
43
72
  * The whole substitution surface, template by template. The render replaces ONLY these literals (plus
@@ -47,9 +76,9 @@ const REPO_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..", "..");
47
76
  */
48
77
  export const TEMPLATE_PINS = {
49
78
  "worker.service": [
50
- "ExecStart=/usr/bin/node worker/src/cli.mjs worker", // /usr/bin/node → process.execPath
51
- "WorkingDirectory=/opt/pi-dispatch", // /opt/pi-dispatch → the real repo root
52
- "EnvironmentFile=/opt/pi-dispatch/.env",
79
+ "ExecStart=/usr/bin/node worker/src/cli.mjs worker", // the WHOLE line → `<execPath> <cliPath> worker` (cli.mjs sits beside this module in src/ in both layouts)
80
+ "WorkingDirectory=/opt/pi-dispatch", // /opt/pi-dispatch → the deployment folder (the cwd `service` runs from)
81
+ "EnvironmentFile=/opt/pi-dispatch/.env", // → <deployDir>/.env — the operator's .env lives beside the units, never inside the package
53
82
  "\nUser=pi\n", // the DIRECTIVE line (the header comment also says User=pi mid-line, hence the \n anchors): stripped for --user scope; rewritten to the invoking user for --system
54
83
  "WantedBy=multi-user.target", // → default.target in user scope (multi-user.target never runs there)
55
84
  // Byte-for-byte survivors — semantics the render must not lose:
@@ -60,7 +89,7 @@ export const TEMPLATE_PINS = {
60
89
  "TimeoutStopSec=30",
61
90
  ],
62
91
  "receiver.service": [
63
- "ExecStart=/usr/bin/node receiver/src/start.mjs",
92
+ "ExecStart=/usr/bin/node receiver/src/start.mjs", // the WHOLE line → `<execPath> <receiverStart>` (the receiver package's resolved ./start export)
64
93
  "WorkingDirectory=/opt/pi-dispatch",
65
94
  "EnvironmentFile=/opt/pi-dispatch/.env",
66
95
  "\nUser=pi\n",
@@ -70,9 +99,9 @@ export const TEMPLATE_PINS = {
70
99
  ],
71
100
  "com.pi-dispatch.worker.plist": [
72
101
  "<string>com.pi-dispatch.worker</string>", // → com.pi-dispatch.receiver for --receiver
73
- "<string>/opt/pi-dispatch/deploy/worker-env-wrapper.sh</string>", // gains a `receiver` argument for --receiver
74
- "<key>WorkingDirectory</key>\n\t<string>/opt/pi-dispatch</string>", // anchor for the PATH injection below
75
- "<string>/opt/pi-dispatch/logs/worker.out.log</string>",
102
+ "<string>/opt/pi-dispatch/deploy/worker-env-wrapper.sh</string>", // → the PACKAGE's wrapper copy, followed by the exec argv (<node> <script> …) the wrapper now runs verbatim
103
+ "<key>WorkingDirectory</key>\n\t<string>/opt/pi-dispatch</string>", // → the deployment folder (load-bearing: the wrapper sources ./.env there); also the anchor for the PATH injection below
104
+ "<string>/opt/pi-dispatch/logs/worker.out.log</string>", // → <deployDir>/logs/… (install creates the dir; launchd will not)
76
105
  "<string>/opt/pi-dispatch/logs/worker.err.log</string>",
77
106
  "<key>SuccessfulExit</key>", // the KeepAlive shape the wrapper's exit-2 conversion pairs with
78
107
  "<integer>30</integer>", // ExitTimeOut — room for the SIGTERM drain
@@ -82,8 +111,8 @@ export const TEMPLATE_PINS = {
82
111
  // the two cannot drift apart — especially the AppExit pair, which is the EXIT_POLICY never-retry.
83
112
  "nssm-install.cmd": [
84
113
  "pi-dispatch-worker",
85
- "C:\\pi-dispatch", // the REPO placeholder → the real repo root
86
- "deploy\\worker-env-wrapper.cmd",
114
+ "C:\\pi-dispatch", // the REPO placeholder → the deployment folder (cwd)
115
+ "deploy\\worker-env-wrapper.cmd", // the sequence points at the PACKAGE's wrapper copy and passes the exec argv behind it
87
116
  "AppStopMethodConsole 15000",
88
117
  "AppThrottle 5000",
89
118
  "AppExit Default Restart",
@@ -116,7 +145,11 @@ export async function runService(argv = [], deps = {}) {
116
145
  platform = process.platform,
117
146
  euid = typeof process.geteuid === "function" ? process.geteuid() : null,
118
147
  execPath = process.execPath,
119
- repoRoot = REPO_ROOT,
148
+ // The deployment folder: where the operator ran `service`, where .env lives, where logs/ goes.
149
+ cwd = process.cwd(),
150
+ // Injectable so tests can render as if from an npm install without installing anything.
151
+ moduleDir = MODULE_DIR,
152
+ resolveReceiver = resolveReceiverStart,
120
153
  home = homedir(),
121
154
  user = env.USER || userInfo().username,
122
155
  tmp = tmpdir(),
@@ -162,12 +195,22 @@ export async function runService(argv = [], deps = {}) {
162
195
  }
163
196
  if (!["darwin", "linux", "win32"].includes(platform)) return fail(err, `unsupported platform: ${platform}`);
164
197
 
198
+ // The templates dir is module-relative like moduleDir itself: worker/deploy in a checkout,
199
+ // <pkg>/deploy under npm — the SHIPPED copies, correct in both layouts (unlike the old repo root).
200
+ const templatesDir = resolve(moduleDir, "..", "deploy");
165
201
  const ctx = {
166
202
  env,
167
203
  platform,
168
204
  euid,
169
205
  execPath,
170
- repoRoot,
206
+ deployDir: cwd,
207
+ cliPath: join(moduleDir, "cli.mjs"),
208
+ templatesDir,
209
+ wrapperSh: join(templatesDir, "worker-env-wrapper.sh"),
210
+ wrapperCmd: join(templatesDir, "worker-env-wrapper.cmd"),
211
+ // Resolved only when asked for: worker-only invocations must not care whether the receiver
212
+ // package exists here at all.
213
+ receiverStart: values.receiver ? resolveReceiver() : null,
171
214
  home,
172
215
  user,
173
216
  tmp,
@@ -240,7 +283,20 @@ function unitPaths(ctx) {
240
283
  }
241
284
 
242
285
  function readTemplate(ctx, name) {
243
- return ctx.fs.readFileSync(join(DEPLOY_DIR, name), "utf8");
286
+ return ctx.fs.readFileSync(join(ctx.templatesDir, name), "utf8");
287
+ }
288
+
289
+ /**
290
+ * The receiver refusal, shared by render and install: a null receiverStart means the receiver package
291
+ * is not resolvable from this worker install. Refusing beats rendering — a unit pointing at a
292
+ * nonexistent start.mjs would install cleanly and then crash-loop at boot, which is exactly the failure
293
+ * mode issue #96 shipped for every path.
294
+ */
295
+ function refuseMissingReceiver(ctx) {
296
+ return fail(
297
+ ctx.err,
298
+ "the receiver package is not installed here — run: npm install @edgehero/pi-dispatch-receiver (from the deployment folder)",
299
+ );
244
300
  }
245
301
 
246
302
  /**
@@ -250,12 +306,23 @@ function readTemplate(ctx, name) {
250
306
  */
251
307
  function renderLinuxUnit(ctx) {
252
308
  const template = ctx.which === "receiver" ? "receiver.service" : "worker.service";
309
+ // The ExecStart line is replaced WHOLE, not path-by-path: the template's script path is relative
310
+ // to a repo-root WorkingDirectory that only a checkout has. The rendered unit points at absolute
311
+ // entries that exist in both layouts — cliPath beside this module; the receiver package's ./start
312
+ // export — so ExecStart works no matter what WorkingDirectory is.
313
+ const execStart =
314
+ ctx.which === "receiver"
315
+ ? ["ExecStart=/usr/bin/node receiver/src/start.mjs", `ExecStart=${ctx.execPath} ${ctx.receiverStart}`]
316
+ : ["ExecStart=/usr/bin/node worker/src/cli.mjs worker", `ExecStart=${ctx.execPath} ${ctx.cliPath} worker`];
253
317
  // The banner outranks the template's own "TEMPLATE/UNTESTED EXAMPLE — set the PLACEHOLDERs" header,
254
318
  // which renders through below (the no-markers design keeps templates byte-usable, so their prose
255
319
  // survives): a reader of the rendered unit should know the placeholders are already substituted.
256
320
  let unit = `# rendered by \`pi-dispatch service\` — paths computed for this host from deploy/${template};\n# the template's PLACEHOLDER prose below is already substituted.\n` +
257
321
  readTemplate(ctx, template)
258
- .replaceAll("/opt/pi-dispatch", ctx.repoRoot)
322
+ .replace(execStart[0], execStart[1])
323
+ // /opt/pi-dispatch → the deployment folder (the cwd this render ran from): WorkingDirectory and
324
+ // EnvironmentFile stay operator territory, never the package dir npm may wipe on update.
325
+ .replaceAll("/opt/pi-dispatch", ctx.deployDir)
259
326
  .replaceAll("/usr/bin/node", ctx.execPath);
260
327
  if (ctx.scope === "user") {
261
328
  // A systemd --user unit always runs as the invoking user, and systemd REJECTS a User= line in
@@ -279,48 +346,67 @@ function renderLinuxUnit(ctx) {
279
346
 
280
347
  /**
281
348
  * Render the launchd plist for this host. For --receiver the worker plist is DERIVED, not a second
282
- * template: same KeepAlive/ExitTimeOut shape, label and log names swapped, and the shared wrapper told
283
- * (via its one argument) to run the receiver. The wrapper's exit-2 conversion is a no-op for the
349
+ * template: same KeepAlive/ExitTimeOut shape, label and log names swapped, and the shared wrapper given
350
+ * the receiver's exec argv instead of the worker's. The wrapper's exit-2 conversion is a no-op for the
284
351
  * receiver — it has no EXIT_POLICY — and harmless.
285
352
  */
286
353
  function renderPlist(ctx) {
287
354
  let plist = readTemplate(ctx, "com.pi-dispatch.worker.plist");
355
+ // One wrapper, two daemons: the exec argv IS the difference now. The wrapper sources ./.env in the
356
+ // unit's WorkingDirectory and runs exactly these arguments — no `receiver` selector flag, no paths
357
+ // guessed inside the wrapper (issue #96: the wrapper's self-relative guess broke under npm install).
358
+ const execArgv = ctx.which === "receiver" ? [ctx.execPath, ctx.receiverStart] : [ctx.execPath, ctx.cliPath, "worker"];
288
359
  if (ctx.which === "receiver") {
289
360
  plist = plist
290
361
  .replace("<string>com.pi-dispatch.worker</string>", "<string>com.pi-dispatch.receiver</string>")
291
- .replace(
292
- "<string>/opt/pi-dispatch/deploy/worker-env-wrapper.sh</string>",
293
- "<string>/opt/pi-dispatch/deploy/worker-env-wrapper.sh</string>\n\t\t<!-- the argument that makes the shared .env wrapper run the receiver instead of the worker -->\n\t\t<string>receiver</string>",
294
- )
295
362
  .replaceAll("worker.out.log", "receiver.out.log")
296
363
  .replaceAll("worker.err.log", "receiver.err.log");
297
364
  }
298
- // launchd's default PATH is /usr/bin:/bin — an nvm or Homebrew node is invisible to it, and the
299
- // wrapper invokes bare `node`. Prepend the directory of the node that ran this render so the
300
- // service runs the SAME binary, no guessing. PATH is configuration, not a secret: the template's
301
- // deliberate no-EnvironmentVariables stance is about credentials, which still live only in .env.
365
+ // The template's two-element ProgramArguments (sh + wrapper) becomes sh + the PACKAGE's wrapper +
366
+ // the command: absolute node, absolute script. The wrapper path is module-relative (templatesDir),
367
+ // so it exists in a checkout AND under node_modules — unlike the old repo-root guess.
368
+ plist = plist.replace(
369
+ "<string>/opt/pi-dispatch/deploy/worker-env-wrapper.sh</string>",
370
+ [
371
+ `<string>${ctx.wrapperSh}</string>`,
372
+ "<!-- the command the wrapper execs after sourcing ./.env in WorkingDirectory - absolute paths, nothing guessed -->",
373
+ ...execArgv.map((a) => `<string>${a}</string>`),
374
+ ].join("\n\t\t"),
375
+ );
376
+ // launchd's default PATH is /usr/bin:/bin — an nvm or Homebrew node is invisible to it. The exec
377
+ // argv above pins THIS node absolutely, but the worker's own children (npx-style hooks, tooling
378
+ // that spawns bare `node`) still resolve via PATH; prepending the render node's directory keeps
379
+ // them on the SAME binary. PATH is configuration, not a secret: the template's deliberate
380
+ // no-EnvironmentVariables stance is about credentials, which still live only in .env.
302
381
  plist = plist.replace(
303
382
  "<key>WorkingDirectory</key>\n\t<string>/opt/pi-dispatch</string>",
304
- "<key>WorkingDirectory</key>\n\t<string>/opt/pi-dispatch</string>\n\n\t<!-- Injected by `pi-dispatch service`: launchd's default PATH cannot see an nvm/Homebrew node,\n\t and the wrapper calls bare `node`. Not a secrets dict - credentials still live only in .env\n\t (see the header comment). -->\n\t<key>EnvironmentVariables</key>\n\t<dict>\n\t\t<key>PATH</key>\n\t\t<string>" +
383
+ "<key>WorkingDirectory</key>\n\t<string>/opt/pi-dispatch</string>\n\n\t<!-- Injected by `pi-dispatch service`: launchd's default PATH cannot see an nvm/Homebrew node,\n\t and child processes may call bare `node`. Not a secrets dict - credentials still live only\n\t in .env (see the header comment). -->\n\t<key>EnvironmentVariables</key>\n\t<dict>\n\t\t<key>PATH</key>\n\t\t<string>" +
305
384
  `${dirname(ctx.execPath)}:/usr/bin:/bin:/usr/sbin:/sbin</string>\n\t</dict>`,
306
385
  );
307
- return plist.replaceAll("/opt/pi-dispatch", ctx.repoRoot);
386
+ // Everything left standing on /opt/pi-dispatch — WorkingDirectory, the log paths, comment prose —
387
+ // belongs to the deployment folder.
388
+ return plist.replaceAll("/opt/pi-dispatch", ctx.deployDir);
308
389
  }
309
390
 
310
391
  /**
311
- * The nssm command sequence — deploy/nssm-install.cmd's exact steps with computed paths. Values that
312
- * carry semantics (AppStopMethodConsole 15000, AppThrottle 5000, AppExit Default Restart, AppExit 2
313
- * Exit) mirror the template byte-for-byte and are pinned. Backslashes on purpose: this argv reaches
314
- * nssm on a real Windows host, where repoRoot is already a Windows path.
392
+ * The nssm command sequence — deploy/nssm-install.cmd's exact steps with computed paths: the service
393
+ * Application is the PACKAGE's .cmd wrapper, its AppParameters are the exec argv (<node> <script> …)
394
+ * the wrapper now runs verbatim, and AppDirectory is the deployment folder so the wrapper finds ./.env
395
+ * there (the same WorkingDirectory contract as launchd). Values that carry semantics
396
+ * (AppStopMethodConsole 15000, AppThrottle 5000, AppExit Default Restart, AppExit 2 Exit) mirror the
397
+ * template byte-for-byte and are pinned. Backslashes on purpose where paths are BUILT here: this argv
398
+ * reaches nssm on a real Windows host, where deployDir is already a Windows path (wrapperCmd and
399
+ * cliPath come from win32 path.join and need no help).
315
400
  */
316
401
  function nssmSequence(ctx) {
317
402
  const service = `pi-dispatch-${ctx.which}`;
318
- const logDir = `${ctx.repoRoot}\\logs`;
403
+ const logDir = `${ctx.deployDir}\\logs`;
404
+ const execArgv = ctx.which === "receiver" ? [ctx.execPath, ctx.receiverStart] : [ctx.execPath, ctx.cliPath, "worker"];
319
405
  return {
320
406
  service,
321
407
  commands: [
322
- ["install", service, `${ctx.repoRoot}\\deploy\\worker-env-wrapper.cmd`, ...(ctx.which === "receiver" ? ["receiver"] : [])],
323
- ["set", service, "AppDirectory", ctx.repoRoot],
408
+ ["install", service, ctx.wrapperCmd, ...execArgv],
409
+ ["set", service, "AppDirectory", ctx.deployDir],
324
410
  ["set", service, "AppStdout", `${logDir}\\${ctx.which}.out.log`],
325
411
  ["set", service, "AppStderr", `${logDir}\\${ctx.which}.err.log`],
326
412
  ["set", service, "AppStopMethodConsole", "15000"],
@@ -332,12 +418,13 @@ function nssmSequence(ctx) {
332
418
  }
333
419
 
334
420
  function doRender(ctx) {
421
+ if (ctx.which === "receiver" && !ctx.receiverStart) return refuseMissingReceiver(ctx);
335
422
  const paths = unitPaths(ctx);
336
423
  if (ctx.platform === "darwin") {
337
424
  ctx.out(`# → ${paths.installPath}\n`);
338
425
  ctx.out(renderPlist(ctx));
339
426
  ctx.out(
340
- "\n# note: ProgramArguments runs deploy/worker-env-wrapper.sh — launchd has no EnvironmentFile,\n# so the wrapper loads .env at runtime. The wrapper also converts a policy refusal (exit 2,\n# EXIT_POLICY) into a clean exit, so KeepAlive never relaunch-loops a refusal into a provider bill.\n",
427
+ "\n# note: ProgramArguments runs the package's worker-env-wrapper.sh, which sources ./.env in the\n# WorkingDirectory above and then runs the argv that follows it — launchd has no EnvironmentFile.\n# The wrapper also converts a policy refusal (exit 2, EXIT_POLICY) into a clean exit, so KeepAlive\n# never relaunch-loops a refusal into a provider bill.\n",
341
428
  );
342
429
  return 0;
343
430
  }
@@ -354,6 +441,7 @@ function doRender(ctx) {
354
441
  }
355
442
 
356
443
  async function doInstall(ctx) {
444
+ if (ctx.which === "receiver" && !ctx.receiverStart) return refuseMissingReceiver(ctx);
357
445
  const paths = unitPaths(ctx);
358
446
 
359
447
  // THE refusal, worker only: one worker per docker daemon (DES-CONCURRENCY-3). The worker's boot
@@ -396,6 +484,9 @@ async function installDarwin(ctx, paths) {
396
484
  await run(ctx, "launchctl", ["bootout", `gui/${ctx.euid}/${paths.name}`]);
397
485
  }
398
486
  ctx.fs.mkdirSync(dirname(paths.installPath), { recursive: true });
487
+ // launchd creates the StandardOutPath FILES but not their parent directory: without this the job
488
+ // spawns and dies with its error unwritable. Done at install, not render — render stays read-only.
489
+ ctx.fs.mkdirSync(join(ctx.deployDir, "logs"), { recursive: true });
399
490
  ctx.fs.writeFileSync(paths.installPath, renderPlist(ctx));
400
491
  const bootstrap = await run(ctx, "launchctl", ["bootstrap", `gui/${ctx.euid}`, paths.installPath]);
401
492
  if (bootstrap !== 0) {
@@ -459,6 +550,10 @@ async function installWindows(ctx) {
459
550
  const removed = await run(ctx, "nssm", ["remove", service, "confirm"]);
460
551
  if (removed !== 0) return fail(ctx.err, `nssm remove ${service} confirm failed (exit ${removed})`);
461
552
  }
553
+ // nssm, like launchd, does not create the AppStdout/AppStderr directory. join(), not the
554
+ // sequence's literal backslashes: this branch runs on the actual Windows host, where join is
555
+ // win32-flavoured anyway.
556
+ ctx.fs.mkdirSync(join(ctx.deployDir, "logs"), { recursive: true });
462
557
  for (const args of commands) {
463
558
  const code = await run(ctx, "nssm", args);
464
559
  if (code !== 0) return fail(ctx.err, `nssm ${args.join(" ")} failed (exit ${code})`);
@@ -32,9 +32,12 @@ import { sessionKeyFor } from "./session-key.mjs";
32
32
  *
33
33
  * NEVER THROWS. Every path returns `{ resume, reason, ... }` or null-ish, because a disk fault must not
34
34
  * fail a prepare that only asked whether there was a transcript -- the posture makeFindPreviousRun
35
- * already sets. The one fail-CLOSED case lives in the processor, not here: a trigger that armed
36
- * run.resume while PI_SESSIONS_DIR is unset is a pre-spend policy refusal, because running it silently
37
- * without persistence is the failure validatePackagesFlag's own comment describes.
35
+ * already sets. The one fail-CLOSED case lives in the processor, not here, and it is a gate this module is
36
+ * never asked: runJob returns a `sessions-dir-unset` policy refusal for a job whose trigger armed
37
+ * `run.resume` while `sessionsDir` is null (processor.mjs, before the mint and before reserveBudget), so a
38
+ * job that reaches `resolveSession` at all has already been proven to have somewhere to persist to.
39
+ * Refused rather than run, because running it silently without persistence is the failure
40
+ * validatePackagesFlag's own comment describes.
38
41
  */
39
42
 
40
43
  /** Container-side name, fixed. Nothing key-derived crosses the boundary -- see makeSessionStore. */
@@ -68,7 +71,13 @@ export function makeSessionStore({
68
71
  */
69
72
  function resolveSession(job, { jobDir, resolved = {}, piVersion = null } = {}) {
70
73
  try {
71
- if (!sessionsDir) return null; // feature unavailable; the processor refuses armed triggers earlier
74
+ // Unreachable in a wired worker, and deliberately kept: resolveSession is only ever called for a
75
+ // job that armed run.resume (prepare-github.mjs), and processor.mjs refuses exactly that job
76
+ // pre-spend when this is null -- the `sessions-dir-unset` policy return. This stays as the
77
+ // DI-seam backstop, because both the store and the preparer are injected and neither can assume
78
+ // the caller came through that gate; a null here is the same no-mount, nothing-written shape a
79
+ // pre-feature job had.
80
+ if (!sessionsDir) return null;
72
81
  const key = sessionKeyFor(job, resolved);
73
82
  // No key is not a failure and not a degradation: this job has no durable identity (a fork PR, a
74
83
  // CLI run, an unresolvable head ref), so it gets no mount and no transcript on disk.
@@ -106,6 +115,12 @@ export function makeSessionStore({
106
115
  * agents' turns into one transcript.
107
116
  */
108
117
  function promoteSession(session, { piVersion = null } = {}) {
118
+ // The second DI-seam backstop, and unreachable for the same reason as the `!sessionsDir` return
119
+ // above: sessionKeyFor is total and binary (null, or 32 hex chars), so resolveSession returns null
120
+ // rather than a keyless session, and processor.mjs only calls this when prepare handed it one. Kept
121
+ // because the store and the preparer are separately injected and neither can assume the other. It is
122
+ // NOT in INT-RUN-HISTORY-FILE-CONTRACT's session.reason enum, deliberately: a token no wired worker
123
+ // can emit does not belong in the record's vocabulary, and `promote-failed` below does.
109
124
  if (!session?.key) return { promoted: false, reason: "no-key" };
110
125
  try {
111
126
  const staged = join(session.hostDir, SESSION_FILE_NAME);
package/src/start.mjs CHANGED
@@ -320,14 +320,51 @@ export async function startWorker(
320
320
  // (CONST-RETRY-INFRA-ONLY). The processor calls it as the sole COMPLETED-path chain step.
321
321
  const collectChain = makeCollectChain({ queue: runtimeQueue, config, log });
322
322
 
323
- // REQ-GLOBAL-PI-OVERLAY staged packages: read the operator's stage manifest ONCE at boot. The staged set
324
- // is deploy-time state under the :ro overlay -- identical for every job -- so a per-job re-read would buy
325
- // nothing and put a filesystem read on the hot path. A missing or unreadable manifest yields [] plus one
326
- // log line and NEVER a boot failure: a deployment that never opted into packages must not be blocked by
327
- // it, and `pi-dispatch doctor` is what fails loud on a mismatch between the overlay and the triggers.
328
- const stagedPackages = config.globalPiDir ? readStageManifest({ globalPiDir: config.globalPiDir }) : null;
329
- const packagePaths = stagedPackages ? containerPackagePaths(stagedPackages) : [];
330
- if (config.globalPiDir && !stagedPackages) log("packages_manifest_absent", { overlay: config.globalPiDir });
323
+ // REQ-GLOBAL-PI-OVERLAY staged packages: read the operator's stage manifest at EACH job start, like
324
+ // getSettings above and the pause-window ref below.
325
+ //
326
+ // This was a boot-time read until issue #102, and the argument for that was sound while it held: the
327
+ // staged set was deploy-time state under a :ro mount, identical for every job, so a per-job read bought
328
+ // nothing. What changed is that `import-pi --with-packages` now discovers what the operator installed in
329
+ // pi, which makes `pi install X` then re-stage a ROUTINE act rather than a rare one. Under the boot read
330
+ // the jobs after such a re-stage keep the old set until someone restarts the worker, and when the re-stage
331
+ // DROPS a package the symptom is worse than staleness: the runner refuses a missing staged dir at
332
+ // container start (exit 2), and budget is reserved before the container, so every job burns a daily-cap
333
+ // slot until the restart. A free filesystem read that prevents a reserved-and-wasted slot is exactly what
334
+ // CONST-BUDGET-BEFORE-TOKENS asks for.
335
+ //
336
+ // Last-known-good on a failed read, never []: an empty set emits no PI_PACKAGES at all, so the runner's
337
+ // assertPackagePathsExist has nothing to refuse and the job would run WITHOUT its tools and still exit 0.
338
+ // That is the silent no-op this project refuses. And never a throw: a transient overlay fault must not
339
+ // become a queue retry (CONST-RETRY-INFRA-ONLY).
340
+ let lastGoodPackagePaths = [];
341
+ let lastPackageKey = null;
342
+ // Logged once per CHANGE, not once per job: a line every job would drown the log it is meant to serve.
343
+ // EVERY resolved read records its key, including the empty one, so "nothing staged" becoming "one package
344
+ // staged" is the change it obviously is rather than a first read that logs nothing.
345
+ const notePackageKey = (key) => {
346
+ if (lastPackageKey !== null && key !== lastPackageKey) log("packages_stage_changed", { count: key === "" ? 0 : key.split(":").length });
347
+ lastPackageKey = key;
348
+ };
349
+ const getPackagePaths = () => {
350
+ if (!config.globalPiDir) return [];
351
+ const staged = readStageManifest({ globalPiDir: config.globalPiDir });
352
+ if (!staged) {
353
+ if (lastGoodPackagePaths.length > 0) {
354
+ log("packages_manifest_unreadable", { overlay: config.globalPiDir, keeping: lastGoodPackagePaths.length });
355
+ return lastGoodPackagePaths;
356
+ }
357
+ notePackageKey("");
358
+ return [];
359
+ }
360
+ const paths = containerPackagePaths(staged);
361
+ notePackageKey(paths.join(":"));
362
+ lastGoodPackagePaths = paths;
363
+ return paths;
364
+ };
365
+ // One read at boot, for the same log line the boot read always emitted, and to seed last-known-good.
366
+ if (config.globalPiDir && !readStageManifest({ globalPiDir: config.globalPiDir })) log("packages_manifest_absent", { overlay: config.globalPiDir });
367
+ getPackagePaths();
331
368
 
332
369
  const worker = createWorkerFn({
333
370
  connection: parseConnection(config.valkeyUrl),
@@ -352,13 +389,21 @@ export async function startWorker(
352
389
  // Completed-only, so a policy or infra exit leaves the canonical transcript byte-identical and a
353
390
  // retry starts from what the first attempt did (CONST-RETRY-INFRA-ONLY).
354
391
  promoteSession: sessionStore.promoteSession,
392
+ // The same value the store above was built from, passed explicitly so the processor's fail-closed
393
+ // `run.resume` gate answers from THIS config rather than from its own env default. Identical on the
394
+ // real path; the difference shows under an injected env, where the store would be built from the
395
+ // synthetic value while the gate read the process one.
396
+ sessionsDir: config.sessionsDir,
355
397
  runContainer: makeRunContainerFn({
356
398
  image: config.jobImage,
357
399
  hostEnv: env,
358
400
  openJobLog,
359
401
  globalPiDir: config.globalPiDir, // REQ-GLOBAL-PI-OVERLAY: :ro overlay mount when configured
360
402
  allowGlobalExtensions: config.allowGlobalExtensions,
361
- packagePaths, // REQ-GLOBAL-PI-OVERLAY: staged package paths; every job receives them unless its trigger set packages:false
403
+ // REQ-GLOBAL-PI-OVERLAY: staged package paths; every job receives them unless its trigger set
404
+ // packages:false. A RESOLVER, not the array: the factory is still constructed exactly once, only
405
+ // the value it reads became a call, so a re-stage takes effect on the next job without a restart.
406
+ packagePaths: getPackagePaths,
362
407
  forwardEnv: config.forwardEnv,
363
408
  authFromPi: config.authFromPi, // source the provider key from ~/.pi/agent/auth.json when env has none
364
409
  // Self-hosted instance URLs, keyed by forge. A MAP rather than one scalar per forge: the table says