@edgehero/pi-dispatch-receiver 4.3.0 → 5.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/receiver.mjs CHANGED
@@ -19,6 +19,8 @@
19
19
  * session and binds no port.
20
20
  */
21
21
 
22
+ import { makeReceiverObservations } from "@edgehero/pi-dispatch/receiver-status";
23
+
22
24
  import { makeVerifiedHandler } from "./verify.mjs";
23
25
  import { filter, wantsCloserAuthority } from "./filter.mjs";
24
26
  import { enqueueForgeJob, enqueueGitHubJob, enqueueGitLabJob } from "@edgehero/pi-dispatch/queue";
@@ -30,9 +32,18 @@ import { parseForgejoSubset } from "./forgejo-subset.mjs";
30
32
  import { filterAzure } from "./filter-azure.mjs";
31
33
  import { parseAzureSubset } from "./azure-subset.mjs";
32
34
  import { makeAzureVerifiedHandler } from "./verify-azure.mjs";
35
+ import { trySteer } from "./steer.mjs";
36
+ import { holdAfterDedup, holdBeforeLabel, wantsHoldAfterDedup } from "./held.mjs";
37
+ import { makeWatchHandler } from "./watch.mjs";
38
+ import { makeWatchStore } from "@edgehero/pi-dispatch/watch-store";
39
+ import { enqueueWatchJob } from "@edgehero/pi-dispatch/queue";
33
40
 
34
41
  /** Write a small JSON body with an explicit status; a 204 carries no body. verify's `respond` is private. */
