@edgehero/pi-dispatch-receiver 4.3.1 → 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/README.md CHANGED
@@ -1,18 +1,20 @@
1
1
  # @edgehero/pi-dispatch-receiver
2
2
 
3
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.
4
+ [pi](https://github.com/earendil-works/pi) coding agent for you in the background. Most people want the
5
+ [main README](https://github.com/edgehero/pi-dispatch#readme) first: it explains why and how to use it.
5
6
 
6
7
  The receiver turns forge activity into queued jobs for the
7
8
  [worker](https://www.npmjs.com/package/@edgehero/pi-dispatch). It reads the same `triggers.json` as the
8
9
  worker, checks each delivery, and queues at most one job per event (one per replica when a trigger sets
9
10
  `run.replicas`). It never runs a job itself.
10
11
 
11
- ## Two ways to run it
12
+ ## Three ways to run it
12
13
 
13
14
  ```bash
14
15
  npx @edgehero/pi-dispatch-receiver # serve: the webhook receiver (the default)
15
16
  npx @edgehero/pi-dispatch-receiver poll # poll: no public URL, reads GitHub with your own credential
17
+ npx @edgehero/pi-dispatch-receiver watch # watch: pulls findings from your own alert sources
16
18
  ```
17
19
 
18
20
  - **`serve`** verifies GitHub, GitLab, Forgejo and Azure DevOps webhooks (at `/`, `/gitlab`, `/forgejo`
@@ -23,6 +25,14 @@ npx @edgehero/pi-dispatch-receiver poll # poll: no public URL, reads GitHub w
23
25
  - **`poll`** fetches GitHub issue events, comments and pull requests about once a minute, over TLS, with
24
26
  the same gates and the same queue. A new poller starts from now and never replays old labels.
25
27
 
28
+ - **`watch`** pulls findings from the sources in `triggers.json`. `serve` takes them pushed to `/watch/<source>` as well. A finding starts a
29
+ read only investigation or files an issue, and cannot choose a job's permissions
30
+ ([watch](https://github.com/edgehero/pi-dispatch/blob/main/docs/watch.md)).
31
+
32
+ **Steering from a comment.** While a job of a steer armed trigger runs on an issue or pull request, a comment with your comment trigger's
33
+ phrase goes to that running job instead of starting a second one, once the comment trigger has `"steer": true` as well
34
+ ([steering](https://github.com/edgehero/pi-dispatch/blob/main/docs/steering.md#steering-from-a-comment)).
35
+
26
36
  The receiver checks each delivery's signature, the bot loop guard, the forge's own permission check and
27
37
  dedup. The worker then applies quiet hours, the image preflight, branch protection and the spend caps.
28
38
  Who may fire a trigger is decided by your forge. See
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@edgehero/pi-dispatch-receiver",
3
- "version": "4.3.1",
3
+ "version": "5.0.0",
4
4
  "type": "module",
5
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": [
@@ -48,7 +48,7 @@
48
48
  },
49
49
  "dependencies": {
50
50
  "@octokit/webhooks": "14.2.0",
51
- "@edgehero/pi-dispatch": "^4.3.1",
51
+ "@edgehero/pi-dispatch": "^5.0.0",
52
52
  "bullmq": "5.80.4",
53
53
  "ioredis": "5.11.1"
54
54
  }
@@ -15,7 +15,9 @@
15
15
  * it. `OQ-013` is amended to say so rather than leaving it implied.
16
16
  *
17
17
  * The verdict shape is every other forge's: `{ authorized }` | `{ indeterminate }`. A determinate "not a
18
- * member" refuses; anything that could not be answered is a 503 so Azure redelivers.
18
+ * member" refuses; anything that could not be answered is a 503, which Azure resends up to 8 times over about
19
+ * 3 minutes, then retries on probation up to 7 more times over about 36 hours while new events are lost
20
+ * (receiver.mjs, WHAT A 503 MEANS).
19
21
  *
20
22
  * WHY MEMBERSHIP AND NOT A PERMISSION EVALUATION. Azure's Security namespace API can answer "may this
21
23
  * subject contribute to this repository" exactly, which is closer to the property the constitution wants.
@@ -153,8 +155,9 @@ export function makeResolveAzureAuthority({ orgUrl, token, fetchFn = fetch }) {
153
155
  * `null`, which the caller refuses. This is the honest refusal: the entire directory was read.
154
156
  * - the PAGE CAP was reached while Azure was still offering more -> `{ indeterminate }`, never `null`.
155
157
  * The search was abandoned, not completed, and the module's own docblock is explicit that
156
- * indeterminate and unauthorized must not be conflated: this way the receiver answers 503 and Azure
157
- * redelivers, instead of burying an exhausted search inside a 204 that reads as a policy decision.
158
+ * indeterminate and unauthorized must not be conflated: this way the receiver answers 503, which Azure
159
+ * resends for about 3 minutes and then, on probation, for about 36 hours, instead of burying an
160
+ * exhausted search inside a 204 that reads as a policy decision.
158
161
  *
159
162
  * A FILTERED LOOKUP WOULD BE BETTER AND IS NOT USED. Nothing in the Graph surface this module already
160
163
  * speaks offers a by-mail filter on `graph/users` (`subjectTypes` selects kinds of subject, not
@@ -66,7 +66,7 @@ export const IDENTITY_ATTEMPT_TIMEOUT_MS = 10_000;
66
66
  * same-tick; on exhaustion rethrows the LAST untagged error, so the entry still exits 1 and the
67
67
  * supervisor restarts into a fresh window.
68
68
  *
69
- * `now` and `sleep` are seams (CLAUDE.md: a window takes an injected clock, always; issue #284);
69
+ * `now` and `sleep` are seams (AGENTS.md: a window takes an injected clock, always; issue #284);
70
70
  * `setTimeoutFn`/`clearTimeoutFn` are the FUSE's own pair, separately injectable so a test can prove
71
71
  * the clearing rather than trust it.
72
72
  */
package/src/cli.mjs CHANGED
@@ -19,6 +19,7 @@ import { isEntryModule } from "@edgehero/pi-dispatch/entry";
19
19
  const USAGE = `pi-dispatch-receiver — the always-on trigger edge: turns GitHub activity into queued jobs
20
20
 
21
21
  pi-dispatch-receiver serve start the webhook receiver (the default when no command is given)
22
+ pi-dispatch-receiver watch poll reviewed file, journald and Loki watch sources
22
23
  pi-dispatch-receiver poll start the polling producer — no public URL, no DNS, no tunnel: it
23
24
  reads api.github.com with the operator's own credential instead
24
25
 
@@ -38,7 +39,7 @@ serve listens, and POLL_REPOS / POLL_INTERVAL_SECONDS shape what poll watches an
38
39
  // injects a collector instead of reassigning `process.stdout.write`. That matters because `node --test`
39
40
  // runs each file in a child process that serialises its own results over that same stdout, so a test
40
41
  // 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) } = {}) {
42
+ export async function main(argv = process.argv.slice(2), env = process.env, { start, startPoll, startWatch, write = (chunk) => process.stdout.write(chunk) } = {}) {
42
43
  const cmd = argv[0];
43
44
 
44
45
  if (cmd === undefined || cmd === "serve") {
@@ -58,6 +59,13 @@ export async function main(argv = process.argv.slice(2), env = process.env, { st
58
59
  return 0;
59
60
  }
60
61
 
62
+ if (cmd === "watch") {
63
+ const startWatchPuller = startWatch ?? (await import("./watch-puller.mjs")).startWatchPuller;
64
+ const puller = await startWatchPuller(env);
65
+ await puller?.done;
66
+ return 0;
67
+ }
68
+
61
69
  write(`${USAGE}\n`);
62
70
  return cmd === "--help" || cmd === "-h" ? 0 : 1; // asked-for help is success; a typo is not
63
71
  }
package/src/config.mjs CHANGED
@@ -31,8 +31,9 @@
31
31
  */
32
32
 
33
33
  import { existsSync, readFileSync } from "node:fs";
34
+ import { groupTriggers } from "./trigger-groups.mjs";
34
35
  import { configError, loadGitHubAuth, positiveInt } from "@edgehero/pi-dispatch/config";
35
- import { FORGE_KINDS, PR_CLOSE_ACTIONS, parseTriggers } from "@edgehero/pi-dispatch/triggers";
36
+ import { FORGE_KINDS, parseTriggersDocument } from "@edgehero/pi-dispatch/triggers";
36
37
 
37
38
  // Cwd-relative, matching what `pi-dispatch init` scaffolds (and the admin's default): the receiver's
38
39
  // default must be the file init just told the operator it created, not a demo buried in the repo.
@@ -100,6 +101,19 @@ export function loadReceiverConfig(env = process.env, { readFile = readFileSync,
100
101
  };
101
102
  }
102
103
 
104
+ /**
105
+ * The forges this receiver serves, in `FORGE_KINDS` order, for its status record (`INT-RECEIVER-STATUS-KEY`, issue
106
+ * #824): GitHub by `servesGithub` (`cfg.github` is its auth block, present whether GitHub is served or not), and every
107
+ * other forge by its own block, which the loader sets to null when nothing names the forge and which `startReceiver`
108
+ * arms a route for exactly when it is set. A kind with no block at all is a forge added to the table that this loader
109
+ * does not read yet, so nothing arms a route for it, and it is unserved. Throwing instead would cost the receiver its
110
+ * whole record, every forge's counts, at boot, for a gap the operator cannot close. The developer who adds the forge
111
+ * learns it from receiver/test/receiver-status-start.test.mjs, which requires a block for every `FORGE_KINDS` member.
112
+ */
113
+ export function servedForges(cfg, kinds = FORGE_KINDS) {
114
+ return kinds.filter((kind) => kind === "github" ? cfg.servesGithub === true : Object.hasOwn(cfg, kind) && Boolean(cfg[kind]));
115
+ }
116
+
103
117
  /**
104
118
  * Does this deployment actually SERVE GitHub? Three things hang off the answer -- the WEBHOOK_SECRET
105
119
  * requirement above, whether `/` is mounted (receiver.mjs), and whether boot resolves the harness's own
@@ -166,7 +180,10 @@ function loadGitLabConfig(env) {
166
180
  }
167
181
  // The gate needs this token BEFORE any job runs: the actor's access level is what authorises a GitLab
168
182
  // trigger at all (CONST-TRIGGER-AUTHOR-GATE), and without a token every lookup is indeterminate and
169
- // every delivery 503s. Refusing at boot is the difference between one clear message and a redelivery loop.
183
+ // every delivery 503s, which GitLab never resends. After 4 in a row GitLab disables the hook for a while
184
+ // (on gitlab.com, and a group hook anywhere; a self-managed project hook only where the
185
+ // `auto_disabling_web_hooks` feature flag is on). Refusing at boot is the difference between one clear
186
+ // message and every delivery failing, and perhaps the hook switched off.
170
187
  if (typeof token !== "string" || token.trim() === "") {
171
188
  throw configError("GITLAB_TOKEN is required for gitlab triggers -- the receiver resolves the actor's project access level before it may enqueue");
172
189
  }
@@ -212,7 +229,8 @@ function loadForgejoConfig(env) {
212
229
  }
213
230
  // Needed BEFORE any job runs: the actor's repository permission is what authorises a forgejo trigger at
214
231
  // all (CONST-TRIGGER-AUTHOR-GATE), and without a token every lookup is indeterminate and every delivery
215
- // 503s. Refusing at boot is the difference between one clear message and a redelivery loop.
232
+ // 503s, which Forgejo never resends. Refusing at boot is the difference between one clear message and
233
+ // every delivery failing in a history nobody is watching.
216
234
  if (typeof token !== "string" || token.trim() === "") {
217
235
  throw configError("FORGEJO_TOKEN is required for forgejo triggers -- the receiver resolves the actor's repository permission before it may enqueue");
218
236
  }
@@ -311,61 +329,13 @@ function loadTriggers(env, readFile, fileExists) {
311
329
  throw configError(`triggers file not found: ${path} -- run \`pi-dispatch init\` in this folder to scaffold one, or set PI_TRIGGERS_FILE to yours`);
312
330
  }
313
331
 
314
- const parsed = parseTriggers(readFile(path, "utf8"), path); // fail-loud
332
+ const document = parseTriggersDocument(readFile(path, "utf8"), path);
333
+ const parsed = document.triggers;
334
+ const watchSources = document.watchSources ?? {};
335
+ for (const source of Object.values(watchSources)) if (typeof env[source.secretEnv] !== "string" || !env[source.secretEnv].trim()) throw configError("watch: a configured source secret is missing");
315
336
  void 0;
316
337
 
317
- // Every forge gets a group whether or not the file names it, so the filter can read
318
- // `cfg.triggers[kind].label` without a presence check and an unconfigured forge simply matches nothing.
319
- //
320
- // Built FROM the forge table rather than written out, because the failure of forgetting one is unusually
321
- // quiet: `groups[run.kind]` would be undefined, `group.label.push` would throw, and `reloadTriggers`
322
- // below catches everything and KEEPS the previously-loaded triggers. The operator would see one
323
- // "invalid" message and their old rules would go on firing indefinitely. A test asserts this object's
324
- // keys are exactly FORGE_KINDS, so the two can never drift.
325
- const groups = Object.fromEntries(FORGE_KINDS.map((kind) => [kind, emptyGroup()]));
326
- const knownFlows = new Set();
327
-
328
- for (const [index, { on, run }] of parsed.entries()) {
329
- if (on.type === "cron") continue; // the worker owns cron; the receiver never fires it -- but it keeps its index
330
- // Guarded because a command trigger (issue #189) has NO run.flow, and `Set.add(undefined)` would put
331
- // undefined into the comment `<phrase> <flow>` override allowlist. That set exists to bound which
332
- // names a collaborator's comment may summon; polluting it with a non-name is how a later loose
333
- // membership test (or a `[...set]` enumeration in a message) starts treating "no flow" as a flow.
334
- if (typeof run.flow === "string") {
335
- knownFlows.add(run.flow);
336
- }
337
- const group = groups[run.kind];
338
- if (on.type === "label") {
339
- 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, mcp: run.mcp, provider: run.provider, model: run.model, maxTurns: run.maxTurns, models: run.models, maxCostUsd: run.maxCostUsd, skillsDir: run.skillsDir, instructions: run.instructions, resume: run.resume, awaitInput: run.awaitInput, secrets: run.secrets, secretsProfile: run.secretsProfile, waitFor: run.waitFor, replicas: run.replicas, repository: run.repository });
340
- } else if (on.type === "comment") {
341
- group.comment = { index, phrase: on.phrase, defaultFlow: run.flow, command: run.command, packages: run.packages, image: run.image, backend: run.backend, excludeTools: run.excludeTools, mcp: run.mcp, provider: run.provider, model: run.model, maxTurns: run.maxTurns, models: run.models, maxCostUsd: run.maxCostUsd, skillsDir: run.skillsDir, instructions: run.instructions, resume: run.resume, awaitInput: run.awaitInput, secrets: run.secrets, secretsProfile: run.secretsProfile, waitFor: run.waitFor, replicas: run.replicas, repository: run.repository }; // parseTriggers guarantees at most one per forge
342
- } else if (on.type === "pull_request") {
343
- // The close-only split (issue #231): a close rule routes through the close gate (the CLOSER's
344
- // resolved authority) and every other PR rule through the author gate, so they live in separate
345
- // groups and neither route ever re-derives the distinction -- the loader already refused any
346
- // list mixing the close word with another action, which is what makes this split total. The
347
- // split reads the SHARED close-word table, never a re-typed word.
348
- const closeWord = PR_CLOSE_ACTIONS[run.kind];
349
- if (closeWord !== undefined && on.action.includes(closeWord)) {
350
- 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, mcp: run.mcp, provider: run.provider, model: run.model, maxTurns: run.maxTurns, models: run.models, maxCostUsd: run.maxCostUsd, skillsDir: run.skillsDir, instructions: run.instructions, resume: run.resume, awaitInput: run.awaitInput, secrets: run.secrets, secretsProfile: run.secretsProfile, waitFor: run.waitFor, replicas: run.replicas });
351
- } else {
352
- // `reviewStates` is null rather than an empty Set when unnarrowed: the filter tests it for
353
- // presence, and an empty Set would read as "no verdict matches" and silently refuse everything.
354
- 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, mcp: run.mcp, provider: run.provider, model: run.model, maxTurns: run.maxTurns, models: run.models, maxCostUsd: run.maxCostUsd, skillsDir: run.skillsDir, instructions: run.instructions, resume: run.resume, awaitInput: run.awaitInput, secrets: run.secrets, secretsProfile: run.secretsProfile, waitFor: run.waitFor, replicas: run.replicas });
355
- }
356
- } else if (on.type === "issue") {
357
- // The close-trigger kind (issue #231). No predicate and no reviewStates by construction (the
358
- // loader refused both), and `number`/`once` ride conditionally-present exactly as the loader
359
- // normalized them -- the filter's close route reads them, nothing else does.
360
- 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, mcp: run.mcp, provider: run.provider, model: run.model, maxTurns: run.maxTurns, models: run.models, maxCostUsd: run.maxCostUsd, skillsDir: run.skillsDir, instructions: run.instructions, resume: run.resume, awaitInput: run.awaitInput, secrets: run.secrets, secretsProfile: run.secretsProfile, waitFor: run.waitFor, replicas: run.replicas });
361
- }
362
- }
363
-
364
- return { ...groups, knownFlows };
365
- }
366
-
367
- function emptyGroup() {
368
- return { label: [], comment: null, pullRequest: [], issue: [], prClose: [] };
338
+ return groupTriggers(parsed, { watchSources });
369
339
  }
