@edgehero/pi-dispatch 4.3.1 → 5.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (260) hide show
  1. package/.env.example +84 -3
  2. package/README.md +21 -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 +118 -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 +136 -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 +190 -29
  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 +325 -0
  58. package/src/console-client.mjs +848 -0
  59. package/src/console-devices.mjs +372 -0
  60. package/src/console-install.mjs +239 -0
  61. package/src/console-open.mjs +99 -0
  62. package/src/console-probe.mjs +106 -0
  63. package/src/console-tokens.mjs +459 -0
  64. package/src/container-names.mjs +51 -0
  65. package/src/container-spec.mjs +20 -1
  66. package/src/cpu-reserve.mjs +4 -1
  67. package/src/defer-reasons.mjs +200 -0
  68. package/src/defer-write.mjs +208 -0
  69. package/src/deployment-venue.mjs +23 -6
  70. package/src/docker-run.mjs +11 -0
  71. package/src/doctor-bundles.mjs +130 -0
  72. package/src/doctor-model-proposals.mjs +109 -0
  73. package/src/doctor-openshell.mjs +238 -0
  74. package/src/doctor-results.mjs +602 -0
  75. package/src/doctor-spawner.mjs +215 -0
  76. package/src/doctor.mjs +1657 -196
  77. package/src/dollar-budget.mjs +212 -3
  78. package/src/endpoint-status.mjs +100 -0
  79. package/src/env-allowlist.mjs +96 -6
  80. package/src/env-file.mjs +6 -4
  81. package/src/envelope.mjs +5 -3
  82. package/src/file-expect.mjs +47 -0
  83. package/src/forgejo-host.mjs +42 -5
  84. package/src/forgejo-prompt.mjs +5 -4
  85. package/src/forges.mjs +33 -3
  86. package/src/fork-job.mjs +58 -0
  87. package/src/fork-policy.mjs +23 -0
  88. package/src/fork-store.mjs +338 -0
  89. package/src/get-token.mjs +1 -1
  90. package/src/git-dirty.mjs +119 -14
  91. package/src/git-hardening.mjs +85 -0
  92. package/src/github-host.mjs +31 -6
  93. package/src/github-prompt.mjs +7 -16
  94. package/src/gitlab-host.mjs +42 -3
  95. package/src/gitlab-prompt.mjs +5 -4
  96. package/src/held-document.mjs +23 -0
  97. package/src/held-replies.mjs +325 -0
  98. package/src/held-settle.mjs +266 -0
  99. package/src/hook-runs.mjs +188 -0
  100. package/src/host-budget.mjs +8 -5
  101. package/src/host-clone.mjs +72 -0
  102. package/src/host-loads.mjs +557 -0
  103. package/src/host-registry.mjs +66 -6
  104. package/src/host-telemetry.mjs +92 -0
  105. package/src/http-body.mjs +78 -0
  106. package/src/image-preflight.mjs +156 -13
  107. package/src/import-pi.mjs +60 -172
  108. package/src/inbox-relay.mjs +482 -0
  109. package/src/inbox-settle.mjs +196 -0
  110. package/src/index.mjs +734 -129
  111. package/src/init.mjs +35 -5
  112. package/src/job-data-version.mjs +75 -0
  113. package/src/job-id.mjs +30 -15
  114. package/src/job-layout.mjs +22 -0
  115. package/src/job-size.mjs +16 -0
  116. package/src/live-jobs.mjs +14 -6
  117. package/src/live-probes.mjs +6 -2
  118. package/src/live-transcript.mjs +872 -0
  119. package/src/loaded-record.mjs +609 -0
  120. package/src/mcp-config.mjs +4 -2
  121. package/src/mcp-preflight.mjs +36 -5
  122. package/src/model-attempts.mjs +84 -0
  123. package/src/model-catalog.mjs +83 -0
  124. package/src/model-cooldowns.mjs +22 -0
  125. package/src/model-failover-protocol.mjs +11 -0
  126. package/src/model-failover.mjs +134 -0
  127. package/src/model-observation.mjs +50 -0
  128. package/src/model-proposal-protocol.mjs +21 -0
  129. package/src/model-proposals.mjs +75 -0
  130. package/src/model-ref.mjs +67 -10
  131. package/src/model-roles-file.mjs +24 -0
  132. package/src/model-roles.mjs +73 -0
  133. package/src/model-status.mjs +34 -0
  134. package/src/models-json.mjs +12 -2
  135. package/src/money.mjs +1 -1
  136. package/src/oauth-openai.mjs +121 -0
  137. package/src/older-state.mjs +249 -0
  138. package/src/on-failure.mjs +17 -13
  139. package/src/open-browser.mjs +31 -2
  140. package/src/opener-cli.mjs +135 -0
  141. package/src/opener-protocol.mjs +27 -0
  142. package/src/opener-terminal.mjs +128 -0
  143. package/src/openshell-cli.mjs +155 -0
  144. package/src/openshell-policy.mjs +163 -0
  145. package/src/outbox.mjs +14 -0
  146. package/src/output-cap.mjs +1 -0
  147. package/src/package-canary-record.mjs +26 -0
  148. package/src/package-canary.mjs +135 -0
  149. package/src/package-names.mjs +9 -0
  150. package/src/package-stage.mjs +418 -0
  151. package/src/packages.mjs +24 -16
  152. package/src/parked.mjs +2 -1
  153. package/src/pause-windows.mjs +136 -17
  154. package/src/podman-stack.mjs +40 -14
  155. package/src/portfolio-snapshot.mjs +26 -7
  156. package/src/prepare-github.mjs +41 -77
  157. package/src/prepare-local.mjs +32 -2
  158. package/src/prepare.mjs +45 -11
  159. package/src/private-store-boundary.mjs +70 -0
  160. package/src/processor.mjs +1550 -173
  161. package/src/projects.mjs +23 -0
  162. package/src/prompt-fence.mjs +18 -0
  163. package/src/provider-key.mjs +7 -0
  164. package/src/provider-steering.mjs +1 -0
  165. package/src/question-context.mjs +35 -0
  166. package/src/question-group-runtime.mjs +91 -0
  167. package/src/question-groups.mjs +171 -0
  168. package/src/queue.mjs +264 -55
  169. package/src/reason-catalog.mjs +448 -0
  170. package/src/receiver-status.mjs +230 -0
  171. package/src/record-reason.mjs +37 -0
  172. package/src/recovery-queue.mjs +566 -0
  173. package/src/redact-secrets.mjs +219 -0
  174. package/src/reserve-estimate.mjs +101 -0
  175. package/src/reserved-env.mjs +36 -1
  176. package/src/retention.mjs +67 -0
  177. package/src/run-budget-ledger.mjs +283 -0
  178. package/src/run-checkpoint.mjs +627 -0
  179. package/src/run-container.mjs +749 -33
  180. package/src/run-history.mjs +791 -45
  181. package/src/run-mirror.mjs +75 -7
  182. package/src/run-progress.mjs +751 -0
  183. package/src/runtime-observations.mjs +3 -1
  184. package/src/runtime-settings.mjs +272 -37
  185. package/src/sandbox-cli.mjs +78 -2
  186. package/src/sandbox-deployment.mjs +51 -0
  187. package/src/sandbox-gateway-protocol.mjs +378 -0
  188. package/src/sandbox-hosts.mjs +100 -0
  189. package/src/sandbox-inventory.mjs +83 -0
  190. package/src/sandbox-policy.mjs +225 -0
  191. package/src/sandbox-prepare-requests.mjs +145 -0
  192. package/src/sandbox-prepare.mjs +285 -0
  193. package/src/sandbox-refs.mjs +70 -0
  194. package/src/sandbox-session-monitor.mjs +4 -0
  195. package/src/sandbox-session-watch.mjs +124 -0
  196. package/src/sandbox-sessions.mjs +161 -0
  197. package/src/sandbox-store.mjs +63 -6
  198. package/src/sandbox-workspace-guard.mjs +39 -0
  199. package/src/sandbox.mjs +27 -200
  200. package/src/scheduler-stall-guard.mjs +14 -8
  201. package/src/schedules.mjs +19 -6
  202. package/src/secret-bundles.mjs +245 -0
  203. package/src/secret-profiles.mjs +103 -3
  204. package/src/secrets.mjs +19 -9
  205. package/src/service-env.mjs +38 -5
  206. package/src/service.mjs +638 -106
  207. package/src/session-key.mjs +60 -0
  208. package/src/session-store.mjs +168 -10
  209. package/src/setup-contract.mjs +40 -0
  210. package/src/setup-egress.mjs +113 -0
  211. package/src/setup-host-entry.mjs +36 -0
  212. package/src/setup-host.mjs +242 -0
  213. package/src/setup-lock.mjs +41 -0
  214. package/src/size-records.mjs +15 -3
  215. package/src/start.mjs +1353 -135
  216. package/src/status-comment.mjs +415 -0
  217. package/src/stderr-transcript.mjs +318 -0
  218. package/src/steer-ack.mjs +122 -0
  219. package/src/steer-cli.mjs +213 -0
  220. package/src/steer-framing.mjs +185 -0
  221. package/src/steer-inbox.mjs +693 -0
  222. package/src/store-version.mjs +139 -0
  223. package/src/subscription-config.mjs +56 -0
  224. package/src/subscription-limits.mjs +3 -0
  225. package/src/subscription-quota.mjs +259 -0
  226. package/src/subscription-runtime.mjs +123 -0
  227. package/src/subscription-status.mjs +42 -0
  228. package/src/subscription-wait.mjs +11 -0
  229. package/src/subscriptions.mjs +7 -1
  230. package/src/thinking-level.mjs +19 -0
  231. package/src/touch-outbox.mjs +61 -0
  232. package/src/touch-state.mjs +133 -0
  233. package/src/triggers-file.mjs +299 -39
  234. package/src/triggers.mjs +780 -38
  235. package/src/trust-paths.mjs +171 -0
  236. package/src/up.mjs +402 -53
  237. package/src/updates-cli.mjs +61 -0
  238. package/src/updates-look.mjs +127 -0
  239. package/src/updates-reconcile.mjs +182 -0
  240. package/src/updates-subscription-adapters.mjs +8 -0
  241. package/src/updates.mjs +252 -0
  242. package/src/valkey-auth.mjs +32 -9
  243. package/src/valkey-endpoint.mjs +14 -4
  244. package/src/wait-for.mjs +1 -1
  245. package/src/wait-state.mjs +62 -3
  246. package/src/watch-board.mjs +38 -0
  247. package/src/watch-closer.mjs +1 -1
  248. package/src/watch-contract.mjs +102 -0
  249. package/src/watch-decision.mjs +78 -0
  250. package/src/watch-drainer.mjs +14 -0
  251. package/src/watch-issue-adapter.mjs +44 -0
  252. package/src/watch-issue.mjs +111 -0
  253. package/src/watch-record.mjs +10 -0
  254. package/src/watch-runtime.mjs +167 -0
  255. package/src/watch-store.mjs +183 -0
  256. package/src/watch-trace.mjs +34 -0
  257. package/src/workspace-checkpoint.mjs +1750 -0
  258. package/src/zero-touch-fixes.mjs +99 -0
  259. package/src/zero-touch.mjs +225 -0
  260. package/src/repeat-slot.mjs +0 -13
