@edgehero/pi-dispatch 4.3.0 → 5.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (257) hide show
  1. package/.env.example +94 -4
  2. package/README.md +20 -7
  3. package/deploy/console.service +52 -0
  4. package/deploy/docker-compose.yml +3 -1
  5. package/deploy/pi-dispatch-valkey.container +4 -2
  6. package/deploy/sandbox-gateway.service +23 -0
  7. package/deploy/worker-env-wrapper.cmd +6 -0
  8. package/deploy/worker-env-wrapper.sh +10 -1
  9. package/package.json +117 -19
  10. package/src/agent-snapshot.mjs +49 -0
  11. package/src/answerer-runtime.mjs +194 -0
  12. package/src/answerer.mjs +178 -0
  13. package/src/approval-policy.mjs +76 -0
  14. package/src/approval-runtime.mjs +188 -0
  15. package/src/approval-session.mjs +137 -0
  16. package/src/approval-state.mjs +170 -0
  17. package/src/auth-cli.mjs +86 -0
  18. package/src/auth-connections.mjs +145 -0
  19. package/src/auth-policy.mjs +32 -0
  20. package/src/auth-store.mjs +97 -0
  21. package/src/azure-host.mjs +66 -4
  22. package/src/azure-prompt.mjs +5 -4
  23. package/src/backend-conformance.mjs +34 -2
  24. package/src/backend-local.mjs +103 -34
  25. package/src/backend-openshell.mjs +1540 -0
  26. package/src/backend-podman.mjs +63 -2
  27. package/src/backend-registry.mjs +61 -4
  28. package/src/backends.mjs +395 -6
  29. package/src/bullmq-internals.mjs +208 -0
  30. package/src/canary-cli.mjs +28 -0
  31. package/src/canary-lock.mjs +41 -0
  32. package/src/canary-runtime.mjs +237 -0
  33. package/src/canary.mjs +53 -0
  34. package/src/cancel-cli.mjs +47 -34
  35. package/src/cancel-state.mjs +213 -15
  36. package/src/capabilities.mjs +36 -1
  37. package/src/capacity-cli.mjs +28 -8
  38. package/src/capacity-records.mjs +75 -16
  39. package/src/capacity.mjs +65 -0
  40. package/src/chat-cli.mjs +288 -0
  41. package/src/chat-fork.mjs +547 -0
  42. package/src/chat-mode.mjs +156 -0
  43. package/src/chat-view.mjs +720 -0
  44. package/src/checkpoint-paths.mjs +113 -0
  45. package/src/cli.mjs +128 -41
  46. package/src/comment-tables.mjs +322 -0
  47. package/src/community-auth-adapters.mjs +87 -0
  48. package/src/community-auth-helper.mjs +76 -0
  49. package/src/community-auth-metadata.mjs +6 -0
  50. package/src/community-auth-output-pins.mjs +38 -0
  51. package/src/community-auth-patches.mjs +168 -0
  52. package/src/community-auth-pins.mjs +38 -0
  53. package/src/config.mjs +199 -30
  54. package/src/connection.mjs +83 -7
  55. package/src/console-answer.mjs +113 -0
  56. package/src/console-audit.mjs +604 -0
  57. package/src/console-bootstrap.mjs +159 -0
  58. package/src/console-client.mjs +848 -0
  59. package/src/console-probe.mjs +106 -0
  60. package/src/console-tokens.mjs +459 -0
  61. package/src/container-names.mjs +51 -0
  62. package/src/container-spec.mjs +20 -1
  63. package/src/cpu-reserve.mjs +4 -1
  64. package/src/defer-reasons.mjs +200 -0
  65. package/src/defer-write.mjs +208 -0
  66. package/src/deployment-venue.mjs +23 -6
  67. package/src/docker-run.mjs +11 -0
  68. package/src/doctor-bundles.mjs +130 -0
  69. package/src/doctor-model-proposals.mjs +109 -0
  70. package/src/doctor-openshell.mjs +238 -0
  71. package/src/doctor-results.mjs +602 -0
  72. package/src/doctor-spawner.mjs +215 -0
  73. package/src/doctor.mjs +1663 -198
  74. package/src/dollar-budget.mjs +212 -3
  75. package/src/endpoint-status.mjs +100 -0
  76. package/src/env-allowlist.mjs +103 -9
  77. package/src/env-file.mjs +6 -4
  78. package/src/file-expect.mjs +47 -0
  79. package/src/forgejo-auth.mjs +4 -0
  80. package/src/forgejo-host.mjs +127 -31
  81. package/src/forgejo-prompt.mjs +5 -4
  82. package/src/forges.mjs +73 -15
  83. package/src/fork-job.mjs +58 -0
  84. package/src/fork-policy.mjs +23 -0
  85. package/src/fork-store.mjs +338 -0
  86. package/src/get-token.mjs +1 -1
  87. package/src/git-dirty.mjs +119 -14
  88. package/src/git-hardening.mjs +85 -0
  89. package/src/github-host.mjs +31 -6
  90. package/src/github-prompt.mjs +7 -16
  91. package/src/gitlab-host.mjs +42 -3
  92. package/src/gitlab-prompt.mjs +5 -4
  93. package/src/held-document.mjs +23 -0
  94. package/src/held-replies.mjs +325 -0
  95. package/src/held-settle.mjs +266 -0
  96. package/src/hook-runs.mjs +188 -0
  97. package/src/host-budget.mjs +8 -5
  98. package/src/host-clone.mjs +72 -0
  99. package/src/host-loads.mjs +557 -0
  100. package/src/host-registry.mjs +66 -6
  101. package/src/host-telemetry.mjs +92 -0
  102. package/src/http-body.mjs +78 -0
  103. package/src/image-preflight.mjs +156 -13
  104. package/src/import-pi.mjs +60 -172
  105. package/src/inbox-relay.mjs +482 -0
  106. package/src/inbox-settle.mjs +196 -0
  107. package/src/index.mjs +740 -129
  108. package/src/init.mjs +35 -5
  109. package/src/job-data-version.mjs +75 -0
  110. package/src/job-id.mjs +30 -15
  111. package/src/job-layout.mjs +22 -0
  112. package/src/job-size.mjs +16 -0
  113. package/src/live-jobs.mjs +14 -6
  114. package/src/live-probes.mjs +6 -2
  115. package/src/live-transcript.mjs +872 -0
  116. package/src/loaded-record.mjs +609 -0
  117. package/src/mcp-config.mjs +1 -1
  118. package/src/mcp-preflight.mjs +36 -5
  119. package/src/model-attempts.mjs +84 -0
  120. package/src/model-catalog.mjs +83 -0
  121. package/src/model-cooldowns.mjs +22 -0
  122. package/src/model-failover-protocol.mjs +11 -0
  123. package/src/model-failover.mjs +134 -0
  124. package/src/model-observation.mjs +50 -0
  125. package/src/model-proposal-protocol.mjs +21 -0
  126. package/src/model-proposals.mjs +75 -0
  127. package/src/model-ref.mjs +67 -10
  128. package/src/model-roles-file.mjs +24 -0
  129. package/src/model-roles.mjs +73 -0
  130. package/src/model-status.mjs +34 -0
  131. package/src/models-json.mjs +4 -1
  132. package/src/money.mjs +1 -1
  133. package/src/oauth-openai.mjs +121 -0
  134. package/src/older-state.mjs +249 -0
  135. package/src/on-failure.mjs +17 -13
  136. package/src/open-browser.mjs +31 -2
  137. package/src/opener-cli.mjs +135 -0
  138. package/src/opener-protocol.mjs +27 -0
  139. package/src/opener-terminal.mjs +128 -0
  140. package/src/openshell-cli.mjs +155 -0
  141. package/src/openshell-policy.mjs +163 -0
  142. package/src/outbox.mjs +14 -0
  143. package/src/output-cap.mjs +1 -0
  144. package/src/package-canary-record.mjs +26 -0
  145. package/src/package-canary.mjs +135 -0
  146. package/src/package-names.mjs +9 -0
  147. package/src/package-stage.mjs +418 -0
  148. package/src/packages.mjs +24 -16
  149. package/src/parked.mjs +2 -1
  150. package/src/pause-windows.mjs +136 -17
  151. package/src/podman-stack.mjs +40 -14
  152. package/src/portfolio-snapshot.mjs +26 -7
  153. package/src/prepare-github.mjs +41 -77
  154. package/src/prepare-local.mjs +32 -2
  155. package/src/prepare.mjs +45 -11
  156. package/src/private-store-boundary.mjs +70 -0
  157. package/src/processor.mjs +1554 -167
  158. package/src/projects.mjs +23 -0
  159. package/src/prompt-fence.mjs +18 -0
  160. package/src/provider-key.mjs +7 -0
  161. package/src/provider-steering.mjs +1 -0
  162. package/src/question-context.mjs +35 -0
  163. package/src/question-group-runtime.mjs +91 -0
  164. package/src/question-groups.mjs +171 -0
  165. package/src/queue.mjs +296 -55
  166. package/src/reason-catalog.mjs +448 -0
  167. package/src/receiver-status.mjs +230 -0
  168. package/src/record-reason.mjs +37 -0
  169. package/src/recovery-queue.mjs +566 -0
  170. package/src/redact-secrets.mjs +219 -0
  171. package/src/reserve-estimate.mjs +101 -0
  172. package/src/reserved-env.mjs +36 -1
  173. package/src/retention.mjs +67 -0
  174. package/src/run-budget-ledger.mjs +283 -0
  175. package/src/run-checkpoint.mjs +627 -0
  176. package/src/run-container.mjs +749 -33
  177. package/src/run-history.mjs +791 -45
  178. package/src/run-mirror.mjs +75 -7
  179. package/src/run-progress.mjs +751 -0
  180. package/src/runtime-observations.mjs +3 -1
  181. package/src/runtime-settings.mjs +272 -37
  182. package/src/sandbox-cli.mjs +78 -2
  183. package/src/sandbox-deployment.mjs +51 -0
  184. package/src/sandbox-gateway-protocol.mjs +378 -0
  185. package/src/sandbox-hosts.mjs +100 -0
  186. package/src/sandbox-inventory.mjs +83 -0
  187. package/src/sandbox-policy.mjs +225 -0
  188. package/src/sandbox-prepare-requests.mjs +145 -0
  189. package/src/sandbox-prepare.mjs +285 -0
  190. package/src/sandbox-refs.mjs +70 -0
  191. package/src/sandbox-session-monitor.mjs +4 -0
  192. package/src/sandbox-session-watch.mjs +124 -0
  193. package/src/sandbox-sessions.mjs +161 -0
  194. package/src/sandbox-store.mjs +63 -6
  195. package/src/sandbox-workspace-guard.mjs +39 -0
  196. package/src/sandbox.mjs +27 -200
  197. package/src/scheduler-stall-guard.mjs +14 -8
  198. package/src/schedules.mjs +19 -6
  199. package/src/secret-bundles.mjs +245 -0
  200. package/src/secret-profiles.mjs +103 -3
  201. package/src/secrets.mjs +19 -9
  202. package/src/service-env.mjs +38 -5
  203. package/src/service.mjs +638 -106
  204. package/src/session-key.mjs +60 -0
  205. package/src/session-store.mjs +168 -10
  206. package/src/setup-contract.mjs +40 -0
  207. package/src/setup-egress.mjs +113 -0
  208. package/src/setup-host-entry.mjs +33 -0
  209. package/src/setup-host.mjs +242 -0
  210. package/src/setup-lock.mjs +41 -0
  211. package/src/size-records.mjs +15 -3
  212. package/src/start.mjs +1353 -135
  213. package/src/status-comment.mjs +415 -0
  214. package/src/stderr-transcript.mjs +318 -0
  215. package/src/steer-ack.mjs +122 -0
  216. package/src/steer-cli.mjs +213 -0
  217. package/src/steer-framing.mjs +185 -0
  218. package/src/steer-inbox.mjs +693 -0
  219. package/src/store-version.mjs +139 -0
  220. package/src/subscription-config.mjs +56 -0
  221. package/src/subscription-limits.mjs +3 -0
  222. package/src/subscription-quota.mjs +259 -0
  223. package/src/subscription-runtime.mjs +123 -0
  224. package/src/subscription-status.mjs +42 -0
  225. package/src/subscription-wait.mjs +11 -0
  226. package/src/subscriptions.mjs +7 -1
  227. package/src/thinking-level.mjs +19 -0
  228. package/src/touch-outbox.mjs +61 -0
  229. package/src/touch-state.mjs +133 -0
  230. package/src/triggers-file.mjs +299 -39
  231. package/src/triggers.mjs +782 -40
  232. package/src/trust-paths.mjs +171 -0
  233. package/src/up.mjs +398 -51
  234. package/src/updates-cli.mjs +61 -0
  235. package/src/updates-look.mjs +127 -0
  236. package/src/updates-reconcile.mjs +182 -0
  237. package/src/updates-subscription-adapters.mjs +8 -0
  238. package/src/updates.mjs +252 -0
  239. package/src/valkey-auth.mjs +32 -9
  240. package/src/valkey-endpoint.mjs +14 -4
  241. package/src/wait-for.mjs +1 -1
  242. package/src/wait-state.mjs +62 -3
  243. package/src/watch-board.mjs +38 -0
  244. package/src/watch-closer.mjs +1 -1
  245. package/src/watch-contract.mjs +102 -0
  246. package/src/watch-decision.mjs +78 -0
  247. package/src/watch-drainer.mjs +14 -0
  248. package/src/watch-issue-adapter.mjs +44 -0
  249. package/src/watch-issue.mjs +111 -0
  250. package/src/watch-record.mjs +10 -0
  251. package/src/watch-runtime.mjs +167 -0
  252. package/src/watch-store.mjs +183 -0
  253. package/src/watch-trace.mjs +34 -0
  254. package/src/workspace-checkpoint.mjs +1750 -0
  255. package/src/zero-touch-fixes.mjs +99 -0
  256. package/src/zero-touch.mjs +225 -0
  257. package/src/repeat-slot.mjs +0 -13
