@edgehero/pi-dispatch-receiver 1.0.0 → 1.2.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@edgehero/pi-dispatch-receiver",
3
- "version": "1.0.0",
3
+ "version": "1.2.0",
4
4
  "type": "module",
5
5
  "description": "Webhook receiver for pi-dispatch: the always-on edge that verifies GitHub, GitLab, Forgejo and Azure DevOps deliveries and enqueues (at most) one job per event for the worker.",
6
6
  "keywords": [
@@ -45,7 +45,7 @@
45
45
  },
46
46
  "dependencies": {
47
47
  "@octokit/webhooks": "14.2.0",
48
- "@edgehero/pi-dispatch": "^1.0.0",
48
+ "@edgehero/pi-dispatch": "^1.3.0",
49
49
  "bullmq": "5.80.4",
50
50
  "ioredis": "5.11.1"
51
51
  }
package/src/config.mjs CHANGED
@@ -283,13 +283,13 @@ function loadTriggers(env, readFile, fileExists) {
283
283
  }
284
284
  const group = groups[run.kind];
285
285
  if (on.type === "label") {
286
- group.label.push({ index, predicate: { any: on.any, all: on.all, none: on.none }, flow: run.flow, command: run.command, packages: run.packages, image: run.image, skillsDir: run.skillsDir, instructions: run.instructions, resume: run.resume, replicas: run.replicas, repository: run.repository });
286
+ group.label.push({ index, predicate: { any: on.any, all: on.all, none: on.none }, flow: run.flow, command: run.command, packages: run.packages, image: run.image, skillsDir: run.skillsDir, instructions: run.instructions, resume: run.resume, secrets: run.secrets, secretsProfile: run.secretsProfile, replicas: run.replicas, repository: run.repository });
287
287
  } else if (on.type === "comment") {
288
- group.comment = { index, phrase: on.phrase, defaultFlow: run.flow, command: run.command, packages: run.packages, image: run.image, skillsDir: run.skillsDir, instructions: run.instructions, resume: run.resume, replicas: run.replicas, repository: run.repository }; // parseTriggers guarantees at most one per forge
288
+ group.comment = { index, phrase: on.phrase, defaultFlow: run.flow, command: run.command, packages: run.packages, image: run.image, skillsDir: run.skillsDir, instructions: run.instructions, resume: run.resume, secrets: run.secrets, secretsProfile: run.secretsProfile, replicas: run.replicas, repository: run.repository }; // parseTriggers guarantees at most one per forge
289
289
  } else if (on.type === "pull_request") {
290
290
  // `reviewStates` is null rather than an empty Set when unnarrowed: the filter tests it for
291
291
  // presence, and an empty Set would read as "no verdict matches" and silently refuse everything.
292
- group.pullRequest.push({ index, actions: new Set(on.action), reviewStates: on.reviewState ? new Set(on.reviewState) : null, predicate: { any: on.any, all: on.all, none: on.none }, flow: run.flow, command: run.command, packages: run.packages, image: run.image, skillsDir: run.skillsDir, instructions: run.instructions, resume: run.resume, replicas: run.replicas });
292
+ group.pullRequest.push({ index, actions: new Set(on.action), reviewStates: on.reviewState ? new Set(on.reviewState) : null, predicate: { any: on.any, all: on.all, none: on.none }, flow: run.flow, command: run.command, packages: run.packages, image: run.image, skillsDir: run.skillsDir, instructions: run.instructions, resume: run.resume, secrets: run.secrets, secretsProfile: run.secretsProfile, replicas: run.replicas });
293
293
  }
294
294
  }
295
295
 
