@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
package/src/queue.mjs CHANGED
@@ -1,9 +1,15 @@
1
+ import { makeApprovalStore } from "./approval-state.mjs";
2
+ import { validateFork } from "./fork-job.mjs";
1
3
  import { randomUUID } from "node:crypto";
2
4
  import { Queue } from "bullmq";
3
- import { assertJudgedConnection, onValkeyError } from "./connection.mjs";
4
- import { chainedJobId, localJobId, deliveryJobId, gitlabDeliveryJobId, forgeDeliveryJobId, manualTriggerJobId } from "./job-id.mjs";
5
+ import { metaKeyPattern, queueNameOfMetaKey } from "./bullmq-internals.mjs";
6
+ import { assertJudgedConnection, givesUp, onValkeyError } from "./connection.mjs";
7
+ import { chainedJobId, localJobId, deliveryJobId, gitlabDeliveryJobId, forgeDeliveryJobId, manualCanaryJobId, manualTriggerJobId } from "./job-id.mjs";
5
8
  import { targetSeparator } from "./forges.mjs";
6
9
  import { PR_CLOSE_ACTIONS } from "./triggers.mjs";
10
+ import { consoleAnswerBy, consoleDraftOf } from "./console-answer.mjs";
11
+ import { JOB_DATA_VERSION_FIELD, withJobDataVersion } from "./job-data-version.mjs";
12
+ import { FORGE_JOB_RETENTION_S, LOCAL_JOB_KEPT_COMPLETED_S, LOCAL_JOB_KEPT_FAILED_S } from "./retention.mjs";
7
13
 
8
14
  // The close words in every forge's spelling, derived from the one table (never re-typed here): a
9
15
  // matched PR action in this set marks a close job for the semantic-key discriminant below.
@@ -11,6 +17,48 @@ const PR_CLOSE_WORDS = new Set(Object.values(PR_CLOSE_ACTIONS));
11
17
 
12
18
  export const QUEUE = "pi-jobs";
13
19
 
20
+ /**
21
+ * A builder's job data stamped with the version it needs (issue #761, `DES-STORE-VERSION-WHEN-NEEDED`). `dataVersion`
22
+ * is the version the builder's INPUT arrived with: a job a newer release stored that this build re-enqueues (a held
23
+ * reply, a late message's fallback, a rerun), so the stamp never falls below it. Throws when that version is one this
24
+ * build cannot read, above its own or not a whole number of 1 or more (`withJobDataVersion`'s RangeError): such input
25
+ * cannot be rebuilt here without dropping what this build does not know, and the error carries `code: "newer-release"`,
26
+ * so a caller that falls back to a reply logs why. Nothing is stamped while the data needs 1, so every job of today's
27
+ * fields is stored byte for byte as before. `dataVersions` (`{ table, valueTable, supported }`) is the version rule,
28
+ * injectable for tests.
29
+ */
30
+ function stampJobData(data, dataVersion, dataVersions = {}) {
31
+ try {
32
+ return withJobDataVersion(dataVersion === undefined ? data : { ...data, [JOB_DATA_VERSION_FIELD]: dataVersion }, dataVersions);
33
+ } catch (error) {
34
+ if (error instanceof RangeError && error.code === undefined) error.code = "newer-release";
35
+ throw error;
36
+ }
37
+ }
38
+
39
+ /** Deterministic source/run binding survives a lost enqueue acknowledgement. No question or draft is queued. */
40
+ export async function enqueueAnswerDraftJob(queue, { runId, ruleId, draftRunId }) {
41
+ if (!/^[A-Za-z0-9._:-]{1,200}$/.test(runId ?? "") || !/^[a-z][a-z0-9_-]{0,63}$/.test(ruleId ?? "") || !/^answer-draft-[a-f0-9]{40}$/.test(draftRunId ?? "")) throw new Error("invalid answer draft reference");
42
+ await queue.add("answer-draft", { kind: "answer-draft", purpose: "answer-draft", answerDraft: { runId, ruleId } }, { jobId: draftRunId, attempts: 2, backoff: { type: "exponential", delay: 5000 }, removeOnComplete: { age: FORGE_JOB_RETENTION_S, count: 5000 }, removeOnFail: { age: FORGE_JOB_RETENTION_S, count: 5000 } });
43
+ return draftRunId;
44
+ }
45
+
46
+ /** A private evidence reference, never collector-supplied execution authority. */
47
+ export async function enqueueWatchJob(queue, fields, { now = Date.now, phase = "dispatch" } = {}) {
48
+ if (!/^[a-f0-9]{64}$/.test(fields.id) || !/^[A-Za-z0-9_-]{1,128}$/.test(fields.ruleId) || !["dispatch", "action"].includes(phase)) throw new Error("invalid watch queue reference");
49
+ const id = `watch-${phase}-${fields.id}`;
50
+ await queue.add("watch", { kind: "watch", watch: { findingId: fields.id, ruleId: fields.ruleId, phase } }, { jobId: id, delay: Math.max(0, (fields.dueAt ?? now()) - now()), attempts: 2, backoff: { type: "exponential", delay: 5000 }, removeOnComplete: { age: 7 * 86400, count: 10000 }, removeOnFail: { age: 7 * 86400, count: 10000 } });
51
+ return id;
52
+ }
53
+
54
+ /** A confirmed grouping request has no private text and one paid attempt. Collection failures never buy another. */
55
+ export async function enqueueQuestionGroupJob(queue, { runId, digest, configDigest }, { nonce = randomUUID } = {}) {
56
+ if (!/^[A-Za-z0-9._:-]{1,200}$/.test(runId ?? "") || !/^[a-f0-9]{64}$/.test(digest ?? "") || !/^[a-f0-9]{64}$/.test(configDigest ?? "")) throw new Error("invalid question grouping reference");
57
+ const jobId = `question-group-${nonce()}`;
58
+ await queue.add("question-grouping", { kind: "question-grouping", questionGrouping: { runId, digest, configDigest } }, { jobId, attempts: 1, removeOnComplete: { age: FORGE_JOB_RETENTION_S, count: 5000 }, removeOnFail: { age: FORGE_JOB_RETENTION_S, count: 5000 } });
59
+ return jobId;
60
+ }
61
+
14
62
  /**
15
63
  * The queue a HOST-AFFINE job goes to (issue #57): work only one machine can do, because the folder, the
16
64
  * secret resolver or the wait-check script lives there.
@@ -63,7 +111,40 @@ export function fleetQueueNames(hosts) {
63
111
  /** The name charset, duplicated from `config.mjs` deliberately: this module imports nothing. */
