@edgehero/pi-dispatch 0.1.0 → 0.1.2

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
@@ -87,7 +87,8 @@ PI_SCHEDULER_STALL_MAX=2 # tear down a scheduler after N consecutive
87
87
  # PI_CHAIN_MAX_PER_JOB=2 # max request-<n>.json collected per completed job
88
88
 
89
89
  # --- GitHub trigger (receiver + worker auth) ---
90
- # Webhook receiver
90
+ # Webhook receiver. Required only when your triggers name github (or GITHUB_AUTH_SOURCE is set): every forge arm is
91
+ # conditional, so a gitlab/forgejo/azure-only deployment needs no value here and answers 404 on the github endpoint
91
92
  WEBHOOK_SECRET=
92
93
  RECEIVER_PORT=3000
93
94
  RECEIVER_BIND=0.0.0.0
@@ -5,10 +5,18 @@
5
5
  unit. Adapt it. The Linux/systemd equivalent is deploy/worker.service.
6
6
 
7
7
  ProgramArguments points at deploy/worker-env-wrapper.sh; that wrapper is what loads `.env`, because
8
- launchd has no EnvironmentFile mechanism. NO secrets are inlined here: there is deliberately no
9
- EnvironmentVariables dict, since that would commit credentials into this file. The wrapper reads
10
- `.env` at runtime instead (note the ANTHROPIC_OAUTH_TOKEN over ANTHROPIC_API_KEY precedence trap
11
- documented in the wrapper).
8
+ launchd has no EnvironmentFile mechanism. The wrapper's contract (issue #96; see its header): it
9
+ sources ./.env from the WorkingDirectory below (which makes that key load-bearing, not a nicety)
10
+ and then runs the REST of ProgramArguments as the command. When hand-editing this template, append
11
+ the command after the wrapper path, e.g.
12
+ <string>/usr/bin/node</string>
13
+ <string>/opt/pi-dispatch/worker/src/cli.mjs</string>
14
+ <string>worker</string>
15
+ (`pi-dispatch service render` composes exactly that argv with this host's real paths; the wrapper
16
+ refuses an empty argv rather than guessing what to run). NO secrets are inlined here: there is
17
+ deliberately no EnvironmentVariables dict, since that would commit credentials into this file. The
18
+ wrapper reads `.env` at runtime instead (note the ANTHROPIC_OAUTH_TOKEN over ANTHROPIC_API_KEY
19
+ precedence trap documented in the wrapper).
12
20
 
13
21
  Graceful shutdown needs NO macOS-specific code: `launchctl bootout` sends SIGTERM, which the wrapper