370
340
 
371
341
  /** The triggers file path the receiver reads (env override or the cwd default matching what `pi-dispatch init` scaffolds). */
@@ -25,7 +25,7 @@
25
25
  */
26
26
 
27
27
  import { createHash } from "node:crypto";
28
- import { escapeRegExp, firstMatchingRule, labelSet, matchedLabel, matchesRule } from "./predicate.mjs";
28
+ import { escapeRegExp, firstMatchingRule, heldCandidateOr, labelSet, matchedLabel, matchesRule, steerModeOf } from "./predicate.mjs";
29
29
  import { PR_COMMENT_EVENT, PR_EVENTS, WORK_ITEM_EVENTS } from "./azure-subset.mjs";
30
30
  import { hasParkedLabel } from "@edgehero/pi-dispatch/parked";
31
31
 
@@ -80,95 +80,120 @@ export function filterAzure(subset, triggers, knownFlows, selfId, authorized, de
80
80
  return { enqueue: false, reason: "unhandled-event" };
81
81
  }
82
82
 
83
- if (!resolved.enqueue) return resolved; // carries the drop reason
83
+ // A drop is returned as it is, except a phrase-free reply that failed ONLY for the missing parked marker (issue #638,
84
+ // DES-AWAIT-INPUT-ENDS-THE-JOB): that one also carries `heldCandidate`, the job the parked-reply route would have
85
+ // built, through the SAME `build` below, so the receiver can hold it for a run that is parking right now and the job
86
+ // that run starts is byte for byte the one the marker would have produced. Still a drop: nothing here enqueues it.
87
+ if (!resolved.enqueue) return heldCandidateOr(resolved, build);
88
+ return build(resolved);
84
89
 