@@ -48,6 +48,13 @@ export const CONTAINER_GLOBAL_PI_DIR = "/opt/pi-global";
48
48
  export const CONTAINER_SESSION_DIR = "/session";
49
49
  export const CONTAINER_SESSION_FILE = `${CONTAINER_SESSION_DIR}/current.jsonl`;
50
50
 
51
+ /**
52
+ * Where a steer-armed job's inbox lands (issue #646, INT-STEER-INBOX-CONTRACT): the runner (image/runner/src/inbox.mjs
53
+ * `INBOX_DIR`) reads it, the worker's relay writes the host side. A constant path, like `/session`, so nothing about
54
+ * the host's layout is legible inside the job.
55
+ */
56
+ export const CONTAINER_INBOX_DIR = "/inbox";
57
+
51
58
  /**
52
59
  * The job image's home, and the uid its `USER` directive runs as (issue #341, `DES-JOB-USER-INFERRED-READ-BACK-ON-
53
60
  * REQUEST`). A job run under `--user` needs `HOME=CONTAINER_HOME` beside it, because such a uid has no passwd entry in
@@ -132,6 +139,8 @@ export function assertUserns(userns) {
132
139
  * @param sessionDir host path to this job's OWN copy of its session transcript (REQ-RESUMABLE-SESSION);
133
140
  * mounted /session:rw. Per-job, like jobDir -- never the shared store.
134
141
  * @param globalPiDir host path to the operator's global pi overlay (REQ-GLOBAL-PI-OVERLAY); mounted /opt/pi-global:ro
142
+ * @param steerInboxDir host path to a steer-armed job's inbox (issue #646), `<jobDir>.inbox`; mounted /inbox:ro. Absent
143
+ * for every job that is not armed, so its argv is byte-identical to one built before the feature.
135
144
  * @param name container name (for `docker stop` at the timeout)
136
145
  * @param size the job's size, `{ memMiB, cpuCenti }` (issue #596, `job-size.mjs`); the built-in 4g and 2 when
137
146
  * absent. It becomes `memory`, `memorySwap` (equal: no swap beyond memory), `cpuShares` and `shmSize`
@@ -169,6 +178,7 @@ export function containerSpec({
169
178
  outboxDir,
170
179
  sessionDir,
171
180
  globalPiDir,
181
+ steerInboxDir,
172
182
  name,
173
183
  size = DEFAULT_JOB_SIZE,
174
184
  hostCpus = null,
@@ -181,6 +191,7 @@ export function containerSpec({
181
191
  extraFlags = [],
182
192
  relabel = false,
183
193
  workspaceOwned = false,
194
+ workspaceReadOnly = false,
184
195
  }) {
185
196
  if (!image) throw new Error("docker run: image is required");
186
197
  assertJobUser(user);
@@ -194,6 +205,7 @@ export function containerSpec({
194
205
  // Booleans, strictly: a truthy string from a caller that forwarded an option bag must not re-own host directories.
195
206
  if (typeof relabel !== "boolean") throw new Error(`docker run: relabel must be a boolean; got ${typeof relabel}`);
196
207
  if (typeof workspaceOwned !== "boolean") throw new Error(`docker run: workspaceOwned must be a boolean; got ${typeof workspaceOwned}`);
208
+ if (typeof workspaceReadOnly !== "boolean") throw new Error("docker run: workspaceReadOnly must be a boolean");
197
209
 
198
210
  // Issue #355. A SPREAD rather than a `relabel: null` field, so a spec built without it has mount objects byte-identical
199
211
  // to every one built before this feature existed. PRIVATE, never shared: each of these directories is made for this
@@ -208,7 +220,7 @@ export function containerSpec({
208
220
  // second container mounting it afterwards is denied, and the label an operator chose is gone. So only a workspace the
209
221
  // worker made is relabelled; an operator's folder needs the `semanage fcontext` fix doctor names, and the runner
210
222
  // refuses one it cannot read before any spend.
211
- mounts.push({ host: workspace, container: "/workspace", readOnly: false, ...(workspaceOwned ? own : {}) });
223
+ mounts.push({ host: workspace, container: "/workspace", readOnly: workspaceReadOnly, ...(workspaceOwned ? own : {}) });
212
224
  // Local jobs get a writable /outbox host bind, the same host-bind mechanism as /workspace
213
225
  // (DES-WORKER-ON-HOST). github jobs pass no outboxDir, so the request channel does not exist for
214
226
  // them -- an untrusted issue author cannot chain (INT-OUTBOX-CONTRACT).
@@ -223,6 +235,13 @@ export function containerSpec({
223
235
  // resolved, so an unarmed job's argv is byte-identical to one built before this feature existed.
224
236
  if (sessionDir) mounts.push({ host: sessionDir, container: CONTAINER_SESSION_DIR, readOnly: false, ...own });
225
237
 
238
+ // A steer-armed job's inbox (issue #646, DES-STEER-VIA-READONLY-INBOX-MOUNT, INT-STEER-INBOX-CONTRACT). READ-ONLY: the
239
+ // worker writes the messages, the job only reads them, so the channel is one way and nothing the agent writes there
240
+ // can reach the worker (the delivery ack travels in the transcript, MACed, never through this directory). Per job and
241
+ // BESIDE the job directory, never inside it: `/job` is merge-gated input only. Relabelled like the other per-job
242
+ // directories (issue #355), since it is made for this one container. Absent unless armed.
243
+ if (steerInboxDir) mounts.push({ host: steerInboxDir, container: CONTAINER_INBOX_DIR, readOnly: true, ...own });
244
+
226
245
  // The operator's global pi overlay (REQ-GLOBAL-PI-OVERLAY): custom models, global skills, a global
227
246
  // persona, layered UNDER each repo's own .pi/. Read-only -- it is operator-authored deploy-time config,
228
247
  // the same trust class as the baked floor, but the agent still must not rewrite it. Both job kinds.
@@ -179,13 +179,16 @@ export function cgroupParentFor(runtime) {
179
179
  * `parent` false when jobs run without the parent (`cgroupParentFor`): their shares are then capped instead
180
180
  * `method` `user-systemd` | `helper` | `system-systemd` | null (the quota is not kept here; `why` says which)
181
181
  * `why` a fixed token: `podman-cgroupfs`, `cgroup-v1`, `remote-daemon`, `rootless-docker`, `driver-unknown`,
182
- * `podman-rootful-remote` or null
182
+ * `podman-rootful-remote`, `venue-unmanaged` or null
183
183
  * `facts` is the venue's parsed info: for `local` the `docker info` facts (`parseDaemonFacts`, with `cgroupDriver` and
184
184
  * `cgroupVersion`), for `podman` the `podman info` read (`parsePodmanInfo`). `endpointLocal` is the docker endpoint's
185
185
  * observed locality, and `platform` this process's: a systemd read on THIS host says nothing about a remote daemon's.
186
186
  */