42
+ const responseResults = new WeakMap();
35
43
  function respond(res, status, obj) {
44
+ // Only our own closed response words disambiguate 202 and 503; no request body is inspected.
45
+ if (status === 202) responseResults.set(res, obj?.status);
46
+ if (status === 503) responseResults.set(res, obj?.error === "enqueue-failed" ? "enqueue-failed" : "lookup-failed");
36
47
  if (status === 204) {
37
48
  res.writeHead(204);
38
49
  res.end();
@@ -134,7 +145,14 @@ export function parseSubset(payload) {
134
145
  * undefined, i.e. never drops the harness's own comments. Absent property therefore means no route; the
135
146
  * failure of a forgotten property is a 404 an operator sees, never a paid recursion they get billed for.
136
147
  */
137
- export function makeReceiver({ queue, selfId, cfg, log, gitlab = null, forgejo = null, azure = null, resolveAuthority, router = null }) {
148
+ export function makeReceiver({ queue, selfId, cfg, log, gitlab = null, forgejo = null, azure = null, resolveAuthority, router = null, steerRedis, now = Date.now, watchEnv = process.env, watchStore = null, enqueueWatch = enqueueWatchJob, observations = makeReceiverObservations({ now }) }) {
149
+ const watch = makeWatchHandler({ cfg, env: watchEnv, now, log: (event, fields) => log?.({ event, ...fields }), store: watchStore ?? Object.fromEntries(["ingest", "appendTrace", "recordVerdict", "noteIssueActivity"].map(method => [method, async (...args) => makeWatchStore(await queue.client, { now })[method](...args)])), enqueue: fields => enqueueWatch(queue, fields, { now }) });
150
+ // The Valkey client comment steering (issue #646) reads the live runs with and pushes into: by default the shared
151
+ // queue's own connection (`queue.client`, BullMQ's promise of its ioredis client), so steering opens no second
152
+ // connection and has no lifecycle of its own to close. Asked only for a job that carries `steerMode`, so a
153
+ // deployment with no steer-armed comment trigger never touches it. `steerRedis` is the seam.
154
+ const steerClient = typeof steerRedis === "function" ? steerRedis : () => queue?.client;
155
+ const steer = { steerClient, now, log };
138
156
  // Which queue a delivery is enqueued onto (issue #57, `OQ-032`). The default is the shared queue for
139
157
  // EVERY job, which is what this receiver did before multi-host existed -- so a deployment that wires no
140
158
  // router, and every test that constructs one without it, is byte-identical.
@@ -147,7 +165,7 @@ export function makeReceiver({ queue, selfId, cfg, log, gitlab = null, forgejo =
147
165
  // `resolveAuthority` is the GITHUB closer resolver (issue #231), riding top-level beside `selfId`
148
166
  // because github's dependencies always have -- the other forges bundle theirs in per-forge objects.
149
167
  // Optional, because only a close delivery an armed close rule matches ever consults it.
150
- const github = cfg.servesGithub ? makeGitHubHandler({ queue, routeTo, selfId, cfg, log, resolveAuthority }) : null;
168
+ const github = cfg.servesGithub ? makeGitHubHandler({ queue, routeTo, selfId, cfg, log, resolveAuthority, steer }) : null;
151
169
 
152
170
  // A TABLE, built once, rather than one `if` per forge. Two forges made that a single branch; four make
153
171
  // it a chain, and a chain is where one arm quietly ends up checked after the fallthrough. A path present
@@ -155,13 +173,14 @@ export function makeReceiver({ queue, selfId, cfg, log, gitlab = null, forgejo =
155
173
  // falls through to GitHub, which is what keeps `/` working -- and a configured-off GitHub answers the
156
174
  // same 404 from the fallthrough itself, below.
157
175
  const routes = {
158
- "/gitlab": gitlab ? makeGitLabHandler({ queue, routeTo, cfg, log, ...gitlab }) : null,
159
- "/forgejo": forgejo ? makeForgejoHandler({ queue, routeTo, cfg, log, ...forgejo }) : null,
160
- "/azure": azure ? makeAzureHandler({ queue, routeTo, cfg, log, ...azure }) : null,
176
+ "/gitlab": gitlab ? makeGitLabHandler({ queue, routeTo, cfg, log, ...gitlab, steer }) : null,
177
+ "/forgejo": forgejo ? makeForgejoHandler({ queue, routeTo, cfg, log, ...forgejo, steer }) : null,
178
+ "/azure": azure ? makeAzureHandler({ queue, routeTo, cfg, log, ...azure, steer }) : null,
161
179
  };
162
180
 
163
- return async function receiverHandler(req, res) {
181
+ const route = async (req, res) => {
164
182
  const path = pathOf(req.url);
183
+ if (path === "/watch" || path.startsWith("/watch/")) return watch(req, res);
165
184
  if (Object.hasOwn(routes, path)) {
166
185
  const handler = routes[path];
167
186
  // Not a 401. An endpoint that answers "unauthorized" for a forge nobody configured is an endpoint
@@ -177,6 +196,24 @@ export function makeReceiver({ queue, selfId, cfg, log, gitlab = null, forgejo =
177
196
  if (!github) return respond(res, 404, { error: "github webhooks are not configured" });
178
197
  return await github(req, res);
179
198
  };
199
+ const receiverHandler = async (req, res) => {
200
+ const path = pathOf(req.url);
201
+ if (path === "/watch" || path.startsWith("/watch/")) return route(req, res);
202
+ const forge = ({ "/gitlab": "gitlab", "/forgejo": "forgejo", "/azure": "azure" })[path] ?? "github";
203
+ const end = res.end;
204
+ let counted = false;
205
+ res.end = function (...args) {
206
+ const result = end.apply(this, args);
207
+ if (!counted) {
208
+ counted = true;
209
+ try { observations.observe(forge, res.statusCode, responseResults.get(res)); } catch {}
210
+ }
211
+ return result;
212
+ };
213
+ try { return await route(req, res); }
214
+ finally { res.end = end; responseResults.delete(res); }
215
+ };
216
+ return Object.assign(receiverHandler, { observations });
180
217
  }
181
218
 
182
219
  /** The path portion of a request URL, without query or trailing slash. Never throws on a malformed URL. */
@@ -198,9 +235,17 @@ function pathOf(url) {
198
235
  * Absent `replicas` is `1` and the call below is byte-identical to the single enqueue it replaced: no
199
236
  * `replica` key on the job, so the jobId, the dedup id and `data` are exactly what they were.
200
237
  *
201
- * PARTIAL FAILURE IS IDEMPOTENT BY CONSTRUCTION, which is why there is no compensating logic here: if
202
- * replica k throws, the caller's catch answers 503, the forge redelivers, replicas 1..k-1 dedup on their
203
- * own now-taken jobIds and k..n enqueue. The retry converges on exactly n jobs rather than n + (k-1).
238
+ * A PARTIAL FAILURE LEAVES REPLICAS 1..k-1 QUEUED, and nothing here takes them back. If replica k throws,
239
+ * the caller answers 503 (`answerEnqueueFailed`), and what happens next is the forge's (WHAT A 503 MEANS,
240
+ * below): on GitHub, GitLab and Forgejo nothing, so k-1 of the n jobs run and the rest never queue unless
241
+ * someone sends the delivery again. A GitHub or GitLab redelivery by hand, or Azure's own resend, keeps the
242
+ * delivery id, so replicas 1..k-1 dedup on their taken jobIds, k..n enqueue, and it converges on exactly n.
243
+ * A Forgejo replay is a NEW delivery id: inside the 10-minute semantic window the queued replicas coalesce
244
+ * on their `:r<i>` keys and it still converges on n, but after it all n queue again beside the k-1, so
245
+ * cancel those first. Compensating here was rejected: removing 1..k-1 needs the Valkey that just failed,
246
+ * and one of them may already be running. So the failure says how far it got instead: past one replica,
247
+ * the error that goes up carries `queued` (k-1, created or swallowed by the window) and `replicas` (n) for
248
+ * the `enqueue_failed` line. A single enqueue's error goes up untouched, so its line is byte-identical.
204
249
  *
205
250
  * `enqueue` is a callback because the four arms spell their enqueue differently (a named github/gitlab
206
251
  * wrapper, or `enqueueForgeJob` with an explicit kind); the fanout itself is forge-blind.
@@ -216,24 +261,103 @@ async function fanout(job, enqueue) {
216
261
  let created = 0;
217
262
  const deduplicated = [];
218
263
  for (let i = 1; i <= replicas; i++) {
219
- const r = await enqueue(replicas > 1 ? { ...job, replica: i } : job);
264
+ let r;
265
+ try {
266
+ r = await enqueue(replicas > 1 ? { ...job, replica: i } : job);
267
+ } catch (err) {
268
+ if (replicas === 1) throw err;
269
+ throw Object.assign(new Error(err?.message, { cause: err }), { queued: i - 1, replicas });
270
+ }
220
271
  if (r && typeof r === "object" && r.deduplicated === true) deduplicated.push({ jobId: r.jobId, survivingJobId: r.survivingJobId });
221
272
  else created += 1;
222
273
  }
223
274
  return { replicas, created, deduplicated };
224
275
  }
225
276
 
277
+ /**
278
+ * WHAT A 503 MEANS FOR EACH FORGE. Every arm answers 503 when it cannot finish a delivery it would otherwise
279
+ * queue: an INDETERMINATE author lookup (and the GitHub arm's unwired resolver), or an enqueue that throws.
280
+ * The delivery is not queued, and the answer is kept because it is the true one: a 204 would read as a
281
+ * stranger correctly refused, and a 202 would say "queued". What happens to the delivery is the forge's:
282
+ * - GitHub never resends a failed delivery on its own (docs.github.com, "Handling failed webhook
283
+ * deliveries"). It is lost unless someone redelivers it from the hook's Recent Deliveries or the REST
284
+ * API within 3 days; that keeps `X-GitHub-Delivery`, so it queues at most once.
285
+ * - GitLab never resends a 5xx or a timeout either, and after 4 failures in a row it disables the hook
286
+ * for a while (on gitlab.com, and a group hook anywhere; on a self-managed instance a project hook only
287
+ * where the `auto_disabling_web_hooks` feature flag is on). A "Resend Request" (Recent events, 2 days;
288
+ * the API's resend since 17.4) keeps the `Idempotency-Key` and is signed afresh, so it queues at most
289
+ * once and the replay window holds.
290
+ * - Forgejo makes one attempt (`services/webhook/deliver.go`). Its replay, from the UI, is a NEW hook task
291
+ * with a NEW delivery id: inside the 10-minute semantic window it coalesces with what did queue, after
292
+ * the window it runs again, which matters for a delivery Forgejo saw fail only because the answer came
293
+ * after its 5 s `DELIVER_TIMEOUT`, when the job had in fact queued.
294
+ * - Azure DevOps treats a 503 as transient and resends the notification up to 8 times over about 3
295
+ * minutes. It resends the same notification, so it carries the same payload `id` and queues at most
296
+ * once. Past that the subscription goes on probation: Azure retries the failed notification up to 7
297
+ * more times over about 36 hours, and new events are lost meanwhile. So a delivery someone re-triggers
298
+ * by hand in that time (a new event, a new `id`) can run twice: a probation retry that lands outside the
299
+ * 10-minute semantic window is not coalesced with it. A 500 is an enduring failure at once.
300
+ * The receiver logs each 503 with the delivery id an operator redelivers by, and the Connections page counts
301
+ * `lookup-failed` and `enqueue-failed` per forge and warns when either is above 0.
302
+ *
303
+ * This function is the enqueue-failure answer, shared by the four arms for the four-copies reason `fanout`
304
+ * gives. Their own try/catch exists so a Valkey-down enqueue is this 503 and not verify's outer 500, which
305
+ * Azure would count as an enduring failure. The `enqueue_failed` line names the delivery and the reason; past
306
+ * one replica it adds how many of the `replicas` were `queued` before the failure (`fanout`), which is what an
307
+ * operator needs before redelivering, and on Forgejo before replaying.
308
+ */
309
+ function answerEnqueueFailed({ log, res, delivery, err }) {
310
+ // Past one replica `err` is `fanout`'s wrapper, and the reason is its cause's: a thrown non-Error has no
311
+ // `message`, so its line keeps leaving `reason` out instead of logging the wrapper's empty one.
312
+ const fanned = err?.replicas > 1;
313
+ const partial = fanned ? { queued: err.queued, replicas: err.replicas } : {};
314
+ log?.({ event: "enqueue_failed", delivery, reason: (fanned ? err.cause : err)?.message, ...partial });
315
+ return respond(res, 503, { error: "enqueue-failed" });
316
+ }
317
+
318
+ /**
319
+ * Comment steering (issue #646), shared by all four arms for the four-copies reason `fanout` gives. Runs after the gate
320
+ * and before any enqueue, and only for a job the filter marked `steerMode` (a phrase comment from an admitted author on
321
+ * a steer-armed trigger): every other delivery passes through untouched, so its job is byte-identical to before.
322
+ *
323
+ * Returns `{ handled: true }` when a live run took the comment, after answering `202 {status:"steered", runs}`; else
324
+ * `{ job }`, the job to enqueue exactly as today, with `steerMode` removed (`enqueueForgeJob` names its fields and
325
+ * would drop it anyway; removing it here keeps it off every seam a test or a router sees). The `steered` line names the
326
+ * delivery, the target in the arm's own grammar, the mode, and the runs, never the comment's text.
327
+ */
328
+ async function steerOrEnqueue({ steer, res, kind, delivery, job, target }) {
329
+ if (job.steerMode === undefined) return { job };
330
+ const { steerMode: mode, ...rest } = job;
331
+ let redis = null;
332
+ try {
333
+ redis = (await steer.steerClient()) ?? null;
334
+ } catch {
335
+ redis = null; // trySteer logs `steer_lookup_failed` for a missing client and the comment is enqueued
336
+ }
337
+ const steered = await trySteer({ redis, job, kind, now: steer.now, log: (line) => steer.log?.(line) });
338
+ if (steered === null) return { job: rest };
339
+ // A redelivery of a delivery steered earlier (its run has ended since): answered, never enqueued. trySteer logged it.
340
+ if (steered.redelivered === true) {
341
+ respond(res, 202, { status: "steered", runs: 0 });
342
+ return { handled: true };
343
+ }
344
+ steer.log?.({ event: "steered", delivery, repo: job.repo, target, mode, runs: steered.steered, runIds: steered.runIds });
345
+ respond(res, 202, { status: "steered", runs: steered.steered });
346
+ return { handled: true };
347
+ }
348
+
226
349
  /**
227
350
  * One log-and-answer for a fanout outcome, shared by all four arms so a weakened copy cannot hide (the
228
351
  * four-copies doctrine above). Issue #289's honesty rule: `enqueued` is logged only when something was
229
352
  * CREATED, with `replicas` meaning jobs that now exist; every semantic-window swallow gets its own
230
353
  * `deduplicated` line naming the surviving job's id (every field forge- or worker-minted, no payload
231
354
  * text); and a delivery that created NOTHING answers `202 {status:"deduplicated"}` -- still a 2xx,
232
- * because a non-2xx would trigger a redelivery storm for a delivery that was handled, but no longer
355
+ * because a non-2xx would record a delivery that was handled as failed in the forge's own history, count
356
+ * toward GitLab disabling the hook, and make Azure resend it, but no longer
233
357
  * "queued", because a receiver that logs the swallow while answering success on the wire would be the
234
358
  * honest-log/lying-wire split this project refuses. Each arm passes its own target grammar.
235
359
  */
236
- function respondEnqueueOutcome({ log, res, delivery, repo, target, flow, outcome }) {
360
+ function respondEnqueueOutcome({ log, res, delivery, repo, target, flow, outcome, held = null }) {
237
361
  for (const d of outcome.deduplicated) {
238
362
  log?.({ event: "deduplicated", delivery, repo, target, flow, jobId: d.jobId, survivingJobId: d.survivingJobId });
239
363
  }
@@ -241,9 +365,54 @@ function respondEnqueueOutcome({ log, res, delivery, repo, target, flow, outcome
241
365
  log?.({ event: "enqueued", delivery, repo, target, flow, replicas: outcome.created });
242
366
  return respond(res, 202, { status: "queued" });
243
367
  }
368
+ // Issue #638: a swallow a parking run took as its held answer is not "nothing happened" either; the `held` lines
369
+ // were written by held.mjs, and the wire says so the same honest way.
370
+ if (held !== null) return respond(res, 202, { status: "held", runs: held.held });
244
371
  return respond(res, 202, { status: "deduplicated" });
245
372
  }
246
373
 
374
+ /** The Valkey client held replies use: the steer one (`steer.steerClient()`), null when there is none. */
375
+ async function heldClientOf(steer) {
376
+ try {
377
+ return (await steer?.steerClient?.()) ?? null;
378
+ } catch {
379
+ return null;
380
+ }
381
+ }
382
+
383
+ /**
384
+ * The fanout outcome, answered, with held replies (issue #638) in between, shared by the four arms. Only a delivery
385
+ * `wantsHoldAfterDedup` names (a resume comment of an awaitInput trigger that the window swallowed) asks Valkey at all,
386
+ * so every other delivery's log lines and answer are exactly `respondEnqueueOutcome`'s as they were.
387
+ */
388
+ async function answerEnqueueOutcome({ steer, kind, log, res, delivery, job, target, outcome }) {
389
+ let held = null;
390
+ if (wantsHoldAfterDedup(job, outcome)) {
391
+ held = await holdAfterDedup({ redis: await heldClientOf(steer), job, kind, outcome, now: steer?.now, log: (line) => log?.(line), delivery, target });
392
+ }
393
+ return respondEnqueueOutcome({ log, res, delivery, repo: job.repo, target, flow: job.flow, outcome, held });
394
+ }
395
+
396
+ /** The two target grammars the arms log in: `type#n` (github, forgejo) and `repo#n` / `repo!n` (gitlab, azure). */
397
+ const hashTarget = (j) => `${j?.target?.type}#${j?.target?.number}`;
398
+ const sepTarget = (j) => `${j?.repo}${j?.target?.type === "pull_request" ? "!" : "#"}${j?.target?.number}`;
399
+
400
+ /**
401
+ * A filter drop that carries `heldCandidate` (issue #638): held under every awaitInput run live on its target, then
402
+ * answered `202 {status:"held", runs}` and "held". "enqueue" when a run parked on the target within the last minute
403
+ * (held.mjs): the caller enqueues the candidate as the parked reply it is. Null for every other drop, and for a
404
+ * candidate nothing took, and the caller answers its drop exactly as before. A missing client is today's behaviour.
405
+ */
406
+ async function heldBeforeLabelOr({ steer, res, kind, delivery, result, targetOf }) {
407
+ if (result?.heldCandidate === undefined) return null;
408
+ const candidate = result.heldCandidate;
409
+ const held = await holdBeforeLabel({ redis: await heldClientOf(steer), candidate, kind, now: steer?.now, log: (line) => steer?.log?.(line), delivery, target: targetOf(candidate) });
410
+ if (held === null) return null;
411
+ if (held.enqueue === true) return "enqueue";
412
+ respond(res, 202, { status: "held", runs: held.held });
413
+ return "held";
414
+ }
415
+
247
416
  /**
248
417
  * The GitHub arm. Returns `makeVerifiedHandler`'s handler directly, so it only ever sees an already-verified
249
418
  * request. `onVerified` owns parse, filter, enqueue, and response; a good signature is the sole
@@ -256,7 +425,7 @@ function respondEnqueueOutcome({ log, res, delivery, repo, target, flow, outcome
256
425
  * uses, so a lookup is never spent on a delivery the gate then ignores -- every label, comment, PR and
257
426
  * review delivery, and every close nothing wants, stays payload-only and byte-identical to before.
258
427
  */
259
- function makeGitHubHandler({ queue, routeTo, selfId, cfg, log, resolveAuthority }) {
428
+ function makeGitHubHandler({ queue, routeTo, selfId, cfg, log, resolveAuthority, steer }) {
260
429
  return makeVerifiedHandler({ secret: cfg.webhookSecret }, async ({ rawBody, event, delivery }, res) => {
261
430
  let subset;
262
431
  try {
@@ -270,7 +439,8 @@ function makeGitHubHandler({ queue, routeTo, selfId, cfg, log, resolveAuthority
270
439
  if (!resolveAuthority) {
271
440
  // Defensive only: unreachable in a wired receiver -- start.mjs hard-fails on github auth
272
441
  // before `/` is ever mounted, and the resolver is built over that same auth object. Fail
273
- // closed but RETRYABLE, because a wiring fault must not read on the wire as a stranger
442
+ // closed with a 503, a failed delivery in the hook's history that can be redelivered once
443
+ // the wiring is fixed, because a wiring fault must not read on the wire as a stranger
274
444
  // being correctly refused.
275
445
  return respond(res, 503, { error: "permission-lookup-failed" });
276
446
  }
@@ -279,32 +449,45 @@ function makeGitHubHandler({ queue, routeTo, selfId, cfg, log, resolveAuthority
279
449
  // The reason names the lookup, never the actor -- `sender.login` is personal data and exists
280
450
  // here only to have been asked about (no-pii-in-logs).
281
451
  log?.({ event: "github_permission_lookup_failed", delivery, reason: resolved.indeterminate });
282
- // 503: GitHub redelivers, the GUID jobId coalesces the retry. GitHub's auto-redelivery is
283
- // weaker than GitLab's, so a lookup outage outlasting the window can lose a webhook-only
284
- // deployment's close -- the polling transport's closed source is the backstop where the
285
- // poller runs, with its own bounded retry against the same lookup.
452
+ // 503, and GitHub never resends it on its own (WHAT A 503 MEANS, above `answerEnqueueFailed`):
453
+ // the close is lost unless someone redelivers it within 3 days, which keeps the GUID jobId and
454
+ // so queues it at most once. The polling transport's closed source is the backstop on a repo
455
+ // the poller already polls, with its own bounded retry against the same lookup; a
456
+ // webhook-only deployment has none.
286
457
  return respond(res, 503, { error: "permission-lookup-failed" });
287
458
  }
288
459
  closerAuthorized = resolved.authorized;
289
460
  }
290
461
 
291
- const result = filter(event, subset, cfg, selfId, delivery, closerAuthorized);
462
+ let result = filter(event, subset, cfg, selfId, delivery, closerAuthorized);
292
463
  if (!result.enqueue) {
293
- log?.({ event: "dropped", delivery, reason: result.reason });
294
- return respond(res, 204);
464
+ // Issue #638: a phrase-free reply that missed only the parked marker is held for a run parking on its target, or
465
+ // enqueued as a parked reply when a run just parked there; otherwise this is today's drop, byte for byte.
466
+ const rescued = await heldBeforeLabelOr({ steer, res, kind: "github", delivery, result, targetOf: hashTarget });
467
+ if (rescued === "held") return;
468
+ if (rescued === "enqueue") {
469
+ result = { enqueue: true, job: result.heldCandidate };
470
+ } else {
471
+ log?.({ event: "dropped", delivery, reason: result.reason });
472
+ return respond(res, 204);
473
+ }
295
474
  }
296
475
 
476
+ const target = `${result.job.target.type}#${result.job.target.number}`;
477
+ const steered = await steerOrEnqueue({ steer, res, kind: "github", delivery, job: result.job, target });
478
+ if (steered.handled) return;
479
+ const job = steered.job;
480
+
297
481
  // Fanout (REQ-REPLICA-RUNS) lives in `fanout` above; the 202/503 decision stays here, where it always was.
298
482
  let outcome;
299
483
  try {
300
- outcome = await fanout(result.job, async (j) => await enqueueGitHubJob(await routeTo("github", j), j));
484
+ outcome = await fanout(job, async (j) => await enqueueGitHubJob(await routeTo("github", j), j));
301
485
  } catch (err) {
302
- // Own try/catch so a Valkey-down enqueue is a 503 (retryable), not verify's outer 500.
303
- log?.({ event: "enqueue_failed", delivery, reason: err?.message });
304
- return respond(res, 503, { error: "enqueue-failed" }); // GitHub redelivers; dedup by GUID coalesces
486
+ // Lost unless redelivered by hand within 3 days; the redelivery keeps the GUID (WHAT A 503 MEANS).
487
+ return answerEnqueueFailed({ log, res, delivery, err });
305
488
  }
306
489
 
307
- return respondEnqueueOutcome({ log, res, delivery, repo: result.job.repo, target: `${result.job.target.type}#${result.job.target.number}`, flow: result.job.flow, outcome });
490
+ return await answerEnqueueOutcome({ steer, kind: "github", log, res, delivery, job, target, outcome });
308
491
  });
309
492
  }
310
493
 
@@ -315,13 +498,15 @@ function makeGitHubHandler({ queue, routeTo, selfId, cfg, log, resolveAuthority
315
498
  *
316
499
  * That resolution is a network call, and its three outcomes are deliberately not two:
317
500
  * - a determinate verdict (authorized or not) is handed to the gate, which decides;
318
- * - an INDETERMINATE lookup is a 503, so GitLab redelivers and the stable `webhook-id` dedups the
319
- * retry. Answering 204 would drop real work during an outage, and it would look on the wire exactly
320
- * like a stranger being correctly refused.
501
+ * - an INDETERMINATE lookup is a 503. GitLab never resends it on its own (WHAT A 503 MEANS, above
502
+ * `answerEnqueueFailed`), so the delivery waits for a "Resend Request", which keeps the `webhook-id`
503
+ * and so queues at most once. Answering 204 would drop real work during an outage behind a success
504
+ * nobody would think to resend, and it would look on the wire exactly like a stranger being correctly
505
+ * refused.
321
506
  * The lookup runs only for events that could still fire -- after verification, and after the payload has
322
507
  * been projected -- so an unauthenticated flood cannot make this project call GitLab at all.
323
508
  */
324
- function makeGitLabHandler({ routeTo, queue, cfg, log, mode, secret, selfId, resolveAuthority, now }) {
509
+ function makeGitLabHandler({ routeTo, queue, cfg, log, mode, secret, selfId, resolveAuthority, now, steer }) {
325
510
  return makeGitLabVerifiedHandler({ mode, secret, ...(now ? { now } : {}) }, async ({ rawBody, delivery }, res) => {
326
511
  let subset;
327
512
  try {
@@ -335,27 +520,41 @@ function makeGitLabHandler({ routeTo, queue, cfg, log, mode, secret, selfId, res
335
520
  // The reason names the lookup, never the actor -- `user.username` is personal data and exists
336
521
  // only to have been asked about (no-pii-in-logs).
337
522
  log?.({ event: "gitlab_access_lookup_failed", delivery, reason: resolved.indeterminate });
523
+ // Lost unless resent from Recent events within 2 days (WHAT A 503 MEANS): GitLab never resends.
338
524
  return respond(res, 503, { error: "access-lookup-failed" });
339
525
  }
340
526
 
341
- const result = filterGitLab(subset, cfg.triggers?.gitlab, cfg.triggers?.knownFlows, selfId, resolved.authorized, delivery);
527
+ let result = filterGitLab(subset, cfg.triggers?.gitlab, cfg.triggers?.knownFlows, selfId, resolved.authorized, delivery);
342
528
  if (!result.enqueue) {
343
- log?.({ event: "dropped", delivery, reason: result.reason });
344
- return respond(res, 204);
529
+ // Issue #638: a phrase-free reply that missed only the parked marker is held for a run parking on its target, or
530
+ // enqueued as a parked reply when a run just parked there; otherwise this is today's drop, byte for byte.
531
+ const rescued = await heldBeforeLabelOr({ steer, res, kind: "gitlab", delivery, result, targetOf: sepTarget });
532
+ if (rescued === "held") return;
533
+ if (rescued === "enqueue") {
534
+ result = { enqueue: true, job: result.heldCandidate };
535
+ } else {
536
+ log?.({ event: "dropped", delivery, reason: result.reason });
537
+ return respond(res, 204);
538
+ }
345
539
  }
346
540
 
541
+ // `!` for a merge request, `#` for an issue -- GitLab's own notation, and the same discrimination
542
+ // the semantic dedup key makes, because the two are separate number sequences.
543
+ const sep = result.job.target.type === "pull_request" ? "!" : "#";
544
+ const target = `${result.job.repo}${sep}${result.job.target.number}`;
545
+ const steered = await steerOrEnqueue({ steer, res, kind: "gitlab", delivery, job: result.job, target });
546
+ if (steered.handled) return;
547
+ const job = steered.job;
548
+
347
549
  let outcome;
348
550
  try {
349
- outcome = await fanout(result.job, async (j) => await enqueueGitLabJob(await routeTo("gitlab", j), j));
551
+ outcome = await fanout(job, async (j) => await enqueueGitLabJob(await routeTo("gitlab", j), j));
350
552
  } catch (err) {
351
- log?.({ event: "enqueue_failed", delivery, reason: err?.message });
352
- return respond(res, 503, { error: "enqueue-failed" }); // GitLab redelivers; dedup by webhook-id coalesces
553
+ // Lost unless resent by hand within 2 days; the resend keeps the webhook-id (WHAT A 503 MEANS).
554
+ return answerEnqueueFailed({ log, res, delivery, err });
353
555
  }
354
556
 
355
- // `!` for a merge request, `#` for an issue -- GitLab's own notation, and the same discrimination
356
- // the semantic dedup key makes, because the two are separate number sequences.
357
- const sep = result.job.target.type === "pull_request" ? "!" : "#";
358
- return respondEnqueueOutcome({ log, res, delivery, repo: result.job.repo, target: `${result.job.repo}${sep}${result.job.target.number}`, flow: result.job.flow, outcome });
557
+ return await answerEnqueueOutcome({ steer, kind: "gitlab", log, res, delivery, job, target, outcome });
359
558
  });
360
559
  }
361
560
 
@@ -367,12 +566,13 @@ function makeGitLabHandler({ routeTo, queue, cfg, log, mode, secret, selfId, res
367
566
  * Forgejo signs the raw body with HMAC-SHA256 and sends `X-Hub-Signature-256`, `X-GitHub-Delivery` and
368
567
  * `X-GitHub-Event`, which is byte-for-byte what `makeVerifiedHandler` already reads. So
369
568
  * CONST-HMAC-OVER-RAW-BODY is satisfied here by EXISTING code rather than new code, and
370
- * REQ-DEDUP-BY-DELIVERY-GUID transfers with no adaptation at all. What Forgejo needs is its own SECRET,
569
+ * REQ-DEDUP-BY-DELIVERY-GUID's mechanism transfers with no adaptation at all (what it cannot see is a replay,
570
+ * which Forgejo sends under a new GUID: WHAT A 503 MEANS). What Forgejo needs is its own SECRET,
371
571
  * which is why it is a separate handler and a separate path: `makeVerifiedHandler` closes over one secret
372
572
  * per construction, and serving two sources from one would mean either sharing a secret between forges or
373
573
  * letting the request choose which one it was checked against.
374
574
  */
375
- function makeForgejoHandler({ routeTo, queue, cfg, log, secret, selfId, resolveAuthority }) {
575
+ function makeForgejoHandler({ routeTo, queue, cfg, log, secret, selfId, resolveAuthority, steer }) {
376
576
  return makeVerifiedHandler({ secret }, async ({ rawBody, event, delivery }, res) => {
377
577
  let subset;
378
578
  try {
@@ -386,24 +586,38 @@ function makeForgejoHandler({ routeTo, queue, cfg, log, secret, selfId, resolveA
386
586
  // The reason names the lookup, never the actor -- `sender.login` is personal data and exists here
387
587
  // only to have been asked about (no-pii-in-logs).
388
588
  log?.({ event: "forgejo_permission_lookup_failed", delivery, reason: resolved.indeterminate });
589
+ // Lost unless replayed from the hook's history (WHAT A 503 MEANS): Forgejo makes one attempt.
389
590
  return respond(res, 503, { error: "permission-lookup-failed" });
390
591
  }
391
592
 
392
- const result = filterForgejo(event, subset, cfg.triggers?.forgejo, cfg.triggers?.knownFlows, selfId, resolved.authorized, delivery);
593
+ let result = filterForgejo(event, subset, cfg.triggers?.forgejo, cfg.triggers?.knownFlows, selfId, resolved.authorized, delivery);
393
594
  if (!result.enqueue) {
394
- log?.({ event: "dropped", delivery, reason: result.reason });
395
- return respond(res, 204);
595
+ // Issue #638: a phrase-free reply that missed only the parked marker is held for a run parking on its target, or
596
+ // enqueued as a parked reply when a run just parked there; otherwise this is today's drop, byte for byte.
597
+ const rescued = await heldBeforeLabelOr({ steer, res, kind: "forgejo", delivery, result, targetOf: hashTarget });
598
+ if (rescued === "held") return;
599
+ if (rescued === "enqueue") {
600
+ result = { enqueue: true, job: result.heldCandidate };
601
+ } else {
602
+ log?.({ event: "dropped", delivery, reason: result.reason });
603
+ return respond(res, 204);
604
+ }
396
605
  }
397
606
 
607
+ const target = `${result.job.target.type}#${result.job.target.number}`;
608
+ const steered = await steerOrEnqueue({ steer, res, kind: "forgejo", delivery, job: result.job, target });
609
+ if (steered.handled) return;
610
+ const job = steered.job;
611
+
398
612
  let outcome;
399
613
  try {
400
- outcome = await fanout(result.job, async (j) => await enqueueForgeJob(await routeTo("forgejo", j), "forgejo", j));
614
+ outcome = await fanout(job, async (j) => await enqueueForgeJob(await routeTo("forgejo", j), "forgejo", j));
401
615
  } catch (err) {
402
- log?.({ event: "enqueue_failed", delivery, reason: err?.message });
403
- return respond(res, 503, { error: "enqueue-failed" }); // Forgejo redelivers; dedup by GUID coalesces
616
+ // Lost unless replayed by hand, and a replay is a new GUID: only the semantic window coalesces it.
617
+ return answerEnqueueFailed({ log, res, delivery, err });
404
618
  }
405
619
 
406
- return respondEnqueueOutcome({ log, res, delivery, repo: result.job.repo, target: `${result.job.target.type}#${result.job.target.number}`, flow: result.job.flow, outcome });
620
+ return await answerEnqueueOutcome({ steer, kind: "forgejo", log, res, delivery, job, target, outcome });
407
621
  });
408
622
  }
409
623
 
@@ -421,7 +635,7 @@ function makeForgejoHandler({ routeTo, queue, cfg, log, secret, selfId, resolveA
421
635
  * `"Display Name <email>"`. Both the resolver and the bot-loop guard handle both forms -- see
422
636
  * filter-azure.mjs, where the ordering constraint is stated in full.
423
637
  */
424
- function makeAzureHandler({ routeTo, queue, cfg, log, mode, secret, headerName, selfId, resolveAuthority }) {
638
+ function makeAzureHandler({ routeTo, queue, cfg, log, mode, secret, headerName, selfId, resolveAuthority, steer }) {
425
639
  return makeAzureVerifiedHandler({ mode, secret, headerName }, async ({ rawBody }, res) => {
426
640
  let subset;
427
641
  try {
@@ -443,26 +657,42 @@ function makeAzureHandler({ routeTo, queue, cfg, log, mode, secret, headerName,
443
657
  // The reason names the lookup, never the actor -- an email address is personal data and exists
444
658
  // here only to have been asked about (no-pii-in-logs).
445
659
  log?.({ event: "azure_membership_lookup_failed", delivery, reason: resolved.indeterminate });
660
+ // Azure resends the same notification up to 8 times over about 3 minutes, so with the same `id`,
661
+ // then retries it on probation for about 36 hours (WHAT A 503 MEANS).
446
662
  return respond(res, 503, { error: "membership-lookup-failed" });
447
663
  }
448
664
 
449
- const result = filterAzure(subset, cfg.triggers?.azure, cfg.triggers?.knownFlows, selfId, resolved.authorized, delivery);
665
+ let result = filterAzure(subset, cfg.triggers?.azure, cfg.triggers?.knownFlows, selfId, resolved.authorized, delivery);
450
666
  if (!result.enqueue) {
451
- log?.({ event: "dropped", delivery, reason: result.reason });
452
- return respond(res, 204);
667
+ // Issue #638: a phrase-free reply that missed only the parked marker is held for a run parking on its target, or
668
+ // enqueued as a parked reply when a run just parked there; otherwise this is today's drop, byte for byte.
669
+ const rescued = await heldBeforeLabelOr({ steer, res, kind: "azure", delivery, result, targetOf: sepTarget });
670
+ if (rescued === "held") return;
671
+ if (rescued === "enqueue") {
672
+ result = { enqueue: true, job: result.heldCandidate };
673
+ } else {
674
+ log?.({ event: "dropped", delivery, reason: result.reason });
675
+ return respond(res, 204);
676
+ }
453
677
  }
454
678
 
679
+ // `!` for a pull request, `#` for a work item -- Azure numbers them separately, and this is the same
680
+ // discrimination the semantic dedup key makes.
681
+ const sep = result.job.target.type === "pull_request" ? "!" : "#";
682
+ const target = `${result.job.repo}${sep}${result.job.target.number}`;
683
+ const steered = await steerOrEnqueue({ steer, res, kind: "azure", delivery, job: result.job, target });
684
+ if (steered.handled) return;
685
+ const job = steered.job;
686
+
455
687
  let outcome;
456
688
  try {
457
- outcome = await fanout(result.job, async (j) => await enqueueForgeJob(await routeTo("azure", j), "azure", j));
689
+ outcome = await fanout(job, async (j) => await enqueueForgeJob(await routeTo("azure", j), "azure", j));
458
690
  } catch (err) {
459
- log?.({ event: "enqueue_failed", delivery, reason: err?.message });
460
- return respond(res, 503, { error: "enqueue-failed" });
691
+ // Azure resends the same notification, so the same `id`, for about 3 minutes, then on probation
692
+ // for about 36 hours, losing new events meanwhile (WHAT A 503 MEANS).
693
+ return answerEnqueueFailed({ log, res, delivery, err });
461
694
  }
462
695
 
463
- // `!` for a pull request, `#` for a work item -- Azure numbers them separately, and this is the same
464
- // discrimination the semantic dedup key makes.
465
- const sep = result.job.target.type === "pull_request" ? "!" : "#";
466
- return respondEnqueueOutcome({ log, res, delivery, repo: result.job.repo, target: `${result.job.repo}${sep}${result.job.target.number}`, flow: result.job.flow, outcome });
696
+ return await answerEnqueueOutcome({ steer, kind: "azure", log, res, delivery, job, target, outcome });
467
697
  });
468
698
  }
package/src/start.mjs CHANGED
@@ -30,9 +30,11 @@
30
30
  */
31
31
 
32
32
  import http from "node:http";
33
+ import { workerName } from "@edgehero/pi-dispatch/config";
34
+ import { makeReceiverPublisher, receiverBind } from "@edgehero/pi-dispatch/receiver-status";
33
35
  import { readFileSync, watch } from "node:fs";
34
36
  import { dirname, basename } from "node:path";
35
- import { loadReceiverConfig, triggersFilePath, reloadTriggers } from "./config.mjs";
37
+ import { loadReceiverConfig, servedForges, triggersFilePath, reloadTriggers } from "./config.mjs";
36
38
  import { makeReceiver } from "./receiver.mjs";
37
39
  import { entryExitCode } from "./cli.mjs";
38
40
  import { installRejectionPrinter } from "@edgehero/pi-dispatch/exit-code";
@@ -52,6 +54,11 @@ import { WATCH_DEBOUNCE_MS, changedWhileArming, makeWatchCloser, readBeforeArmin
52
54
  import { githubFailureFields } from "@edgehero/pi-dispatch/octokit-log";
53
55
  import { retryIdentity } from "./boot-retry.mjs";
54
56
 
57
+ const RECEIVER_VERSION = (() => {
58
+ try { return JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")).version ?? ""; }
59
+ catch { return ""; }
60
+ })();
61
+
55
62
  /**
56
63
  * Boot the receiver. Collaborators are injected (defaulting to the real ones) so the whole wiring is
57
64
  * testable offline with no GitHub, no Valkey, and no socket. Returns the listening server.
@@ -67,6 +74,7 @@ export async function startReceiver(
67
74
  makeQueueFn = makeQueue,
68
75
  makeForgeRouterFn = makeForgeRouter,
69
76
  createServer = http.createServer,
77
+ makeReceiverPublisherFn = makeReceiverPublisher,
70
78
  resolveGitLabSelfId: resolveSelfIdFn = resolveGitLabSelfId,
71
79
  makeResolveAuthority: makeResolveAuthorityFn = makeResolveAuthority,
72
80
  resolveForgejoSelfId: resolveForgejoSelfIdFn = resolveForgejoSelfId,
@@ -211,10 +219,14 @@ export async function startReceiver(
211
219
  if (cfg.forgejo) {
212
220
  const forgejoSelfId = await retryIdentity(() => resolveForgejoSelfIdFn({ apiUrl: cfg.forgejo.apiUrl, token: cfg.forgejo.token, botId: cfg.forgejo.botId }), { forge: "forgejo", ...retryOpts });
213
221
  log({ event: "self_identity", forge: "forgejo", id: forgejoSelfId, source: cfg.forgejo.botId ? "FORGEJO_BOT_ID" : "api" });
222
+ // The author gate's lookup token, by NAME only (OQ-043): FORGEJO_PERMISSION_TOKEN when set, else FORGEJO_TOKEN,
223
+ // whose account must then be a repository admin. Identity above stays FORGEJO_TOKEN's: it is the account the
224
+ // jobs post as, which the bot-loop guard must recognise.
225
+ log({ event: "forgejo_permission_token", source: cfg.forgejo.lookupTokenVar });
214
226
  forgejo = {
215
227
  secret: cfg.forgejo.secret,
216
228
  selfId: forgejoSelfId,
217
- resolveAuthority: makeResolveForgejoAuthorityFn({ apiUrl: cfg.forgejo.apiUrl, token: cfg.forgejo.token }),
229
+ resolveAuthority: makeResolveForgejoAuthorityFn({ apiUrl: cfg.forgejo.apiUrl, token: cfg.forgejo.lookupToken, tokenVar: cfg.forgejo.lookupTokenVar }),
218
230
  };
219
231
  }
220
232
 
@@ -234,11 +246,23 @@ export async function startReceiver(
234
246
  };
235
247
  }
236
248
 
237
- const handler = makeReceiver({ queue, router, selfId, cfg, log, gitlab, forgejo, azure, resolveAuthority });
249
+ const handler = makeReceiver({ queue, router, selfId, cfg, log, gitlab, forgejo, azure, resolveAuthority, watchEnv: env, now });
238
250
  const server = createServer(handler);
239
- server.listen(cfg.port, cfg.bind, () =>
240
- log({ event: "receiver_started", port: cfg.port, bind: cfg.bind, valkey: cfg.valkeyUrl }),
241
- );
251
+ let receiverPublisher = null;
252
+ server.listen(cfg.port, cfg.bind, () => {
253
+ log({ event: "receiver_started", port: cfg.port, bind: cfg.bind, valkey: cfg.valkeyUrl });
254
+ // A socket, not configuration, proves where the process actually listens. Publication is optional:
255
+ // an unusable name or missing socket fact must never change the receiver's startup decision.
256
+ try {
257
+ const socket = server.address?.();
258
+ if (!socket || typeof socket !== "object" || !Number.isInteger(socket.port) || socket.port < 1 || socket.port > 65535) throw new Error("socket");
259
+ const name = workerName(env);
260
+ const publisher = makeReceiverPublisherFn({ client: () => queue.client, name, version: RECEIVER_VERSION, port: socket?.port, bind: receiverBind(socket?.address), forges: servedForges(cfg), observations: handler.observations, now, log: (event, fields) => log({ event, ...fields }) });
261
+ receiverPublisher = publisher;
262
+ closers.push(publisher);
263
+ publisher.start();
264
+ } catch { try { log({ event: "receiver_status_unavailable", reason: "identity-or-socket" }); } catch {} }
265
+ });
242
266
 
243
267
  // The watch arms UNCONDITIONALLY now (issue #301). Armed only on the real entry, the lifecycle defect
244
268
  // was muted under test rather than closed, and this file had no coverage that the watch arms at all --
@@ -260,6 +284,7 @@ export async function startReceiver(
260
284
  const shutdown = async (signal) => {
261
285
  log({ event: "receiver_stopping", signal });
262
286
  await new Promise((resolve) => server.close(resolve));
287
+ try { receiverPublisher?.close(); } catch {}
263
288
  await queue.close();
264
289
  await router.close();
265
290
  // The watch closer, and whatever joins it later. Per-item try, because a throw here would strand