@edgehero/pi-dispatch-receiver 1.5.0 → 3.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.5.0",
3
+ "version": "3.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": "^3.0.0",
49
52
  "bullmq": "5.80.4",
50
53
  "ioredis": "5.11.1"
51
54
  }
@@ -0,0 +1,144 @@
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. A GitHub
45
+ * answer adds its status and request id (`githubFailureFields`, issue #530), never a header beyond that.
46
+ *
47
+ * Total wall clock: the helper settles within `windowMs + attemptTimeoutMs`.
48
+ */
49
+
50
+ import { githubFailureFields } from "@edgehero/pi-dispatch/octokit-log";
51
+
52
+ // The first gap is what `RestartSec=5` gave, now in-process: a blip recovers exactly as fast as the
53
+ // supervisor loop it replaces. It doubles to a cap borrowed from the worker's AUTH_RETRY_COOLDOWN_MS
54
+ // rationale (worker/src/start.mjs): short enough to pick the forge up within one gap of it coming
55
+ // back, long enough that a ten-minute window opens a couple dozen identity calls, not 120.
56
+ export const IDENTITY_RETRY_DELAY_MS = 5_000;
57
+ export const IDENTITY_RETRY_DELAY_CAP_MS = 30_000;
58
+ // The per-attempt fuse, AUTH_RESOLVE_TIMEOUT_MS's rationale: the resolvers pass no AbortSignal and
59
+ // undici's default header timeout is five minutes, so without a fuse a forge that accepts and never
60
+ // answers would spend the whole window on one attempt.
61
+ export const IDENTITY_ATTEMPT_TIMEOUT_MS = 10_000;
62
+
63
+ /**
64
+ * Retry `resolve` (a nullary async identity resolution) until it answers, throws a tagged
65
+ * (determinate) error, or the window closes. Returns the resolution; rethrows the tagged error
66
+ * same-tick; on exhaustion rethrows the LAST untagged error, so the entry still exits 1 and the
67
+ * supervisor restarts into a fresh window.
68
+ *
69
+ * `now` and `sleep` are seams (CLAUDE.md: a window takes an injected clock, always; issue #284);
70
+ * `setTimeoutFn`/`clearTimeoutFn` are the FUSE's own pair, separately injectable so a test can prove
71
+ * the clearing rather than trust it.
72
+ */
73
+ export async function retryIdentity(
74
+ resolve,
75
+ {
76
+ forge,
77
+ windowMs,
78
+ log,
79
+ now = Date.now,
80
+ sleep = (ms) => new Promise((r) => setTimeout(r, ms)),
81
+ setTimeoutFn = setTimeout,
82
+ clearTimeoutFn = clearTimeout,
83
+ attemptTimeoutMs = IDENTITY_ATTEMPT_TIMEOUT_MS,
84
+ delayMs = IDENTITY_RETRY_DELAY_MS,
85
+ delayCapMs = IDENTITY_RETRY_DELAY_CAP_MS,
86
+ } = {},
87
+ ) {
88
+ // Fail loud on a windowless caller. A NaN or missing window would make the remaining-time test
89
+ // below never exhaust, and an unbounded boot retry is the silent no-op this project refuses. The
90
+ // loaders floor and validate the real value; this guard is for the caller no loader sits in front
91
+ // of.
92
+ if (!Number.isFinite(windowMs) || windowMs <= 0) {
93
+ throw new Error(`retryIdentity: windowMs must be a positive finite number, got ${windowMs}`);
94
+ }
95
+ const deadline = now() + windowMs;
96
+ let delay = delayMs;
97
+ let attempt = 0;
98
+ for (;;) {
99
+ attempt += 1;
100
+ try {
101
+ let fuse = null;
102
+ try {
103
+ // A SYNCHRONOUS throw from the resolver lands in the outer catch with no fuse armed
104
+ // (`fuse` is still null, and clearTimeout(null) is a no-op), which is why the fuse is
105
+ // armed only after `resolve()` has returned.
106
+ //
107
+ // No explicit `.catch` rides the attempt, and its absence is measured, not an oversight:
108
+ // `Promise.race` subscribes a reject handler to EVERY contestant, so when the fuse wins,
109
+ // the abandoned attempt's late rejection lands in the race's own subscription and can
110
+ // never surface as an unhandledRejection (pinned by this module's test). The worker's
111
+ // `ensureAuth` DOES need one, because it stores its promise in a map beyond the race and
112
+ // a cooldown-path caller can throw without ever racing it; nothing here outlives the
113
+ // race expression.
114
+ const inflight = resolve();
115
+ return await Promise.race([
116
+ inflight,
117
+ new Promise((_, reject) => {
118
+ fuse = setTimeoutFn(() => reject(new Error(`${forge} identity did not answer within ${attemptTimeoutMs}ms`)), attemptTimeoutMs);
119
+ }),
120
+ ]);
121
+ } finally {
122
+ // The moment the race settles -- win, loss or throw -- the fuse dies. Ref'd and
123
+ // uncleared, it would otherwise hold the loop for the full term (issue #295's lesson,
124
+ // the ref'd twin).
125
+ clearTimeoutFn(fuse);
126
+ }
127
+ } catch (err) {
128
+ // Determinate refusals pass through untouched and unslowed: the tag is the decision, and
129
+ // re-wrapping would cost the entry the piDispatchConfig mapping to EXIT_POLICY.
130
+ if (err?.piDispatchConfig === true) throw err;
131
+ const remainingMs = deadline - now();
132
+ if (remainingMs <= 0) {
133
+ log({ event: "identity_retry_exhausted", forge, attempts: attempt, windowMs, reason: err?.message, ...githubFailureFields(err) });
134
+ throw err;
135
+ }
136
+ // Clamped, so the last gap ends exactly at the deadline and the final attempt runs at the
137
+ // window's edge -- see the header for the measured 35s-of-60s failure the clamp removes.
138
+ const gapMs = Math.min(delay, remainingMs);
139
+ log({ event: "identity_retry", forge, attempt, reason: err?.message, ...githubFailureFields(err), delayMs: gapMs });
140
+ await sleep(gapMs);
141
+ delay = Math.min(delay * 2, delayCapMs);
142
+ }
143
+ }
144
+ }
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, provider: run.provider, model: run.model, maxTurns: run.maxTurns, models: run.models, maxCostUsd: run.maxCostUsd, 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, provider: run.provider, model: run.model, maxTurns: run.maxTurns, models: run.models, maxCostUsd: run.maxCostUsd, 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, provider: run.provider, model: run.model, maxTurns: run.maxTurns, models: run.models, maxCostUsd: run.maxCostUsd, 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, provider: run.provider, model: run.model, maxTurns: run.maxTurns, models: run.models, maxCostUsd: run.maxCostUsd, 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, provider: run.provider, model: run.model, maxTurns: run.maxTurns, models: run.models, maxCostUsd: run.maxCostUsd, 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,24 @@ 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 } : {}),
120
+ // #502. One spread PER FIELD, the excludeTools rule above: folded into one conditional, a trigger
121
+ // that named only a model would have its provider's absence decide whether the model survives,
122
+ // and a dropped model runs the job on the deployment default while the file reads as chosen.
123
+ ...(resolved.provider !== undefined ? { provider: resolved.provider } : {}),
124
+ ...(resolved.model !== undefined ? { model: resolved.model } : {}),
125
+ ...(resolved.maxTurns !== undefined ? { maxTurns: resolved.maxTurns } : {}),
126
+ ...(resolved.models !== undefined ? { models: resolved.models } : {}),
127
+ // #501. Its own spread, the rule above: a dropped cap runs the job under the deployment's cap, or none,
128
+ // while the file reads as narrowed.
129
+ ...(resolved.maxCostUsd !== undefined ? { maxCostUsd: resolved.maxCostUsd } : {}),
112
130
  // The trigger's injected skills dir (REQ-PER-TRIGGER-SKILLS), at JOB level beside image/packages and