64
112
  const WORKER_NAME_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
65
113
 
66
- export { chainedJobId, localJobId, deliveryJobId, gitlabDeliveryJobId, forgeDeliveryJobId, manualTriggerJobId };
114
+ export { chainedJobId, localJobId, deliveryJobId, gitlabDeliveryJobId, forgeDeliveryJobId, manualCanaryJobId, manualTriggerJobId };
115
+
116
+ /**
117
+ * BullMQ's `jobScheduler` and `repeat` getters are `new Promise(async (resolve) => { ... await this.client ... })` (read
118
+ * at 5.80.4). A connection that gave up rejects `this.client` inside that async executor, where no caller can reach it:
119
+ * the outer promise never settles and the rejection is unhandled, which ends the process. One `getJobSchedulers` on a
120
+ * refused or absent Valkey took the whole console down, from behind every `.catch` and timeout its readers carry.
121
+ *
122
+ * So the connection is awaited FIRST, out here, where a rejection is the caller's. Past that `this.client` is settled and
123
+ * resolved, so the getter's own await cannot reject. Guarding each call site instead was the other way, and one of them
124
+ * already did (`await q.client` in trigger-schedule-zones): the next caller would have to know, and `makeQueue` is the one
125
+ * place every queue of this project is built.
126
+ *
127
+ * The constructor is the same class of fault at another address. `RedisConnection.close` removes every listener, and a
128
+ * connect still in flight then fails into `emit("error")`, which with no listener throws out of a promise chain nothing can
129
+ * reach, so a read that closed its queue at its own bound ended the process a moment after it answered. A queue that was
130
+ * closed has nobody left to tell, so that one emit says nothing.
131
+ */
132
+ class SettledQueue extends Queue {
133
+ constructor(name, options) {
134
+ super(name, options);
135
+ const { connection } = this;
136
+ const emit = connection.emit.bind(connection);
137
+ connection.emit = (event, ...args) => (event === "error" && connection.listenerCount("error") === 0 ? false : emit(event, ...args));
138
+ }
139
+
140
+ get jobScheduler() {
141
+ return this.client.then(() => super.jobScheduler);
142
+ }
143
+
144
+ get repeat() {
145
+ return this.client.then(() => super.repeat);
146
+ }
147
+ }
67
148
 
68
149
  /**
69
150
  * A queue handle. `name` defaults to the shared queue, so every existing caller is unchanged and a
@@ -72,9 +153,10 @@ export { chainedJobId, localJobId, deliveryJobId, gitlabDeliveryJobId, forgeDeli
72
153
  export function makeQueue(connection, { name = QUEUE } = {}) {
73
154
  // Issue #464: only a connection `parseConnection` built, which judges and pins the Valkey it dials.
74
155
  assertJudgedConnection(connection);
75
- const queue = new Queue(name, { connection });
156
+ const queue = new SettledQueue(name, { connection });
76
157
  // Issue #468: an error of this queue is one line, its message, never BullMQ's console.error of the whole object.
77
- onValkeyError(queue, `queue ${name}`);
158
+ // A queue that gives up is the kind a long-lived reader replaces, so it is the kind that repeats the line per read.
159
+ onValkeyError(queue, `queue ${name}`, undefined, { quietly: givesUp(connection) });
78
160
  return queue;
79
161
  }
80
162
 
@@ -85,8 +167,8 @@ export function makeQueue(connection, { name = QUEUE } = {}) {
85
167
  * removeOnComplete keeps the dedup window ~= the retention. Unlike webhooks, local jobs are not
86
168
  * redelivered, so a modest window is enough.
87
169
  */