85
- // WHICH REPOSITORY the job clones. A pull-request event names its own and that is authoritative. A WORK
86
- // ITEM names none -- Azure work items belong to a project, and a project may hold many repositories --
87
- // so the matched rule's `run.repository` supplies it. The loader requires that field on exactly the
88
- // azure trigger types a work item can fire, so reaching here without one is not possible; the guard is
89
- // here anyway because "not possible" and "cannot happen" are different claims and only one is testable.
90
- const scope = resolveScope(subset, resolved);
91
- if (scope === null) {
92
- return { enqueue: false, reason: "no-repository" };
93
- }
94
- resolved.repo = scope.repo;
95
- resolved.azure = scope.azure;
90
+ // The job from a resolved route. One function for every route and for the held candidate (issue #638), declared
91
+ // here so it closes over this delivery's subset and ids.
92
+ function build(resolved) {
93
+ // WHICH REPOSITORY the job clones. A pull-request event names its own and that is authoritative. A WORK
94
+ // ITEM names none -- Azure work items belong to a project, and a project may hold many repositories --
95
+ // so the matched rule's `run.repository` supplies it. The loader requires that field on exactly the
96
+ // azure trigger types a work item can fire, so reaching here without one is not possible; the guard is
97
+ // here anyway because "not possible" and "cannot happen" are different claims and only one is testable.
98
+ const scope = resolveScope(subset, resolved);
99
+ if (scope === null) {
100
+ return { enqueue: false, reason: "no-repository" };
101
+ }
102
+ resolved.repo = scope.repo;
103
+ resolved.azure = scope.azure;
96
104
 
97
- const job = {
98
- // `<project>/<repository>`, WITHOUT the organization -- exactly as a gitlab job's `repo` omits the
99
- // instance. A deployment has one AZURE_ORG_URL, so the org is constant across every azure job here
100
- // and repeating it in a pause-window scope and a run-record label would be noise, not information.
101
- repo: resolved.repo,
102
- // The structured triple the API paths need, alongside the human-readable label -- the same split
103
- // gitlab makes when it carries `projectId` next to `repo`.
104
- azure: resolved.azure,
105
- target: resolved.target,
106
- // EXACTLY ONE of flow/command, decided by the matched rule (issue #189; the shared parser enforces
107
- // the exclusivity at load). A command rule dispatches a registered pi extension command instead of
108
- // resolving a flow, and its job must carry NO flow key at all. The spread keeps a flow rule's
109
- // literal byte-identical to what it always was -- the same move filter.mjs makes.
110
- ...(resolved.command !== undefined ? { command: resolved.command } : { flow: resolved.flow }),
111
- ...(resolved.packages !== undefined ? { packages: resolved.packages } : {}),
112
- ...(resolved.image !== undefined ? { image: resolved.image } : {}),
113
- // #227. A SEPARATE spread, and this is the whole of the bug it replaces: folded into the `image`
114
- // conditional above, a trigger that named a venue but no image had its venue silently dropped here --
115
- // it loaded, validated, reached this function on the rule, and then ran on the default. That is
116
- // exactly the destructive absence `validateBackend`'s near-miss sweep refuses a misspelling for,
117
- // arriving through the plumbing instead of the spelling, one file downstream of the guard.
118
- ...(resolved.backend !== undefined ? { backend: resolved.backend } : {}),
119
- // #291. A separate spread, filter.mjs's backend rule: a dropped exclusion runs the job WITH the tool.
120
- ...(resolved.excludeTools !== undefined ? { excludeTools: resolved.excludeTools } : {}),
121
- // REQ-MCP-OPERATOR-SERVERS. Its own spread, the excludeTools rule above: a dropped list runs
122
- // the job without its servers.
123
- ...(resolved.mcp !== undefined ? { mcp: resolved.mcp } : {}),
124
- // #502. One spread PER FIELD, the excludeTools rule above: folded into one conditional, a trigger
125
- // that named only a model would have its provider's absence decide whether the model survives,
126
- // and a dropped model runs the job on the deployment default while the file reads as chosen.
127
- ...(resolved.provider !== undefined ? { provider: resolved.provider } : {}),
128
- ...(resolved.model !== undefined ? { model: resolved.model } : {}),
129
- ...(resolved.maxTurns !== undefined ? { maxTurns: resolved.maxTurns } : {}),
130
- ...(resolved.models !== undefined ? { models: resolved.models } : {}),
131
- // #501. Its own spread, the rule above: a dropped cap runs the job under the deployment's cap, or none,
132
- // while the file reads as narrowed.
133
- ...(resolved.maxCostUsd !== undefined ? { maxCostUsd: resolved.maxCostUsd } : {}),
134
- // The trigger's injected skills dir (REQ-PER-TRIGGER-SKILLS), at JOB level beside image/packages and
135
- // NEVER inside `trigger`. That placement is sharpest here of all: `trigger` is carried into
136
- // /job/event.json, and a worker-host path in an agent-readable file is the leak prepare-local's
137
- // basename(folder) restraint already exists to prevent.
138
- ...(resolved.skillsDir !== undefined ? { skillsDir: resolved.skillsDir } : {}),
139
- // REQ-TRIGGER-SECRETS. Conditional exactly like skillsDir above, so a trigger that binds no
140
- // secret enqueues the job data it always has. The map holds REFERENCES, never values: the
141
- // receiver has no resolver and reaches no vault -- the worker resolves them pre-spend.
142
- ...(resolved.secrets !== undefined ? { secrets: resolved.secrets } : {}),
143
- ...(resolved.secretsProfile !== undefined ? { secretsProfile: resolved.secretsProfile } : {}),
144
- ...(resolved.waitFor !== undefined ? { waitFor: resolved.waitFor } : {}),
145
- ...(resolved.instructions !== undefined ? { instructions: resolved.instructions } : {}),
146
- ...(resolved.resume !== undefined ? { resume: resolved.resume } : {}),
147
- // REQ-AWAIT-INPUT (issue #625): whether the job may end awaiting a person's answer. Present only when armed,
148
- // resume's rule, so an unflagged job's data stays byte-identical.
149
- ...(resolved.awaitInput === true ? { awaitInput: true } : {}),
150
- // A phrase-free reply to a parked issue (issue #625): the worker gates it on the `parked` sidecar before any spend.
151
- ...(resolved.parkedReply === true ? { parkedReply: true } : {}),
152
- // How many independent sandboxes race this flow (REQ-REPLICA-RUNS). At JOB level and conditional for the
153
- // reasons filter.mjs states in full: receiver.mjs reads this to decide how many times to enqueue, and an
154
- // unflagged job's data must stay byte-identical to today's.
155
- ...(resolved.replicas !== undefined ? { replicas: resolved.replicas } : {}),
156
- trigger: {
157
- event,
158
- action: resolved.action,
159
- deliveryId,
160
- // A GUID when the delivery carried one, else a HASH of the address -- never the address itself.
161
- // A work-item payload offers nothing but `"Display Name <email>"`, and this object is copied
162
- // verbatim into /job/event.json and summarised into the durable run record, both of which are
163
- // PII-free BY CONSTRUCTION. Hashing keeps "the same actor twice" answerable without putting
164
- // somebody's email in a file the agent reads and an operator greps -- the same move
165
- // `session-key.mjs` makes for branch names.
166
- sender: { id: hasId ? actor.id : stableActorId(actor.email) },
167
- matched: resolved.matched,
168
- ...(resolved.comment ? { comment: resolved.comment } : {}),
169
- },
170
- };
171
- return { enqueue: true, job };
105
+ const job = {
106
+ // `<project>/<repository>`, WITHOUT the organization -- exactly as a gitlab job's `repo` omits the
107
+ // instance. A deployment has one AZURE_ORG_URL, so the org is constant across every azure job here
108
+ // and repeating it in a pause-window scope and a run-record label would be noise, not information.
109
+ repo: resolved.repo,
110
+ // The structured triple the API paths need, alongside the human-readable label -- the same split
111
+ // gitlab makes when it carries `projectId` next to `repo`.
112
+ azure: resolved.azure,
113
+ target: resolved.target,
114
+ // EXACTLY ONE of flow/command, decided by the matched rule (issue #189; the shared parser enforces
115
+ // the exclusivity at load). A command rule dispatches a registered pi extension command instead of
116
+ // resolving a flow, and its job must carry NO flow key at all. The spread keeps a flow rule's
117
+ // literal byte-identical to what it always was -- the same move filter.mjs makes.
118
+ ...(resolved.command !== undefined ? { command: resolved.command } : { flow: resolved.flow }),
119
+ ...(resolved.packages !== undefined ? { packages: resolved.packages } : {}),
120
+ ...(resolved.image !== undefined ? { image: resolved.image } : {}),
121
+ // #227. A SEPARATE spread, and this is the whole of the bug it replaces: folded into the `image`
122
+ // conditional above, a trigger that named a venue but no image had its venue silently dropped here --
123
+ // it loaded, validated, reached this function on the rule, and then ran on the default. That is
124
+ // exactly the destructive absence `validateBackend`'s near-miss sweep refuses a misspelling for,
125
+ // arriving through the plumbing instead of the spelling, one file downstream of the guard.
126
+ ...(resolved.backend !== undefined ? { backend: resolved.backend } : {}),
127
+ // #291. A separate spread, filter.mjs's backend rule: a dropped exclusion runs the job WITH the tool.
128
+ ...(resolved.excludeTools !== undefined ? { excludeTools: resolved.excludeTools } : {}),
129
+ // Issue #800, each its own spread by the same rule: a dropped list runs the job with what the file takes away.
130
+ ...(resolved.excludeExtensions !== undefined ? { excludeExtensions: resolved.excludeExtensions } : {}),
131
+ ...(resolved.excludeExtensionTools !== undefined ? { excludeExtensionTools: resolved.excludeExtensionTools } : {}),
132
+ // REQ-MCP-OPERATOR-SERVERS. Its own spread, the excludeTools rule above: a dropped list runs
133
+ // the job without its servers.
134
+ ...(resolved.mcp !== undefined ? { mcp: resolved.mcp } : {}),
135
+ // #502. One spread PER FIELD, the excludeTools rule above: folded into one conditional, a trigger
136
+ // that named only a model would have its provider's absence decide whether the model survives,
137
+ // and a dropped model runs the job on the deployment default while the file reads as chosen.
138
+ ...(resolved.provider !== undefined ? { provider: resolved.provider } : {}),
139
+ ...(resolved.model !== undefined ? { model: resolved.model } : {}),
140
+ ...(resolved.maxTurns !== undefined ? { maxTurns: resolved.maxTurns } : {}),
141
+ ...(resolved.models !== undefined ? { models: resolved.models } : {}), ...(resolved.fallbackModels !== undefined ? { fallbackModels: resolved.fallbackModels } : {}),
142
+ // #501. Its own spread, the rule above: a dropped cap runs the job under the deployment's cap, or none,
143
+ // while the file reads as narrowed.
144
+ ...(resolved.maxCostUsd !== undefined ? { maxCostUsd: resolved.maxCostUsd } : {}),
145
+ // The trigger's injected skills dir (REQ-PER-TRIGGER-SKILLS), at JOB level beside image/packages and
146
+ // NEVER inside `trigger`. That placement is sharpest here of all: `trigger` is carried into
147
+ // /job/event.json, and a worker-host path in an agent-readable file is the leak prepare-local's
148
+ // basename(folder) restraint already exists to prevent.
149
+ ...(resolved.skillsDir !== undefined ? { skillsDir: resolved.skillsDir } : {}),
150
+ // REQ-TRIGGER-SECRETS. Conditional exactly like skillsDir above, so a trigger that binds no
151
+ // secret enqueues the job data it always has. The map holds REFERENCES, never values: the
152
+ // receiver has no resolver and reaches no vault -- the worker resolves them pre-spend.
153
+ ...(resolved.secrets !== undefined ? { secrets: resolved.secrets } : {}),
154
+ ...(resolved.secretsProfile !== undefined ? { secretsProfile: resolved.secretsProfile } : {}),
155
+ ...(resolved.waitFor !== undefined ? { waitFor: resolved.waitFor } : {}),
156
+ ...(resolved.instructions !== undefined ? { instructions: resolved.instructions } : {}),
157
+ ...(resolved.resume !== undefined ? { resume: resolved.resume } : {}),
158
+ // REQ-AWAIT-INPUT (issue #625): whether the job may end awaiting a person's answer. Present only when armed,
159
+ // resume's rule, so an unflagged job's data stays byte-identical.
160
+ ...(resolved.awaitInput === true ? { awaitInput: true } : {}),
161
+ ...(resolved.gates !== undefined ? { gates: resolved.gates, ...(resolved.gateTimeout !== undefined ? { gateTimeout: resolved.gateTimeout } : {}), ...(resolved.gateBudgetRaiseUsd !== undefined ? { gateBudgetRaiseUsd: resolved.gateBudgetRaiseUsd } : {}) } : {}),
162
+ // Issue #644: the progress tool and the forge status comment, each present only when armed, awaitInput's rule.
163
+ ...(resolved.progress === true ? { progress: true } : {}),
164
+ ...(resolved.statusComment === true ? { statusComment: true } : {}),
165
+ // Issue #645: durable runs, present only when armed, the same rule.
166
+ ...(resolved.recover === true ? { recover: true } : {}),
167
+ // Issue #646: steering a running job, present only when armed, the same rule.
168
+ ...(resolved.steer === true ? { steer: true } : {}),
169
+ // Issue #646: how this phrase comment steers a job of its trigger that is already running, set only on the comment
170
+ // route of a steer-armed trigger (never a parked reply, never a command). The receiver reads it to try the run's
171
+ // inbox before it enqueues, and `enqueueForgeJob` names its fields, so it never reaches a queue. At JOB level, never
172
+ // inside `trigger`, which is copied verbatim into /job/event.json.
173
+ ...(resolved.steerMode !== undefined ? { steerMode: resolved.steerMode } : {}),
174
+ // A phrase-free reply to a parked issue (issue #625): the worker gates it on the `parked` sidecar before any spend.
175
+ ...(resolved.parkedReply === true ? { parkedReply: true } : {}),
176
+ // How many independent sandboxes race this flow (REQ-REPLICA-RUNS). At JOB level and conditional for the
177
+ // reasons filter.mjs states in full: receiver.mjs reads this to decide how many times to enqueue, and an
178
+ // unflagged job's data must stay byte-identical to today's.
179
+ ...(resolved.replicas !== undefined ? { replicas: resolved.replicas } : {}),
180
+ trigger: {
181
+ event,
182
+ action: resolved.action,
183
+ deliveryId,
184
+ // A GUID when the delivery carried one, else a HASH of the address -- never the address itself.
185
+ // A work-item payload offers nothing but `"Display Name <email>"`, and this object is copied
186
+ // verbatim into /job/event.json and summarised into the durable run record, both of which are
187
+ // PII-free BY CONSTRUCTION. Hashing keeps "the same actor twice" answerable without putting
188
+ // somebody's email in a file the agent reads and an operator greps -- the same move
189
+ // `session-key.mjs` makes for branch names.
190
+ sender: { id: hasId ? actor.id : stableActorId(actor.email) },
191
+ matched: resolved.matched,
192
+ ...(resolved.comment ? { comment: resolved.comment } : {}),
193
+ },
194
+ };
195
+ return { enqueue: true, job };
196
+ }
172
197
  }