187
187
  export function reservePlan({ venue, facts, endpointLocal = false, platform = process.platform }) {
188
188
  const plan = (parent, method, why) => ({ venue, parent, method, why });
189
+ // Issue #681: only the two venues whose facts this reads are planned. Another venue used to fall through to docker's
190
+ // rules and could be handed docker's helper; it keeps no quota here, said with a token rather than guessed.
191
+ if (venue !== "local" && venue !== "podman") return plan(false, null, "venue-unmanaged");
189
192
  if (venue === "podman") {
190
193
  if (cgroupParentFor({ podman: true, cgroupManager: facts?.cgroupManager }) === null) return plan(false, null, "podman-cgroupfs");
191
194
  if (facts?.cgroupVersion && facts.cgroupVersion !== "v2") return plan(true, null, "cgroup-v1");
@@ -0,0 +1,200 @@
1
+ /**
2
+ * Why a job waits (issue #737): the closed vocabulary the worker writes into `defer:job:<id>` when it puts a job back
3
+ * on the delayed set, and the rules a reader holds each field to. A LEAF: it imports nothing but the BullMQ leaf below,
4
+ * so the admin console can import it (`@edgehero/pi-dispatch/defer-reasons`) without pulling the worker's dependencies
5
+ * into its bundle. The writer is `defer-write.mjs`; the reader is the admin's `readWaiting`.
6
+ *
7
+ * The record is the worker's own words for its own decision, written at the moment it defers, so a reader never has to
8
+ * guess a reason from a job's data (which it must not read: `INT-WAIT-PROFILES-CONTRACT`) or from its timing. A job
9
+ * with no record says so ("No reason was recorded"), and one whose record names a reason this console does not know
10
+ * says that; each is true, never a guess. Every value is judged here on the way in (`deferHsetArgs`) and on the way out
11
+ * (`parseDefer`), because the hash lives in a shared Valkey that anything with its URL can write.
12
+ */
13
+
14
+ /** The hash's key prefix; the job's id follows it. */
15
+ export const DEFER_KEY = "defer:job:";
16
+
17
+ /** The defer record's key for a job id. */
18
+ export const deferKey = (jobId) => `${DEFER_KEY}${jobId}`;
19
+
20
+ /**
21
+ * The buckets a console groups waiting jobs by, in the order it shows them. The last four are the reader's own and no
22
+ * reason in `DEFER_WHYS` is in them: `paused` holds a job in a paused queue, `due` a delayed job whose due instant has
23
+ * passed with no worker taking it (judged from the delayed set and the read's clock, whatever its record says),
24
+ * `unknown` a job whose record names a reason this console does not know (it has the reason charset but is not in
25
+ * `DEFER_WHYS`: most often a newer worker's), and `none` a job with no record, or with one whose reason fails the
26
+ * charset (no reason at all). Each title is true of every job in its bucket, which is why a reason this console cannot
27
+ * explain has a bucket of its own rather than sharing `none`'s "No reason was recorded".
28
+ */
29
+ export const DEFER_BUCKETS = Object.freeze(["slot", "run", "check", "retry", "pause", "ready", "paused", "due", "unknown", "none"]);
30
+
31
+ /** Each bucket's words: a waitlist's title and its short form (a chip, a row's tag). */
32
+ export const DEFER_BUCKET_WORDS = Object.freeze({
33
+ slot: Object.freeze({ title: "Waiting for a free slot", short: "Free slot" }),
34
+ run: Object.freeze({ title: "Waiting for another run to finish", short: "Another run" }),
35
+ check: Object.freeze({ title: "Waiting on a time or a check", short: "Time or check" }),
36
+ retry: Object.freeze({ title: "Retry backoff", short: "Retry backoff" }),
37
+ pause: Object.freeze({ title: "In a pause window", short: "Pause window" }),
38
+ ready: Object.freeze({ title: "Waiting for its host to get ready", short: "Host getting ready" }),
39
+ paused: Object.freeze({ title: "In a paused queue", short: "Queue paused" }),
40
+ due: Object.freeze({ title: "Due, and no worker has taken it", short: "Due, not taken" }),
41
+ unknown: Object.freeze({ title: "A reason this console does not know", short: "Unknown reason" }),
42
+ none: Object.freeze({ title: "No reason was recorded", short: "No reason recorded" }),
43
+ });
44
+
45
+ /**
46
+ * The `due` bucket's line, whose `{due}` is the job's due instant (the delayed set's, drawn as a `<time>`). Not a reason
47
+ * in `DEFER_WHYS`: no worker records it, since the job is overdue exactly because no worker has looked at it since.
48
+ */
49
+ export const DEFER_DUE_SENTENCE = "Due since {due}; no worker has taken it";
50
+
51
+ /**
52
+ * Every reason the worker can record: its bucket and the sentence a console shows, whose `{placeholders}` the
53
+ * console fills from the record's own fields (escaped; times drawn as `<time>`). A reason not in this table is
54
+ * written by nobody: `deferHsetArgs` throws on one (a programming error), and `parseDefer` reads one as unknown.
55
+ */
56
+ export const DEFER_WHYS = Object.freeze({
57
+ canary: Object.freeze({ bucket: "run", sentence: "Waiting for the operator model canary to finish" }),
58
+ "pause-window": Object.freeze({ bucket: "pause", sentence: "In a pause window until {until}" }),
59
+ "size-never-fits": Object.freeze({ bucket: "slot", sentence: "Its size fits no slot on {host}; another host may take it" }),
60
+ "host-slot": Object.freeze({ bucket: "slot", sentence: "{host} has no free job slot" }),
61
+ "scope-folder": Object.freeze({ bucket: "slot", sentence: "Its folder is busy with another run" }),
62
+ "scope-repo": Object.freeze({ bucket: "slot", sentence: "Its repository's concurrent limit is full" }),
63
+ "scope-project": Object.freeze({ bucket: "slot", sentence: "Its project's concurrent limit is full" }),
64
+ endpoint: Object.freeze({ bucket: "slot", sentence: "Every slot of model endpoint {endpoint} is taken" }),
65
+ "subscription-quota": Object.freeze({ bucket: "check", sentence: "The subscription account is exhausted; waits for newer evidence, a known reset or an operator trial. Asks again at {until}" }),
66
+ "subscription-owner": Object.freeze({ bucket: "ready", sentence: "Waits for the host that owns its subscription connection" }),
67
+ "subscription-auth": Object.freeze({ bucket: "check", sentence: "Waits until the subscription credential can be renewed at {until}" }),
68
+ "model-cooldown": Object.freeze({ bucket: "check", sentence: "No reviewed model is available; a model is cooling down. Asks again at {until}" }),
69
+ "model-window": Object.freeze({ bucket: "check", sentence: "No reviewed model is available; a model's dollar window is full. Asks again at {until}" }),
70
+ "host-budget": Object.freeze({ bucket: "slot", sentence: "{host}'s budget has no room for it yet" }),
71
+ "bundle-profile": Object.freeze({ bucket: "slot", sentence: "Waits for a host that serves its secret bundle profile" }),
72
+ chained: Object.freeze({ bucket: "run", sentence: "Chained after {holder}; starts when its folder is free" }),
73
+ "recovery-wait": Object.freeze({ bucket: "run", sentence: "Recovers run {holder}, whose attempt is still running" }),
74
+ "wait-after": Object.freeze({ bucket: "check", sentence: "Held until {until}" }),
75
+ "wait-hold": Object.freeze({ bucket: "check", sentence: "Held on its wait check; asks again at {until}" }),
76
+ "wait-throttled": Object.freeze({ bucket: "check", sentence: "Its wait check waits for a free check slot" }),
77
+ "wait-supersede": Object.freeze({ bucket: "check", sentence: "Asks again whether the delivery holding its target still waits" }),
78
+ "wait-aborted": Object.freeze({ bucket: "check", sentence: "Its wait check was stopped by a restart or a cancel; asks again soon" }),
79
+ "wait-unclear": Object.freeze({ bucket: "check", sentence: "Its wait check answered in a way the worker cannot read; asks again at {until}" }),
80
+ "podman-restart": Object.freeze({ bucket: "ready", sentence: "Waits for rootful Podman's service on {host} to restart" }),
81
+ "netns-keeper": Object.freeze({ bucket: "ready", sentence: "Waits for the network keeper on {host} to settle" }),
82
+ "newer-release": Object.freeze({ bucket: "ready", sentence: "Queued or configured by a newer pi-dispatch: waits for this host's upgrade" }),
83
+ retry: Object.freeze({ bucket: "retry", sentence: "Attempt {attempt} of {attempts} at {until}" }),
84
+ });
85
+
86
+ /** The hash's fields, in the order a reader's HMGET asks for them. `n` is the writer's own nonce, never shown. */
87
+ export const DEFER_FIELDS = Object.freeze(["why", "at", "until", "project", "host", "holder", "endpoint", "budgetWhy", "trigger", "attempt", "attempts", "n"]);
88
+
89
+ /** A reason's charset: what `parseDefer` takes as a reason word at all (a word outside the table reads as unknown). */
90
+ export const DEFER_WHY_RE = /^[a-z][a-z0-9-]{0,31}$/;
91
+ /** The writer's nonce: 16 hex characters, so a compare-and-delete can tell its own record from another worker's. */
92
+ export const DEFER_N_RE = /^[0-9a-f]{16}$/;
93
+ /** A job id the record may name (`holder`). Pinned equal to the admin reader's `RUN_ID_RE` by a test. */
94
+ export const DEFER_ID_RE = /^[A-Za-z0-9._:-]{1,200}$/;
95
+ /** A project id. A copy of `project-id.mjs`'s `PROJECT_ID_RE`, pinned equal to it by a test (this module imports nothing). */
96
+ export const DEFER_PROJECT_RE = /^[a-z0-9][a-z0-9-]{0,31}$/;
97
+ /** A host's name. A copy of `worker-name.mjs`'s `WORKER_NAME_RE`, pinned equal to it by a test. */
98
+ export const DEFER_HOST_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
99
+ /** A model endpoint's id. A copy of `model-endpoints.mjs`'s `MODEL_ENDPOINT_ID_RE`, pinned equal to it by a test. */
100
+ export const DEFER_ENDPOINT_RE = /^[a-z0-9-]{1,32}$/;
101
+
102
+ /**
103
+ * The last instant a UTC date-time can be written with a four-digit year: 9999-12-31T23:59:59.999Z. Every instant this
104
+ * leaf passes on (`at`, `until`, a due time) is at most this, so a console can always draw it. NOT the latest instant a
105
+ * JavaScript Date holds (8.64e15 ms, in the year 275760): `toISOString` writes a year past 9999 as six digits with a
106
+ * sign (`+275760-09-13T00:00:00.000Z`), and a console that draws the first sixteen characters as `YYYY-MM-DD HH:MM`
107
+ * would show a date that is not the instant's.
108
+ */
109
+ export const DEFER_TIME_MAX_MS = 253402300799999;
110
+
111
+ /** How long a record outlives the job's due time, so a record is still there while the job is promoted and picked up. */
112
+ export const DEFER_TTL_SLACK_MS = 60_000;
113
+ /** A record's lifetime: until the job is due, plus the slack, and never less than the slack. */
114
+ export const deferTtlMs = (untilMs, nowMs) => Math.max(DEFER_TTL_SLACK_MS, untilMs - nowMs + DEFER_TTL_SLACK_MS);
115
+
116
+ /**
117
+ * The delayed set's score and a scheduler's next run are BullMQ's facts, so they live in `bullmq-internals.mjs` (issue
118
+ * #761) and are re-exported here, where they were first published: a reader of this subpath keeps every name.
119
+ */
120
+ export { DELAYED_SCORE_SHIFT, dueOfScore, isSchedulerNext } from "./bullmq-internals.mjs";
121
+
122
+ /** A decimal safe integer, from a number or its string form, or null. */
123
+ function intOf(value) {
124
+ if (typeof value === "number") return Number.isSafeInteger(value) ? value : null;
125
+ if (typeof value !== "string" || !/^(?:0|[1-9][0-9]{0,15})$/.test(value)) return null;
126
+ const n = Number(value);
127
+ return Number.isSafeInteger(n) ? n : null;
128
+ }
129
+
130
+ /** An integer within [lo, hi], or null. */
131
+ function intIn(value, lo, hi) {
132
+ const n = intOf(value);
133
+ return n !== null && n >= lo && n <= hi ? n : null;
134
+ }
135
+
136
+ /** A string that passes `re`, or null. */
137
+ const textOf = (value, re) => (typeof value === "string" && re.test(value) ? value : null);
138
+
139
+ /** Each field's rule: the value as the record holds it (a number or a string), or null when it fails. */
140
+ const FIELD_RULES = Object.freeze({
141
+ why: (v) => (typeof v === "string" && Object.hasOwn(DEFER_WHYS, v) ? v : null),
142
+ at: (v) => intIn(v, 0, DEFER_TIME_MAX_MS),
143
+ until: (v) => intIn(v, 0, DEFER_TIME_MAX_MS),
144
+ project: (v) => textOf(v, DEFER_PROJECT_RE),
145
+ host: (v) => textOf(v, DEFER_HOST_RE),
146
+ holder: (v) => textOf(v, DEFER_ID_RE),
147
+ endpoint: (v) => textOf(v, DEFER_ENDPOINT_RE),
148
+ budgetWhy: (v) => textOf(v, DEFER_WHY_RE),
149
+ trigger: (v) => intIn(v, 0, 9999),
150
+ attempt: (v) => intIn(v, 1, 1000),
151
+ attempts: (v) => intIn(v, 1, 1000),
152
+ n: (v) => textOf(v, DEFER_N_RE),
153
+ });
154
+
155
+ /**
156
+ * The HSET arguments for a record, `[field, value, ...]`, each value judged by its field's rule and left out when it
157
+ * fails (a record with a missing field is still worth writing; one with a wrong field is not worth trusting). A
158
+ * reason not in `DEFER_WHYS` throws: every call site names its reason by a literal, so an unknown one is a bug.
159
+ */
160
+ export function deferHsetArgs(rec) {
161
+ const why = rec?.why;
162
+ if (typeof why !== "string" || !Object.hasOwn(DEFER_WHYS, why)) throw new RangeError(`defer-reasons: unknown reason ${JSON.stringify(why)}`);
163
+ const args = [];
164
+ for (const field of DEFER_FIELDS) {
165
+ const value = FIELD_RULES[field](rec[field]);
166
+ if (value !== null) args.push(field, String(value));
167
+ }
168
+ return args;
169
+ }
170
+
171
+ /**
172
+ * A record from an HMGET of `DEFER_FIELDS`, in that order: null when there is no `why` (no record, or none worth
173
+ * reading); otherwise every field, each null when it is absent or fails its rule. A `why` with the reason charset but
174
+ * not in the table is read as unknown (`known` false, bucket `unknown`): a newer worker's reason an older console does
175
+ * not know. A `why` outside the charset is no reason at all (`why` null, bucket `none`). The nonce `n` is the writer's
176
+ * and is never returned.
177
+ */
178
+ export function parseDefer(values) {
179
+ if (!Array.isArray(values)) return null;
180
+ const raw = values[0];
181
+ if (raw === null || raw === undefined || raw === "") return null;
182
+ const why = typeof raw === "string" && DEFER_WHY_RE.test(raw) ? raw : null;
183
+ const known = why !== null && Object.hasOwn(DEFER_WHYS, why);
184
+ const at = (i) => values[i];
185
+ return {
186
+ why,
187
+ known,
188
+ bucket: known ? DEFER_WHYS[why].bucket : why !== null ? "unknown" : "none",
189
+ at: FIELD_RULES.at(at(1)),
190
+ until: FIELD_RULES.until(at(2)),
191
+ project: FIELD_RULES.project(at(3)),
192
+ host: FIELD_RULES.host(at(4)),
193
+ holder: FIELD_RULES.holder(at(5)),
194
+ endpoint: FIELD_RULES.endpoint(at(6)),
195
+ budgetWhy: FIELD_RULES.budgetWhy(at(7)),
196
+ trigger: FIELD_RULES.trigger(at(8)),
197
+ attempt: FIELD_RULES.attempt(at(9)),
198
+ attempts: FIELD_RULES.attempts(at(10)),
199
+ };
200
+ }
@@ -0,0 +1,208 @@
1
+ /**
2
+ * The worker's half of "why does this job wait" (issue #737): every gate that puts a job back on the delayed set
3
+ * writes its reason into `defer:job:<id>` (`defer-reasons.mjs` holds the vocabulary and the field rules) just before
4
+ * BullMQ's move, through ONE function, `deferTo`, so no deferral can move a job without saying why. A retry after an
5
+ * infrastructure failure, which BullMQ delays by itself after the processor throws, is noted by `noteRetry` before
6
+ * the throw. Worker-only, and free of bullmq itself: the processor hands it BullMQ's `DelayedError` class (index.mjs
7
+ * imports bullmq; `valkey-clients.test.mjs` keeps every other module from naming that library), so the throw is the
8
+ * very class the processor's own catch tells a deferral apart by. The admin console never loads it (it reads the leaf).
9
+ *
10
+ * THE RECORD IS ADVISORY, so it FAILS OPEN and is BOUNDED. A deferral never fails or changes for its reason, and waits
11
+ * for it at most `DEFER_WRITE_TIMEOUT_MS` (2 s): a write that fails or takes longer is logged once per episode and the
12
+ * job is moved all the same, and a logger that throws is swallowed rather than rejecting the deferral. The bound is not
13
+ * decoration: the worker's client queues commands during an outage rather than rejecting them
14
+ * (`maxRetriesPerRequest: null`, the host registry's reason), so an unbounded write would hold a gate's deferral for
15
+ * the whole outage. The move itself is BullMQ's, exactly as it was before this file.
16
+ *
17
+ * WRITE, THEN MOVE. A reader starts from the delayed set and looks up the records of the ids it finds there, so a
18
+ * record written before the move is in place the moment the job is delayed, and one for a job that is still active
19
+ * is never read. The write is one MULTI (`DEL`, `HSET`, `PEXPIRE`), so a reader never sees this deferral's fields
20
+ * mixed with an earlier one's, and every record carries a TTL (`deferTtlMs`: the due time plus a minute's slack,
21
+ * so the record outlives the promotion and the pickup that follows it).
22
+ *
23
+ * A MOVE THAT REJECTS (a lost lock, Valkey gone) leaves a record for a job that is not delayed, so its own record is
24
+ * removed by a compare-and-delete on the writer's nonce (`n`): a plain DEL could remove the record another worker
25
+ * wrote for a later deferral of the same job id. The rejection is rethrown unchanged, so it reaches BullMQ's
26
+ * failed-attempt handling exactly as a raw `moveToDelayed` rejection always has.
27
+ *
28
+ * A TIMED-OUT WRITE MAY STILL LAND. The bound stops the waiting, not the command: a queued MULTI runs when the
29
+ * connection comes back. That is why a timed-out write still returns its nonce: the compare-and-delete is sent on
30
+ * the same connection after it, so it runs after the MULTI if that lands at all. A record that lands for a job that
31
+ * then ran is not read while the job is not delayed, and expires with its TTL.
32
+ *
33
+ * WHAT A RECORD CANNOT PROMISE. A write that failed leaves the previous deferral's record, until it expires, beside a
34
+ * job now delayed for another reason; and a job BullMQ delays by itself (a move's rejection retried, a backoff
35
+ * `retryDelayOf` cannot know) gets no fresh record. Both show as a record whose `until` is not the job's due time in
36
+ * the delayed set, which is how a reader tells a record that describes the wait from one that does not.
37
+ */
38
+ import { randomBytes } from "node:crypto";
39
+ import { DEFER_N_RE, DEFER_WHY_RE, deferHsetArgs, deferKey, deferTtlMs } from "./defer-reasons.mjs";
40
+
41
+ /** How long a gate waits for its reason's write, and a failed move for its compare-and-delete, before going on. */
42
+ export const DEFER_WRITE_TIMEOUT_MS = 2_000;
43
+
44
+ /** Delete the record only while it still carries this writer's nonce: never another worker's later record. */
45
+ export const DEFER_CAD_LUA = "if redis.call('HGET', KEYS[1], 'n') == ARGV[1] then return redis.call('DEL', KEYS[1]) end return 0";
46
+
47
+ /**
48
+ * The delay BullMQ will put on this job's retry after the current attempt throws, in ms, or null when it will not
49
+ * retry it on a delay this function can know. Mirrors the pinned BullMQ's `shouldRetryJob` and its builtin backoff
50
+ * strategies (job.js, backoffs.js at 5.80.4): it retries while `attemptsMade + 1 < opts.attempts` and the job is not
51
+ * discarded; a number is a `fixed` backoff; `fixed` waits `delay`, `exponential` waits `round(2^attemptsMade * delay)`
52
+ * (BullMQ calls the strategy with `attemptsMade + 1` and raises 2 to one less).
53
+ *
54
+ * Null for everything else, never a guess: a jittered backoff (its delay is random, so no instant could be told), a
55
+ * custom strategy (it is the queue's code), and a delay at or below zero (BullMQ then retries at once, through the
56
+ * wait list rather than the delayed set).
57
+ */
58
+ export function retryDelayOf(job) {
59
+ const attempts = job?.opts?.attempts;
60
+ const made = job?.attemptsMade;
61
+ if (!Number.isInteger(attempts) || !Number.isInteger(made) || made < 0) return null;
62
+ if (!(made + 1 < attempts) || job.discarded === true) return null;
63
+ const raw = job.opts.backoff;
64
+ const backoff = Number.isFinite(raw) ? { type: "fixed", delay: raw } : raw;
65
+ if (backoff === null || typeof backoff !== "object") return null;
66
+ // BullMQ's own test (`jitter > 0`), so a value it ignores is ignored here too.
67
+ if (backoff.jitter > 0) return null;
68
+ const delay = backoff.delay;
69
+ if (!Number.isFinite(delay) || delay <= 0) return null;
70
+ if (backoff.type === "fixed") return delay;
71
+ if (backoff.type === "exponential") return Math.round(2 ** made * delay);
72
+ return null;
73
+ }
74
+
75
+ /** Settle `promise` or give up after `ms`, never rejecting: `{ value }`, `{ error }` or `{ timedOut: true }`. */
76
+ function settleWithin(promise, ms) {
77
+ return new Promise((resolve) => {
78
+ const timer = setTimeout(() => resolve({ timedOut: true }), ms);
79
+ Promise.resolve(promise).then(
80
+ (value) => {
81
+ clearTimeout(timer);
82
+ resolve({ value });
83
+ },
84
+ (error) => {
85
+ clearTimeout(timer);
86
+ resolve({ error });
87
+ },
88
+ );
89
+ });
90
+ }
91
+
92
+ /** A failure's name for the log: a fixed word or an errno-like code, never an error's message (it can quote a URL). */
93
+ function failureOf(outcome) {
94
+ if (outcome.timedOut) return "timeout";
95
+ if (outcome.threw) return "threw";
96
+ if (outcome.error) return typeof outcome.error.code === "string" && /^[A-Z][A-Z0-9_]{0,31}$/.test(outcome.error.code) ? outcome.error.code : "rejected";
97
+ return "error-reply";
98
+ }
99
+
100
+ /**
101
+ * Build the writer for one processor. `redis` is the processor's client; `DelayedError` is BullMQ's class (required:
102
+ * a stand-in would pass BullMQ's name test and fail the processor's `instanceof`, so a deferral would read as a
103
+ * failure there); `hostName` is this worker's declared name, recorded as the record's host when it passes the host
104
+ * rule; `now` is the processor's clock, the one every gate's due time is computed from.
105
+ *
106
+ * Returns:
107
+ * - `writeReason(job, { why, untilMs, atMs, project, holder, endpoint, budgetWhy, attempt, attempts })`: the record's
108
+ * write, bounded. `atMs` is the record's `at`, by default the clock when the write is built; a caller that measured
109
+ * `untilMs` from its own reading passes that reading, so the two are exactly the delay apart. Resolves `{ n }` once
110
+ * the MULTI was sent (it landed, or may still land: see the header) and null when nothing was sent: a client with
111
+ * no `multi` (a bare test wiring), a job with no id, or a write that could not be built. Never rejects. `why` must
112
+ * be a reason of `DEFER_WHYS`; any other field that fails its rule is left out of the record rather than failing it.
113
+ * - `deferTo(job, token, untilMs, why, extra)`: the one deferral. Writes the reason, then `job.moveToDelayed(untilMs,
114
+ * token)`, then throws BullMQ's `DelayedError`, the throw by which BullMQ learns the job was deferred. NEVER
115
+ * returns. A rejected move removes this write's record and is rethrown unchanged.
116
+ * - `noteRetry(job, { project })`: before an infrastructure retry's throw, the `retry` record, when BullMQ will
117
+ * delay the retry by a known amount (`retryDelayOf`). Never throws.
118
+ *
119
+ * The log says `defer_reason_unwritten { why, reason }` on the first failed write and nothing more until a write
120
+ * succeeds again, which says `defer_reason_restored { why }` once: an outage is one episode, not a line per wake.
121
+ */
122
+ export function makeDeferTo({ redis, DelayedError, now = () => Date.now(), hostName = "", log = () => {}, nonce = () => randomBytes(8).toString("hex"), timeoutMs = DEFER_WRITE_TIMEOUT_MS } = {}) {
123
+ if (typeof DelayedError !== "function") throw new TypeError("makeDeferTo needs BullMQ's DelayedError class, the one the processor's catch recognises a deferral by");
124
+ let failing = false;
125
+ const said = (why) => (typeof why === "string" && DEFER_WHY_RE.test(why) ? why : null);
126
+ // Every line goes through `say`, so a logger that throws is swallowed here: the log describes a deferral, and a line
127
+ // that could not be written must not turn the deferral into a rejection before its move (the job left active, BullMQ
128
+ // counting a failed attempt). The episode's state is set before the line, so it holds even when the line is lost.
129
+ const say = (event, fields) => {
130
+ try {
131
+ log(event, fields);
132
+ } catch {
133
+ // The line is lost, the deferral goes on.
134
+ }
135
+ };
136
+ const failed = (why, outcome) => {
137
+ if (failing) return;
138
+ failing = true;
139
+ say("defer_reason_unwritten", { why: said(why), reason: failureOf(outcome) });
140
+ };
141
+ const succeeded = (why) => {
142
+ if (!failing) return;
143
+ failing = false;
144
+ say("defer_reason_restored", { why: said(why) });
145
+ };
146
+
147
+ const writeReason = async (job, { why, untilMs, atMs, project = null, holder, endpoint, budgetWhy, attempt, attempts } = {}) => {
148
+ if (typeof redis?.multi !== "function") return null;
149
+ if (typeof job?.id !== "string" || job.id === "") return null;
150
+ let sent;
151
+ let n = null;
152
+ try {
153
+ const at = Number.isFinite(atMs) ? Math.floor(atMs) : now();
154
+ const drawn = nonce();
155
+ n = typeof drawn === "string" && DEFER_N_RE.test(drawn) ? drawn : null;
156
+ // Whole milliseconds: the record's rule takes integers, and a gate's arithmetic (a jittered delay, a keeper's
157
+ // wait) need not produce one.
158
+ const until = Number.isFinite(untilMs) ? Math.floor(untilMs) : null;
159
+ const args = deferHsetArgs({ why, at, until, project, host: hostName, holder, endpoint, budgetWhy, trigger: job.data?.trigger?.matched?.index, attempt, attempts, n });
160
+ const key = deferKey(job.id);
161
+ const ttl = Math.ceil(deferTtlMs(until ?? at, at));
162
+ sent = redis.multi().del(key).hset(key, ...args).pexpire(key, ttl).exec();
163
+ } catch (error) {
164
+ // Nothing was sent: a client whose transaction could not be built, or a reason outside the table (a defect at
165
+ // the call site, which `defer-write.test.mjs` pins by literal).
166
+ failed(why, { threw: true, error });
167
+ return null;
168
+ }
169
+ const outcome = await settleWithin(sent, timeoutMs);
170
+ const replies = outcome.value;
171
+ const errorReply = Array.isArray(replies) ? replies.some((reply) => Array.isArray(reply) && reply[0]) : replies !== undefined;
172
+ if (outcome.timedOut || outcome.error || errorReply) failed(why, outcome);
173
+ else succeeded(why);
174
+ return { n };
175
+ };
176
+
177
+ const deferTo = async (job, token, untilMs, why, extra = {}) => {
178
+ const written = await writeReason(job, { ...extra, why, untilMs });
179
+ try {
180
+ await job.moveToDelayed(untilMs, token);
181
+ } catch (error) {
182
+ if (written?.n && typeof redis?.eval === "function") {
183
+ let removal;
184
+ try {
185
+ removal = redis.eval(DEFER_CAD_LUA, 1, deferKey(job.id), written.n);
186
+ } catch {
187
+ removal = null;
188
+ }
189
+ await settleWithin(removal, timeoutMs);
190
+ }
191
+ throw error;
192
+ }
193
+ throw new DelayedError();
194
+ };
195
+
196
+ const noteRetry = async (job, { project = null } = {}) => {
197
+ try {
198
+ const delay = retryDelayOf(job);
199
+ if (delay === null) return;
200
+ const at = now();
201
+ await writeReason(job, { why: "retry", untilMs: at + delay, atMs: at, project, attempt: job.attemptsMade + 2, attempts: job.opts.attempts });
202
+ } catch {
203
+ // Never in the way of the throw it precedes: the retry happens whether or not its reason was written.
204
+ }
205
+ };
206
+
207
+ return { writeReason, deferTo, noteRetry };
208
+ }
@@ -4,11 +4,14 @@
4
4
  * different venue than the service they described. The deployment's `.env` is read with `readStackKeys`, exactly as
5
5
  * `service install` reads it, and a key this shell sets differently stops the command, since the service runs the file.
6
6
  */
7
- import { parseBackendList } from "./backends.mjs";
7
+ import { parseBackendList, venuesOf } from "./backends.mjs";
8
8
  import { egressArmed, egressProxyName } from "./egress.mjs";
9
9
  import { envValueShown } from "./env-file.mjs";
10
10
  import { STACK_KEYS, VALKEY_SHARED_KEY, readStackKeys, readValkeyKeys } from "./podman-stack.mjs";
11
11
 
12
+ /** The venue keys only the openshell venues read (issue #707). */
13
+ const OPENSHELL_KEYS = Object.freeze(["PI_OPENSHELL_ENGINE", "PI_OPENSHELL_GATEWAY"]);
14
+
12
15
  /** How each command says what a shell/file disagreement would make it do, the clause before "while the service ...". */
13
16
  const COMMAND_SAYS = Object.freeze({
14
17
  up: "up would drive the shell's venue",
@@ -39,10 +42,16 @@ export function deploymentVenueEnv({ env, fs, envPath, platform, command = "up",
39
42
  text = fs.readFileSync(envPath);
40
43
  } catch (err) {
41
44
  // Said rather than silent (round 2 nit): the venue then comes from this shell alone.
42
- notes.push(`${envPath} could not be read (${err?.message}), so PI_BACKENDS, PI_EGRESS and PI_EGRESS_PROXY come from this shell alone`);
45
+ notes.push(`${envPath} could not be read (${err?.message}), so the venue keys (${STACK_KEYS.join(", ")}) come from this shell alone`);
43
46
  }
44
47
  if (text !== null) {
45
- const read = readStackKeys(text, { loader, path: envPath });
48
+ // The openshell keys are refused only where an openshell venue is blessed (review round 2 of #715): the file's
49
+ // PI_BACKENDS says so to the reader itself, this shell's is said here.
50
+ let shellOpenshell = false;
51
+ try {
52
+ shellOpenshell = typeof env.PI_BACKENDS === "string" && venuesOf({ PI_BACKENDS: env.PI_BACKENDS }).openshellUsed;
53
+ } catch {}
54
+ const read = readStackKeys(text, { loader, path: envPath, openshellBlessed: shellOpenshell });
46
55
  if (read.error && linux) return { error: read.error };
47
56
  if (read.error) notes.push(`${read.error}; off Linux the podman venue refuses this host anyway, so this pass reads the venue from this shell`);
48
57
  else keys = read.keys;
@@ -57,7 +66,7 @@ export function deploymentVenueEnv({ env, fs, envPath, platform, command = "up",
57
66
  if (typeof env[key] === "string") {
58
67
  // Compared as the worker READS them (round 3, D4), not as strings: ` podman` and `podman,podman` are the list
59
68
  // `podman`, and refusing them sent an operator to reconcile two values that already agree.
60
- if (venueKeyMeaning(key, env[key]) !== venueKeyMeaning(key, keys[key])) conflicts.push(`${key} is ${quotedShown(env[key])} in this shell and ${quotedShown(keys[key])} in ${envPath}`);
69
+ if (venueKeyMeaning(key, env[key]) !== venueKeyMeaning(key, keys[key])) conflicts.push({ key, text: `${key} is ${quotedShown(env[key])} in this shell and ${quotedShown(keys[key])} in ${envPath}` });
61
70
  continue;
62
71
  }
63
72
  merged[key] = keys[key];
@@ -66,8 +75,12 @@ export function deploymentVenueEnv({ env, fs, envPath, platform, command = "up",
66
75
  // A KNOWN disagreement is a refusal (round 2, E5), not a warning: this pass would stand up one venue's stack and the
67
76
  // service installed next would run the other. The file is what the service runs, so it is the one to change unless
68
77
  // the shell's value was a one-off.
69
- if (conflicts.length > 0) {
70
- return { error: `${conflicts.join("; ")}. ${COMMAND_SAYS[command] ?? COMMAND_SAYS.up} while the service runs the file's. Make them agree: change ${envPath} (what the service reads), or unset the key in this shell` };
78
+ // The openshell venues' two keys disagree only where one of those venues is blessed (review round 1 of #715): elsewhere
79
+ // neither the worker nor any command reads them, so a stray value in one place is no reason to stop.
80
+ const openshellBlessed = venuesOf(merged).openshellUsed;
81
+ const said = conflicts.filter((c) => openshellBlessed || !OPENSHELL_KEYS.includes(c.key)).map((c) => c.text);
82
+ if (said.length > 0) {
83
+ return { error: `${said.join("; ")}. ${COMMAND_SAYS[command] ?? COMMAND_SAYS.up} while the service runs the file's. Make them agree: change ${envPath} (what the service reads), or unset the key in this shell` };
71
84
  }
72
85
  return { env: merged, fromFile, notes };
73
86
  }
@@ -146,6 +159,10 @@ function venueKeyMeaning(key, value) {
146
159
  try {
147
160
  if (key === "PI_BACKENDS") return `list:${parseBackendList(value).join(",")}`;
148
161
  if (key === "PI_EGRESS") return `egress:${egressArmed({ PI_EGRESS: value }) ? "on" : "off"}`;
162
+ // Issue #707: the openshell venues' two keys, as `openshellFrom` reads them: an empty engine is docker, an empty
163
+ // gateway the CLI's active one. Not trimmed, as the worker trims neither.
164
+ if (key === "PI_OPENSHELL_ENGINE") return `engine:${value === "" ? "docker" : value}`;
165
+ if (key === "PI_OPENSHELL_GATEWAY") return `gateway:${value}`;
149
166
  return `proxy:${egressProxyName({ PI_EGRESS_PROXY: value })}`;
150
167
  } catch {
151
168
  return `raw:${value}`;
@@ -22,6 +22,7 @@
22
22
  export { containerSpec, CONTAINER_GLOBAL_PI_DIR, CONTAINER_SESSION_DIR, CONTAINER_SESSION_FILE } from "./container-spec.mjs";
23
23
  import { isAbsolute, relative } from "node:path";
24
24
  import { SIZE_LABEL_CPU, SIZE_LABEL_MEM, assertCidFile, assertJobUser, containerSpec } from "./container-spec.mjs";
25
+ import { LAYOUTS } from "./job-layout.mjs";
25
26
  import { CGROUP_PARENT, CPU_SHARES_MAX, CPU_SHARES_MIN } from "./job-size.mjs";
26
27
 
27
28
  /** The fixed isolation flags. Not configurable -- these ARE the boundary. */
@@ -165,6 +166,10 @@ export const DOCKER_EXTRA_FORBIDDEN = [
165
166
  // The job user is a spec field (issue #341). Here so a repeat cannot override it with uid 0.
166
167
  "--user",
167
168
  "-u",
169
+ // Issue #683: the builder pins the working directory (see `--workdir=` in `argsFromSpec`). A repeat would start the
170
+ // job outside its workspace, where a relative path the agent writes lands in the image's own tree.
171
+ "--workdir",
172
+ "-w",
168
173
  ];
169
174
 
170
175
  /**
@@ -331,6 +336,12 @@ function argsFromSpec(spec, { userns }) {
331
336
  args.push("-v", `${m.host}:${m.container}${options.length > 0 ? `:${options.join(",")}` : ""}`);
332
337
  }
333
338
 
339
+ // Issue #683: the job starts in its workspace mount because the ARGV says so, not because the image's WORKDIR
340
+ // happens to match. The shipped image's WORKDIR is now /sandbox (a copying venue's writable tree, image/Dockerfile),
341
+ // so without this a binding venue's job would start in an empty /sandbox and every relative path the agent writes
342
+ // would land outside the repository it was asked to change. A binding venue always uses the `mounts` layout. An
343
+ // older image, whose WORKDIR was /workspace, starts exactly where it did.
344
+ args.push(`--workdir=${LAYOUTS.mounts.workspace}`);
334
345
  args.push(spec.image);
335
346
  return args;
336
347
  }