88
- export async function enqueueLocalJob(queue, fields) {
89
- return (await addLocalJob(queue, fields)).id;
170
+ export async function enqueueLocalJob(queue, fields, { dataVersions } = {}) {
171
+ return (await addLocalJob(queue, fields, { dataVersions })).id;
90
172
  }
91
173
 
92
174
  /**
@@ -119,8 +201,8 @@ export async function enqueueLocalJob(queue, fields) {
119
201
  * done rather than inventing one. A read that fails outright returns `existing: undefined`: whether this
120
202
  * call queued anything is then unknown, and the caller says so instead of guessing either way.
121
203
  */
122
- export async function enqueueLocalJobReporting(queue, fields) {
123
- return addReporting(queue, (enqueueNonce) => addLocalJob(queue, { ...fields, enqueueNonce }));
204
+ export async function enqueueLocalJobReporting(queue, fields, { dataVersions } = {}) {
205
+ return addReporting(queue, (enqueueNonce) => addLocalJob(queue, { ...fields, enqueueNonce }, { dataVersions }));
124
206
  }
125
207
 
126
208
  /**
@@ -135,8 +217,7 @@ export async function enqueueLocalJobReporting(queue, fields) {
135
217
  * job is not retried, as a scheduled tick is not (a scheduler job carries no `attempts`). The id is
136
218
  * `manualTriggerJobId`'s, so `localEventContext` reports `source: "cron"` with `scheduledFor: null`.
137
219
  */
138
- export async function enqueueTriggerRunReporting(queue, schedule, { now = new Date() } = {}) {
139
- const jobId = manualTriggerJobId({ triggerId: schedule.schedulerId, now });
220
+ export async function enqueueTriggerRunReporting(queue, schedule, { now = new Date(), jobId = manualTriggerJobId({ triggerId: schedule.schedulerId, now }) } = {}) {
140
221
  return addReporting(queue, async (enqueueNonce) => {
141
222
  await queue.add(schedule.name, { ...schedule.data, enqueueNonce }, { ...schedule.opts, jobId });
142
223
  return { id: jobId };
@@ -176,7 +257,7 @@ export function swallowedRunSentence(jobId, existing) {
176
257
  return `an identical run was queued${hhmm} as ${jobId} (${existing?.state ?? "already queued or done"}); nothing new was queued.`;
177
258
  }
178
259
 
179
- async function addLocalJob(queue, { folder, flow, task, command, provider, model, maxTurns, models, maxCostUsd, image, backend, excludeTools, mcp, skillsDir, secrets, secretsProfile, chainDepth, parentJobId, jobId, enqueueNonce, now = new Date() }) {
260
+ async function addLocalJob(queue, { folder, flow, task, command, provider, model, maxTurns, models, fallbackModels, maxCostUsd, packages, image, backend, excludeTools, excludeExtensions, excludeExtensionTools, mcp, skillsDir, secrets, secretsProfile, recover, steer, chainDepth, parentJobId, jobId, enqueueNonce, dataVersion, now = new Date() }, { dataVersions } = {}) {
180
261
  const minute = now.toISOString().slice(0, 16); // YYYY-MM-DDTHH:MM -- the dedup window
181
262
  // A caller-supplied jobId (the outbox collector's retry-idempotent chainedJobId) wins; otherwise the
182
263
  // minute-windowed localJobId is the dedup key. A command job (issue #189) fills the flow slot with
@@ -185,11 +266,13 @@ async function addLocalJob(queue, { folder, flow, task, command, provider, model
185
266
  // vanish silently -- and the `cmd:` prefix keeps a command named X from colliding with a flow named X
186
267
  // (`:` is outside the skill-name charset, so no real flow can spell the prefixed form). A flow job's
187
268
  // key is byte-identical to before the feature.
188
- const id = jobId ?? localJobId({ folder, flow: command !== undefined ? `cmd:${command}` : flow, task, minute, provider, model, models });
269
+ const id = jobId ?? localJobId({ folder, flow: command !== undefined ? `cmd:${command}` : flow, task, minute, provider, model, models, fallbackModels });
189
270
  // image/chainDepth/parentJobId land on `data` only when present, so a plain non-chained job's data is
190
271
  // byte-identical. `image` is the container image this job runs in (INT-TRIGGERS-FILE-CONTRACT); absent
191
272
  // resolves the deployment default at job start, never a value frozen here.
192
- const data = {
273
+ // Stamped with the version its fields need (issue #761, `stampJobData`): nothing while that is 1. Before the add, so
274
+ // input from a newer release throws here and nothing is queued.
275
+ const data = stampJobData({
193
276
  kind: "local",
194
277
  folder,
195
278
  flow,
@@ -204,8 +287,12 @@ async function addLocalJob(queue, { folder, flow, task, command, provider, model
204
287
  // Issue #502: the allowed-model list, conditional like `image`, so a job whose trigger named none keeps its
205
288
  // data byte-identical. An outbox child carries its parent's (outbox.mjs), never one its request file names.
206
289
  ...(models !== undefined && { models }),
290
+ ...(fallbackModels !== undefined && { fallbackModels }),
207
291
  // Issue #501: the per-job dollar cap, passed through for the outbox child that inherits its parent's.
208
292
  ...(maxCostUsd !== undefined && { maxCostUsd }),
293
+ // Issue #800: the outbox child's `"packages": false`, off its parent's data. Not in this function's list before, so the collector could not have
294
+ // passed it. A key every worker already reads, so it asks for no `dataVersion`. Conditional like `image`, so no other job's data moves.
295
+ ...(packages !== undefined && { packages }),
209
296
  ...(image !== undefined && { image }),
210
297
  // #227. WHERE this job's container is built. Conditional like `image`, so a trigger that named no
211
298
  // venue produces byte-identical job data -- and at JOB level, never inside `trigger`, for the reason
@@ -217,6 +304,10 @@ async function addLocalJob(queue, { folder, flow, task, command, provider, model
217
304
  // same reason: `trigger` is copied verbatim into /job/event.json, and a permission boundary is the
218
305
  // worker's business, never agent-visible input to reason about.
219
306
  ...(excludeTools !== undefined && { excludeTools }),
307
+ // Issue #800. Each its own conditional, and at JOB level, excludeTools' two reasons: a dropped list runs the job with what the
308
+ // file takes away, and what the session may not load is the worker's business, never agent-visible input.
309
+ ...(excludeExtensions !== undefined && { excludeExtensions }),
310
+ ...(excludeExtensionTools !== undefined && { excludeExtensionTools }),
220
311
  ...(mcp !== undefined && { mcp }),
221
312
  // The host directory of operator-authored skills this trigger injects (REQ-PER-TRIGGER-SKILLS).
222
313
  // Conditional like `image`, so an unflagged job's data stays byte-identical, and at JOB level rather
@@ -228,23 +319,31 @@ async function addLocalJob(queue, { folder, flow, task, command, provider, model
228
319
  // this differs from `replicas` -- that one is refused on a local job and this one is not.
229
320
  ...(secrets !== undefined && { secrets }),
230
321
  ...(secretsProfile !== undefined && { secretsProfile }),
322
+ // Issue #645: durable runs, enqueueForgeJob's rule (only when armed, job level, never part of the id). No local
323
+ // producer passes it today, and no trigger can: the loader refuses run.recover on cron. It is accepted here so a
324
+ // local producer that gains it later cannot quietly move the minute-windowed dedup key by doing so.
325
+ ...(recover === true && { recover: true }),
326
+ // Issue #646: steering a running job, the same rule (only when armed, job level, never part of the id). Unlike
327
+ // recover a local producer can pass it: the loader allows run.steer on cron, and an operator's local run may ask.
328
+ ...(steer === true && { steer: true }),
231
329
  ...(chainDepth !== undefined && { chainDepth }),
232
330
  ...(parentJobId !== undefined && { parentJobId }),
233
331
  // Issue #524: `enqueueLocalJobReporting`'s own mark, so it can tell whether ITS add is the one stored.
234
332
  ...(enqueueNonce !== undefined && { enqueueNonce }),
235
- };
333
+ }, dataVersion, dataVersions);
236
334
  await queue.add("local", data, {
237
335
  jobId: id,
238
336
  attempts: 2,
239
337
  backoff: { type: "exponential", delay: 60_000 },
240
- removeOnComplete: { age: 24 * 3600 },
241
- removeOnFail: { age: 7 * 24 * 3600 },
338
+ removeOnComplete: { age: LOCAL_JOB_KEPT_COMPLETED_S },
339
+ removeOnFail: { age: LOCAL_JOB_KEPT_FAILED_S },
242
340
  });
243
341
  return { id };
244
342
  }
245
343
 
246
- // Coalesces rapid re-label spam; the GUID jobId + 31d retention handle exact redelivery, so this
247
- // window only needs to absorb burst re-labels, not the full redelivery window.
344
+ // Coalesces rapid re-label spam; the GUID jobId + its retention (`FORGE_JOB_RETENTION_S`) handle exact redelivery, so
345
+ // this window only needs to absorb burst re-labels, not the full redelivery window. A Forgejo replay is the exception:
346
+ // it carries a new delivery id, so this window is the only layer that coalesces one, and only inside it.
248
347
  const SEMANTIC_WINDOW_MS = 10 * 60 * 1000;
249
348
 
250
349
  /**
@@ -343,14 +442,100 @@ export async function enqueueGitLabJob(queue, fields) {
343
442
  * window, replicas never coalesce against each other, and an unflagged job's dedup id is the same string it
344
443
  * has always been.
345
444
  */
346
- export async function enqueueForgeJob(queue, kind, { repo, projectId, azure, target, flow, command, trigger, provider, model, maxTurns, models, maxCostUsd, packages, image, backend, excludeTools, mcp, skillsDir, instructions, resume, awaitInput, parkedReply, secrets, secretsProfile, waitFor, replica, replicas }) {
445
+ export async function enqueueForgeJob(queue, kind, fields, { dataVersions } = {}) {
446
+ const { jobId, data: built, dedupId } = forgeJobOf(kind, fields, { dataVersions });
447
+ let data = built;
448
+ let routedName = null;
449
+ // Bind the delivery to the request visible at ingress: a delayed old reply must never decide a later park.
450
+ // A reply held before publication has no binding yet; its post-park enqueue reads again here.
451
+ if ((data.gates?.length > 0 && data.trigger?.comment) || data.approvalRequestId !== undefined) {
452
+ const found = await makeApprovalStore({ redis: await queue.client }).find(data);
453
+ if (!found.ok) throw new Error("approval-state-unavailable");
454
+ if (data.approvalRequestId !== undefined) {
455
+ if (found.request?.requestId !== data.approvalRequestId) throw new TypeError("approval-request-stale");
456
+ } else if (found.request?.state === "pending") {
457
+ data.approvalRequestId = found.request.requestId;
458
+ }
459
+ if (data.approvalRequestId !== undefined && found.request?.hostQueue) routedName = found.request.hostQueue;
460
+ // Issue #761: the binding above adds a field to built data, so the stamp is asked again for what is stored.
461
+ data = stampJobData(data, undefined, dataVersions);
462
+ }
463
+ // Only the host owning the retained session can consume this decision. Central routing also covers console
464
+ // answers and held replies, whose callers may have selected the shared queue before a request existed.
465
+ const routed = routedName !== null && routedName !== queue.name ? makeQueue(queue.opts.connection, { name: routedName }) : null;
466
+ let added;
467
+ try {
468
+ added = await (routed ?? queue).add(kind, data, {
469
+ jobId,
470
+ deduplication: { id: dedupId, ttl: SEMANTIC_WINDOW_MS }, // ttl in ms
471
+ ...forgeJobOpts(),
472
+ });
473
+ } finally {
474
+ if (routed) await routed.close();
475
+ }
476
+ // Issue #289: WHICH of the two dedup layers spoke is readable off `queue.add`'s return, and only one
477
+ // of them is. Verified at bullmq 5.80.4's own Lua: a jobId collision (the GUID shield,
478
+ // REQ-DEDUP-BY-DELIVERY-GUID) returns THE SAME id -- structurally invisible here, and correctly so,
479
+ // because a resend of a delivery that IS queued (a hand redelivery, or Azure's own resend) deserves
480
+ // the answer "queued" -- while the `deduplication` option (the 10-minute semantic window) returns the
481
+ // EXISTING job's DIFFERENT id.
482
+ // So `added.id !== jobId` means this delivery was swallowed by the window and created nothing, which
483
+ // the receiver used to log as `enqueued` and answer as success. A defensive undefined (a fake queue
484
+ // predating the comparison) reads as not-deduplicated, today's behaviour exactly.
485
+ if (added?.id && added.id !== jobId) return { jobId, deduplicated: true, survivingJobId: added.id, ...(routed ? { queue: routedName } : {}) };
486
+ return { jobId, deduplicated: false, ...(routed ? { queue: routedName } : {}) };
487
+ }
488
+
489
+ // A forge job's retry policy and retention, one table for both of its enqueue paths (the window-deduplicated one above
490
+ // and the late-message resume below). The retention is in SECONDS (`FORGE_JOB_RETENTION_S`, the delivery dedup window
491
+ // of REQ-DEDUP-BY-DELIVERY-GUID, retention.mjs); the semantic window's ttl is in ms.
492
+ // A function, so every add gets its own objects (bullmq keeps the options it is handed on the job).
493
+ const forgeJobOpts = () => ({
494
+ attempts: 2,
495
+ backoff: { type: "exponential", delay: 60_000 },
496
+ removeOnComplete: { age: FORGE_JOB_RETENTION_S },
497
+ removeOnFail: { age: FORGE_JOB_RETENTION_S },
498
+ });
499
+
500
+ /**
501
+ * Enqueue the job a LATE forge message would have started (issue #646, DES-STEER-LATE-MESSAGE-RESUME-OR-REPLY, branch
502
+ * A): `fields` is what the receiver stored as the message's fallback (the object it would have handed
503
+ * `enqueueForgeJob`), rebuilt here through the same explicit field list, under the same delivery-GUID id and the same
504
+ * retry policy, on whichever queue the caller names. NO `deduplication` option: the run it follows held this target's
505
+ * semantic window, and the message is exactly the follow-up that window must not swallow. The GUID layer still holds,
506
+ * so two runs that both ended holding the message (replicas a comment fanned out to) make one job between them.
507
+ * Returns `{ jobId }` (an id bullmq already held answers the same, by design); throws what `queue.add` throws, and
508
+ * the caller falls back to a reply.
509
+ */
510
+ export async function enqueueLateForgeJob(queue, kind, fields, { dataVersions } = {}) {
511
+ const { jobId, data } = forgeJobOf(kind, fields, { dataVersions });
512
+ await queue.add(kind, data, { jobId, ...forgeJobOpts() });
513
+ return { jobId };
514
+ }
515
+
516
+ /**
517
+ * The forge job's id, its data (an explicit field list, see `enqueueForgeJob`, stamped by `stampJobData`) and its
518
+ * semantic dedup id. Throws on a bad delivery id or console name, and on input a newer release stored with a
519
+ * `dataVersion` above this build's (issue #761; every caller that rebuilds stored input answers with a reply then).
520
+ */
521
+ function forgeJobOf(kind, { repo, projectId, azure, target, flow, command, trigger, provider, model, maxTurns, models, fallbackModels, maxCostUsd, packages, image, backend, excludeTools, excludeExtensions, excludeExtensionTools, mcp, skillsDir, instructions, resume, awaitInput, gates, approvalRequestId, gateTimeout, gateBudgetRaiseUsd, progress, statusComment, recover, steer, fork, forkPolicy, forkReserveMicros, thinkingLevel, parkedReply, replyReceivedAt, console: consoleMeta, secrets, secretsProfile, waitFor, replica, replicas, dataVersion }, { dataVersions } = {}) {
347
522
  const jobId = forgeDeliveryJobId(kind, trigger?.deliveryId, replica);
523
+ if (approvalRequestId !== undefined && (typeof approvalRequestId !== "string" || !/^[A-Za-z0-9._:-]{1,200}$/.test(approvalRequestId))) throw new TypeError("approval-request-invalid");
524
+ if (replyReceivedAt !== undefined && (!Number.isSafeInteger(replyReceivedAt) || replyReceivedAt < 0 || resume !== true || typeof trigger?.comment?.body !== "string")) throw new TypeError("reply-receipt-invalid");
525
+ // Issue #642: an answer typed into the operator console carries WHO typed it, `{ by: <token name> }`, and nothing
526
+ // else. Rebuilt from the one field rather than copied, `parkedReply`'s rule for durable job data, and refused when the
527
+ // name is not a console token's: the worker prints it on the forge in the answer's trail comment, so a name outside
528
+ // that charset (an `@`, markdown, a control byte) must never reach a queue.
529
+ const consoleBy = consoleMeta === undefined ? null : consoleAnswerBy(consoleMeta);
530
+ const consoleDraft = consoleDraftOf(consoleMeta);
531
+ if (consoleDraft?.invalid) throw new TypeError("invalid answer draft provenance");
532
+ if (consoleMeta !== undefined && consoleBy === null) throw new TypeError("enqueueForgeJob: console.by must be a console token name ([a-z0-9][a-z0-9._-]{0,31})");
348
533
  // `packages` (whether to load the operator-staged pi packages) and `image` (which container image to run)
349
534
  // come off the MATCHED trigger (INT-TRIGGERS-FILE-CONTRACT / REQ-GLOBAL-PI-OVERLAY) and land on `data`
350
535
  // only when the filter resolved one, exactly like chainDepth/parentJobId above, so an unflagged trigger's
351
536
  // job data is byte-identical. Both sit at JOB level, never inside `trigger` -- that object is descriptive
352
537
  // and is copied verbatim into /job/event.json, where an execution knob has no business.
353
- const data = {
538
+ const data = stampJobData({
354
539
  kind,
355
540
  repo,
356
541
  ...(projectId !== undefined && { projectId }),
@@ -372,6 +557,7 @@ export async function enqueueForgeJob(queue, kind, { repo, projectId, azure, tar
372
557
  // Issue #502: the allowed-model list, conditional like `packages` below so an unflagged trigger's job data is
373
558
  // byte-identical, and at JOB level, never inside `trigger` (copied verbatim into /job/event.json).
374
559
  ...(models !== undefined && { models }),
560
+ ...(fallbackModels !== undefined && { fallbackModels }),
375
561
  ...(packages !== undefined && { packages }),
376
562
  ...(image !== undefined && { image }),
377
563
  // #227. WHERE this job's container is built. Conditional like `image`, so a trigger that named no
@@ -384,6 +570,10 @@ export async function enqueueForgeJob(queue, kind, { repo, projectId, azure, tar
384
570
  // same reason: `trigger` is copied verbatim into /job/event.json, and a permission boundary is the
385
571
  // worker's business, never agent-visible input to reason about.
386
572
  ...(excludeTools !== undefined && { excludeTools }),
573
+ // Issue #800. Each its own conditional, and at JOB level, excludeTools' two reasons: a dropped list runs the job with what the
574
+ // file takes away, and what the session may not load is the worker's business, never agent-visible input.
575
+ ...(excludeExtensions !== undefined && { excludeExtensions }),
576
+ ...(excludeExtensionTools !== undefined && { excludeExtensionTools }),
387
577
  ...(mcp !== undefined && { mcp }),
388
578
  // #501. The trigger's per-job dollar cap, as written. Conditional like `excludeTools`, at JOB level for its
389
579
  // reason, and a narrowing like it: the worker runs the job under the smaller of this and the deployment cap.
@@ -401,9 +591,29 @@ export async function enqueueForgeJob(queue, kind, { repo, projectId, azure, tar
401
591
  // REQ-AWAIT-INPUT (issue #625): only when armed, so an unflagged job's data is byte-identical, and at JOB level
402
592
  // for resume's reason: `trigger` is copied verbatim into /job/event.json.
403
593
  ...(awaitInput === true && { awaitInput: true }),
594
+ ...(gates !== undefined && { gates }),
595
+ ...(approvalRequestId !== undefined && { approvalRequestId }),
596
+ ...(gateTimeout !== undefined && { gateTimeout }),
597
+ ...(gateBudgetRaiseUsd !== undefined && { gateBudgetRaiseUsd }),
598
+ // Issue #644: the report_progress tool, and the forge status comment. Only when armed, awaitInput's rule and
599
+ // for its reasons (byte-identical unflagged data; job level, never inside `trigger`).
600
+ ...(progress === true && { progress: true }),
601
+ ...(statusComment === true && { statusComment: true }),
602
+ // Issue #645: durable runs. Only when armed, the same rule and reasons; never part of an id either, since whether a
603
+ // lost run continues from its checkpoint says nothing about which delivery this is.
604
+ ...(recover === true && { recover: true }),
605
+ // Issue #646: steering a running job. Only when armed, the same rule and reasons; never part of an id, since whether
606
+ // a run accepts messages says nothing about which delivery started it.
607
+ ...(steer === true && { steer: true }),
404
608
  // A phrase-free reply to a parked issue (issue #625), gated by the worker on the key's `parked` mark. Never part
405
609
  // of an id: it says how the delivery was routed, not what it is.
406
610
  ...(parkedReply === true && { parkedReply: true }),
611
+ // The held-reply settle supplies the receiver's original clock, not a timestamp from comment text.
612
+ // It is private accounting metadata, so /job/event.json's trigger remains byte-identical.
613
+ ...(replyReceivedAt !== undefined && { replyReceivedAt }),
614
+ // Issue #642: the console answer's provenance, at JOB level and never inside `trigger` (copied verbatim into
615
+ // /job/event.json): it is for the worker's trail comment, not for the agent. Never part of an id.
616
+ ...(consoleBy !== null && { console: { by: consoleBy, ...(consoleDraft ?? {}) } }),
407
617
  // REQ-TRIGGER-SECRETS. The env variables this trigger binds and the profile whose resolver reads
408
618
  // them. REFERENCES only: no value is ever enqueued, because a queued job is durable and a resolved
409
619
  // credential in Redis would outlive the container it was scoped to. The worker resolves them at job
@@ -424,9 +634,11 @@ export async function enqueueForgeJob(queue, kind, { repo, projectId, azure, tar
424
634
  // Conditional for the same reason packages/image/resume are: an unflagged job's data must keep
425
635
  // exactly the keys it has today. `replica` is this job's 1-based index and `replicas` the set size;
426
636
  // both are integers, so the run record they land in stays PII-free by construction.
637
+ ...(fork !== undefined && { fork: validateFork(fork), forkPolicy, forkReserveMicros }),
638
+ ...(thinkingLevel !== undefined && { thinkingLevel }),
427
639
  ...(replica !== undefined && { replica }),
428
640
  ...(replicas !== undefined && { replicas }),
429
- };
641
+ }, dataVersion, dataVersions);
430
642
  // A close-triggered job (issue #231) leads the semantic key's flow slot with `closed:`. Without it,
431
643
  // a label/comment/PR job on the same target and flow inside the 10-minute window silently swallows
432
644
  // the close job -- and because a swallowed close job writes no run record, the once trigger it was
@@ -442,30 +654,14 @@ export async function enqueueForgeJob(queue, kind, { repo, projectId, azure, tar
442
654
  // narrow to the matched action word, like the PR half already does.
443
655
  const isCloseJob = matched?.type === "issue" || (matched?.type === "pull_request" && PR_CLOSE_WORDS.has(matched?.action));
444
656
  const flowSlot = `${isCloseJob ? "closed:" : ""}${command !== undefined ? `cmd:${command}` : flow}`;
445
- const added = await queue.add(kind, data, {
446
- jobId,
447
- // A command job (issue #189) fills the semantic key's flow slot with `cmd:<command>`: a command
448
- // trigger carries no flow, so the slot would otherwise read `undefined` for every command and one
449
- // command's 10-minute window would swallow a different command's delivery on the same target. The
450
- // `cmd:` prefix keeps a command named X from coalescing against a flow named X -- `:` is outside
451
- // the skill-name charset, so no real flow can spell the prefixed form -- and a flow job's key
452
- // stays byte-identical to before the feature.
453
- deduplication: { id: `${repo}${targetSeparator(kind, target?.type)}${target.number}:${flowSlot}${replica !== undefined ? `:r${replica}` : ""}`, ttl: SEMANTIC_WINDOW_MS }, // ttl in ms
454
- attempts: 2,
455
- backoff: { type: "exponential", delay: 60_000 },
456
- removeOnComplete: { age: 31 * 24 * 3600 }, // age in seconds -- do not cross units with the ms ttl above
457
- removeOnFail: { age: 31 * 24 * 3600 },
458
- });
459
- // Issue #289: WHICH of the two dedup layers spoke is readable off `queue.add`'s return, and only one
460
- // of them is. Verified at bullmq 5.80.4's own Lua: a jobId collision (the GUID shield,
461
- // REQ-DEDUP-BY-DELIVERY-GUID) returns THE SAME id -- structurally invisible here, and correctly so,
462
- // because a forge retry of a delivery that IS queued deserves the answer "queued" -- while the
463
- // `deduplication` option (the 10-minute semantic window) returns the EXISTING job's DIFFERENT id.
464
- // So `added.id !== jobId` means this delivery was swallowed by the window and created nothing, which
465
- // the receiver used to log as `enqueued` and answer as success. A defensive undefined (a fake queue
466
- // predating the comparison) reads as not-deduplicated, today's behaviour exactly.
467
- if (added?.id && added.id !== jobId) return { jobId, deduplicated: true, survivingJobId: added.id };
468
- return { jobId, deduplicated: false };
657
+ // A command job (issue #189) fills the semantic key's flow slot with `cmd:<command>`: a command
658
+ // trigger carries no flow, so the slot would otherwise read `undefined` for every command and one
659
+ // command's 10-minute window would swallow a different command's delivery on the same target. The
660
+ // `cmd:` prefix keeps a command named X from coalescing against a flow named X -- `:` is outside
661
+ // the skill-name charset, so no real flow can spell the prefixed form -- and a flow job's key
662
+ // stays byte-identical to before the feature.
663
+ const dedupId = `${repo}${targetSeparator(kind, target?.type)}${target.number}:${flowSlot}${fork !== undefined ? `:fork:${fork.id}` : ""}${replica !== undefined ? `:r${replica}` : ""}`;
664
+ return { jobId, data, dedupId };
469
665
  }
470
666
 
471
667
  /**
@@ -490,12 +686,23 @@ export async function enqueueForgeJob(queue, kind, { repo, projectId, azure, tar
490
686
  * being wrong costs money, not for a reader that runs every second. Fails open to `[]`, so an unreadable
491
687
  * keyspace degrades to the registry's answer rather than refusing.
492
688
  */
493
- export async function discoverHostQueues(redis, { timeoutMs = 2_000, count = 500 } = {}) {
494
- const prefix = `bull:${QUEUE}@`;
689
+ export async function discoverHostQueues(redis, opts = {}) {
690
+ return (await discoverHostQueuesReporting(redis, opts)).names;
691
+ }
692
+
693
+ /**
694
+ * `discoverHostQueues`, and whether its answer is the whole keyspace (issue #710, review round 1): `{ names, incomplete }`,
695
+ * `incomplete` null for a scan that reached its end, else why it did not (the scan threw, or ran out of time with
696
+ * `names` the part it read). For a reader for which a queue it did not ask is not an answer: the console's run page
697
+ * asks whether a job's data is still held, and "no queue holds it" off a scan that never finished is a false refusal.
698
+ * The kill switch keeps `discoverHostQueues`, which fails open to what it read.
699
+ */
700
+ export async function discoverHostQueuesReporting(redis, { timeoutMs = 2_000, count = 500 } = {}) {
701
+ const prefix = `${QUEUE}@`;
495
702
  const names = new Set();
703
+ let cursor = "0";
496
704
  try {
497
705
  const deadline = Date.now() + timeoutMs;
498
- let cursor = "0";
499
706
  do {
500
707
  // BOUNDED, because BullMQ's connections carry `maxRetriesPerRequest: null` and a command against
501
708
  // an unreachable server therefore QUEUES FOREVER rather than rejecting -- so an unguarded await
@@ -508,24 +715,26 @@ export async function discoverHostQueues(redis, { timeoutMs = 2_000, count = 500
508
715
  // thing on the loop, which is the one case it exists for.
509
716
  let timer;
510
717
  const [next, keys] = await Promise.race([
511
- redis.scan(cursor, "MATCH", `${prefix}*:meta`, "COUNT", count),
718
+ redis.scan(cursor, "MATCH", metaKeyPattern(prefix), "COUNT", count),
512
719
  new Promise((_, reject) => {
513
720
  timer = setTimeout(() => reject(new Error("scan timed out")), Math.max(1, deadline - Date.now()));
514
721
  }),
515
722
  ]).finally(() => clearTimeout(timer));
516
723
  cursor = next;
517
724
  for (const key of keys ?? []) {
518
- const name = String(key).slice(prefix.length, -":meta".length);
725
+ const queueName = queueNameOfMetaKey(String(key));
726
+ if (queueName === null || !queueName.startsWith(prefix)) continue;
727
+ const name = queueName.slice(prefix.length);
519
728
  // Validated like every other peer-derived name: a key an operator hand-created could hold
520
729
  // anything, and `new Queue` throws on a `:`, which would take the kill switch out entirely.
521
730
  if (WORKER_NAME_RE.test(name)) names.add(name);
522
731
  }
523
732
  } while (cursor !== "0" && Date.now() < deadline);
524
- } catch {
525
- // Fail open: the registry's answer alone is still better than refusing to pause.
526
- return [];
733
+ } catch (err) {
734
+ // Fail open: the registry's answer alone is still better than refusing to pause. Nothing read is kept, as before.
735
+ return { names: [], incomplete: err?.message ?? String(err) };
527
736
  }
528
- return [...names].sort().map(hostQueueName);
737
+ return { names: [...names].sort().map(hostQueueName), incomplete: String(cursor) === "0" ? null : "the queue scan ran out of time" };
529
738
  }
530
739
 
531
740
  /**