@@ -8,6 +8,8 @@
8
8
  * held on run.waitFor -> the shared removeHeldJob sequence (hold keys first, then the job); no record
9
9
  * (the dispatch_wait_cancel rule).
10
10
  * delayed / queued -> job.remove(); no record, same rule.
11
+ * Both through `removeQueuedJob` (cancel-state.mjs, issue #737), the sequence the
12
+ * console's cancel-queued shares, which also deletes the removed job's defer record.
11
13
  * active -> a `cancel:req` key + a brief ack poll (cancel-state.mjs), because the abort can
12
14
  * only be raised by the process that holds the job. No ack within the window is a
13
15
  * NAMED failure, never a silent one: the job may be on a host that is down, or on
@@ -22,17 +24,29 @@
22
24
  * job's attempt count.
23
25
  */
24
26
 
25
- import { cancelReqKey, ranBefore, removeHeldJob, requestCancel } from "./cancel-state.mjs";
26
- import { jobKey } from "./wait-state.mjs";
27
+ import { cancelReqKey, ranBefore, removeQueuedJob, requestCancel } from "./cancel-state.mjs";
27
28
 
28
29
  /** Same budget the kill switch gives the host registry before acting on what the keyspace alone says. */
29
30
  const FLEET_READ_TIMEOUT_MS = 2_000;
30
31
 
32
+ /**
33
+ * A sentence another module wrote (BullMQ's, a client's, Valkey's own: of any length, holding any bytes), as a line of
34
+ * this command carries it to stderr, which is a terminal: every control character a terminal acts on (C0, DEL and C1:
35
+ * an ESC sequence, a CSI with no ESC, a bell, a carriage return that overwrites the line) made a space, then cut at 200
36
+ * characters. The console carries such text by the same substitution and the same cut (`boundedText` in the admin's
37
+ * job-actions.mjs, over its `scrubControls`), which a test holds this to on the class a terminal executes. The
38
+ * console's class is wider (it also substitutes what draws as nothing, or as a blank that is not a space, for a
39
+ * reader), and it is asked of the admin's own width table, which the worker does not import.
40
+ */
41
+ export function boundedText(text) {
42
+ return String(text ?? "no reason given").replace(/[\u0000-\u001f\u007f-\u009f]/g, " ").slice(0, 200);
43
+ }
44
+
31
45
  /**
32
46
  * Run the cancel. Returns the process exit code. Every collaborator is a seam with the production default,
33
47
  * so tests drive states and races without a queue; `write` is the stdout seam cli.mjs already injects.
34
48
  */
35
- export async function runCancel(jobId, url, { write = (chunk) => process.stdout.write(chunk), errWrite = (chunk) => process.stderr.write(chunk), redisFn, queueFn, parseConnectionFn, readLiveHostsFn, discoverHostQueuesFn, fleetQueueNamesFn, unionQueueNamesFn, ackTimeoutMs = 10_000, pollMs = 250, sleep, refusalFn } = {}) {
49
+ export async function runCancel(jobId, url, { write = (chunk) => process.stdout.write(chunk), errWrite = (chunk) => process.stderr.write(chunk), redisFn, queueFn, parseConnectionFn, readLiveHostsFn, discoverHostQueuesFn, fleetQueueNamesFn, unionQueueNamesFn, ackTimeoutMs = 10_000, pollMs = 250, sleep, refusalFn, touchStore, touchOutbox = null, now = Date.now } = {}) {
36
50
  const fail = (message) => {
37
51
  errWrite(`error: ${message}\n`);
38
52
  return 1;
@@ -98,45 +112,44 @@ export async function runCancel(jobId, url, { write = (chunk) => process.stdout.
98
112
  }
99
113
  }
100
114
  if (!job) {
101
- return fail(`no job ${jobId} in ${names.length} queue(s)${blind ? ` [registry unreadable: ${blind} — a host that is not registered may still hold it]` : ""}`);
115
+ return fail(`no job ${jobId} in ${names.length} queue(s)${blind ? ` [registry unreadable: ${boundedText(blind)} — a host that is not registered may still hold it]` : ""}`);
102
116
  }
103
117
 
104
- // Held FIRST (see the header). The hash tells held apart from plain-delayed; `since` is the clock
105
- // that makes it a real hold rather than a counter key.
106
- const hash = await probe.hgetall(jobKey(jobId)).catch(() => null);
107
- if (hash?.since) {
108
- const res = await removeHeldJob({ redis: probe, queue: owner, jobId });
109
- if (res.ok) {
110
- // A hold consumes no attempt (`skipAttempt`), so a held job that ran is a retry held again by its conditions.
111
- write(`cancelled ${jobId}: it was held on its wait condition, and ${ranBefore(res)}\n`);
112
- return 0;
113
- }
114
- return fail(`could not cancel held job ${jobId}: ${res.invalid}`);
118
+ // Held FIRST, then a job waiting to start: the shared sequence (`removeQueuedJob`, cancel-state.mjs), which the
119
+ // console's cancel-queued runs too. It judges the hold by the `wait:job` hash's `since`, the clock that makes it a
120
+ // real hold rather than a counter key, and deletes the removed job's defer record. This verb's reach is its own:
121
+ // it removes a job waiting to retry as well, which the console refuses.
122
+ const queued = await removeQueuedJob({ redis: probe, queue: owner, job, jobId, touchStore, touchOutbox, now });
123
+ if (queued.ok && queued.touchWarning) errWrite("warning: the job was removed; touch tracking is incomplete. Do not repeat the cancellation.\n");
124
+ if (queued.ok && queued.held) {
125
+ // A hold consumes no attempt (`skipAttempt`), so a held job that ran is a retry held again by its conditions.
126
+ write(`cancelled ${jobId}: it was held on its wait condition, and ${ranBefore(queued)}\n`);
127
+ return 0;
115
128
  }
116
-
117
- let state = await job.getState().catch(() => "unknown");
118
- if (state === "delayed" || state === "waiting" || state === "prioritized" || state === "paused") {
119
- try {
120
- await job.remove();
121
- write(`removed ${jobId} (${state}): ${ranBefore(job)}\n`);
122
- return 0;
123
- } catch (error) {
124
- // The one race worth handling by name: picked up between getState and remove. remove() throws
125
- // on a locked job, so re-read and fall through to the active path rather than failing an
126
- // operator whose job is now exactly the case the active path exists for.
127
- state = await job.getState().catch(() => "unknown");
128
- // The re-read state and the throw's own words together, labelled apart: the error came from
129
- // the remove attempt, the state from after it, and gluing them unlabelled read as one
130
- // mismatched diagnosis (review finding).
131
- if (state !== "active") return fail(`could not remove ${jobId} — it is now ${state}; the remove failed with: ${error.message}`);
132
- }
129
+ // A removal that failed after the hold's keys were deleted changed something: the job lost its hold and was not removed,
130
+ // so the line says so and how to finish, never only the error (which reads as "nothing happened"). Every error a
131
+ // line carries is bounded (`boundedText`): it is another module's words, of any length and with any bytes.
132
+ if (queued.invalid !== undefined && queued.cleared === true) {
133
+ return fail(`could not cancel held job ${jobId}: its hold was cleared, but the job itself could not be removed (${boundedText(queued.invalid)}); it may still be queued, now without its hold. Run \`pi-dispatch cancel ${jobId}\` again to remove or stop it`);
134
+ }
135
+ if (queued.invalid !== undefined) return fail(`could not cancel held job ${jobId}: ${boundedText(queued.invalid)}`);
136
+ if (queued.ok) {
137
+ write(`removed ${jobId} (${queued.state}): ${ranBefore(job)}\n`);
138
+ return 0;
133
139
  }
140
+ const state = queued.state;
141
+ // The one race worth handling by name: picked up between getState and remove. remove() throws on a locked job, so
142
+ // the state is re-read and an active job falls through to the active path rather than failing an operator whose
143
+ // job is now exactly the case the active path exists for. Otherwise the re-read state and the throw's own words
144
+ // together, labelled apart: the error came from the remove attempt, the state from after it, and gluing them
145
+ // unlabelled read as one mismatched diagnosis.
146
+ if (queued.raced && state !== "active") return fail(`could not remove ${jobId} — it is now ${state}; the remove failed with: ${boundedText(queued.error.message)}`);
134
147
 
135
148
  if (state === "active") {
136
149
  requested = true;
137
150
  const res = await requestCancel({ redis: probe, jobId, ackTimeoutMs, pollMs, ...(sleep ? { sleep } : {}) });
138
151
  if (res.ack !== undefined) {
139
- write(`cancel accepted by ${res.ack === "" ? "the worker" : res.ack} — stopping the container (allow ~30s); the run record will say operator-cancel\n`);
152
+ write(`cancel accepted by ${res.ack === "" ? "the worker" : boundedText(res.ack)} — stopping the container (allow ~30s); the run record will say operator-cancel\n`);
140
153
  return 0;
141
154
  }
142
155
  // Gate round 2 of PR #479: the worker stops its cancel poll before it holds or retries a job, so a request that
@@ -165,7 +178,7 @@ export async function runCancel(jobId, url, { write = (chunk) => process.stdout.
165
178
  ]);
166
179
  clearTimeout(bound);
167
180
  }
168
- return fail(error?.valkeyRefused ? error.message : `could not reach Valkey at ${(await import("./connection.mjs")).urlShown(url)}: ${(await import("./valkey-auth.mjs")).valkeyDownHint(url)}\n ${error.message}`);
181
+ return fail(error?.valkeyRefused ? error.message : `could not reach Valkey at ${(await import("./connection.mjs")).urlShown(url)}: ${(await import("./valkey-auth.mjs")).valkeyDownHint(url)}\n ${boundedText(error?.message)}`);
169
182
  } finally {
170
183
  probe.disconnect?.();
171
184
  for (const q of queues) await q.close().catch(() => {});
@@ -1,3 +1,17 @@
1
+ import { makeTouchStore, touchScopeOf } from "./touch-state.mjs";
2
+ import { trackTouchAction } from "./touch-outbox.mjs";
3
+ import { isProjectId } from "./project-id.mjs";
4
+
5
+ const isWorkload = (job) => job.data?.kind !== "question-grouping" && !["zero-touch-grouping", "answer-draft"].includes(job.data?.purpose);
6
+
7
+ function stopTouch(job, queue, jobId, now) {
8
+ const data = job.data ?? {};
9
+ const owner = /^pi-jobs@([A-Za-z0-9._-]{1,64})$/.exec(queue?.name ?? "")?.[1] ?? null;
10
+ // A shared-queue local job has no owning machine yet. Hashing an empty host manufactures a different
11
+ // folder identity from its eventual run, so leave that attribution unknown rather than claiming a mismatch.
12
+ const knownScope = /^[a-f0-9]{64}$/.test(data.touchScopeId ?? "") || !(data.kind === "local" || data.folder) || owner !== null;
13
+ return { type: "stop", runId: jobId, at: now(), ...(knownScope ? { scopeId: touchScopeOf(data, owner) } : {}), ...(isProjectId(data.projectId) ? { project: data.projectId } : {}), nonce: `stop:${jobId}` };
14
+ }
1
15
  /**
2
16
  * The `cancel:` keyspace: how an operator's cancel reaches the worker that owns a running job (issue #287).
3
17
  *
@@ -24,8 +38,19 @@
24
38
  * silently loses an operator's edit. This is a transient, attended, TTL-bounded one-shot whose loss is
25
39
  * REPORTED: the requester is watching the ack poll, and a request that reaches nobody comes back as a
26
40
  * named timeout, never as silence.
41
+ *
42
+ * BESIDE IT, THE `defer:` KEYSPACE (issue #737), which this module deletes from and never writes:
43
+ *
44
+ * defer:job:<jobId> HASH, why the worker put a waiting job back on the delayed set
45
+ * (`defer-reasons.mjs` holds the fields and their rules, `defer-write.mjs` writes
46
+ * it, with its own TTL). `removeQueuedJob` deletes it once the job is removed, so a
47
+ * removed job leaves no reason behind. The TTL is the backstop for a delete that
48
+ * fails, and a record without its job is never shown: every reader starts from
49
+ * the queue's own sets.
27
50
  */
28
51
 
52
+ import { deferKey } from "./defer-reasons.mjs";
53
+ import { dropKey, dropModelAttempt, modelAttemptIdOf } from "./model-attempts.mjs";
29
54
  import { HELD_SET, jobKey, leaseKey } from "./wait-state.mjs";
30
55
 
31
56
  /** How long an unanswered request may sit before redis reaps it (the requester deletes it sooner). */
@@ -36,6 +61,21 @@ export const CANCEL_ACK_TTL_MS = 5 * 60_000;
36
61
 
37
62
  export const cancelReqKey = (jobId) => `cancel:req:${jobId}`;
38
63
  export const cancelAckKey = (jobId) => `cancel:ack:${jobId}`;
64
+ // Separate keys make an older worker fail closed: it cannot mistake an agent request for a human's.
65
+ export const agentCancelReqKey = (jobId) => `cancel:agent:req:${jobId}`;
66
+ export const agentCancelAckKey = (jobId) => `cancel:agent:ack:${jobId}`;
67
+
68
+ const cancelRequestKeyOf = (jobId, interventionOrigin) => (interventionOrigin === "agent" ? agentCancelReqKey(jobId) : cancelReqKey(jobId));
69
+
70
+ /**
71
+ * Takes back a request whose answer was lost, for a caller that goes on to report a failure (the console's fleet reader; the
72
+ * CLI's catch does the same under its own bound). A SET with no answer can still land: a Valkey that stopped answering holds
73
+ * it on its socket and runs it when it wakes, so the DEL goes right behind it, in order. Not awaited: on the same outage it
74
+ * has its own bound to wait out, and the caller already has its answer.
75
+ */
76
+ export function withdrawCancel({ redis, jobId, interventionOrigin = "human" }) {
77
+ Promise.resolve().then(() => redis.del(cancelRequestKeyOf(jobId, interventionOrigin))).catch(() => {});
78
+ }
39
79
 
40
80
  /**
41
81
  * Ask whichever worker holds `jobId` to cancel it, and wait briefly for the answer.
@@ -48,17 +88,32 @@ export const cancelAckKey = (jobId) => `cancel:ack:${jobId}`;
48
88
  *
49
89
  * `pollMs`/`sleep` are injectable so tests never wait on a wall clock.
50
90
  */
51
- export async function requestCancel({ redis, jobId, ackTimeoutMs = 10_000, pollMs = 250, sleep = (ms) => new Promise((r) => setTimeout(r, ms)) }) {
52
- await redis.set(cancelReqKey(jobId), "operator-cancel", "PX", CANCEL_REQ_TTL_MS);
91
+ export async function requestCancel({ redis, jobId, interventionOrigin = "human", ackTimeoutMs = 10_000, pollMs = 250, sleep = (ms) => new Promise((r) => setTimeout(r, ms)) }) {
92
+ const requestKey = cancelRequestKeyOf(jobId, interventionOrigin);
93
+ const ackKey = interventionOrigin === "agent" ? agentCancelAckKey(jobId) : cancelAckKey(jobId);
94
+ await redis.set(requestKey, interventionOrigin === "agent" ? "agent-cancel" : "operator-cancel", "PX", CANCEL_REQ_TTL_MS);
53
95
  for (let waited = 0; ; waited += pollMs) {
54
- const host = await redis.get(cancelAckKey(jobId));
96
+ let host;
97
+ try {
98
+ host = await redis.get(ackKey);
99
+ } catch (err) {
100
+ // The request went out and its answer is what was lost, so no caller may say nothing was changed.
101
+ if (err && typeof err === "object") err.valkeyMayStillRun = true;
102
+ throw err;
103
+ }
55
104
  if (host !== null && host !== undefined) return { ack: host };
56
105
  if (waited + pollMs > ackTimeoutMs) break;
57
106
  await sleep(pollMs);
58
107
  }
59
- // Best-effort: losing this DEL to a blip leaves the 60s TTL as the backstop, and the worker-side GET
60
- // racing it by one tick is a named residual (the operator is told to re-check `status`).
61
- await redis.del(cancelReqKey(jobId)).catch(() => {});
108
+ // Losing this DEL leaves the request standing for its 60s TTL, where a worker can still read it, so that is no "nothing was
109
+ // changed" and it rejects, marked, as a lost answer does. The worker-side GET racing the DEL by one tick is a named residual
110
+ // (the operator is told to re-check `status`).
111
+ try {
112
+ await redis.del(requestKey);
113
+ } catch (err) {
114
+ if (err && typeof err === "object") err.valkeyMayStillRun = true;
115
+ throw err;
116
+ }
62
117
  return { timeout: true };
63
118
  }
64
119
 
@@ -80,18 +135,42 @@ export function ranBefore(job) {
80
135
  : `it made ${made} attempts before and was waiting to retry; whatever those attempts recorded stays, and cancel writes none`;
81
136
  }
82
137
 
138
+ /**
139
+ * Whether the hold is still there, read back after its delete failed: its CLOCK (`since`), the field that makes the hash
140
+ * a hold, since the worker's own counters can re-create the key without it. False when it cannot be read.
141
+ */
142
+ async function holdInPlace(redis, jobId) {
143
+ if (typeof redis?.hexists !== "function") return false;
144
+ try {
145
+ return (await redis.hexists(jobKey(jobId), "since")) === 1;
146
+ } catch {
147
+ return false;
148
+ }
149
+ }
150
+
83
151
  /**
84
152
  * Remove a job that is held on a `run.waitFor` condition: hold keys first, then the job (issue #230's
85
153
  * sequence, issue #287's shared home). This is the inner body of the admin's `cancelHeldJob`, moved here
86
154
  * so the CLI's held path and the panel's are ONE sequence rather than two copies that can drift -- the
87
155
  * read-model's own rule is that key derivations are imported from the worker, never re-implemented.
88
156
  *
89
- * Returns `{ ok: true, jobId, attemptsMade }` or `{ invalid: <reason> }`; never throws. Writes no run record of its
90
- * own (INT-RUN-HISTORY-FILE-CONTRACT records terminal states of runs, and a removed hold is none); `attemptsMade` is
91
- * what the job had done before, for the caller's sentence (`ranBefore`, issue #477): a job held again on a retry has
92
- * made attempts, and whatever they recorded stays.
157
+ * Returns `{ ok: true, jobId, attemptsMade }`, `{ invalid: <reason> }` (refused, or failed before anything was
158
+ * deleted: nothing was changed), or `{ invalid: <reason>, cleared: true }` when it failed once the hold's first key had
159
+ * been sent for deletion (most often `remove` refusing a job a worker locked in between): the hold is gone, or partly
160
+ * gone, while the job's own removal failed, which a caller must never word as "nothing was changed". A first delete
161
+ * that failed answers `{ invalid }` only when the hold is read back in place afterwards (Valkey refused the command:
162
+ * NOPERM, READONLY, MISCONF); a hold not read back, or a read that fails, keeps the mark, since a refusal can answer a
163
+ * resend of a delete that already ran. Never throws.
164
+ * Writes no run record of its own (INT-RUN-HISTORY-FILE-CONTRACT records terminal states of runs, and a removed hold
165
+ * is none); `attemptsMade` is what the job had done before, for the caller's sentence (`ranBefore`, issue #477): a job
166
+ * held again on a retry has made attempts, and whatever they recorded stays. Once the job is removed, its model attempt
167
+ * key goes too (`dropModelAttempt`, issue #812: bounded by `timeoutMs`, best-effort), since no retry is left to read it.
168
+ * `dropDefer` (removeQueuedJob's) deletes the job's defer record beside it, both sent before either is waited for, so the
169
+ * two cost a caller one bound, as they do for a waiting job, rather than two in a row.
93
170
  */
94
- export async function removeHeldJob({ redis, queue, jobId }) {
171
+ export async function removeHeldJob({ redis, queue, jobId, timeoutMs = 2_000, dropDefer = false, touchStore = undefined, touchOutbox = null, now = Date.now, interventionOrigin = "human" }) {
172
+ // Set as the first hold key's delete is sent, before it is answered: a delete lost in transit may still have landed.
173
+ let cleared = false;
95
174
  try {
96
175
  const hash = await redis.hgetall(jobKey(jobId));
97
176
  // A hold is a hash with a CLOCK, not merely a non-empty hash: the worker's own counters create this
@@ -110,16 +189,135 @@ export async function removeHeldJob({ redis, queue, jobId }) {
110
189
  // The hold goes FIRST. If `remove` throws (an active job is locked) or a caller's timeout fires
111
190
  // mid-sequence, an orphaned hash would keep a row on the panel for a job that no longer exists; an
112
191
  // orphaned JOB is merely a job that still runs, which is the state the operator was already in.
113
- await redis.del(jobKey(jobId));
192
+ cleared = true;
193
+ try {
194
+ await redis.del(jobKey(jobId));
195
+ } catch (err) {
196
+ // A failed DEL may still have run: ioredis resends an unanswered command after a reconnect, and the refusal
197
+ // (an ACL without DEL, a read-only replica, a failed save) can be the answer to that resend. So the hold is
198
+ // read back, and only a hold still in place makes "nothing was changed" true; a read that fails keeps the mark.
199
+ if (await holdInPlace(redis, jobId)) cleared = false;
200
+ throw err;
201
+ }
114
202
  await redis.srem(HELD_SET, jobId).catch(() => {});
115
203
  if (hash.dedupId) {
116
204
  const holder = await redis.get(leaseKey(hash.dedupId));
117
205
  if (holder === jobId) await redis.del(leaseKey(hash.dedupId));
118
206
  }
119
- await job.remove();
207
+ const store = touchStore === undefined ? (typeof redis?.eval === "function" ? makeTouchStore({ redis, now }) : null) : touchStore;
208
+ const removal = interventionOrigin === "agent" ? (await job.remove(), { ok: true }) : await trackTouchAction({ store, outbox: touchOutbox, event: stopTouch(job, queue, jobId, now), act: async () => { await job.remove(); return { ok: true }; }, succeeded: () => !(job.attemptsMade > 0) && isWorkload(job) });
209
+ await Promise.all([dropDefer ? dropDeferRecord(redis, jobId, timeoutMs) : null, dropAttemptOf(redis, queue, jobId, timeoutMs)]);
120
210
  // Issue #477: what the job had done, for the caller's sentence (`ranBefore`); read off the job just removed.
121
- return { ok: true, jobId, attemptsMade: Number.isInteger(job.attemptsMade) && job.attemptsMade > 0 ? job.attemptsMade : 0 };
211
+ return { ok: true, jobId, attemptsMade: Number.isInteger(job.attemptsMade) && job.attemptsMade > 0 ? job.attemptsMade : 0, ...(removal.touchWarning ? { touchWarning: removal.touchWarning } : {}) };
122
212
  } catch (err) {
123
- return { invalid: err?.message ?? String(err) };
213
+ const invalid = err?.message ?? String(err);
214
+ return cleared ? { invalid, cleared: true } : { invalid };
124
215
  }
125
216
  }
217
+
218
+ /**
219
+ * The states a job that has not started waits in, by the names `job.getState()` answers with: what `removeQueuedJob`
220
+ * removes. `paused` is the CLI's own list since issue #287; BullMQ 5.80.4 reads a job in a paused queue's list as
221
+ * `waiting`, so on it that job is removed under that word.
222
+ */
223
+ export const QUEUED_STATES = Object.freeze(["delayed", "waiting", "prioritized", "paused"]);
224
+
225
+ /**
226
+ * Remove a job that waits to start (issue #737): the ONE sequence `pi-dispatch cancel` and the console's cancel-queued
227
+ * run, so the two cannot drift. Who may be removed is the caller's rule, not this function's: the CLI removes a job
228
+ * waiting to retry too, and the console refuses one that has ever started. `job` is the job the caller found on `queue`.
229
+ *
230
+ * held on run.waitFor (its `wait:job` hash has `since`) -> `removeHeldJob`, hold keys first, then the job;
231
+ * delayed, waiting, prioritized or paused (`QUEUED_STATES`) -> `job.remove()`;
232
+ * any other state -> nothing is changed.
233
+ *
234
+ * A HOLD IS JUDGED FIRST: a held job IS delayed, and the plain path would strand its `wait:*` keys as a panel row for a
235
+ * job that no longer exists. A hash that cannot be read is not a hold, as the CLI has always read it. `state` is the
236
+ * state the caller already read, or null to read it here.
237
+ *
238
+ * `guard` (optional; the CLI passes none) is a caller's last check, asked with `job` right before the removal is sent:
239
+ * before `removeHeldJob` for a held job, and after the state check, before `remove()`, otherwise. It answers null to go
240
+ * on, or what to refuse with; a guard that throws refuses too. BullMQ's remove checks only the job's lock and its
241
+ * scheduler, so a caller whose rule is about what the job has DONE (the console's never-started rule) reads that again
242
+ * here, one round trip before the remove (for a held job, before `removeHeldJob`'s own reads and deletes), rather than
243
+ * at its own earlier check.
244
+ *
245
+ * Once the job is removed, its defer record (`defer:job:<jobId>`) is deleted too: bounded by `timeoutMs`, best-effort,
246
+ * and only after a removal that succeeded, so a job that stays keeps its reason. Its model attempt key goes the same way
247
+ * (issue #812), beside the record, so the two deletes share one bound: here for a waiting job, inside `removeHeldJob`
248
+ * (`dropDefer`) for a held one. Returns, and never throws:
249
+ * `{ ok: true, held: true, jobId, attemptsMade }` a held job, removed;
250
+ * `{ invalid }` `removeHeldJob`'s own refusal of a held job, nothing changed;
251
+ * `{ invalid, cleared: true }` `removeHeldJob` failed after its hold's keys were deleted: the
252
+ * hold is gone and the job's own removal failed (never "nothing
253
+ * was changed");
254
+ * `{ refused, error? }` the guard's answer (`refused`), or its throw (`refused` null,
255
+ * `error` the throw); nothing was sent;
256
+ * `{ notQueued: true, state }` not waiting to start (`active`, `completed`, `unknown`...);
257
+ * `{ ok: true, held: false, jobId, state, attemptsMade }` a waiting job, removed;
258
+ * `{ raced: true, state, error }` `remove()` threw (most often: a worker took the job between
259
+ * the read and the remove, or it is a job scheduler's next
260
+ * run, which BullMQ refuses to remove); `state` is read again.
261
+ * `attemptsMade` is the job's own count for the caller's sentence (`ranBefore`), 0 when it is not a positive whole number.
262
+ */
263
+ export async function removeQueuedJob({ redis, queue, job, jobId, state = null, timeoutMs = 2_000, guard = null, touchStore = undefined, touchOutbox = null, now = Date.now, interventionOrigin = "human" }) {
264
+ const hash = await Promise.resolve()
265
+ .then(() => redis.hgetall(jobKey(jobId)))
266
+ .catch(() => null);
267
+ if (hash?.since) {
268
+ const stop = await guarded(guard, job);
269
+ if (stop !== null) return stop;
270
+ const res = await removeHeldJob({ redis, queue, jobId, timeoutMs, dropDefer: true, touchStore, touchOutbox, now, interventionOrigin });
271
+ if (!res.ok) return res.cleared === true ? { invalid: res.invalid, cleared: true } : { invalid: res.invalid };
272
+ return { ok: true, held: true, jobId: res.jobId, attemptsMade: res.attemptsMade, ...(res.touchWarning ? { touchWarning: res.touchWarning } : {}) };
273
+ }
274
+ if (!job) return { notQueued: true, state: "unknown" };
275
+ const current = typeof state === "string" ? state : await stateOf(job);
276
+ if (!QUEUED_STATES.includes(current)) return { notQueued: true, state: current };
277
+ const stop = await guarded(guard, job);
278
+ if (stop !== null) return stop;
279
+ let removal;
280
+ try {
281
+ const store = touchStore === undefined ? (typeof redis?.eval === "function" ? makeTouchStore({ redis, now }) : null) : touchStore;
282
+ removal = interventionOrigin === "agent" ? (await job.remove(), { ok: true }) : await trackTouchAction({ store, outbox: touchOutbox, event: stopTouch(job, queue, jobId, now), act: async () => { await job.remove(); return { ok: true }; }, succeeded: () => !(job.attemptsMade > 0) && isWorkload(job) });
283
+ } catch (error) {
284
+ return { raced: true, state: await stateOf(job), error };
285
+ }
286
+ await Promise.all([dropDeferRecord(redis, jobId, timeoutMs), dropAttemptOf(redis, queue, jobId, timeoutMs)]);
287
+ return { ok: true, held: false, jobId, state: current, attemptsMade: Number.isInteger(job.attemptsMade) && job.attemptsMade > 0 ? job.attemptsMade : 0, ...(removal.touchWarning ? { touchWarning: removal.touchWarning } : {}) };
288
+ }
289
+
290
+ /** `removeQueuedJob`'s guard asked: null to go on (no guard, or a null answer), else `{ refused }`, or `{ refused: null, error }` for a throw. */
291
+ async function guarded(guard, job) {
292
+ if (typeof guard !== "function") return null;
293
+ try {
294
+ const answer = await guard(job);
295
+ return answer === null || answer === undefined ? null : { refused: answer };
296
+ } catch (error) {
297
+ return { refused: null, error };
298
+ }
299
+ }
300
+
301
+ /** A job's state, "unknown" where it cannot be read, as the CLI has always read it. */
302
+ function stateOf(job) {
303
+ return Promise.resolve()
304
+ .then(() => job.getState())
305
+ .catch(() => "unknown");
306
+ }
307
+
308
+ /**
309
+ * Delete a removed job's model attempt key (issue #812), under the id the processor wrote it with: the job's queue's name
310
+ * and its id. A queue handle without a name (BullMQ's always has one) names no key, so nothing is sent.
311
+ */
312
+ async function dropAttemptOf(redis, queue, jobId, timeoutMs) {
313
+ if (typeof queue?.name !== "string" || queue.name === "") return;
314
+ await dropModelAttempt(redis, modelAttemptIdOf(queue.name, jobId), timeoutMs);
315
+ }
316
+
317
+ /**
318
+ * Delete a removed job's defer record, bounded and best-effort (model-attempts.mjs `dropKey`, the one such delete): the
319
+ * record's own TTL is the backstop for a delete that fails, and a record whose job is gone is never read.
320
+ */
321
+ function dropDeferRecord(redis, jobId, timeoutMs) {
322
+ return dropKey(redis, deferKey(jobId), timeoutMs);
323
+ }
@@ -96,6 +96,41 @@ export function parseCaps(raw) {
96
96
  return out;
97
97
  }
98
98
 
99
+ /**
100
+ * The implicit profile, `DEFAULT_SECRETS_PROFILE` in `secrets.mjs`, copied rather than imported for the
101
+ * reason the charset above is: that module spawns resolvers and this one is loaded by the receiver. A test
102
+ * holds the two equal.
103
+ */
104
+ export const IMPLICIT_SECRET_PROFILE = "default";
105
+
106
+ /**
107
+ * The profile a job's secrets resolve through, or undefined when it asked for none. `secretsArmed`'s rule
108
+ * (`secrets.mjs`), spelled again here for the same reason as the constant: an explicit profile, or a
109
+ * non-empty `secrets` map, which then resolves through the implicit `default` (issue #704). Before #704 only
110
+ * the explicit field was a need, so a trigger that never wrote `run.secretsProfile` was never routed and took
111
+ * the coin flip this module exists to remove.
112
+ *
113
+ * UPGRADE THE WORKERS BEFORE THE RECEIVER (and the console, whose re-run routes by this rule too). The router
114
+ * only ever picks a host that ADVERTISES every need, so the new need never sends a job to a host that cannot
115
+ * serve it. But an older worker advertises `s:default` only when its ENV declares it: one whose `default`
116
+ * lives in the overlay serves it without saying so. For a job with only this need that costs nothing (no
117
+ * advertiser is abstention 3, the shared queue it used before). For a job with a SECOND need it can regress:
118
+ * `run.secrets` without a profile plus a `w:ci` wait, an older host A declaring `w:ci` and serving `default`
119
+ * from its overlay, host B declaring neither. Before #704 the job needed `[w:ci]` and was routed to A; under
120
+ * a new receiver it needs `[s:default, w:ci]`, no host advertises both, and the shared queue lets B pop it
121
+ * and refuse. Once every worker runs this release, A advertises its overlay `default` and the job is routed
122
+ * to A again. An older receiver computes no such need and routes as before.
123
+ */
124
+ // Exported (issue #712) for the run record's `secretsProfile` (run-history.mjs), which must name the profile this rule
125
+ // routes by and `resolveSecrets` resolves through, and must not import secrets.mjs, which spawns.
126
+ export function secretProfileNeeded(job) {
127
+ const named = job?.secretsProfile;
128
+ if (typeof named === "string" && named !== "") return named;
129
+ const secrets = job?.secrets;
130
+ if (secrets !== null && typeof secrets === "object" && Object.keys(secrets).length > 0) return IMPLICIT_SECRET_PROFILE;
131
+ return undefined;
132
+ }
133
+
99
134
  /**
100
135
  * What a JOB needs, from the same two fields the worker's own refusals read.
101
136
  *
@@ -105,7 +140,7 @@ export function parseCaps(raw) {
105
140
  */
106
141
  export function jobNeeds(job) {
107
142
  const needs = new Set();
108
- const secret = token(CAP_SECRET, job?.secretsProfile);
143
+ const secret = token(CAP_SECRET, secretProfileNeeded(job));
109
144
  if (secret) needs.add(secret);
110
145
  if (Array.isArray(job?.waitFor)) {
111
146
  for (const condition of job.waitFor) {
@@ -16,11 +16,15 @@ import { parseArgs } from "node:util";
16
16
  import { CAPACITY_WINDOWS, computeCapacity } from "./capacity.mjs";
17
17
  import { readCapacityRecords } from "./capacity-records.mjs";
18
18
  import { formatCpus, formatMemory } from "./job-size.mjs";
19
+ import { RUN_MIRROR_MAX_RUNS_MAX, RUN_MIRROR_MAX_RUNS_MIN, runMirrorMaxRunsOf } from "./retention.mjs";
19
20
 
20
21
  /** How long the CLI waits on the host registry, the kill switch's budget. */
21
22
  const FLEET_READ_TIMEOUT_MS = 2_000;
22
- /** The deployment keys this command reads beside the Valkey URL. */
23
- export const CAPACITY_ENV_KEYS = Object.freeze(["PI_LOGS_DIR", "PI_LOG_RETENTION_DAYS", "PI_WORKER_NAME"]);
23
+ /**
24
+ * The deployment keys this command reads beside the Valkey URL. `PI_RUN_MIRROR_MAX_RUNS` (issue #761) is the run index's
25
+ * cap, which tells an index cut by count from one that is only large (`readMirrorWindow`).
26
+ */
27
+ export const CAPACITY_ENV_KEYS = Object.freeze(["PI_LOGS_DIR", "PI_LOG_RETENTION_DAYS", "PI_WORKER_NAME", "PI_RUN_MIRROR_MAX_RUNS"]);
24
28
 
25
29
  /**
26
30
  * Run the command. Returns the exit code. Every collaborator is a seam with the production default.
@@ -45,7 +49,7 @@ export async function runCapacity(args, { env = process.env, write = (chunk) =>
45
49
  if (deployment.problem) return fail(deployment.problem);
46
50
  const facts = await deploymentFacts(deployment.env);
47
51
  if (facts.problem) return fail(facts.problem);
48
- const { logsDir, retentionDays, localHost } = facts;
52
+ const { logsDir, retentionDays, localHost, runMirrorMaxRuns } = facts;
49
53
 
50
54
  const { killSwitchValkeyUrls, makeRedisClient, urlShown } = await import("./connection.mjs");
51
55
  const picked = (pickUrls ?? killSwitchValkeyUrls)({ env, flagUrl: parsed.values["valkey-url"] ?? null });
@@ -75,7 +79,7 @@ export async function runCapacity(args, { env = process.env, write = (chunk) =>
75
79
  // report's clock: a row's age decides how far its running jobs count (`LIVE_FRESH_MS`).
76
80
  const [fleet, read] = await Promise.all([
77
81
  redis ? (readLiveHostsFn ?? readLiveHosts)(redis, { now, timeoutMs: FLEET_READ_TIMEOUT_MS, prune: false }).catch((error) => ({ unreachable: error?.message ?? "registry unreadable" })) : { unreachable: null },
78
- readCapacityRecords({ redis, logsDir, sinceMs: windowStartMs, nowMs, retentionDays, localHost, noMirrorReason: refusedNote, timeoutMs: FLEET_READ_TIMEOUT_MS, ...(fs ? { fs } : {}) }),
82
+ readCapacityRecords({ redis, logsDir, sinceMs: windowStartMs, nowMs, retentionDays, localHost, indexMax: runMirrorMaxRuns, noMirrorReason: refusedNote, timeoutMs: FLEET_READ_TIMEOUT_MS, ...(fs ? { fs } : {}) }),
79
83
  ]);
80
84
  if (named && (fleet.unreachable || read.mirrorState.startsWith("unreachable"))) return fail(`could not read Valkey at ${urlShown(url)}: ${fleet.unreachable ? `host registry ${fleet.unreachable}` : `run mirror ${read.mirrorState}`}`);
81
85
  const coverage = { ...read.coverage, reason: [read.coverage.reason, fleet.unreachable ? `host registry unreadable (${fleet.unreachable})` : null].filter(Boolean).join("; ") || null };
@@ -100,20 +104,23 @@ export function onlyHost(report, host) {
100
104
  if (!known.includes(host)) return { unknown: known };
101
105
  const shown = report.hosts.find((h) => h.name === host);
102
106
  const { fromMs, source: _source, truncated, ...counts } = shown.coverage;
103
- return { report: { ...report, hosts: [shown], coverage: { ...report.coverage, fromMs, truncated, ...counts, historyNotShared: report.coverage.historyNotShared.filter((n) => n === host) } } };
107
+ return { report: { ...report, fleet: undefined, hosts: [shown], coverage: { ...report.coverage, fromMs, truncated, ...counts, historyNotShared: report.coverage.historyNotShared.filter((n) => n === host) } } };
104
108
  }
105
109
 
106
110
  /**
107
111
  * What this command reads of the deployment: the logs directory (config.mjs `logsDirPath`, the worker's rule), the
108
- * retention (30 days unless set; 0 keeps the files) and this host's name (`PI_WORKER_NAME`, else the hostname), or
109
- * `{ problem }` for a retention the worker would refuse to boot with.
112
+ * retention (30 days unless set; 0 keeps the files), this host's name (`PI_WORKER_NAME`, else the hostname) and the run
113
+ * index's cap (`runMirrorMaxRuns`, `PI_RUN_MIRROR_MAX_RUNS`, 5,000 unless set; issue #761), or `{ problem }` for a
114
+ * retention or a cap the worker would refuse to boot with.
110
115
  */
111
116
  export async function deploymentFacts(env) {
112
117
  const raw = env.PI_LOG_RETENTION_DAYS;
113
118
  if (raw !== undefined && raw !== "" && !/^\d{1,6}$/.test(raw)) return { problem: `PI_LOG_RETENTION_DAYS must be a non-negative integer (got ${JSON.stringify(raw)})` };
119
+ const cap = runMirrorMaxRunsOf(env.PI_RUN_MIRROR_MAX_RUNS);
120
+ if (cap.problem) return { problem: `PI_RUN_MIRROR_MAX_RUNS must be an integer ${RUN_MIRROR_MAX_RUNS_MIN}-${RUN_MIRROR_MAX_RUNS_MAX} (got ${JSON.stringify(env.PI_RUN_MIRROR_MAX_RUNS)})` };
114
121
  const { logsDirPath, defaultWorkerName, WORKER_NAME_RE } = await import("./config.mjs");
115
122
  const named = env.PI_WORKER_NAME;
116
- return { logsDir: logsDirPath(env), retentionDays: raw === undefined || raw === "" ? 30 : Number(raw), localHost: typeof named === "string" && WORKER_NAME_RE.test(named) ? named : defaultWorkerName() };
123
+ return { logsDir: logsDirPath(env), retentionDays: raw === undefined || raw === "" ? 30 : Number(raw), localHost: typeof named === "string" && WORKER_NAME_RE.test(named) ? named : defaultWorkerName(), runMirrorMaxRuns: cap.value };
117
124
  }
118
125
 
119
126
  /** A span in the largest whole units that say it: `40s`, `6m`, `2h 5m`, `3d 4h`. */
@@ -164,6 +171,7 @@ const CONTROL = /[\u0000-\u0009\u000b-\u001f\u007f-\u009f]/g;
164
171
  /** The human report: one block per host, then what the fleet's history covers. Control characters stripped. */
165
172
  export function capacityText(report, { since }) {
166
173
  const lines = [];
174
+ if (report.fleet) lines.push(...fleetFacts(report.fleet));
167
175
  for (const h of report.hosts) lines.push(...hostLines(h, since, report.coverage));
168
176
  if (report.hosts.length === 0) lines.push(`${capitalized(noRunText(report.coverage, `in the last ${since}`))}.`);
169
177
  lines.push(...coverageLines(report.coverage));
@@ -373,3 +381,15 @@ export function coverageLines(cov) {
373
381
 
374
382
  /** The fleet's history cut by the run mirror (`coverage.truncated`), as the CLI's coverage line says it. */
375
383
  export const TRUNCATED_NOTE = "history truncated: the run mirror holds nothing older for at least one host, so its earlier time is counted as neither busy nor idle";
384
+
385
+ /** Shared fleet headline wording for the CLI and the Spend capacity view. */
386
+ export function fleetFacts(fleet) {
387
+ const pct = (value) => value === null ? "unknown" : percentText(value);
388
+ const wait = (value) => value === null ? "no run recorded one" : durationText(value);
389
+ return [
390
+ `Fleet busy share of slot time: ${pct(fleet.busyPerMille)}${fleet.incomplete ? " (coverage incomplete)" : ""}`,
391
+ `Fleet peak: ${fleet.peak} jobs at once${fleet.peakAtMs === null ? "" : ` at ${new Date(fleet.peakAtMs).toISOString()}`}`,
392
+ `Fleet wait for a slot: p95 ${wait(fleet.waits.p95Ms)}, median ${wait(fleet.waits.p50Ms)}`,
393
+ `Fleet promised memory at the busiest hour: ${pct(fleet.promisedMemPerMille)}`,
394
+ ];
395
+ }