14
22
  forwards to node (a trap + kill; its former `exec` is gone, see the wrapper's own comments), and the
@@ -21,8 +29,10 @@
21
29
  (exit 2, a determinate config/budget refusal) into a clean exit 0: a policy refusal stays stopped
22
30
  instead of relaunch-looping against a paid provider.
23
31
 
24
- Per-host PLACEHOLDERS: replace /opt/pi-dispatch (the repo root, used in ProgramArguments and
25
- WorkingDirectory) and /opt/pi-dispatch/logs (StandardOutPath, StandardErrorPath) with your paths.
32
+ Per-host PLACEHOLDERS: replace /opt/pi-dispatch (the deployment folder, used in ProgramArguments and
33
+ WorkingDirectory; the folder that holds your `.env`) and /opt/pi-dispatch/logs (StandardOutPath,
34
+ StandardErrorPath; create the directory yourself, launchd will not) with your paths, and append the
35
+ command argv to ProgramArguments as described above.
26
36
 
27
37
  One worker per host (DES-CONCURRENCY-3): parallelism is PI_CONCURRENCY inside the one process.
28
38
  Requires the AOF-enabled Valkey from deploy/docker-compose.yml.
@@ -0,0 +1,66 @@
1
+ # pi-dispatch's worker runs on the HOST, not in a container: it drives the `docker` CLI to launch
2
+ # job containers, and the CLI is what translates bind-mount paths cross-platform (Windows/macOS/
3
+ # Linux) -- see DES-WORKER-ON-HOST. That is settled doctrine, and it is why no service here mounts
4
+ # docker.sock: a socket mount is root-equivalent access to the host, and nothing in this stack needs it.
5
+ #
6
+ # The RECEIVER may run either way. It is the one internet-facing process and needs no Docker access at
7
+ # all, so a container suits it -- but the host mode (`pi-dispatch-receiver`, deploy/receiver.service)
8
+ # stays first-class. The `receiver` profile keeps the container OPT-IN: a plain `up` stays Valkey-only,
9
+ # so worker-on-host deployments that already run the receiver on the host gain no second copy.
10
+ #
11
+ # docker compose -f deploy/docker-compose.yml up -d # start Valkey (default)
12
+ # docker compose -f deploy/docker-compose.yml --profile receiver up -d # Valkey + receiver container
13
+ # pi-dispatch worker # drain the queue (host)
14
+
15
+ services:
16
+ valkey:
17
+ image: valkey/valkey:8
18
+ # AOF on: the wait-list must survive a reboot (REQ-QUEUE-BURST-NO-DROP).
19
+ command: valkey-server --appendonly yes
20
+ # Bound to localhost only -- the queue is not a public surface.
21
+ ports:
22
+ - "127.0.0.1:6379:6379"
23
+ volumes:
24
+ - valkey-data:/data
25
+ restart: unless-stopped
26
+ healthcheck:
27
+ test: ["CMD", "valkey-cli", "ping"]
28
+ interval: 10s
29
+ timeout: 3s
30
+ retries: 5
31
+
32
+ # The containerized receiver (issue #82): the always-on webhook edge, prebuilt for amd64+arm64.
33
+ # Build locally instead with: docker build -f receiver/Dockerfile -t pi-dispatch-receiver .
34
+ #
35
+ # Relative paths below resolve against THIS FILE's directory (deploy/), not the caller's cwd --
36
+ # that is the Compose spec for both env_file and bind mounts -- so `../` reaches the repo root
37
+ # regardless of where `docker compose -f deploy/docker-compose.yml ...` is run from.
38
+ receiver:
39
+ profiles: ["receiver"]
40
+ image: ghcr.io/edgehero/pi-dispatch-receiver:latest
41
+ # The operator's .env at the repo root, same file the host services read (WEBHOOK_SECRET, github
42
+ # auth, forge blocks). Compose refuses to start the profile when it is missing -- correctly: a
43
+ # receiver without WEBHOOK_SECRET refuses to boot anyway, so fail at the clearer message.
44
+ env_file: ../.env
45
+ # `environment` OVERRIDES env_file, deliberately: a host .env says redis://127.0.0.1:6379, which
46
+ # inside a container is the container itself. Service DNS is the container-side truth.
47
+ environment:
48
+ VALKEY_URL: redis://valkey:6379
49
+ PI_TRIGGERS_FILE: /config/triggers.json
50
+ # The repo-root triggers.json (`pi-dispatch init` scaffolds it -- run that first: mounting a path
51
+ # that does not exist makes Docker create it as a DIRECTORY and the boot fails confusingly).
52
+ # Read-only: the receiver live-reloads this file on change; it never writes it.
53
+ volumes:
54
+ - ../triggers.json:/config/triggers.json:ro
55
+ # Loopback only, like Valkey's port above: the operator's reverse proxy or tunnel (TLS, public
56
+ # hostname) is what faces the internet -- never this port raw. Assumes the in-container default
57
+ # RECEIVER_PORT=3000; if your .env overrides it, adjust the right-hand side to match.
58
+ ports:
59
+ - "127.0.0.1:3000:3000"
60
+ depends_on:
61
+ valkey:
62
+ condition: service_healthy
63
+ restart: unless-stopped
64
+
65
+ volumes:
66
+ valkey-data:
@@ -2,10 +2,21 @@
2
2
  REM UNTESTED EXAMPLE -- a starting point for a Windows service, not a shipped, verified unit. Adapt it.
3
3
  REM
4
4
  REM pi-dispatch launcher for Windows service managers (nssm; see deploy/nssm-install.cmd). Windows
5
- REM services have no `.env` mechanism, so this wrapper loads `.env` from the repo root itself, then
6
- REM launches node. It reads ONLY the declared `.env` (see `.env.example`), never the host user profile:
7
- REM the container-boundary rules require an explicit, auditable variable set. Nothing here contains a
8
- REM credential -- the secrets live in `.env`, which is gitignored and read at runtime.
5
+ REM services have no `.env` mechanism, so this wrapper loads `.env` from the current directory itself,
6
+ REM then runs the command it was handed. It reads ONLY the declared `.env` (see `.env.example`), never
7
+ REM the host user profile: the container-boundary rules require an explicit, auditable variable set.
8
+ REM Nothing here contains a credential -- the secrets live in `.env`, which is gitignored and read at
9
+ REM runtime.
10
+ REM
11
+ REM CONTRACT (issue #96 -- mirrors the .sh twin; nothing is guessed from this script's location):
12
+ REM - The current directory IS the deployment folder. nssm's AppDirectory guarantees it, set both by
13
+ REM deploy/nssm-install.cmd and by `pi-dispatch service install`. The old `cd /d "%~dp0.."`
14
+ REM self-guess was right only in a repo checkout; under `npm install` this script lives at
15
+ REM node_modules\@edgehero\pi-dispatch\deploy\, whose parent is the package -- no `.env` there.
16
+ REM - The arguments ARE the command, e.g.: C:\path\to\node.exe C:\...\src\cli.mjs worker
17
+ REM `pi-dispatch service install` passes them via nssm AppParameters. This wrapper no longer
18
+ REM decides WHAT to run -- only the env it runs in and what its exit code means -- so an empty
19
+ REM argument list is a configuration error, refused below.
9
20
  REM
10
21
  REM TRAP: inside pi, ANTHROPIC_OAUTH_TOKEN silently takes precedence over ANTHROPIC_API_KEY. Set exactly
11
22
  REM one in `.env`.
@@ -20,23 +31,21 @@ REM multiple services. Requires the AOF-enabled Valkey from deploy/docker-compos
20
31
 
21
32
  setlocal
22
33
 
23
- REM Resolve repo root relative to this script (deploy\ is one level down).
24
- cd /d "%~dp0.." || exit /b 1
34
+ if "%~1"=="" (
35
+ echo worker-env-wrapper: no command given -- expected: worker-env-wrapper.cmd node-path script-path [args...]; re-render with: pi-dispatch service render 1>&2
36
+ exit /b 1
37
+ )
25
38
 
26
39
  if not exist ".env" (
27
- echo worker-env-wrapper: .env not found in "%CD%" 1>&2
40
+ echo worker-env-wrapper: .env not found in "%CD%" -- this wrapper must be started in the deployment folder, the service's nssm AppDirectory; it no longer guesses a location from its own path 1>&2
28
41
  exit /b 1
29
42
  )
30
43
 
31
44
  for /f "usebackq eol=# tokens=1,* delims==" %%A in (".env") do set "%%A=%%B"
32
45
 
33
- REM One wrapper serves both daemons (see the .sh twin): no argument runs the worker; `receiver`
34
- REM (passed by `pi-dispatch service --receiver`) runs the webhook receiver.
35
- if "%~1"=="receiver" (
36
- node receiver\src\start.mjs
37
- ) else (
38
- node worker\src\cli.mjs worker
39
- )
46
+ REM The argv runs verbatim -- absolute node, absolute script, composed by `pi-dispatch service` (see
47
+ REM the .sh twin for the whole contract).
48
+ %*
40
49
  set "RC=%ERRORLEVEL%"
41
50
 
42
51
  REM Exit 2 is EXIT_POLICY (worker\src\exit-code.mjs): a determinate config/budget refusal. nssm's
@@ -1,9 +1,20 @@
1
1
  #!/bin/sh
2
2
  # pi-dispatch launcher for daemon managers that have NO EnvironmentFile mechanism. systemd reads
3
3
  # `.env` for you via `EnvironmentFile=` (see deploy/worker.service); launchd (macOS) has no equivalent --
4
- # a plist's ProgramArguments cannot name a `.env`. This wrapper closes that gap: launchd execs THIS
5
- # script, which loads the explicit `.env` from the repo root and then runs node. Its exit-code
6
- # conversion and signal forwarding are exercised under `sh` by worker/test/service.test.mjs.
4
+ # a plist's ProgramArguments cannot name a `.env`. This wrapper closes that gap: it sources `./.env`
5
+ # from the directory it is STARTED IN, then runs the command it was handed. Its exit-code conversion
6
+ # and signal forwarding are exercised under `sh` by worker/test/service.test.mjs.
7
+ #
8
+ # CONTRACT (issue #96 -- nothing here is guessed from this script's own location any more):
9
+ # - The current directory IS the deployment folder. The daemon manager guarantees it: launchd sets
10
+ # the plist's WorkingDirectory, nssm sets AppDirectory. The old `cd "$(dirname "$0")/.."` self-guess
11
+ # was right only in a repo checkout; under `npm install` this script lives at
12
+ # node_modules/@edgehero/pi-dispatch/deploy/, whose parent is the package -- no `.env` there, ever.
13
+ # - "$@" IS the command, e.g.: /path/to/node /abs/path/to/src/cli.mjs worker
14
+ # `pi-dispatch service` composes it with absolute paths (the same node that rendered, the worker
15
+ # package's own cli.mjs or the receiver package's start.mjs) and puts it in the unit's
16
+ # ProgramArguments / AppParameters. This wrapper no longer decides WHAT to run -- only the env it
17
+ # runs in and what its exit code means -- so an empty argv is a configuration error, refused below.
7
18
  #
8
19
  # It sources ONLY the declared `.env` (see `.env.example`), never the host login shell: the
9
20
  # container-boundary rules require an explicit, auditable variable set, not whatever the operator's
@@ -17,19 +28,15 @@
17
28
  # One worker per host (DES-CONCURRENCY-3): parallelism is PI_CONCURRENCY inside the single process, not
18
29
  # multiple daemons. Requires the AOF-enabled Valkey from deploy/docker-compose.yml.
19
30
 
20
- # Resolve repo root relative to this script (deploy/ is one level down).
21
- cd "$(dirname "$0")/.." || exit 1
22
- if [ ! -f .env ]; then echo "worker-env-wrapper: .env not found in $(pwd)" >&2; exit 1; fi
23
- set -a; . ./.env; set +a
24
-
25
- # One wrapper serves both daemons, because the gap it closes (no EnvironmentFile under launchd/nssm)
26
- # is identical for both: no argument runs the worker; `receiver` (passed by the derived receiver
27
- # units `pi-dispatch service` renders) runs the webhook receiver.
28
- if [ "$1" = "receiver" ]; then
29
- set -- node receiver/src/start.mjs
30
- else
31
- set -- node worker/src/cli.mjs worker
31
+ if [ "$#" -eq 0 ]; then
32
+ echo "worker-env-wrapper: no command given -- expected: worker-env-wrapper.sh /path/to/node /path/to/script [args...]; the unit's ProgramArguments/AppParameters carry these (re-render with: pi-dispatch service render)" >&2
33
+ exit 1
32
34
  fi
35
+ if [ ! -f ./.env ]; then
36
+ echo "worker-env-wrapper: .env not found in $PWD -- this wrapper must be started in the deployment folder (the unit's WorkingDirectory / nssm AppDirectory); it no longer guesses a location from its own path" >&2
37
+ exit 1
38
+ fi
39
+ set -a; . ./.env; set +a
33
40
 
34
41
  # `exec` is deliberately GONE here (it used to hand this shell's pid straight to node): intercepting
35
42
  # the exit code needs a parent still alive after node exits. launchd's KeepAlive/SuccessfulExit=false
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@edgehero/pi-dispatch",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
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": [
package/src/cli.mjs CHANGED
@@ -36,7 +36,9 @@ const USAGE = `pi-dispatch — run pi coding-agent flows on your own folders
36
36
  pi-dispatch resume resume taking jobs
37
37
  pi-dispatch status show paused state + job counts
38
38
 
39
- Config comes from the environment (see .env.example); flags override it per run.`;
39
+ Config comes from the environment (see .env.example); flags override it per run.
40
+ Prefer being walked through all of this? The operator panel's /dispatch setup does every step
41
+ with a consent per action: pi install npm:@edgehero/pi-dispatch-admin`;
40
42
 
41
43
  export async function main(argv = process.argv.slice(2), env = process.env) {
42
44
  const cmd = argv[0];
package/src/doctor.mjs CHANGED
@@ -859,6 +859,47 @@ export async function collectChecks(env, seams) {
859
859
  });
860
860
  }
861
861
 
862
+ // REQ-SCOPED-PAUSE-WINDOWS, the panel-writes-what-the-worker-ignores trap (issue #99). Three defaults
863
+ // that are individually defensible and together silent:
864
+ //
865
+ // - `pi-dispatch init` SCAFFOLDS ./pause-windows.json and leaves PI_PAUSE_WINDOWS_FILE commented out;
866
+ // - the admin panel defaults to ./pause-windows.json in its OWN cwd, so `w` reads and WRITES that file
867
+ // and reports every window it adds as applied live;
868
+ // - the worker has NO cwd default (config.mjs: `?? null`, and null means the feature is off).
869
+ //
870
+ // So an operator adds quiet hours in the panel, is told it is live, and nothing ever pauses -- the one
871
+ // failure mode where the UI actively asserts the opposite of the truth. The worker's fail-closed default
872
+ // is deliberate and is NOT changed here: a worker must not start honouring a file nobody pointed it at,
873
+ // least of all one that stops paid work. The mismatch is a deployment fact, so doctor is where it
874
+ // belongs. Warn, never fail, like every other setup-shaped check: a deployment can legitimately be
875
+ // mid-setup, and a scaffolded file the operator never intended to use is not a fault.
876
+ //
877
+ // Empty counts as unset, mirroring `if (config.pauseWindowsFile)` at the worker's own load site.
878
+ //
879
+ // NEVER TIER, deliberately no fixAction: doctor cannot know which path the operator meant. This cwd is
880
+ // doctor's, not necessarily the worker's (a service manager sets its own), and writing an env line into
881
+ // .env would be doctor guessing a semantic value -- the same refusal PI_GLOBAL_ALLOW_EXTENSIONS gets.
882
+ // The fix line names the variable and the absolute path, and the operator decides.
883
+ //
884
+ // SUBSCRIPTIONS GET NO SUCH CHECK, checked rather than assumed: ./subscriptions.json is scaffolded by the
885
+ // same init and PI_SUBSCRIPTIONS_FILE is commented out the same way, but the admin extension is its ONLY
886
+ // reader and writer (nothing reads it at job time), and the admin's own default IS ./subscriptions.json
887
+ // (admin/src/read-model.mjs) -- so with the variable unset the one component that cares already finds the
888
+ // scaffolded file. There is no second reader to disagree with, hence no trap, hence no warn: a line that
889
+ // fires where nothing is broken teaches operators to skim past the ones that matter.
890
+ {
891
+ const pauseWindowsFile = env.PI_PAUSE_WINDOWS_FILE;
892
+ const scaffolded = join(cwd, "pause-windows.json");
893
+ if ((typeof pauseWindowsFile !== "string" || pauseWindowsFile.trim() === "") && fileExists(scaffolded)) {
894
+ checks.push({
895
+ ok: false,
896
+ warn: true,
897
+ label: `${scaffolded} exists but PI_PAUSE_WINDOWS_FILE is unset -- the worker ignores it, so scoped pauses are OFF`,
898
+ fix: `set PI_PAUSE_WINDOWS_FILE=${scaffolded} in .env and restart the worker -- unset means the worker loads no windows at all, while the admin panel defaults to this same file and reports each window it writes as applied live; delete the file if this deployment has no quiet hours`,
899
+ });
900
+ }
901
+ }
902
+
862
903
  // REQ-RESURRECTABLE-SANDBOX. A warning, never a failure: retention is a convenience, and the only thing
863
904
  // worth surfacing is that finished runs' directories -- a repository clone plus the run's prompt.md and
864
905
  // event.json, so issue text -- are sitting on disk, and how many. An operator who never opens a sandbox
package/src/flow-gate.mjs CHANGED
@@ -30,10 +30,11 @@ const exec = promisify(execFile);
30
30
  */
31
31
 
32
32
  // The skill name charset: lowercase kebab/underscore, 1-64 chars, no dots (so no "..") and no
33
- // slashes. Mirrors materialize.mjs SKILL_NAME_RE (not exported there) — keep in sync. Validating
34
- // `flow` against it BEFORE building any path is the traversal choke point: a bad name is denied,
35
- // never interpolated into a git path.
36
- const SKILL_NAME_RE = /^[a-z0-9](?:[a-z0-9_-]{0,62}[a-z0-9])?$/;
33
+ // slashes. Validating `flow` against it BEFORE building any path is the traversal choke point: a
34
+ // bad name is denied, never interpolated into a git path. Exported as the single source of truth
35
+ // (issue #92): materialize.mjs imports it, and the admin's setup wizard lists a repo's .pi/skills
36
+ // through it — three keep-in-sync copies would drift exactly where a traversal guard cannot.
37
+ export const SKILL_NAME_RE = /^[a-z0-9](?:[a-z0-9_-]{0,62}[a-z0-9])?$/;
37
38
 
38
39
  export async function readFlowGate({ folder, flow, sha, git = defaultGit }) {
39
40
  // `sha` is REQUIRED and never defaulted: a missing SHA fails closed rather than resolving HEAD,
package/src/init.mjs CHANGED
@@ -73,5 +73,6 @@ Next:
73
73
  5. pi-dispatch worker # drain the queue
74
74
 
75
75
  Operator panel (optional): pi install npm:@edgehero/pi-dispatch-admin then /dispatch
76
+ (or let the panel do all of the above: /dispatch setup walks these steps with a consent per action)
76
77
  `;
77
78
  }
@@ -2,6 +2,7 @@ import { execFile } from "node:child_process";
2
2
  import { mkdirSync, writeFileSync } from "node:fs";
3
3
  import { dirname, isAbsolute, join, relative } from "node:path";
4
4
  import { promisify } from "node:util";
5
+ import { SKILL_NAME_RE } from "./flow-gate.mjs";
5
6
 
6
7
  const exec = promisify(execFile);
7
8
 
@@ -29,9 +30,10 @@ const PI_DIR = ".pi";
29
30
  const APPEND_SYSTEM = `${PI_DIR}/APPEND_SYSTEM.md`;
30
31
  // A skill directory name: lowercase kebab/underscore, 1-64 chars, no dots (so no "..") and no
31
32
  // slashes. This is what makes a traversal name impossible at the source. Matched against the
32
- // CAPTURED segment only, never the whole path.
33
+ // CAPTURED segment only, never the whole path. The name charset itself is imported from
34
+ // flow-gate.mjs (the exported single source of truth, issue #92); the path form stays local
35
+ // because only the materialiser walks whole tree paths.
33
36
  const SKILL_PATH_RE = /^\.pi\/skills\/([a-z0-9](?:[a-z0-9_-]{0,62}[a-z0-9])?)\/SKILL\.md$/;
34
- const SKILL_NAME_RE = /^[a-z0-9](?:[a-z0-9_-]{0,62}[a-z0-9])?$/;
35
37
 
36
38
  /**
37
39
  * Classify a git tree path into the destination we will WRITE, or null to reject.
package/src/processor.mjs CHANGED
@@ -9,18 +9,21 @@ import { EXIT_COMPLETED, EXIT_INFRA, EXIT_POLICY } from "./exit-code.mjs";
9
9
  * The order is the contract, and every step before `runContainer` must be free of provider spend:
10
10
  *
11
11
  * 0. refuse a job image this host does not have -- INT-CONTAINER-RUNTIME-CONTRACT
12
- * 1. mint a scoped token (GitHub jobs, and local jobs opted in via `github: true`)
12
+ * 1. REFUSE an armed `run.resume` with no session store to persist into (the one fail-CLOSED case)
13
+ * -- REQ-RESUMABLE-SESSION
14
+ * 2. mint a scoped token (GitHub jobs, and local jobs opted in via `github: true`)
13
15
  * -- CONST-TOKEN-SCOPED-PER-JOB
14
- * 2. REFUSE an unprotected default branch (GitHub jobs only -- a local job has no repo)
16
+ * 3. REFUSE an unprotected default branch (GitHub jobs only -- a local job has no repo)
15
17
  * -- REQ-BRANCH-PROTECTION-PRECONDITION
16
- * 3. resolve the default-branch SHA (fresh API), clone at it, materialise .pi/, write the prompt
17
- * 4. reserve a budget slot -- CONST-BUDGET-BEFORE-TOKENS
18
- * 5. ONLY NOW run the container (the only step that spends provider tokens)
19
- * 6. map the container exit code to retry-vs-success
18
+ * 4. resolve the default-branch SHA (fresh API), clone at it, materialise .pi/, write the prompt
19
+ * 5. reserve a budget slot -- CONST-BUDGET-BEFORE-TOKENS
20
+ * 6. ONLY NOW run the container (the only step that spends provider tokens)
21
+ * 7. map the container exit code to retry-vs-success
20
22
  *
21
23
  * Budget is reserved as late as possible but strictly before the container, so a refusal from an
22
- * earlier free gate (unprotected repo, clone failure) never consumes a daily slot. The container
23
- * is the only thing that spends money, so "before tokens" means "before this line".
24
+ * earlier free gate (unprotected repo, an armed resume with no store, clone failure) never consumes a
25
+ * daily slot. The container is the only thing that spends money, so "before tokens" means "before this
26
+ * line".
24
27
  *
25
28
  * Returns a result object on a non-retryable outcome; THROWS on a retryable (infra) one so BullMQ
26
29
  * retries per `attempts`. The caller (the BullMQ processor) turns the thrown/returned distinction
@@ -41,6 +44,17 @@ export async function runJob(job, deps) {
41
44
  // the store, on a COMPLETED exit only. Never throws. The default is a no-op so a wiring that omits
42
45
  // it behaves exactly as before -- no store, no promotion, no session in the record.
43
46
  promoteSession = () => null,
47
+ // The session store's root, or null when the feature is unavailable (REQ-RESUMABLE-SESSION). Read
48
+ // ONLY to answer the fail-closed gate below -- nothing here opens it, and it never reaches the
49
+ // container env (docker-run.mjs mounts a per-job COPY, never the store).
50
+ //
51
+ // The default is the env read config.mjs itself performs (`env.PI_SESSIONS_DIR || null`) rather than
52
+ // a bare `null`, and the difference is not cosmetic. A `null` default would make the gate refuse
53
+ // EVERY armed job under any wiring that does not pass this key -- a false refusal that looks exactly
54
+ // like the true one -- whereas the env is the single source both readers derive from, so the two
55
+ // cannot disagree about whether a store exists. A wiring may still pass `sessionsDir` explicitly to
56
+ // make the seam visible; it resolves to the same value.
57
+ sessionsDir = process.env.PI_SESSIONS_DIR || null,
44
58
  // (job) => scoped short-lived token. Takes the JOB, not the repo: which forge mints -- and therefore
45
59
  // which credential the container gets -- is a property of `job.kind`, and only the wiring knows the
46
60
  // map. Called for forge-backed jobs and for local jobs opted in via `github: true`; unflagged local
@@ -141,6 +155,41 @@ export async function runJob(job, deps) {
141
155
  throw new InfraRetry("docker unavailable, image preflight could not run", { reason: "container-never-started", provider: job.provider ?? null, model: job.model ?? null });
142
156
  }
143
157
 
158
+ // REQ-RESUMABLE-SESSION's one fail-CLOSED case. Everything else in that feature fails OPEN and
159
+ // NAMES itself -- absent, expired, too-large, unparseable, locked, no key -- because a cold start is
160
+ // a correct run. This one cannot be: with no `sessionsDir`, resolveSession returns null
161
+ // (session-store.mjs), so nothing is staged, no /session is mounted, the transcript dies with the
162
+ // container, and the NEXT job on that key cold-starts too. The job would exit 0 and look like the
163
+ // feature worked. That is an operator who believes a disclosure is on while it is off, with a green
164
+ // run to confirm the belief -- the inversion validatePackagesFlag's comment describes one flag over,
165
+ // arriving from the other direction.
166
+ //
167
+ // PLACEMENT IS THE POINT. Free, determinate, credential-less and I/O-less -- the answer is two
168
+ // values already in hand -- so it belongs among the free policy refusals and strictly before
169
+ // anything that spends: before the mint (no credential is needed to know the answer, so none is
170
+ // created only to be discarded), before the branch check's API call, before prepareWorkspace's
171
+ // clone, before checkTokenCap's read and before reserveBudget's INCR -- hence `budgetReserved:
172
+ // false`. It sits AFTER the image preflight for the reporting reason the token-cap comment below
173
+ // already states: a missing image blocks EVERY job of EVERY kind on this host, so it is the one an
174
+ // operator must fix first either way, while this blocks only the triggers that armed the flag.
175
+ //
176
+ // Strict `=== true`, the same test prepare-github.mjs uses to decide whether to resolve a session at
177
+ // all, so the gate and the feature cannot disagree about what "armed" means. Kind-agnostic on
178
+ // purpose: only forge jobs can arm the flag today (triggers.mjs refuses it on cron, and a CLI or
179
+ // chained job has no trigger entry that could set it), but a gate written as an enumeration of kinds
180
+ // is a gate the next kind skips silently.
181
+ if (job.resume === true && !sessionsDir) {
182
+ await comment(job, "Refused: this trigger set `run.resume` but PI_SESSIONS_DIR is unset, so there is nowhere to persist the transcript -- the job would run with no session and still report success. Set PI_SESSIONS_DIR to a private directory outside every repo, or drop `run.resume` from this trigger. Not run.");
183
+ // The variable NAME, never a value: there is no path to print here (its absence IS the refusal),
184
+ // and the store's path is the one setting SECURITY.md calls a PII store. `kind` is host-assigned,
185
+ // the same PII class as the `repo` on the branch refusal below.
186
+ log("refused_sessions_dir_unset", { kind: job.kind ?? null });
187
+ // exitCode/turns/tokens null and budgetReserved false: refused pre-container AND pre-reserve,
188
+ // exactly as the image refusals above. RETURNED, not thrown: an unset environment variable is
189
+ // determinate, and no number of retries sets it (CONST-RETRY-INFRA-ONLY).
190
+ return { outcome: "policy", reason: "sessions-dir-unset", exitCode: null, turns: null, tokens: null, provider: job.provider ?? null, model: job.model ?? null, budgetReserved: false }; // return => not retried
191
+ }
192
+
144
193
  if (wantsForgeToken) {
145
194
  token = await mintToken(job);
146
195
 
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.
package/src/start.mjs CHANGED
@@ -352,6 +352,11 @@ export async function startWorker(
352
352
  // Completed-only, so a policy or infra exit leaves the canonical transcript byte-identical and a
353
353
  // retry starts from what the first attempt did (CONST-RETRY-INFRA-ONLY).
354
354
  promoteSession: sessionStore.promoteSession,
355
+ // The same value the store above was built from, passed explicitly so the processor's fail-closed
356
+ // `run.resume` gate answers from THIS config rather than from its own env default. Identical on the
357
+ // real path; the difference shows under an injected env, where the store would be built from the
358
+ // synthetic value while the gate read the process one.
359
+ sessionsDir: config.sessionsDir,
355
360
  runContainer: makeRunContainerFn({
356
361
  image: config.jobImage,
357
362
  hostEnv: env,
package/src/triggers.mjs CHANGED
@@ -183,6 +183,10 @@ function normalizeCron(on, run, index, path, state) {
183
183
 
184
184
  const packages = validatePackagesFlag(run, `cron trigger "${id}"`, path);
185
185
  const image = validateImageRef(run, `cron trigger "${id}"`, path);
186
+ // RETURNED, not discarded like validateReplicas below, because `resume` still has a legal value on a
187
+ // cron entry: only `true` is refused (the local path has nothing to resume with), so what survives is
188
+ // `false` or absent. Both must keep reaching the job payload unchanged -- an operator who wrote down
189
+ // today's default must not get a `data` that disagrees with the file they reviewed.
186
190
  const resume = validateResumeFlag(run, `cron trigger "${id}"`, path);
187
191
  // Called and DISCARDED: on a cron trigger this can only refuse, and the refusal is the point. The
188
192
  // returned `run` below deliberately grows no `replicas` key -- a cron entry can never carry one.
@@ -230,9 +234,25 @@ function validatePackagesFlag(run, at, path) {
230
234
  * to host disk and replayed into a later job on the same key. Disclosures default off. Absent and `false`
231
235
  * both mean today's behaviour, with not one byte written to disk and no /session mount in the argv.
232
236
  *
233
- * Carried on all four kinds for `run.image`'s reason rather than cron-only like `run.github`: continuing a
234
- * conversation is a property of the FLOW, and a cron trigger's flow is a flow. A cron job keys on its own
235
- * scheduler id (session-key.mjs), which is the one key in this feature chosen by nobody untrusted.
237
+ * REFUSED on a CRON trigger, and the refusal is the honest half of this validator rather than a limit of
238
+ * the feature. `resolveSession` is handed to the FORGE preparers only (prepare.mjs); the local branch
239
+ * returns before it is ever in scope, and prepare-local.mjs contains no session code at all -- so an armed
240
+ * cron trigger stages no transcript, mounts no /session, promotes nothing, and then exits 0 as though it
241
+ * had. `validateReplicas` states the argument in one line and it applies verbatim here: a field accepted
242
+ * where it does nothing is how an operator comes to trust one that does nothing.
243
+ *
244
+ * "NOT YET COVERED", not impossible -- validateReplicas' own distinction, kept because the two are
245
+ * different facts and an operator planning work needs the right one. The local key already exists and is
246
+ * the strongest key in this feature: session-key.mjs keys a cron job on its scheduler id, which is
247
+ * operator-authored, unique across the file, stable across fires, and chosen by nobody untrusted. Nothing
248
+ * reaches it. Wiring `resolveSession` into the local path is a feature, and this line is what stops the
249
+ * flag from pretending that feature landed in the meantime.
250
+ *
251
+ * Only `true` is refused, and the asymmetry with validateReplicas -- which refuses ANY value on cron -- is
252
+ * deliberate. `run.replicas: 1` is refused because a one-member replica set is a flag that does nothing,
253
+ * so the field has no legal no-op value; `run.resume: false` IS the documented default, so refusing it
254
+ * would refuse an operator for writing down the behaviour they already have, and would change a normalized
255
+ * shape that has to stay byte-identical.
236
256
  *
237
257
  * Strictly boolean and fail-loud, the house rule -- and here the damaging misreading is a truthy `"false"`
238
258
  * string, which reads to an operator as an opt-out and would arm the disclosure instead. That is the exact
@@ -240,7 +260,9 @@ function validatePackagesFlag(run, at, path) {
240
260
  *
241
261
  * Type here, reality at job start, exactly as `run.image` splits it: this cannot know whether
242
262
  * PI_SESSIONS_DIR is set, whether a key resolves, or whether a transcript exists. Those are the worker's
243
- * to answer, and all but the first degrade to a cold start rather than refusing.
263
+ * to answer, and all but the first degrade to a cold start rather than refusing -- the first is the one
264
+ * pre-spend policy refusal, `sessions-dir-unset` in processor.mjs (REQ-RESUMABLE-SESSION fails CLOSED
265
+ * there and only there).
244
266
  *
245
267
  * `at` is the caller's message prefix. Returns the flag, undefined when absent, so an unflagged trigger
246
268
  * normalizes byte-identically to today's.
@@ -249,6 +271,14 @@ function validateResumeFlag(run, at, path) {
249
271
  if (run.resume !== undefined && typeof run.resume !== "boolean") {
250
272
  throw configError(`${at}: run.resume must be true or false when present: ${path}`);
251
273
  }
274
+ // `run.kind === "local"` IS "this is a cron trigger": normalizeTrigger has already refused every other
275
+ // pairing of on.type and run.kind, so the matrix makes the two synonyms. The same test validateReplicas
276
+ // keys its first refusal on, for the same reason -- neither wants to be re-taught the matrix.
277
+ if (run.resume === true && run.kind === "local") {
278
+ throw configError(
279
+ `${at}: run.resume is not yet covered for cron triggers (forge triggers only in this version) -- resolveSession is handed to the forge preparers only, so a local job would stage no transcript, mount no /session and promote nothing, then exit 0 as though it had; the local session key exists in session-key.mjs and nothing reaches it, so this is a gap to close, not a limit: ${path}`,
280
+ );
281
+ }
252
282
  return run.resume;
253
283
  }
254
284