@edgehero/pi-dispatch-receiver 1.4.0 → 2.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/README.md ADDED
@@ -0,0 +1,41 @@
1
+ # @edgehero/pi-dispatch-receiver
2
+
3
+ The trigger edge of **[pi-dispatch](https://github.com/edgehero/pi-dispatch)**, which runs the
4
+ [pi](https://github.com/earendil-works/pi) coding agent as a self hosted background service.
5
+
6
+ The receiver turns forge activity into queued jobs for the
7
+ [worker](https://www.npmjs.com/package/@edgehero/pi-dispatch). It reads the same `triggers.json` as the
8
+ worker, checks each delivery, and queues at most one job per event (one per replica when a trigger sets
9
+ `run.replicas`). It never runs a job itself.
10
+
11
+ ## Two ways to run it
12
+
13
+ ```bash
14
+ npx @edgehero/pi-dispatch-receiver # serve: the webhook receiver (the default)
15
+ npx @edgehero/pi-dispatch-receiver poll # poll: no public URL, reads GitHub with your own credential
16
+ ```
17
+
18
+ - **`serve`** verifies GitHub, GitLab, Forgejo and Azure DevOps webhooks (at `/`, `/gitlab`, `/forgejo`
19
+ and `/azure`). It listens on all interfaces by default (`RECEIVER_BIND=0.0.0.0`); set
20
+ `RECEIVER_BIND=127.0.0.1` when a local reverse proxy or tunnel does the public exposure. `pi-dispatch
21
+ service install --receiver`
22
+ runs it as a service, and a container image is published as `ghcr.io/edgehero/pi-dispatch-receiver`.
23
+ - **`poll`** fetches GitHub issue events, comments and pull requests about once a minute, over TLS, with
24
+ the same gates and the same queue. A new poller starts from now and never replays old labels.
25
+
26
+ The receiver checks each delivery's signature, the bot loop guard, the forge's own permission check and
27
+ dedup. The worker then applies quiet hours, the image preflight, branch protection and the spend caps.
28
+ Who may fire a trigger is decided by your forge. See
29
+ [`SECURITY.md`](https://github.com/edgehero/pi-dispatch/blob/main/SECURITY.md) for each forge's rule.
30
+
31
+ Configuration comes from the environment (the worker's `.env.example` lists all of it). The main settings
32
+ are `WEBHOOK_SECRET`, `VALKEY_URL` and `VALKEY_PASSWORD`, `PI_TRIGGERS_FILE`, `RECEIVER_PORT` and
33
+ `RECEIVER_BIND`, `POLL_REPOS` with `POLL_INTERVAL_SECONDS` for polling, and each forge's token and
34
+ webhook secret (see its setup guide). It needs `@edgehero/pi-dispatch` of the same major
35
+ version, which it installs as a dependency.
36
+
37
+ Setup guides: [GitHub](https://github.com/edgehero/pi-dispatch/blob/main/docs/github.md),
38
+ [GitLab](https://github.com/edgehero/pi-dispatch/blob/main/docs/gitlab.md),
39
+ [Forgejo](https://github.com/edgehero/pi-dispatch/blob/main/docs/forgejo.md),
40
+ [Azure DevOps](https://github.com/edgehero/pi-dispatch/blob/main/docs/azure-devops.md),
41
+ [polling](https://github.com/edgehero/pi-dispatch/blob/main/docs/polling.md). MIT licensed.
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "@edgehero/pi-dispatch-receiver",
3
- "version": "1.4.0",
3
+ "version": "2.0.0",
4
4
  "type": "module",
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.",
5
+ "description": "The pi-dispatch trigger edge: verifies GitHub, GitLab, Forgejo and Azure DevOps webhooks, or polls GitHub with no public URL, and queues one pi coding agent job per event (per replica, if set) for the worker.",
6
6
  "keywords": [
7
7
  "pi",
8
8
  "pi-coding-agent",
@@ -16,7 +16,10 @@
16
16
  "gitea",
17
17
  "azure-devops",
18
18
  "job-queue",
19
- "bullmq"
19
+ "bullmq",
20
+ "docker",
21
+ "podman",
22
+ "polling"
20
23
  ],
21
24
  "license": "MIT",
22
25
  "author": "Rob Boerman",
@@ -45,7 +48,7 @@
45
48
  },
46
49
  "dependencies": {
47
50
  "@octokit/webhooks": "14.2.0",
48
- "@edgehero/pi-dispatch": "^1.6.0",
51
+ "@edgehero/pi-dispatch": "^2.0.0",
49
52
  "bullmq": "5.80.4",
50
53
  "ioredis": "5.11.1"
51
54
  }
@@ -0,0 +1,141 @@
1
+ /**
2
+ * Bounded in-process retry for the boot's HARD-FAIL identity resolutions (issue #318).
3
+ *
4
+ * Issue #316 made a transient identity failure at boot exit 1 so the supervisor restarts through a
5
+ * forge outage -- and then the shipped unit bounded that recovery at about 25 seconds
6
+ * (`RestartSec=5` against `StartLimitBurst=5`), while launchd and nssm bound it not at all. A forge
7
+ * restart takes minutes, so the receiver still ended up stopped, with the bound a property of
8
+ * whichever supervisor the operator happened to install. This module moves the bound INTO the
9
+ * process: one window, read from `RECEIVER_IDENTITY_RETRY_SECONDS` (config.mjs), identical under
10
+ * systemd, launchd, nssm and compose.
11
+ *
12
+ * What it retries is decided by the tag, nothing else. A `piDispatchConfig` throw is a determinate
13
+ * refusal and rethrows SAME-TICK -- no log line, no sleep, so `entryExitCode` still maps it to
14
+ * EXIT_POLICY (2) as fast as before. Everything untagged is transient by `transientError`'s own
15
+ * contract (worker/src/transient.mjs: the absence of the tag is the whole payload). That module is
16
+ * deliberately not imported -- it is not in the worker's export map, and the tag test is the
17
+ * sanctioned discriminator the receiver's `entryExitCode` already uses.
18
+ *
19
+ * Import-free, like the module whose rule it extends: nothing here can drag `node:fs` or a queue
20
+ * into a caller that has neither.
21
+ *
22
+ * The shape, and why each piece is what it is:
23
+ *
24
+ * - The per-attempt fuse is REF'D, never unref'd. The github arm runs before the queue, the server
25
+ * or any watcher exists, so this await can be the only thing on the event loop -- exactly
26
+ * `settleWithin`'s documented bare-context boundary (worker/src/start.mjs): an unref'd fuse that
27
+ * is the only pending handle never fires and node abandons the await silently, exit 13. Ref'd
28
+ * costs at most `attemptTimeoutMs` of held loop on a path that is about to exit anyway, and the
29
+ * inner `finally` clears it the moment the race settles, so even that is normally unreachable.
30
+ * - A fused-out attempt throws a plain UNTAGGED Error, by decision: a resolver that does not answer
31
+ * within the fuse is indistinguishable from a forge that is down, which is the transient case.
32
+ * None of the resolvers takes an AbortSignal, so the losing attempt itself has no cancel; its
33
+ * late settlement is absorbed by the race's own subscription, and the residual (a pending socket
34
+ * that can outlive a FINAL refusal by up to undici's header timeout) is recorded in
35
+ * `DES-BOOT-IDENTITY-RETRY-IN-PROCESS` -- strictly better than the unbounded hang a mute forge
36
+ * produced before this module existed.
37
+ * - The remaining window is checked BEFORE sleeping and the gap is CLAMPED to it, so no sleep timer
38
+ * can outlive the final attempt (issue #300's discipline: a component that stops has released what
39
+ * it armed) and the final attempt lands at the window's EDGE. The clamp is measured, not neatness:
40
+ * without it a 60s window exhausted at 35s, because the un-clamped 30s gap would have crossed the
41
+ * deadline and was skipped -- a forge that came back at t=50 was missed, and "retries for the
42
+ * whole window" was false in the letter. The clamp is also what makes an uncancellable injected
43
+ * `sleep` safe: every sleep awaited here runs to completion, always followed by one more attempt.
44
+ * - Log lines carry `err?.message` only, never a value -- the `receiver_start_failed` posture.
45
+ *
46
+ * Total wall clock: the helper settles within `windowMs + attemptTimeoutMs`.
47
+ */
48
+
49
+ // The first gap is what `RestartSec=5` gave, now in-process: a blip recovers exactly as fast as the
50
+ // supervisor loop it replaces. It doubles to a cap borrowed from the worker's AUTH_RETRY_COOLDOWN_MS
51
+ // rationale (worker/src/start.mjs): short enough to pick the forge up within one gap of it coming
52
+ // back, long enough that a ten-minute window opens a couple dozen identity calls, not 120.
53
+ export const IDENTITY_RETRY_DELAY_MS = 5_000;
54
+ export const IDENTITY_RETRY_DELAY_CAP_MS = 30_000;
55
+ // The per-attempt fuse, AUTH_RESOLVE_TIMEOUT_MS's rationale: the resolvers pass no AbortSignal and
56
+ // undici's default header timeout is five minutes, so without a fuse a forge that accepts and never
57
+ // answers would spend the whole window on one attempt.
58
+ export const IDENTITY_ATTEMPT_TIMEOUT_MS = 10_000;
59
+
60
+ /**
61
+ * Retry `resolve` (a nullary async identity resolution) until it answers, throws a tagged
62
+ * (determinate) error, or the window closes. Returns the resolution; rethrows the tagged error
63
+ * same-tick; on exhaustion rethrows the LAST untagged error, so the entry still exits 1 and the
64
+ * supervisor restarts into a fresh window.
65
+ *
66
+ * `now` and `sleep` are seams (CLAUDE.md: a window takes an injected clock, always; issue #284);
67
+ * `setTimeoutFn`/`clearTimeoutFn` are the FUSE's own pair, separately injectable so a test can prove
68
+ * the clearing rather than trust it.
69
+ */
70
+ export async function retryIdentity(
71
+ resolve,
72
+ {
73
+ forge,
74
+ windowMs,
75
+ log,
76
+ now = Date.now,
77
+ sleep = (ms) => new Promise((r) => setTimeout(r, ms)),
78
+ setTimeoutFn = setTimeout,
79
+ clearTimeoutFn = clearTimeout,
80
+ attemptTimeoutMs = IDENTITY_ATTEMPT_TIMEOUT_MS,
81
+ delayMs = IDENTITY_RETRY_DELAY_MS,
82
+ delayCapMs = IDENTITY_RETRY_DELAY_CAP_MS,
83
+ } = {},
84
+ ) {
85
+ // Fail loud on a windowless caller. A NaN or missing window would make the remaining-time test
86
+ // below never exhaust, and an unbounded boot retry is the silent no-op this project refuses. The
87
+ // loaders floor and validate the real value; this guard is for the caller no loader sits in front
88
+ // of.
89
+ if (!Number.isFinite(windowMs) || windowMs <= 0) {
90
+ throw new Error(`retryIdentity: windowMs must be a positive finite number, got ${windowMs}`);
91
+ }
92
+ const deadline = now() + windowMs;
93
+ let delay = delayMs;
94
+ let attempt = 0;
95
+ for (;;) {
96
+ attempt += 1;
97
+ try {
98
+ let fuse = null;
99
+ try {
100
+ // A SYNCHRONOUS throw from the resolver lands in the outer catch with no fuse armed
101
+ // (`fuse` is still null, and clearTimeout(null) is a no-op), which is why the fuse is
102
+ // armed only after `resolve()` has returned.
103
+ //
104
+ // No explicit `.catch` rides the attempt, and its absence is measured, not an oversight:
105
+ // `Promise.race` subscribes a reject handler to EVERY contestant, so when the fuse wins,
106
+ // the abandoned attempt's late rejection lands in the race's own subscription and can
107
+ // never surface as an unhandledRejection (pinned by this module's test). The worker's
108
+ // `ensureAuth` DOES need one, because it stores its promise in a map beyond the race and
109
+ // a cooldown-path caller can throw without ever racing it; nothing here outlives the
110
+ // race expression.
111
+ const inflight = resolve();
112
+ return await Promise.race([
113
+ inflight,
114
+ new Promise((_, reject) => {
115
+ fuse = setTimeoutFn(() => reject(new Error(`${forge} identity did not answer within ${attemptTimeoutMs}ms`)), attemptTimeoutMs);
116
+ }),
117
+ ]);
118
+ } finally {
119
+ // The moment the race settles -- win, loss or throw -- the fuse dies. Ref'd and
120
+ // uncleared, it would otherwise hold the loop for the full term (issue #295's lesson,
121
+ // the ref'd twin).
122
+ clearTimeoutFn(fuse);
123
+ }
124
+ } catch (err) {
125
+ // Determinate refusals pass through untouched and unslowed: the tag is the decision, and
126
+ // re-wrapping would cost the entry the piDispatchConfig mapping to EXIT_POLICY.
127
+ if (err?.piDispatchConfig === true) throw err;
128
+ const remainingMs = deadline - now();
129
+ if (remainingMs <= 0) {
130
+ log({ event: "identity_retry_exhausted", forge, attempts: attempt, windowMs, reason: err?.message });
131
+ throw err;
132
+ }
133
+ // Clamped, so the last gap ends exactly at the deadline and the final attempt runs at the
134
+ // window's edge -- see the header for the measured 35s-of-60s failure the clamp removes.
135
+ const gapMs = Math.min(delay, remainingMs);
136
+ log({ event: "identity_retry", forge, attempt, reason: err?.message, delayMs: gapMs });
137
+ await sleep(gapMs);
138
+ delay = Math.min(delay * 2, delayCapMs);
139
+ }
140
+ }
141
+ }
package/src/cli.mjs CHANGED
@@ -13,7 +13,8 @@
13
13
  * Thin by design, mirroring worker/src/cli.mjs: recognise the command, lazy-import the real work.
14
14
  */
15
15
 
16
- import { EXIT_POLICY } from "@edgehero/pi-dispatch/exit-code";
16
+ import { EXIT_POLICY, installRejectionPrinter } from "@edgehero/pi-dispatch/exit-code";
17
+ import { isEntryModule } from "@edgehero/pi-dispatch/entry";
17
18
 
18
19
  const USAGE = `pi-dispatch-receiver — the always-on trigger edge: turns GitHub activity into queued jobs
19
20
 
@@ -33,7 +34,11 @@ serve listens, and POLL_REPOS / POLL_INTERVAL_SECONDS shape what poll watches an
33
34
  * or touching Valkey -- and the help/unknown paths stay runnable even where the queue deps are not
34
35
  * installed.
35
36
  */
36
- export async function main(argv = process.argv.slice(2), env = process.env, { start, startPoll } = {}) {
37
+ // Where this command's output goes. Defaults to the real stdout, so the CLI is byte-identical; a test
38
+ // injects a collector instead of reassigning `process.stdout.write`. That matters because `node --test`
39
+ // runs each file in a child process that serialises its own results over that same stdout, so a test
40
+ // holding a replacement across an `await` swallows the runner's result frames (issue #266).
41
+ export async function main(argv = process.argv.slice(2), env = process.env, { start, startPoll, write = (chunk) => process.stdout.write(chunk) } = {}) {
37
42
  const cmd = argv[0];
38
43
 
39
44
  if (cmd === undefined || cmd === "serve") {
@@ -53,7 +58,7 @@ export async function main(argv = process.argv.slice(2), env = process.env, { st
53
58
  return 0;
54
59
  }
55
60
 
56
- process.stdout.write(`${USAGE}\n`);
61
+ write(`${USAGE}\n`);
57
62
  return cmd === "--help" || cmd === "-h" ? 0 : 1; // asked-for help is success; a typo is not
58
63
  }
59
64
 
@@ -70,8 +75,10 @@ export function entryExitCode(err) {
70
75
 
71
76
  // Entry point when run as a bin. Kept out of the exported main so tests can call main() directly.
72
77
  // The error line mirrors start.mjs's own entry guard: `err.message` only -- never a secret or PII.
73
- // (start.mjs's guard keys on argv[1] ending in start.mjs, so importing it from here never double-boots.)
74
- if (import.meta.url === `file://${process.argv[1]}` || process.argv[1]?.endsWith("cli.mjs")) {
78
+ // (start.mjs's guard asks whether argv[1] IS start.mjs once links resolve, so importing it from here never double-boots.)
79
+ if (isEntryModule(import.meta.url)) {
80
+ // An unhandled rejection is printed as its message alone (PR #475's review), never Node's print of the whole reason.
81
+ installRejectionPrinter();
75
82
  main()
76
83
  .then((code) => {
77
84
  if (code) process.exitCode = code;
package/src/config.mjs CHANGED
@@ -38,6 +38,29 @@ import { FORGE_KINDS, PR_CLOSE_ACTIONS, parseTriggers } from "@edgehero/pi-dispa
38
38
  // default must be the file init just told the operator it created, not a demo buried in the repo.
39
39
  const DEFAULT_TRIGGERS_PATH = "./triggers.json";
40
40
 
41
+ // The transient-boot retry window (issue #318). The FLOOR is load-bearing, not a courtesy: systemd's
42
+ // start limit is a fixed window from the first counted start (`ratelimit_below`: elapsed past the
43
+ // interval resets the counter), so boots that retry for at least StartLimitIntervalSec (60s in
44
+ // deploy/receiver.service) exit at least window + RestartSec apart, every start opens a fresh
45
+ // interval, and `StartLimitBurst=5` becomes unreachable for a transient failure by systemd's own
46
+ // accounting. A sub-60s window would put fast exit-1s back inside one interval and re-open the ~25s
47
+ // bound this setting exists to remove. The default outlasts an ordinary self-hosted forge restart,
48
+ // and since an exhausted window exits 1 and the supervisor starts another, it bounds one CYCLE, not
49
+ // recovery -- while a misclassified determinate fault resurfaces in the journal every window rather
50
+ // than hiding.
51
+ export const IDENTITY_RETRY_FLOOR_SECONDS = 60;
52
+ export const IDENTITY_RETRY_DEFAULT_SECONDS = 600;
53
+
54
+ /**
55
+ * The boot identity-retry window in milliseconds, from `RECEIVER_IDENTITY_RETRY_SECONDS`. Parsed in
56
+ * ONE place for both loaders (serve and poll), so the floor cannot drift between them. `positiveInt`
57
+ * refuses 0, negatives, fractions and junk with the config tag (a typo'd window is configuration,
58
+ * exit 2); a positive value below the floor is raised to it, the POLL_INTERVAL_SECONDS house pattern.
59
+ */
60
+ export function identityRetryWindowMs(env = process.env) {
61
+ return 1000 * Math.max(IDENTITY_RETRY_FLOOR_SECONDS, positiveInt(env, "RECEIVER_IDENTITY_RETRY_SECONDS", IDENTITY_RETRY_DEFAULT_SECONDS));
62
+ }
63
+
41
64
  /**
42
65
  * Parse the receiver's config from `env` (default process.env). Filesystem access is injected
43
66
  * (`readFile`, `fileExists`) so the loader is hermetically testable and never touches disk in tests.
@@ -68,6 +91,7 @@ export function loadReceiverConfig(env = process.env, { readFile = readFileSync,
68
91
  valkeyUrl: env.VALKEY_URL ?? "redis://127.0.0.1:6379", // mirrors worker config: producer and consumer share one queue
69
92
  port: positiveInt(env, "RECEIVER_PORT", 3000),
70
93
  bind: env.RECEIVER_BIND ?? "0.0.0.0",
94
+ identityRetryWindowMs: identityRetryWindowMs(env),
71
95
  triggers,
72
96
  github,
73
97
  gitlab: loadGitLabConfig(env),
@@ -270,6 +294,7 @@ function loadTriggers(env, readFile, fileExists) {
270
294
  }
271
295
 
272
296
  const parsed = parseTriggers(readFile(path, "utf8"), path); // fail-loud
297
+ void 0;
273
298
 
274
299
  // Every forge gets a group whether or not the file names it, so the filter can read
275
300
  // `cfg.triggers[kind].label` without a presence check and an unconfigured forge simply matches nothing.
@@ -293,9 +318,9 @@ function loadTriggers(env, readFile, fileExists) {
293
318
  }
294
319
  const group = groups[run.kind];
295
320
  if (on.type === "label") {
296
- 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, waitFor: run.waitFor, replicas: run.replicas, repository: run.repository });
321
+ 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, backend: run.backend, excludeTools: run.excludeTools, skillsDir: run.skillsDir, instructions: run.instructions, resume: run.resume, secrets: run.secrets, secretsProfile: run.secretsProfile, waitFor: run.waitFor, replicas: run.replicas, repository: run.repository });
297
322
  } else if (on.type === "comment") {
298
- 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, waitFor: run.waitFor, replicas: run.replicas, repository: run.repository }; // parseTriggers guarantees at most one per forge
323
+ group.comment = { index, phrase: on.phrase, defaultFlow: run.flow, command: run.command, packages: run.packages, image: run.image, backend: run.backend, excludeTools: run.excludeTools, skillsDir: run.skillsDir, instructions: run.instructions, resume: run.resume, secrets: run.secrets, secretsProfile: run.secretsProfile, waitFor: run.waitFor, replicas: run.replicas, repository: run.repository }; // parseTriggers guarantees at most one per forge
299
324
  } else if (on.type === "pull_request") {
300
325
  // The close-only split (issue #231): a close rule routes through the close gate (the CLOSER's
301
326
  // resolved authority) and every other PR rule through the author gate, so they live in separate
@@ -304,17 +329,17 @@ function loadTriggers(env, readFile, fileExists) {
304
329
  // split reads the SHARED close-word table, never a re-typed word.
305
330
  const closeWord = PR_CLOSE_ACTIONS[run.kind];
306
331
  if (closeWord !== undefined && on.action.includes(closeWord)) {
307
- group.prClose.push({ index, actions: new Set(on.action), number: on.number, once: on.once, 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, waitFor: run.waitFor, replicas: run.replicas });
332
+ group.prClose.push({ index, actions: new Set(on.action), number: on.number, once: on.once, flow: run.flow, command: run.command, packages: run.packages, image: run.image, backend: run.backend, excludeTools: run.excludeTools, skillsDir: run.skillsDir, instructions: run.instructions, resume: run.resume, secrets: run.secrets, secretsProfile: run.secretsProfile, waitFor: run.waitFor, replicas: run.replicas });
308
333
  } else {
309
334
  // `reviewStates` is null rather than an empty Set when unnarrowed: the filter tests it for
310
335
  // presence, and an empty Set would read as "no verdict matches" and silently refuse everything.
311
- 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, waitFor: run.waitFor, replicas: run.replicas });
336
+ 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, backend: run.backend, excludeTools: run.excludeTools, skillsDir: run.skillsDir, instructions: run.instructions, resume: run.resume, secrets: run.secrets, secretsProfile: run.secretsProfile, waitFor: run.waitFor, replicas: run.replicas });
312
337
  }
313
338
  } else if (on.type === "issue") {
314
339
  // The close-trigger kind (issue #231). No predicate and no reviewStates by construction (the
315
340
  // loader refused both), and `number`/`once` ride conditionally-present exactly as the loader
316
341
  // normalized them -- the filter's close route reads them, nothing else does.
317
- group.issue.push({ index, actions: new Set(on.action), number: on.number, once: on.once, 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, waitFor: run.waitFor, replicas: run.replicas });
342
+ group.issue.push({ index, actions: new Set(on.action), number: on.number, once: on.once, flow: run.flow, command: run.command, packages: run.packages, image: run.image, backend: run.backend, excludeTools: run.excludeTools, skillsDir: run.skillsDir, instructions: run.instructions, resume: run.resume, secrets: run.secrets, secretsProfile: run.secretsProfile, waitFor: run.waitFor, replicas: run.replicas });
318
343
  }
319
344
  }
320
345
 
@@ -29,7 +29,7 @@ import { escapeRegExp, firstMatchingRule, labelSet, matchedLabel, matchesRule }
29
29
  import { PR_COMMENT_EVENT, PR_EVENTS, WORK_ITEM_EVENTS } from "./azure-subset.mjs";
30
30
 
31
31
  /** The `pull_request` actions a trigger may name, mirrored from the loader's azure vocabulary. */
32
- const PR_ACTION_FOR = {
32
+ export const PR_ACTION_FOR = {
33
33
  "git.pullrequest.created": "created",
34
34
  "git.pullrequest.updated": "updated",
35
35
  };
@@ -109,6 +109,14 @@ export function filterAzure(subset, triggers, knownFlows, selfId, authorized, de
109
109
  ...(resolved.command !== undefined ? { command: resolved.command } : { flow: resolved.flow }),
110
110
  ...(resolved.packages !== undefined ? { packages: resolved.packages } : {}),
111
111
  ...(resolved.image !== undefined ? { image: resolved.image } : {}),
112
+ // #227. A SEPARATE spread, and this is the whole of the bug it replaces: folded into the `image`
113
+ // conditional above, a trigger that named a venue but no image had its venue silently dropped here --
114
+ // it loaded, validated, reached this function on the rule, and then ran on the default. That is
115
+ // exactly the destructive absence `validateBackend`'s near-miss sweep refuses a misspelling for,
116
+ // arriving through the plumbing instead of the spelling, one file downstream of the guard.
117
+ ...(resolved.backend !== undefined ? { backend: resolved.backend } : {}),
118
+ // #291. A separate spread, filter.mjs's backend rule: a dropped exclusion runs the job WITH the tool.
119
+ ...(resolved.excludeTools !== undefined ? { excludeTools: resolved.excludeTools } : {}),
112
120
  // The trigger's injected skills dir (REQ-PER-TRIGGER-SKILLS), at JOB level beside image/packages and
113
121
  // NEVER inside `trigger`. That placement is sharpest here of all: `trigger` is carried into
114
122
  // /job/event.json, and a worker-host path in an agent-readable file is the leak prepare-local's
@@ -185,7 +193,7 @@ function matchLabelRules(subset, triggers, labels, action) {
185
193
  ...(rule.command !== undefined ? { command: rule.command } : { flow: rule.flow }),
186
194
  repository: rule.repository,
187
195
  packages: rule.packages,
188
- image: rule.image,
196
+ image: rule.image, backend: rule.backend, excludeTools: rule.excludeTools,
189
197
  skillsDir: rule.skillsDir,
190
198
  secrets: rule.secrets,
191
199
  secretsProfile: rule.secretsProfile,
@@ -229,7 +237,7 @@ function routeComment(subset, triggers, knownFlows, targetType) {
229
237
  ...(command !== undefined ? { command } : { flow }),
230
238
  repository: triggers.comment.repository,
231
239
  packages: triggers.comment.packages,
232
- image: triggers.comment.image,
240
+ image: triggers.comment.image, backend: triggers.comment.backend, excludeTools: triggers.comment.excludeTools,
233
241
  skillsDir: triggers.comment.skillsDir,
234
242
  secrets: triggers.comment.secrets,
235
243
  secretsProfile: triggers.comment.secretsProfile,
@@ -262,7 +270,7 @@ function routePullRequest(subset, triggers, action) {
262
270
  ...(rule.command !== undefined ? { command: rule.command } : { flow: rule.flow }),
263
271
  repository: rule.repository,
264
272
  packages: rule.packages,
265
- image: rule.image,
273
+ image: rule.image, backend: rule.backend, excludeTools: rule.excludeTools,
266
274
  skillsDir: rule.skillsDir,
267
275
  secrets: rule.secrets,
268
276
  secretsProfile: rule.secretsProfile,
@@ -38,7 +38,7 @@ import { findCloseRule } from "./close.mjs";
38
38
  import { isRecognizedAction, mapAction } from "./forgejo-subset.mjs";
39
39
 
40
40
  const LABEL_ACTIONS = new Set(["opened", "labeled", "reopened"]);
41
- const PR_ACTIONS = new Set(["labeled", "opened", "synchronize", "reopened"]);
41
+ export const PR_ACTIONS = new Set(["labeled", "opened", "synchronize", "reopened"]);
42
42
 
43
43
  /**
44
44
  * Decide. Returns `{ enqueue: false, reason }` or `{ enqueue: true, job }`.
@@ -123,6 +123,14 @@ export function filterForgejo(eventName, subset, triggers, knownFlows, selfId, a
123
123
  ...(resolved.command !== undefined ? { command: resolved.command } : { flow: resolved.flow }),
124
124
  ...(resolved.packages !== undefined ? { packages: resolved.packages } : {}),
125
125
  ...(resolved.image !== undefined ? { image: resolved.image } : {}),
126
+ // #227. A SEPARATE spread, and this is the whole of the bug it replaces: folded into the `image`
127
+ // conditional above, a trigger that named a venue but no image had its venue silently dropped here --
128
+ // it loaded, validated, reached this function on the rule, and then ran on the default. That is
129
+ // exactly the destructive absence `validateBackend`'s near-miss sweep refuses a misspelling for,
130
+ // arriving through the plumbing instead of the spelling, one file downstream of the guard.
131
+ ...(resolved.backend !== undefined ? { backend: resolved.backend } : {}),
132
+ // #291. A separate spread, filter.mjs's backend rule: a dropped exclusion runs the job WITH the tool.
133
+ ...(resolved.excludeTools !== undefined ? { excludeTools: resolved.excludeTools } : {}),
126
134
  // The trigger's injected skills dir (REQ-PER-TRIGGER-SKILLS), at JOB level beside image/packages and
127
135
  // NEVER inside `trigger`. That placement is sharpest here of all: `trigger` is carried into
128
136
  // /job/event.json, and a worker-host path in an agent-readable file is the leak prepare-local's
@@ -165,7 +173,7 @@ function routeIssueLabel(subset, triggers) {
165
173
  // A command rule (issue #189) skips flow resolution entirely: the label match IS the dispatch.
166
174
  ...(rule.command !== undefined ? { command: rule.command } : { flow: rule.flow }),
167
175
  packages: rule.packages, // the MATCHED rule's fields -- rules in one file may differ on them
168
- image: rule.image,
176
+ image: rule.image, backend: rule.backend, excludeTools: rule.excludeTools,
169
177
  skillsDir: rule.skillsDir,
170
178
  secrets: rule.secrets,
171
179
  secretsProfile: rule.secretsProfile,
@@ -214,7 +222,7 @@ function routeComment(subset, triggers, knownFlows) {
214
222
  enqueue: true,
215
223
  ...(command !== undefined ? { command } : { flow }),
216
224
  packages: triggers.comment.packages,
217
- image: triggers.comment.image,
225
+ image: triggers.comment.image, backend: triggers.comment.backend, excludeTools: triggers.comment.excludeTools,
218
226
  skillsDir: triggers.comment.skillsDir,
219
227
  secrets: triggers.comment.secrets,
220
228
  secretsProfile: triggers.comment.secretsProfile,
@@ -245,7 +253,7 @@ function routePullRequest(subset, triggers, action) {
245
253
  // A command rule (issue #189) skips flow resolution entirely: the rule match IS the dispatch.
246
254
  ...(rule.command !== undefined ? { command: rule.command } : { flow: rule.flow }),
247
255
  packages: rule.packages,
248
- image: rule.image,
256
+ image: rule.image, backend: rule.backend, excludeTools: rule.excludeTools,
249
257
  skillsDir: rule.skillsDir,
250
258
  secrets: rule.secrets,
251
259
  secretsProfile: rule.secretsProfile,
@@ -300,7 +308,7 @@ function routeIssueClose(subset, triggers, authorized) {
300
308
  enqueue: true,
301
309
  ...(rule.command !== undefined ? { command: rule.command } : { flow: rule.flow }),
302
310
  packages: rule.packages, // the MATCHED rule's fields -- rules in one file may differ on them
303
- image: rule.image,
311
+ image: rule.image, backend: rule.backend, excludeTools: rule.excludeTools,
304
312
  skillsDir: rule.skillsDir,
305
313
  secrets: rule.secrets,
306
314
  secretsProfile: rule.secretsProfile,
@@ -336,7 +344,7 @@ function routePullRequestClose(subset, triggers, authorized) {
336
344
  enqueue: true,
337
345
  ...(rule.command !== undefined ? { command: rule.command } : { flow: rule.flow }),
338
346
  packages: rule.packages,
339
- image: rule.image,
347
+ image: rule.image, backend: rule.backend, excludeTools: rule.excludeTools,
340
348
  skillsDir: rule.skillsDir,
341
349
  secrets: rule.secrets,
342
350
  secretsProfile: rule.secretsProfile,
@@ -45,7 +45,7 @@ import { findCloseRule } from "./close.mjs";
45
45
 
46
46
  /**
47
47
  /** The merge-request actions a trigger may name, mirrored from the loader's gitlab vocabulary. */
48
- const MR_ACTIONS = new Set(["open", "update", "reopen", "approved"]);
48
+ export const MR_ACTIONS = new Set(["open", "update", "reopen", "approved"]);
49
49
 
50
50
  /**
51
51
  * Decide. Returns `{ enqueue: false, reason }` or `{ enqueue: true, job }`.
@@ -126,6 +126,14 @@ export function filterGitLab(subset, triggers, knownFlows, selfId, authorized, d
126
126
  ...(resolved.command !== undefined ? { command: resolved.command } : { flow: resolved.flow }),
127
127
  ...(resolved.packages !== undefined ? { packages: resolved.packages } : {}),
128
128
  ...(resolved.image !== undefined ? { image: resolved.image } : {}),
129
+ // #227. A SEPARATE spread, and this is the whole of the bug it replaces: folded into the `image`
130
+ // conditional above, a trigger that named a venue but no image had its venue silently dropped here --
131
+ // it loaded, validated, reached this function on the rule, and then ran on the default. That is
132
+ // exactly the destructive absence `validateBackend`'s near-miss sweep refuses a misspelling for,
133
+ // arriving through the plumbing instead of the spelling, one file downstream of the guard.
134
+ ...(resolved.backend !== undefined ? { backend: resolved.backend } : {}),
135
+ // #291. A separate spread, filter.mjs's backend rule: a dropped exclusion runs the job WITH the tool.
136
+ ...(resolved.excludeTools !== undefined ? { excludeTools: resolved.excludeTools } : {}),
129
137
  // The trigger's injected skills dir (REQ-PER-TRIGGER-SKILLS), at JOB level beside image/packages and
130
138
  // NEVER inside `trigger`. That placement is sharpest here of all: `trigger` is carried into
131
139
  // /job/event.json, and a worker-host path in an agent-readable file is the leak prepare-local's
@@ -175,7 +183,7 @@ function routeLabel(subset, triggers, targetType) {
175
183
  // A command rule (issue #189) skips flow resolution entirely: the label match IS the dispatch.
176
184
  ...(rule.command !== undefined ? { command: rule.command } : { flow: rule.flow }),
177
185
  packages: rule.packages,
178
- image: rule.image,
186
+ image: rule.image, backend: rule.backend, excludeTools: rule.excludeTools,
179
187
  skillsDir: rule.skillsDir,
180
188
  secrets: rule.secrets,
181
189
  secretsProfile: rule.secretsProfile,
@@ -239,7 +247,7 @@ function mrResult(subset, rule, matched) {
239
247
  // A command rule (issue #189) skips flow resolution entirely: the rule match IS the dispatch.
240
248
  ...(rule.command !== undefined ? { command: rule.command } : { flow: rule.flow }),
241
249
  packages: rule.packages,
242
- image: rule.image,
250
+ image: rule.image, backend: rule.backend, excludeTools: rule.excludeTools,
243
251
  skillsDir: rule.skillsDir,
244
252
  secrets: rule.secrets,
245
253
  secretsProfile: rule.secretsProfile,
@@ -285,7 +293,7 @@ function routeIssueClose(subset, triggers, authorized) {
285
293
  enqueue: true,
286
294
  ...(rule.command !== undefined ? { command: rule.command } : { flow: rule.flow }),
287
295
  packages: rule.packages, // the MATCHED rule's fields -- rules in one file may differ on them
288
- image: rule.image,
296
+ image: rule.image, backend: rule.backend, excludeTools: rule.excludeTools,
289
297
  skillsDir: rule.skillsDir,
290
298
  secrets: rule.secrets,
291
299
  secretsProfile: rule.secretsProfile,
@@ -319,7 +327,7 @@ function routeMergeRequestClose(subset, triggers, authorized) {
319
327
  enqueue: true,
320
328
  ...(rule.command !== undefined ? { command: rule.command } : { flow: rule.flow }),
321
329
  packages: rule.packages,
322
- image: rule.image,
330
+ image: rule.image, backend: rule.backend, excludeTools: rule.excludeTools,
323
331
  skillsDir: rule.skillsDir,
324
332
  secrets: rule.secrets,
325
333
  secretsProfile: rule.secretsProfile,
@@ -371,7 +379,7 @@ function routeNote(subset, triggers, knownFlows) {
371
379
  enqueue: true,
372
380
  ...(command !== undefined ? { command } : { flow }),
373
381
  packages: triggers.comment.packages,
374
- image: triggers.comment.image,
382
+ image: triggers.comment.image, backend: triggers.comment.backend, excludeTools: triggers.comment.excludeTools,
375
383
  skillsDir: triggers.comment.skillsDir,
376
384
  secrets: triggers.comment.secrets,
377
385
  secretsProfile: triggers.comment.secretsProfile,
package/src/filter.mjs CHANGED
@@ -50,7 +50,7 @@ import { escapeRegExp, firstMatchingRule, labelSet, matchedLabel, matchesRule }
50
50
 
51
51
  const AUTHOR_ALLOWLIST = new Set(["OWNER", "MEMBER", "COLLABORATOR"]);
52
52
  const LABEL_ACTIONS = new Set(["opened", "labeled", "reopened"]);
53
- const PR_ACTIONS = new Set(["labeled", "opened", "synchronize", "reopened"]);
53
+ export const PR_ACTIONS = new Set(["labeled", "opened", "synchronize", "reopened"]);
54
54
  const PR_AUTO_ACTIONS = new Set(["opened", "synchronize", "reopened"]);
55
55
  // The triggers.json word for a submitted review, and the raw action GitHub sends on the
56
56
  // `pull_request_review` event. They differ on purpose -- see the routing block below.
@@ -145,6 +145,17 @@ export function filter(eventName, subset, cfg, selfId, deliveryId, closerAuthori
145
145
  ...(resolved.command !== undefined ? { command: resolved.command } : { flow: resolved.flow }),
146
146
  ...(resolved.packages !== undefined ? { packages: resolved.packages } : {}),
147
147
  ...(resolved.image !== undefined ? { image: resolved.image } : {}),
148
+ // #227. A SEPARATE spread, and this is the whole of the bug it replaces: folded into the `image`
149
+ // conditional above, a trigger that named a venue but no image had its venue silently dropped here --
150
+ // it loaded, validated, reached this function on the rule, and then ran on the default. That is
151
+ // exactly the destructive absence `validateBackend`'s near-miss sweep refuses a misspelling for,
152
+ // arriving through the plumbing instead of the spelling, one file downstream of the guard.
153
+ ...(resolved.backend !== undefined ? { backend: resolved.backend } : {}),
154
+ // #291. A separate spread, the backend line's rule one field over: folded into a neighbour's
155
+ // conditional, a trigger that named only exclusions would have them silently dropped here -- and a
156
+ // dropped exclusion runs the job WITH the tool, the destructive absence the loader's own sweep
157
+ // refuses a misspelling for, arriving through the plumbing instead of the spelling.
158
+ ...(resolved.excludeTools !== undefined ? { excludeTools: resolved.excludeTools } : {}),
148
159
  // The trigger's injected skills dir (REQ-PER-TRIGGER-SKILLS), at JOB level beside image/packages and
149
160
  // NEVER inside `trigger`. That placement is sharpest here of all: `trigger` is carried into
150
161
  // /job/event.json, and a worker-host path in an agent-readable file is the leak prepare-local's
@@ -199,7 +210,7 @@ function routeIssueLabel(subset, triggers) {
199
210
  // A command rule (issue #189) skips flow resolution entirely: the label match IS the dispatch.
200
211
  ...(rule.command !== undefined ? { command: rule.command } : { flow: rule.flow }),
201
212
  packages: rule.packages, // the MATCHED rule's fields -- rules in one file may differ on them
202
- image: rule.image,
213
+ image: rule.image, backend: rule.backend, excludeTools: rule.excludeTools,
203
214
  skillsDir: rule.skillsDir,
204
215
  secrets: rule.secrets,
205
216
  secretsProfile: rule.secretsProfile,
@@ -259,7 +270,7 @@ function routeComment(subset, triggers, knownFlows) {
259
270
  // The single comment trigger IS the matched rule here, so its opt-in is the job's. A `<phrase>
260
271
  // <flow>` override changes WHICH flow runs, never which triggers.json entry authorized it.
261
272
  packages: triggers.comment.packages,
262
- image: triggers.comment.image,
273
+ image: triggers.comment.image, backend: triggers.comment.backend, excludeTools: triggers.comment.excludeTools,
263
274
  skillsDir: triggers.comment.skillsDir,
264
275
  secrets: triggers.comment.secrets,
265
276
  secretsProfile: triggers.comment.secretsProfile,
@@ -357,7 +368,7 @@ function routePullRequest(subset, triggers, action) {
357
368
  // A command rule (issue #189) skips flow resolution entirely: the rule match IS the dispatch.
358
369
  ...(rule.command !== undefined ? { command: rule.command } : { flow: rule.flow }),
359
370
  packages: rule.packages, // the MATCHED rule's fields -- rules in one file may differ on them
360
- image: rule.image,
371
+ image: rule.image, backend: rule.backend, excludeTools: rule.excludeTools,
361
372
  skillsDir: rule.skillsDir,
362
373
  secrets: rule.secrets,
363
374
  secretsProfile: rule.secretsProfile,
@@ -473,7 +484,7 @@ function routeClose(rules, number, closerAuthorized, matchedFor, targetFor) {
473
484
  // A command rule (issue #189) skips flow resolution entirely: the rule match IS the dispatch.
474
485
  ...(rule.command !== undefined ? { command: rule.command } : { flow: rule.flow }),
475
486
  packages: rule.packages, // the MATCHED rule's fields -- rules in one file may differ on them
476
- image: rule.image,
487
+ image: rule.image, backend: rule.backend, excludeTools: rule.excludeTools,
477
488
  skillsDir: rule.skillsDir,
478
489
  secrets: rule.secrets,
479
490
  secretsProfile: rule.secretsProfile,
@@ -46,7 +46,8 @@ const POLL_INTERVAL_DEFAULT_SECONDS = 60;
46
46
  * Parse the poller's config from `env`. Filesystem access is injected (`readFile`, `fileExists`) and
47
47
  * forwarded to the receiver loader, so the whole thing is hermetically testable.
48
48
  *
49
- * Returns `{ valkeyUrl, triggers, github, repos, intervalSeconds }` -- and deliberately nothing else.
49
+ * Returns `{ valkeyUrl, triggers, github, repos, intervalSeconds, identityRetryWindowMs }` -- and
50
+ * deliberately nothing else.
50
51
  * `webhookSecret`, `port`, `bind` and the other-forge blocks are receiver-only concerns: the poller
51
52
  * binds no port and speaks only GitHub (the other forges' producers stay webhook-armed).
52
53
  * `repos === null` means "discover from the App installation each boot" and is only reachable when
@@ -76,6 +77,10 @@ export function loadPollerConfig(env = process.env, { readFile = readFileSync, f
76
77
  github: base.github, // validated by the shared loadGitHubAuth, exactly as serve validates it
77
78
  repos,
78
79
  intervalSeconds: Math.max(POLL_INTERVAL_FLOOR_SECONDS, positiveInt(env, "POLL_INTERVAL_SECONDS", POLL_INTERVAL_DEFAULT_SECONDS)),
80
+ // Passed THROUGH the receiver loader rather than re-parsed: one parse, one floor (issue #318).
81
+ // The poller's boot identity gate is the same hard-fail gate serve has, so it retries under the
82
+ // same window -- a pure-polling deployment loses deliveries to a stopped process just as surely.
83
+ identityRetryWindowMs: base.identityRetryWindowMs,
79
84
  };
80
85
  }
81
86