173
198
 
174
199
  /**
@@ -212,15 +237,19 @@ function matchLabelRules(subset, triggers, labels, action) {
212
237
  ...(rule.command !== undefined ? { command: rule.command } : { flow: rule.flow }),
213
238
  repository: rule.repository,
214
239
  packages: rule.packages,
215
- image: rule.image, backend: rule.backend, excludeTools: rule.excludeTools, mcp: rule.mcp,
216
- provider: rule.provider, model: rule.model, maxTurns: rule.maxTurns, models: rule.models, maxCostUsd: rule.maxCostUsd,
240
+ image: rule.image, backend: rule.backend, excludeTools: rule.excludeTools, excludeExtensions: rule.excludeExtensions, excludeExtensionTools: rule.excludeExtensionTools, mcp: rule.mcp,
241
+ provider: rule.provider, model: rule.model, maxTurns: rule.maxTurns, models: rule.models, fallbackModels: rule.fallbackModels, maxCostUsd: rule.maxCostUsd,
217
242
  skillsDir: rule.skillsDir,
218
243
  secrets: rule.secrets,
219
244
  secretsProfile: rule.secretsProfile,
220
245
  waitFor: rule.waitFor,
221
246
  instructions: rule.instructions,
222
247
  resume: rule.resume,
223
- awaitInput: rule.awaitInput,
248
+ awaitInput: rule.awaitInput, gates: rule.gates, gateTimeout: rule.gateTimeout, gateBudgetRaiseUsd: rule.gateBudgetRaiseUsd,
249
+ progress: rule.progress,
250
+ statusComment: rule.statusComment,
251
+ recover: rule.recover,
252
+ steer: rule.steer,
224
253
  replicas: rule.replicas,
225
254
  matched: { index: rule.index, type: "label", label: matchedLabel(L, rule.predicate) },
226
255
  target: { type: "issue", number: subset.target?.number, title: subset.target?.title, body: subset.target?.body },
@@ -228,7 +257,7 @@ function matchLabelRules(subset, triggers, labels, action) {
228
257
  }
229
258
 
230
259
  /** Comment path, on either a work item or a pull request. The authority gate above has already run. */
