@edgehero/pi-dispatch 0.1.1 → 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 +2 -1
- package/deploy/com.pi-dispatch.worker.plist +16 -6
- package/deploy/docker-compose.yml +66 -0
- package/deploy/worker-env-wrapper.cmd +23 -14
- package/deploy/worker-env-wrapper.sh +22 -15
- package/package.json +1 -1
- package/src/doctor.mjs +41 -0
- package/src/processor.mjs +57 -8
- package/src/service.mjs +135 -40
- package/src/session-store.mjs +13 -4
- package/src/start.mjs +5 -0
- package/src/triggers.mjs +34 -4
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.
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
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
|
|
25
|
-
WorkingDirectory) and /opt/pi-dispatch/logs (StandardOutPath,
|
|
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
|
|
6
|
-
REM
|
|
7
|
-
REM the container-boundary rules require an explicit, auditable variable set.
|
|
8
|
-
REM credential -- the secrets live in `.env`, which is gitignored and read at
|
|
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
|
-
|
|
24
|
-
|
|
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
|
|
34
|
-
REM
|
|
35
|
-
|
|
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:
|
|
5
|
-
#
|
|
6
|
-
#
|
|
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
|
-
|
|
21
|
-
|
|
22
|
-
|
|
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.
|
|
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/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/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.
|
|
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
|
-
*
|
|
16
|
+
* 3. REFUSE an unprotected default branch (GitHub jobs only -- a local job has no repo)
|
|
15
17
|
* -- REQ-BRANCH-PROTECTION-PRECONDITION
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
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
|
|
23
|
-
* is the only thing that spends money, so "before tokens" means "before this
|
|
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
|
|
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
|
-
//
|
|
36
|
-
//
|
|
37
|
-
//
|
|
38
|
-
//
|
|
39
|
-
|
|
40
|
-
const
|
|
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", //
|
|
51
|
-
"WorkingDirectory=/opt/pi-dispatch", // /opt/pi-dispatch → the
|
|
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>", //
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
.
|
|
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
|
|
283
|
-
*
|
|
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
|
-
//
|
|
299
|
-
//
|
|
300
|
-
//
|
|
301
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
312
|
-
*
|
|
313
|
-
*
|
|
314
|
-
*
|
|
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.
|
|
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,
|
|
323
|
-
["set", service, "AppDirectory", ctx.
|
|
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
|
|
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})`);
|
package/src/session-store.mjs
CHANGED
|
@@ -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
|
|
36
|
-
*
|
|
37
|
-
*
|
|
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
|
-
|
|
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
|
-
*
|
|
234
|
-
*
|
|
235
|
-
*
|
|
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
|
|