@@ -114,8 +114,17 @@ export function filterAzure(subset, triggers, knownFlows, selfId, authorized, de
114
114
  // /job/event.json, and a worker-host path in an agent-readable file is the leak prepare-local's
115
115
  // basename(folder) restraint already exists to prevent.
116
116
  ...(resolved.skillsDir !== undefined ? { skillsDir: resolved.skillsDir } : {}),
117
+ // REQ-TRIGGER-SECRETS. Conditional exactly like skillsDir above, so a trigger that binds no
118
+ // secret enqueues the job data it always has. The map holds REFERENCES, never values: the
119
+ // receiver has no resolver and reaches no vault -- the worker resolves them pre-spend.
120
+ ...(resolved.secrets !== undefined ? { secrets: resolved.secrets } : {}),
121
+ ...(resolved.secretsProfile !== undefined ? { secretsProfile: resolved.secretsProfile } : {}),
117
122
  ...(resolved.instructions !== undefined ? { instructions: resolved.instructions } : {}),
118
123
  ...(resolved.resume !== undefined ? { resume: resolved.resume } : {}),
124
+ // How many independent sandboxes race this flow (REQ-REPLICA-RUNS). At JOB level and conditional for the
125
+ // reasons filter.mjs states in full: receiver.mjs reads this to decide how many times to enqueue, and an
126
+ // unflagged job's data must stay byte-identical to today's.
127
+ ...(resolved.replicas !== undefined ? { replicas: resolved.replicas } : {}),
119
128
  trigger: {
120
129
  event,
121
130
  action: resolved.action,
@@ -177,8 +186,11 @@ function matchLabelRules(subset, triggers, labels, action) {
177
186
  packages: rule.packages,
178
187
  image: rule.image,
179
188
  skillsDir: rule.skillsDir,
189
+ secrets: rule.secrets,
190
+ secretsProfile: rule.secretsProfile,
180
191
  instructions: rule.instructions,
181
192
  resume: rule.resume,
193
+ replicas: rule.replicas,
182
194
  matched: { index: rule.index, type: "label", label: matchedLabel(L, rule.predicate) },
183
195
  target: { type: "issue", number: subset.target?.number, title: subset.target?.title, body: subset.target?.body },
184
196
  };
@@ -217,8 +229,11 @@ function routeComment(subset, triggers, knownFlows, targetType) {
217
229
  packages: triggers.comment.packages,
218
230
  image: triggers.comment.image,
219
231
  skillsDir: triggers.comment.skillsDir,
232
+ secrets: triggers.comment.secrets,
233
+ secretsProfile: triggers.comment.secretsProfile,
220
234
  instructions: triggers.comment.instructions,
221
235
  resume: triggers.comment.resume,
236
+ replicas: triggers.comment.replicas,
222
237
  matched: { index: triggers.comment.index, type: "comment", phrase },
223
238
  // No author_association: Azure has none, and the authority that admitted this comment was resolved
224
239
  // from the graph, not read off the body.
@@ -246,8 +261,11 @@ function routePullRequest(subset, triggers, action) {
246
261
  packages: rule.packages,
247
262
  image: rule.image,
248
263
  skillsDir: rule.skillsDir,
264
+ secrets: rule.secrets,
265
+ secretsProfile: rule.secretsProfile,
249
266
  instructions: rule.instructions,
250
267
  resume: rule.resume,
268
+ replicas: rule.replicas,
251
269
  matched: { index: rule.index, type: "pull_request", action },
252
270
  target: {
253
271
  type: "pull_request",
@@ -116,8 +116,17 @@ export function filterForgejo(eventName, subset, triggers, knownFlows, selfId, a
116
116
  // /job/event.json, and a worker-host path in an agent-readable file is the leak prepare-local's
117
117
  // basename(folder) restraint already exists to prevent.
118
118
  ...(resolved.skillsDir !== undefined ? { skillsDir: resolved.skillsDir } : {}),
119
+ // REQ-TRIGGER-SECRETS. Conditional exactly like skillsDir above, so a trigger that binds no
120
+ // secret enqueues the job data it always has. The map holds REFERENCES, never values: the
121
+ // receiver has no resolver and reaches no vault -- the worker resolves them pre-spend.
122
+ ...(resolved.secrets !== undefined ? { secrets: resolved.secrets } : {}),
123
+ ...(resolved.secretsProfile !== undefined ? { secretsProfile: resolved.secretsProfile } : {}),
119
124
  ...(resolved.instructions !== undefined ? { instructions: resolved.instructions } : {}),
120
125
  ...(resolved.resume !== undefined ? { resume: resolved.resume } : {}),
126
+ // How many independent sandboxes race this flow (REQ-REPLICA-RUNS). At JOB level and conditional for the
127
+ // reasons filter.mjs states in full: receiver.mjs reads this to decide how many times to enqueue, and an
128
+ // unflagged job's data must stay byte-identical to today's.
129
+ ...(resolved.replicas !== undefined ? { replicas: resolved.replicas } : {}),
121
130
  trigger: {
122
131
  event: eventName,
123
132
  // Forgejo's OWN word, not our translation of it. The run record should say what the forge said.
@@ -145,8 +154,11 @@ function routeIssueLabel(subset, triggers) {
145
154
  packages: rule.packages, // the MATCHED rule's fields -- rules in one file may differ on them
146
155
  image: rule.image,
147
156
  skillsDir: rule.skillsDir,
157
+ secrets: rule.secrets,
158
+ secretsProfile: rule.secretsProfile,
148
159
  instructions: rule.instructions,
149
160
  resume: rule.resume,
161
+ replicas: rule.replicas,
150
162
  matched: { index: rule.index, type: "label", label: matchedLabel(L, rule.predicate) },
151
163
  target: { type: "issue", number: subset.issue?.number, title: subset.issue?.title, body: subset.issue?.body },
152
164
  };
@@ -190,8 +202,11 @@ function routeComment(subset, triggers, knownFlows) {
190
202
  packages: triggers.comment.packages,
191
203
  image: triggers.comment.image,
192
204
  skillsDir: triggers.comment.skillsDir,
205
+ secrets: triggers.comment.secrets,
206
+ secretsProfile: triggers.comment.secretsProfile,
193
207
  instructions: triggers.comment.instructions,
194
208
  resume: triggers.comment.resume,
209
+ replicas: triggers.comment.replicas,
195
210
  matched: { index: triggers.comment.index, type: "comment", phrase },
196
211
  // The invoking comment rides on the trigger. No author_association: Forgejo has none, and the
197
212
  // authority that admitted this comment was resolved from the API, not read off the body.
@@ -217,8 +232,11 @@ function routePullRequest(subset, triggers, action) {
217
232
  packages: rule.packages,
218
233
  image: rule.image,
219
234
  skillsDir: rule.skillsDir,
235
+ secrets: rule.secrets,
236
+ secretsProfile: rule.secretsProfile,
220
237
  instructions: rule.instructions,
221
238
  resume: rule.resume,
239
+ replicas: rule.replicas,
222
240
  matched: { index: rule.index, type: "pull_request", action },
223
241
  target: {
224
242
  type: "pull_request",
@@ -107,10 +107,19 @@ export function filterGitLab(subset, triggers, knownFlows, selfId, authorized, d
107
107
  // /job/event.json, and a worker-host path in an agent-readable file is the leak prepare-local's
108
108
  // basename(folder) restraint already exists to prevent.
109
109
  ...(resolved.skillsDir !== undefined ? { skillsDir: resolved.skillsDir } : {}),
110
+ // REQ-TRIGGER-SECRETS. Conditional exactly like skillsDir above, so a trigger that binds no
111
+ // secret enqueues the job data it always has. The map holds REFERENCES, never values: the
112
+ // receiver has no resolver and reaches no vault -- the worker resolves them pre-spend.
113
+ ...(resolved.secrets !== undefined ? { secrets: resolved.secrets } : {}),
114
+ ...(resolved.secretsProfile !== undefined ? { secretsProfile: resolved.secretsProfile } : {}),
110
115
  ...(resolved.instructions !== undefined ? { instructions: resolved.instructions } : {}),
111
116
  // Conditional like packages/image, and for the same reason: an unflagged job's data must stay
112
117
  // byte-identical to today's, so the key is absent rather than present-and-undefined.
113
118
  ...(resolved.resume !== undefined ? { resume: resolved.resume } : {}),
119
+ // How many independent sandboxes race this flow (REQ-REPLICA-RUNS). At JOB level and conditional for the
120
+ // reasons filter.mjs states in full: receiver.mjs reads this to decide how many times to enqueue, and an
121
+ // unflagged job's data must stay byte-identical to today's.
122
+ ...(resolved.replicas !== undefined ? { replicas: resolved.replicas } : {}),
114
123
  trigger: {
115
124
  event: kind,
116
125
  action: subset.action,
@@ -143,8 +152,11 @@ function routeLabel(subset, triggers, targetType) {
143
152
  packages: rule.packages,
144
153
  image: rule.image,
145
154
  skillsDir: rule.skillsDir,
155
+ secrets: rule.secrets,
156
+ secretsProfile: rule.secretsProfile,
146
157
  instructions: rule.instructions,
147
158
  resume: rule.resume,
159
+ replicas: rule.replicas,
148
160
  matched: { index: rule.index, type: "label", label: matchedLabel(added, rule.predicate) },
149
161
  target: buildTarget(subset, targetType),
150
162
  };
@@ -203,8 +215,11 @@ function mrResult(subset, rule, matched) {
203
215
  packages: rule.packages,
204
216
  image: rule.image,
205
217
  skillsDir: rule.skillsDir,
218
+ secrets: rule.secrets,
219
+ secretsProfile: rule.secretsProfile,
206
220
  instructions: rule.instructions,
207
221
  resume: rule.resume,
222
+ replicas: rule.replicas,
208
223
  matched,
209
224
  target: buildTarget(subset, "pull_request"),
210
225
  };
@@ -249,8 +264,11 @@ function routeNote(subset, triggers, knownFlows) {
249
264
  packages: triggers.comment.packages,
250
265
  image: triggers.comment.image,
251
266
  skillsDir: triggers.comment.skillsDir,
267
+ secrets: triggers.comment.secrets,
268
+ secretsProfile: triggers.comment.secretsProfile,
252
269
  instructions: triggers.comment.instructions,
253
270
  resume: triggers.comment.resume,
271
+ replicas: triggers.comment.replicas,
254
272
  matched: { index: triggers.comment.index, type: "comment", phrase },
255
273
  target: buildTarget(subset, targetType),
256
274
  // The invoking comment rides the job as DATA (CONST-ISSUE-TEXT-IS-DATA). No author_association
package/src/filter.mjs CHANGED
@@ -121,6 +121,11 @@ export function filter(eventName, subset, cfg, selfId, deliveryId) {
121
121
  // /job/event.json, and a worker-host path in an agent-readable file is the leak prepare-local's
122
122
  // basename(folder) restraint already exists to prevent.
123
123
  ...(resolved.skillsDir !== undefined ? { skillsDir: resolved.skillsDir } : {}),
124
+ // REQ-TRIGGER-SECRETS. Conditional exactly like skillsDir above, so a trigger that binds no
125
+ // secret enqueues the job data it always has. The map holds REFERENCES, never values: the
126
+ // receiver has no resolver and reaches no vault -- the worker resolves them pre-spend.
127
+ ...(resolved.secrets !== undefined ? { secrets: resolved.secrets } : {}),
128
+ ...(resolved.secretsProfile !== undefined ? { secretsProfile: resolved.secretsProfile } : {}),
124
129
  ...(resolved.instructions !== undefined ? { instructions: resolved.instructions } : {}),
125
130
  // Conditional like packages/image, and for the same reason: an unflagged job's data must stay
126
131
  // byte-identical to today's, so the key is absent rather than present-and-undefined.
@@ -166,6 +171,8 @@ function routeIssueLabel(subset, triggers) {
166
171
  packages: rule.packages, // the MATCHED rule's fields -- rules in one file may differ on them
167
172
  image: rule.image,
168
173
  skillsDir: rule.skillsDir,
174
+ secrets: rule.secrets,
175
+ secretsProfile: rule.secretsProfile,
169
176
  instructions: rule.instructions,
170
177
  resume: rule.resume,
171
178
  replicas: rule.replicas,
@@ -223,6 +230,8 @@ function routeComment(subset, triggers, knownFlows) {
223
230
  packages: triggers.comment.packages,
224
231
  image: triggers.comment.image,
225
232
  skillsDir: triggers.comment.skillsDir,
233
+ secrets: triggers.comment.secrets,
234
+ secretsProfile: triggers.comment.secretsProfile,
226
235
  instructions: triggers.comment.instructions,
227
236
  resume: triggers.comment.resume,
228
237
  replicas: triggers.comment.replicas,
@@ -318,6 +327,8 @@ function routePullRequest(subset, triggers, action) {
318
327
  packages: rule.packages, // the MATCHED rule's fields -- rules in one file may differ on them
319
328
  image: rule.image,
320
329
  skillsDir: rule.skillsDir,
330
+ secrets: rule.secrets,
331
+ secretsProfile: rule.secretsProfile,
321
332
  instructions: rule.instructions,
322
333
  resume: rule.resume,
323
334
  replicas: rule.replicas,
package/src/poller.mjs CHANGED
@@ -804,7 +804,10 @@ function makeAppInstallationTokenFn(github, { fetchFn, readFile, now }) {
804
804
  let cached = null; // { token, expiresAtMs }
805
805
  return async () => {
806
806
  if (cached !== null && cached.expiresAtMs - now() > 5 * 60_000) return cached.token;
807
- const pem = await readFile(github.privateKeyPath, "utf8");
807
+ // Inline key (GITHUB_APP_PRIVATE_KEY) when the operator supplied one, the file otherwise. The shared
808
+ // loadGitHubAuth normalised and shape-checked it at load and refuses both-set, so there is nothing to
809
+ // decide here (issue #208).
810
+ const pem = github.privateKey ?? (await readFile(github.privateKeyPath, "utf8"));
808
811
  const jwt = appJwt(github.appId, pem, now());
809
812
  const res = await fetchFn(`${API_URL}/app/installations/${github.installationId}/access_tokens`, {
810
813
  method: "POST",
package/src/receiver.mjs CHANGED
@@ -1,7 +1,8 @@
1
1
  /**
2
- * The webhook receiver: a thin producer that turns a verified GitHub webhook into (at most) one queued
2
+ * The webhook receiver: a thin producer that turns a verified forge webhook into (at most) one queued
3
3
  * job -- or, when the matched trigger opted into `run.replicas`, into exactly that many independent ones
4
- * (REQ-REPLICA-RUNS). It composes the three pieces that own the hard parts -- `makeVerifiedHandler` (the
4
+ * (REQ-REPLICA-RUNS, on every forge since #187; the poller stays github-only, so replicas on the other
5
+ * three arrive by webhook alone). It composes the three pieces that own the hard parts -- `makeVerifiedHandler` (the
5
6
  * HMAC trust boundary), `filter` (the trigger/author gate), and the SHARED `enqueueGitHubJob` -- and adds
6
7
  * only the glue: parse the verified body, project the payload subset, route on the filter's verdict, and
7
8
  * map the outcome to a status code.
@@ -169,6 +170,36 @@ function pathOf(url) {
169
170
  return raw.length > 1 && raw.endsWith("/") ? raw.slice(0, -1) : raw;
170
171
  }
171
172
 
173
+ /**
174
+ * REPLICA FANOUT (REQ-REPLICA-RUNS). The one place a single delivery becomes more than one job, shared by
175
+ * all four forge arms since #187 widened `run.replicas` past github.
176
+ *
177
+ * ONE body rather than four, for the reason `enqueueForgeJob` gives for collapsing its own wrappers
178
+ * (queue.mjs): four copies is four places for one of them to be quietly weakened while every test stays
179
+ * green. What would be weakened here is the `replicas > 1` conditional, whose whole job is byte-identity
180
+ * for an unflagged delivery -- spread `replica: i` unconditionally in one arm and that forge's `data`, its
181
+ * jobId and its dedup id all change for every ordinary job, with no test outside that forge to notice.
182
+ *
183
+ * Absent `replicas` is `1` and the call below is byte-identical to the single enqueue it replaced: no
184
+ * `replica` key on the job, so the jobId, the dedup id and `data` are exactly what they were.
185
+ *
186
+ * PARTIAL FAILURE IS IDEMPOTENT BY CONSTRUCTION, which is why there is no compensating logic here: if
187
+ * replica k throws, the caller's catch answers 503, the forge redelivers, replicas 1..k-1 dedup on their
188
+ * own now-taken jobIds and k..n enqueue. The retry converges on exactly n jobs rather than n + (k-1).
189
+ *
190
+ * `enqueue` is a callback because the four arms spell their enqueue differently (a named github/gitlab
191
+ * wrapper, or `enqueueForgeJob` with an explicit kind); the fanout itself is forge-blind.
192
+ *
193
+ * @returns {Promise<number>} how many jobs were enqueued, for the caller's `enqueued` log line.
194
+ */
195
+ async function fanout(job, enqueue) {
196
+ const replicas = job.replicas ?? 1;
197
+ for (let i = 1; i <= replicas; i++) {
198
+ await enqueue(replicas > 1 ? { ...job, replica: i } : job);
199
+ }
200
+ return replicas;
201
+ }
202
+
172
203
  /**
173
204
  * The GitHub arm. Returns `makeVerifiedHandler`'s handler directly, so it only ever sees an already-verified
174
205
  * request. `onVerified` owns parse, filter, enqueue, and response; a good signature is the sole
@@ -189,19 +220,10 @@ function makeGitHubHandler({ queue, selfId, cfg, log }) {
189
220
  return respond(res, 204);
190
221
  }
191
222
 
192
- // REPLICA FANOUT (REQ-REPLICA-RUNS). The one place a single delivery becomes more than one job, and it
193
- // belongs here because this is where the 202/503 decision already lives. Absent `replicas` is `1` and
194
- // the call below is byte-identical to the single enqueue it replaced -- no `replica` key on the job,
195
- // so the jobId, the dedup id and `data` are all exactly what they were.
196
- //
197
- // PARTIAL FAILURE IS IDEMPOTENT BY CONSTRUCTION, which is why there is no compensating logic here: if
198
- // replica k throws, the catch below answers 503, GitHub redelivers, replicas 1..k-1 dedup on their own
199
- // now-taken jobIds and k..n enqueue. The retry converges on exactly n jobs rather than n + (k-1).
200
- const replicas = result.job.replicas ?? 1;
223
+ // Fanout (REQ-REPLICA-RUNS) lives in `fanout` above; the 202/503 decision stays here, where it always was.
224
+ let replicas;
201
225
  try {
202
- for (let i = 1; i <= replicas; i++) {
203
- await enqueueGitHubJob(queue, replicas > 1 ? { ...result.job, replica: i } : result.job);
204
- }
226
+ replicas = await fanout(result.job, (j) => enqueueGitHubJob(queue, j));
205
227
  } catch (err) {
206
228
  // Own try/catch so a Valkey-down enqueue is a 503 (retryable), not verify's outer 500.
207
229
  log?.({ event: "enqueue_failed", delivery, reason: err?.message });
@@ -249,8 +271,9 @@ function makeGitLabHandler({ queue, cfg, log, mode, secret, selfId, resolveAutho
249
271
  return respond(res, 204);
250
272
  }
251
273
 
274
+ let replicas;
252
275
  try {
253
- await enqueueGitLabJob(queue, result.job);
276
+ replicas = await fanout(result.job, (j) => enqueueGitLabJob(queue, j));
254
277
  } catch (err) {
255
278
  log?.({ event: "enqueue_failed", delivery, reason: err?.message });
256
279
  return respond(res, 503, { error: "enqueue-failed" }); // GitLab redelivers; dedup by webhook-id coalesces
@@ -259,7 +282,7 @@ function makeGitLabHandler({ queue, cfg, log, mode, secret, selfId, resolveAutho
259
282
  // `!` for a merge request, `#` for an issue -- GitLab's own notation, and the same discrimination
260
283
  // the semantic dedup key makes, because the two are separate number sequences.
261
284
  const sep = result.job.target.type === "pull_request" ? "!" : "#";
262
- log?.({ event: "enqueued", delivery, repo: result.job.repo, target: `${result.job.repo}${sep}${result.job.target.number}`, flow: result.job.flow });
285
+ log?.({ event: "enqueued", delivery, repo: result.job.repo, target: `${result.job.repo}${sep}${result.job.target.number}`, flow: result.job.flow, replicas });
263
286
  return respond(res, 202, { status: "queued" });
264
287
  });
265
288
  }
@@ -300,14 +323,15 @@ function makeForgejoHandler({ queue, cfg, log, secret, selfId, resolveAuthority
300
323
  return respond(res, 204);
301
324
  }
302
325
 
326
+ let replicas;
303
327
  try {
304
- await enqueueForgeJob(queue, "forgejo", result.job);
328
+ replicas = await fanout(result.job, (j) => enqueueForgeJob(queue, "forgejo", j));
305
329
  } catch (err) {
306
330
  log?.({ event: "enqueue_failed", delivery, reason: err?.message });
307
331
  return respond(res, 503, { error: "enqueue-failed" }); // Forgejo redelivers; dedup by GUID coalesces
308
332
  }
309
333
 
310
- log?.({ event: "enqueued", delivery, repo: result.job.repo, target: `${result.job.target.type}#${result.job.target.number}`, flow: result.job.flow });
334
+ log?.({ event: "enqueued", delivery, repo: result.job.repo, target: `${result.job.target.type}#${result.job.target.number}`, flow: result.job.flow, replicas });
311
335
  return respond(res, 202, { status: "queued" });
312
336
  });
313
337
  }
@@ -357,8 +381,9 @@ function makeAzureHandler({ queue, cfg, log, mode, secret, headerName, selfId, r
357
381
  return respond(res, 204);
358
382
  }
359
383
 
384
+ let replicas;
360
385
  try {
361
- await enqueueForgeJob(queue, "azure", result.job);
386
+ replicas = await fanout(result.job, (j) => enqueueForgeJob(queue, "azure", j));
362
387
  } catch (err) {
363
388
  log?.({ event: "enqueue_failed", delivery, reason: err?.message });
364
389
  return respond(res, 503, { error: "enqueue-failed" });
@@ -367,7 +392,7 @@ function makeAzureHandler({ queue, cfg, log, mode, secret, headerName, selfId, r
367
392
  // `!` for a pull request, `#` for a work item -- Azure numbers them separately, and this is the same
368
393
  // discrimination the semantic dedup key makes.
369
394
  const sep = result.job.target.type === "pull_request" ? "!" : "#";
370
- log?.({ event: "enqueued", delivery, repo: result.job.repo, target: `${result.job.repo}${sep}${result.job.target.number}`, flow: result.job.flow });
395
+ log?.({ event: "enqueued", delivery, repo: result.job.repo, target: `${result.job.repo}${sep}${result.job.target.number}`, flow: result.job.flow, replicas });
371
396
  return respond(res, 202, { status: "queued" });
372
397
  });
373
398
  }
package/src/start.mjs CHANGED
@@ -28,6 +28,7 @@ import { watch } from "node:fs";
28
28
  import { dirname, basename } from "node:path";
29
29
  import { loadReceiverConfig, triggersFilePath, reloadTriggers } from "./config.mjs";
30
30
  import { makeReceiver } from "./receiver.mjs";
31
+ import { entryExitCode } from "./cli.mjs";
31
32
  import { makeGitHubAuth } from "@edgehero/pi-dispatch/get-token";
32
33
  import { resolveGitLabSelfId } from "@edgehero/pi-dispatch/gitlab-identity";
33
34
  import { resolveForgejoSelfId } from "@edgehero/pi-dispatch/forgejo-identity";
@@ -192,6 +193,12 @@ function watchTriggers(env, cfg, log) {
192
193
  if (import.meta.url === `file://${process.argv[1]}` || process.argv[1]?.endsWith("start.mjs")) {
193
194
  startReceiver(process.env).catch((err) => {
194
195
  process.stderr.write(`${JSON.stringify({ event: "receiver_start_failed", reason: err?.message })}\n`);
195
- process.exitCode = 1;
196
+ // entryExitCode, NOT a bare 1. This file is what `receiver.service` execs -- cli.mjs is not on that
197
+ // path -- so the mapping cli.mjs documents ("a supervisor restarting on exit 2 would loop on a config
198
+ // that can never parse") only reaches a real deployment from here. A tagged config refusal exits 2
199
+ // and `RestartPreventExitStatus=2` stops the unit; anything else is infra and stays retryable at 1.
200
+ // IMPORTED rather than restated: two copies of an exit-code rule is one place for it to drift, and
201
+ // the copy that drifts is the one nobody is looking at.
202
+ process.exitCode = entryExitCode(err);
196
203
  });
197
204
  }