231
- function routeComment(subset, triggers, knownFlows, targetType) {
260
+ function routeComment(subset, triggers, knownFlows, targetType, assumeParked = false) {
232
261
  const phrase = triggers.comment?.phrase;
233
262
  const body = subset.comment;
234
263
  const phraseHit = typeof phrase === "string" && typeof body === "string" && body.includes(phrase);
@@ -237,8 +266,17 @@ function routeComment(subset, triggers, knownFlows, targetType) {
237
266
  // trigger arms run.resume and is not a command, and only when the forge says the issue carries the marker. The
238
267
  // author gate above already ran: this widens WHICH comments start a job, never WHO may. The marker is a hint; the
239
268
  // worker refuses the job before any spend unless the key's `parked` sidecar exists.
240
- const parkedReply = !phraseHit && typeof phrase === "string" && typeof body === "string" && triggers.comment?.resume === true && triggers.comment?.command === undefined && targetType === "issue" && hasParkedLabel(subset.tags);
269
+ const parkedCandidate = !phraseHit && typeof phrase === "string" && typeof body === "string" && triggers.comment?.resume === true && triggers.comment?.command === undefined && targetType === "issue";
270
+ const parkedReply = parkedCandidate && (assumeParked || hasParkedLabel(subset.tags));
241
271
  if (!phraseHit && !parkedReply) {
272
+ // Issue #638: a reply that fails ONLY for the missing marker (the run asked its question and is still parking, so
273
+ // the label is not on yet) is still a drop, but it carries the route the marker would have taken, built by this
274
+ // same function with the marker assumed (so every other rule above still decided it). The receiver holds it for a
275
+ // run that is parking on this target, or drops it exactly as today.
276
+ if (parkedCandidate) {
277
+ const held = routeComment(subset, triggers, knownFlows, targetType, true);
278
+ if (held.enqueue) return { enqueue: false, reason: "no-trigger-phrase", heldResolved: held };
279
+ }
242
280
  return { enqueue: false, reason: "no-trigger-phrase" };
243
281
  }
244
282
  // On a COMMAND rule (issue #189) the flow-resolution block below -- default flow, the `<phrase> <flow>`
@@ -265,18 +303,27 @@ function routeComment(subset, triggers, knownFlows, targetType) {
265
303
  ...(command !== undefined ? { command } : { flow }),
266
304
  repository: triggers.comment.repository,
267
305
  packages: triggers.comment.packages,
268
- image: triggers.comment.image, backend: triggers.comment.backend, excludeTools: triggers.comment.excludeTools, mcp: triggers.comment.mcp,
269
- provider: triggers.comment.provider, model: triggers.comment.model, maxTurns: triggers.comment.maxTurns, models: triggers.comment.models, maxCostUsd: triggers.comment.maxCostUsd,
306
+ image: triggers.comment.image, backend: triggers.comment.backend, excludeTools: triggers.comment.excludeTools, excludeExtensions: triggers.comment.excludeExtensions, excludeExtensionTools: triggers.comment.excludeExtensionTools, mcp: triggers.comment.mcp,
307
+ provider: triggers.comment.provider, model: triggers.comment.model, maxTurns: triggers.comment.maxTurns, models: triggers.comment.models, fallbackModels: triggers.comment.fallbackModels, maxCostUsd: triggers.comment.maxCostUsd,
270
308
  skillsDir: triggers.comment.skillsDir,
271
309
  secrets: triggers.comment.secrets,
272
310
  secretsProfile: triggers.comment.secretsProfile,
273
311
  waitFor: triggers.comment.waitFor,
274
312
  instructions: triggers.comment.instructions,
275
313
  resume: triggers.comment.resume,
276
- awaitInput: triggers.comment.awaitInput,
314
+ awaitInput: triggers.comment.awaitInput, gates: triggers.comment.gates, gateTimeout: triggers.comment.gateTimeout, gateBudgetRaiseUsd: triggers.comment.gateBudgetRaiseUsd,
315
+ progress: triggers.comment.progress,
316
+ statusComment: triggers.comment.statusComment,
317
+ recover: triggers.comment.recover,
318
+ steer: triggers.comment.steer,
277
319
  replicas: triggers.comment.replicas,
278
320
  matched: { index: triggers.comment.index, type: "comment", phrase },
279
321
  ...(parkedReply && { parkedReply: true }),
322
+ // Issue #646: a phrase comment on a steer-armed trigger may steer a run of it that is already going. Only with the
323
+ // phrase (a parked reply is an answer to a parked run, never a steer) and never on a command rule (the loader refuses
324
+ // `steer` beside `command`; checked again here so a drifted file cannot reach a run). The author gate above already
325
+ // ran: this changes where an admitted comment goes, never who may send one. The `later` grammar is `steerModeOf`'s.
326
+ ...(phraseHit && command === undefined && triggers.comment.steer === true && { steerMode: steerModeOf(body, phrase) }),
280
327
  // No author_association: Azure has none, and the authority that admitted this comment was resolved
281
328
  // from the graph, not read off the body.
282
329
  comment: { body },
@@ -301,15 +348,19 @@ function routePullRequest(subset, triggers, action) {
301
348
  ...(rule.command !== undefined ? { command: rule.command } : { flow: rule.flow }),
302
349
  repository: rule.repository,
303
350
  packages: rule.packages,
304
- image: rule.image, backend: rule.backend, excludeTools: rule.excludeTools, mcp: rule.mcp,
305
- provider: rule.provider, model: rule.model, maxTurns: rule.maxTurns, models: rule.models, maxCostUsd: rule.maxCostUsd,
351
+ image: rule.image, backend: rule.backend, excludeTools: rule.excludeTools, excludeExtensions: rule.excludeExtensions, excludeExtensionTools: rule.excludeExtensionTools, mcp: rule.mcp,
352
+ provider: rule.provider, model: rule.model, maxTurns: rule.maxTurns, models: rule.models, fallbackModels: rule.fallbackModels, maxCostUsd: rule.maxCostUsd,
306
353
  skillsDir: rule.skillsDir,
307
354
  secrets: rule.secrets,
308
355
  secretsProfile: rule.secretsProfile,
309
356
  waitFor: rule.waitFor,
310
357
  instructions: rule.instructions,
311
358
  resume: rule.resume,
312
- awaitInput: rule.awaitInput,
359
+ awaitInput: rule.awaitInput, gates: rule.gates, gateTimeout: rule.gateTimeout, gateBudgetRaiseUsd: rule.gateBudgetRaiseUsd,
360
+ progress: rule.progress,
361
+ statusComment: rule.statusComment,
362
+ recover: rule.recover,
363
+ steer: rule.steer,
313
364
  replicas: rule.replicas,
314
365
  matched: { index: rule.index, type: "pull_request", action },
315
366
  target: {