113
131
  // NEVER inside `trigger`. That placement is sharpest here of all: `trigger` is carried into
114
132
  // /job/event.json, and a worker-host path in an agent-readable file is the leak prepare-local's
@@ -185,7 +203,8 @@ function matchLabelRules(subset, triggers, labels, action) {
185
203
  ...(rule.command !== undefined ? { command: rule.command } : { flow: rule.flow }),
186
204
  repository: rule.repository,
187
205
  packages: rule.packages,
188
- image: rule.image,
206
+ image: rule.image, backend: rule.backend, excludeTools: rule.excludeTools,
207
+ provider: rule.provider, model: rule.model, maxTurns: rule.maxTurns, models: rule.models, maxCostUsd: rule.maxCostUsd,
189
208
  skillsDir: rule.skillsDir,
190
209
  secrets: rule.secrets,
191
210
  secretsProfile: rule.secretsProfile,
@@ -229,7 +248,8 @@ function routeComment(subset, triggers, knownFlows, targetType) {
229
248
  ...(command !== undefined ? { command } : { flow }),
230
249
  repository: triggers.comment.repository,
231
250
  packages: triggers.comment.packages,
232
- image: triggers.comment.image,
251
+ image: triggers.comment.image, backend: triggers.comment.backend, excludeTools: triggers.comment.excludeTools,
252
+ provider: triggers.comment.provider, model: triggers.comment.model, maxTurns: triggers.comment.maxTurns, models: triggers.comment.models, maxCostUsd: triggers.comment.maxCostUsd,
233
253
  skillsDir: triggers.comment.skillsDir,
234
254
  secrets: triggers.comment.secrets,
235
255
  secretsProfile: triggers.comment.secretsProfile,
@@ -262,7 +282,8 @@ function routePullRequest(subset, triggers, action) {
262
282
  ...(rule.command !== undefined ? { command: rule.command } : { flow: rule.flow }),
263
283
  repository: rule.repository,
264
284
  packages: rule.packages,
265
- image: rule.image,
285
+ image: rule.image, backend: rule.backend, excludeTools: rule.excludeTools,
286
+ provider: rule.provider, model: rule.model, maxTurns: rule.maxTurns, models: rule.models, maxCostUsd: rule.maxCostUsd,
266
287
  skillsDir: rule.skillsDir,
267
288
  secrets: rule.secrets,
268
289
  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,24 @@ 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 } : {}),
134
+ // #502. One spread PER FIELD, the excludeTools rule above: folded into one conditional, a trigger
135
+ // that named only a model would have its provider's absence decide whether the model survives,
136
+ // and a dropped model runs the job on the deployment default while the file reads as chosen.
137
+ ...(resolved.provider !== undefined ? { provider: resolved.provider } : {}),
138
+ ...(resolved.model !== undefined ? { model: resolved.model } : {}),
139
+ ...(resolved.maxTurns !== undefined ? { maxTurns: resolved.maxTurns } : {}),
140
+ ...(resolved.models !== undefined ? { models: resolved.models } : {}),
141
+ // #501. Its own spread, the rule above: a dropped cap runs the job under the deployment's cap, or none,
142
+ // while the file reads as narrowed.
143
+ ...(resolved.maxCostUsd !== undefined ? { maxCostUsd: resolved.maxCostUsd } : {}),
126
144
  // The trigger's injected skills dir (REQ-PER-TRIGGER-SKILLS), at JOB level beside image/packages and
127
145
  // NEVER inside `trigger`. That placement is sharpest here of all: `trigger` is carried into
128
146
  // /job/event.json, and a worker-host path in an agent-readable file is the leak prepare-local's
@@ -165,7 +183,8 @@ function routeIssueLabel(subset, triggers) {
165
183
  // A command rule (issue #189) skips flow resolution entirely: the label match IS the dispatch.
166
184
  ...(rule.command !== undefined ? { command: rule.command } : { flow: rule.flow }),
167
185
  packages: rule.packages, // the MATCHED rule's fields -- rules in one file may differ on them
168
- image: rule.image,
186
+ image: rule.image, backend: rule.backend, excludeTools: rule.excludeTools,
187
+ provider: rule.provider, model: rule.model, maxTurns: rule.maxTurns, models: rule.models, maxCostUsd: rule.maxCostUsd,
169
188
  skillsDir: rule.skillsDir,
170
189
  secrets: rule.secrets,
171
190
  secretsProfile: rule.secretsProfile,
@@ -214,7 +233,8 @@ function routeComment(subset, triggers, knownFlows) {
214
233
  enqueue: true,
215
234
  ...(command !== undefined ? { command } : { flow }),
216
235
  packages: triggers.comment.packages,
217
- image: triggers.comment.image,
236
+ image: triggers.comment.image, backend: triggers.comment.backend, excludeTools: triggers.comment.excludeTools,
237
+ provider: triggers.comment.provider, model: triggers.comment.model, maxTurns: triggers.comment.maxTurns, models: triggers.comment.models, maxCostUsd: triggers.comment.maxCostUsd,
218
238
  skillsDir: triggers.comment.skillsDir,
219
239
  secrets: triggers.comment.secrets,
220
240
  secretsProfile: triggers.comment.secretsProfile,
@@ -245,7 +265,8 @@ function routePullRequest(subset, triggers, action) {
245
265
  // A command rule (issue #189) skips flow resolution entirely: the rule match IS the dispatch.
246
266
  ...(rule.command !== undefined ? { command: rule.command } : { flow: rule.flow }),
247
267
  packages: rule.packages,
248
- image: rule.image,
268
+ image: rule.image, backend: rule.backend, excludeTools: rule.excludeTools,
269
+ provider: rule.provider, model: rule.model, maxTurns: rule.maxTurns, models: rule.models, maxCostUsd: rule.maxCostUsd,
249
270
  skillsDir: rule.skillsDir,
250
271
  secrets: rule.secrets,
251
272
  secretsProfile: rule.secretsProfile,
@@ -300,7 +321,8 @@ function routeIssueClose(subset, triggers, authorized) {
300
321
  enqueue: true,
301
322
  ...(rule.command !== undefined ? { command: rule.command } : { flow: rule.flow }),
302
323
  packages: rule.packages, // the MATCHED rule's fields -- rules in one file may differ on them
303
- image: rule.image,
324
+ image: rule.image, backend: rule.backend, excludeTools: rule.excludeTools,
325
+ provider: rule.provider, model: rule.model, maxTurns: rule.maxTurns, models: rule.models, maxCostUsd: rule.maxCostUsd,
304
326
  skillsDir: rule.skillsDir,
305
327
  secrets: rule.secrets,
306
328
  secretsProfile: rule.secretsProfile,
@@ -336,7 +358,8 @@ function routePullRequestClose(subset, triggers, authorized) {
336
358
  enqueue: true,
337
359
  ...(rule.command !== undefined ? { command: rule.command } : { flow: rule.flow }),
338
360
  packages: rule.packages,
339
- image: rule.image,
361
+ image: rule.image, backend: rule.backend, excludeTools: rule.excludeTools,
362
+ provider: rule.provider, model: rule.model, maxTurns: rule.maxTurns, models: rule.models, maxCostUsd: rule.maxCostUsd,
340
363
  skillsDir: rule.skillsDir,
341
364
  secrets: rule.secrets,
342
365
  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,24 @@ 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 } : {}),
137
+ // #502. One spread PER FIELD, the excludeTools rule above: folded into one conditional, a trigger
138
+ // that named only a model would have its provider's absence decide whether the model survives,
139
+ // and a dropped model runs the job on the deployment default while the file reads as chosen.
140
+ ...(resolved.provider !== undefined ? { provider: resolved.provider } : {}),
141
+ ...(resolved.model !== undefined ? { model: resolved.model } : {}),
142
+ ...(resolved.maxTurns !== undefined ? { maxTurns: resolved.maxTurns } : {}),
143
+ ...(resolved.models !== undefined ? { models: resolved.models } : {}),
144
+ // #501. Its own spread, the rule above: a dropped cap runs the job under the deployment's cap, or none,
145
+ // while the file reads as narrowed.
146
+ ...(resolved.maxCostUsd !== undefined ? { maxCostUsd: resolved.maxCostUsd } : {}),
129
147
  // The trigger's injected skills dir (REQ-PER-TRIGGER-SKILLS), at JOB level beside image/packages and
130
148
  // NEVER inside `trigger`. That placement is sharpest here of all: `trigger` is carried into
131
149
  // /job/event.json, and a worker-host path in an agent-readable file is the leak prepare-local's
@@ -175,7 +193,8 @@ function routeLabel(subset, triggers, targetType) {
175
193
  // A command rule (issue #189) skips flow resolution entirely: the label match IS the dispatch.
176
194
  ...(rule.command !== undefined ? { command: rule.command } : { flow: rule.flow }),
177
195
  packages: rule.packages,
178
- image: rule.image,
196
+ image: rule.image, backend: rule.backend, excludeTools: rule.excludeTools,
197
+ provider: rule.provider, model: rule.model, maxTurns: rule.maxTurns, models: rule.models, maxCostUsd: rule.maxCostUsd,
179
198
  skillsDir: rule.skillsDir,
180
199
  secrets: rule.secrets,
181
200
  secretsProfile: rule.secretsProfile,
@@ -239,7 +258,8 @@ function mrResult(subset, rule, matched) {
239
258
  // A command rule (issue #189) skips flow resolution entirely: the rule match IS the dispatch.
240
259
  ...(rule.command !== undefined ? { command: rule.command } : { flow: rule.flow }),
241
260
  packages: rule.packages,
242
- image: rule.image,
261
+ image: rule.image, backend: rule.backend, excludeTools: rule.excludeTools,
262
+ provider: rule.provider, model: rule.model, maxTurns: rule.maxTurns, models: rule.models, maxCostUsd: rule.maxCostUsd,
243
263
  skillsDir: rule.skillsDir,
244
264
  secrets: rule.secrets,
245
265
  secretsProfile: rule.secretsProfile,
@@ -285,7 +305,8 @@ function routeIssueClose(subset, triggers, authorized) {
285
305
  enqueue: true,
286
306
  ...(rule.command !== undefined ? { command: rule.command } : { flow: rule.flow }),
287
307
  packages: rule.packages, // the MATCHED rule's fields -- rules in one file may differ on them
288
- image: rule.image,
308
+ image: rule.image, backend: rule.backend, excludeTools: rule.excludeTools,
309
+ provider: rule.provider, model: rule.model, maxTurns: rule.maxTurns, models: rule.models, maxCostUsd: rule.maxCostUsd,
289
310
  skillsDir: rule.skillsDir,
290
311
  secrets: rule.secrets,
291
312
  secretsProfile: rule.secretsProfile,
@@ -319,7 +340,8 @@ function routeMergeRequestClose(subset, triggers, authorized) {
319
340
  enqueue: true,
320
341
  ...(rule.command !== undefined ? { command: rule.command } : { flow: rule.flow }),
321
342
  packages: rule.packages,
322
- image: rule.image,
343
+ image: rule.image, backend: rule.backend, excludeTools: rule.excludeTools,
344
+ provider: rule.provider, model: rule.model, maxTurns: rule.maxTurns, models: rule.models, maxCostUsd: rule.maxCostUsd,
323
345
  skillsDir: rule.skillsDir,
324
346
  secrets: rule.secrets,
325
347
  secretsProfile: rule.secretsProfile,
@@ -371,7 +393,8 @@ function routeNote(subset, triggers, knownFlows) {
371
393
  enqueue: true,
372
394
  ...(command !== undefined ? { command } : { flow }),
373
395
  packages: triggers.comment.packages,
374
- image: triggers.comment.image,
396
+ image: triggers.comment.image, backend: triggers.comment.backend, excludeTools: triggers.comment.excludeTools,
397
+ provider: triggers.comment.provider, model: triggers.comment.model, maxTurns: triggers.comment.maxTurns, models: triggers.comment.models, maxCostUsd: triggers.comment.maxCostUsd,
375
398
  skillsDir: triggers.comment.skillsDir,
376
399
  secrets: triggers.comment.secrets,
377
400
  secretsProfile: triggers.comment.secretsProfile,