@edgehero/pi-dispatch-receiver 4.1.0 → 4.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@edgehero/pi-dispatch-receiver",
3
- "version": "4.1.0",
3
+ "version": "4.3.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.1.0",
51
+ "@edgehero/pi-dispatch": "^4.3.0",
52
52
  "bullmq": "5.80.4",
53
53
  "ioredis": "5.11.1"
54
54
  }
package/src/config.mjs CHANGED
@@ -318,9 +318,9 @@ function loadTriggers(env, readFile, fileExists) {
318
318
  }
319
319
  const group = groups[run.kind];
320
320
  if (on.type === "label") {
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 });
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, 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 });
322
322
  } else if (on.type === "comment") {
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
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, 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
324
324
  } else if (on.type === "pull_request") {
325
325
  // The close-only split (issue #231): a close rule routes through the close gate (the CLOSER's
326
326
  // resolved authority) and every other PR rule through the author gate, so they live in separate
@@ -329,17 +329,17 @@ function loadTriggers(env, readFile, fileExists) {
329
329
  // split reads the SHARED close-word table, never a re-typed word.
330
330
  const closeWord = PR_CLOSE_ACTIONS[run.kind];
331
331
  if (closeWord !== undefined && on.action.includes(closeWord)) {
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 });
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, 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 });
333
333
  } else {
334
334
  // `reviewStates` is null rather than an empty Set when unnarrowed: the filter tests it for
335
335
  // presence, and an empty Set would read as "no verdict matches" and silently refuse everything.
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 });
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, 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 });
337
337
  }
338
338
  } else if (on.type === "issue") {
339
339
  // The close-trigger kind (issue #231). No predicate and no reviewStates by construction (the
340
340
  // loader refused both), and `number`/`once` ride conditionally-present exactly as the loader
341
341
  // normalized them -- the filter's close route reads them, nothing else does.
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 });
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, 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 });
343
343
  }
344
344
  }
345
345
 
@@ -27,6 +27,7 @@
27
27
  import { createHash } from "node:crypto";
28
28
  import { escapeRegExp, firstMatchingRule, labelSet, matchedLabel, matchesRule } from "./predicate.mjs";
29
29
  import { PR_COMMENT_EVENT, PR_EVENTS, WORK_ITEM_EVENTS } from "./azure-subset.mjs";
30
+ import { hasParkedLabel } from "@edgehero/pi-dispatch/parked";
30
31
 
31
32
  /** The `pull_request` actions a trigger may name, mirrored from the loader's azure vocabulary. */
32
33
  export const PR_ACTION_FOR = {
@@ -117,6 +118,9 @@ export function filterAzure(subset, triggers, knownFlows, selfId, authorized, de
117
118
  ...(resolved.backend !== undefined ? { backend: resolved.backend } : {}),
118
119
  // #291. A separate spread, filter.mjs's backend rule: a dropped exclusion runs the job WITH the tool.
119
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 } : {}),
120
124
  // #502. One spread PER FIELD, the excludeTools rule above: folded into one conditional, a trigger
121
125
  // that named only a model would have its provider's absence decide whether the model survives,
122
126
  // and a dropped model runs the job on the deployment default while the file reads as chosen.
@@ -140,6 +144,11 @@ export function filterAzure(subset, triggers, knownFlows, selfId, authorized, de
140
144
  ...(resolved.waitFor !== undefined ? { waitFor: resolved.waitFor } : {}),
141
145
  ...(resolved.instructions !== undefined ? { instructions: resolved.instructions } : {}),
142
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 } : {}),
143
152
  // How many independent sandboxes race this flow (REQ-REPLICA-RUNS). At JOB level and conditional for the
144
153
  // reasons filter.mjs states in full: receiver.mjs reads this to decide how many times to enqueue, and an
145
154
  // unflagged job's data must stay byte-identical to today's.
@@ -203,7 +212,7 @@ function matchLabelRules(subset, triggers, labels, action) {
203
212
  ...(rule.command !== undefined ? { command: rule.command } : { flow: rule.flow }),
204
213
  repository: rule.repository,
205
214
  packages: rule.packages,
206
- image: rule.image, backend: rule.backend, excludeTools: rule.excludeTools,
215
+ image: rule.image, backend: rule.backend, excludeTools: rule.excludeTools, mcp: rule.mcp,
207
216
  provider: rule.provider, model: rule.model, maxTurns: rule.maxTurns, models: rule.models, maxCostUsd: rule.maxCostUsd,
208
217
  skillsDir: rule.skillsDir,
209
218
  secrets: rule.secrets,
@@ -211,6 +220,7 @@ function matchLabelRules(subset, triggers, labels, action) {
211
220
  waitFor: rule.waitFor,
212
221
  instructions: rule.instructions,
213
222
  resume: rule.resume,
223
+ awaitInput: rule.awaitInput,
214
224
  replicas: rule.replicas,
215
225
  matched: { index: rule.index, type: "label", label: matchedLabel(L, rule.predicate) },
216
226
  target: { type: "issue", number: subset.target?.number, title: subset.target?.title, body: subset.target?.body },
@@ -221,7 +231,14 @@ function matchLabelRules(subset, triggers, labels, action) {
221
231
  function routeComment(subset, triggers, knownFlows, targetType) {
222
232
  const phrase = triggers.comment?.phrase;
223
233
  const body = subset.comment;
224
- if (typeof phrase !== "string" || typeof body !== "string" || !body.includes(phrase)) {
234
+ const phraseHit = typeof phrase === "string" && typeof body === "string" && body.includes(phrase);
235
+ // A reply WITHOUT the phrase resumes a parked issue (issue #625, parked.mjs): only on an issue (a pull request's key
236
+ // needs its head branch, which costs a token to read, so its reply keeps the phrase), only when the comment
237
+ // trigger arms run.resume and is not a command, and only when the forge says the issue carries the marker. The
238
+ // author gate above already ran: this widens WHICH comments start a job, never WHO may. The marker is a hint; the
239
+ // 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);
241
+ if (!phraseHit && !parkedReply) {
225
242
  return { enqueue: false, reason: "no-trigger-phrase" };
226
243
  }
227
244
  // On a COMMAND rule (issue #189) the flow-resolution block below -- default flow, the `<phrase> <flow>`
@@ -248,7 +265,7 @@ function routeComment(subset, triggers, knownFlows, targetType) {
248
265
  ...(command !== undefined ? { command } : { flow }),
249
266
  repository: triggers.comment.repository,
250
267
  packages: triggers.comment.packages,
251
- image: triggers.comment.image, backend: triggers.comment.backend, excludeTools: triggers.comment.excludeTools,
268
+ image: triggers.comment.image, backend: triggers.comment.backend, excludeTools: triggers.comment.excludeTools, mcp: triggers.comment.mcp,
252
269
  provider: triggers.comment.provider, model: triggers.comment.model, maxTurns: triggers.comment.maxTurns, models: triggers.comment.models, maxCostUsd: triggers.comment.maxCostUsd,
253
270
  skillsDir: triggers.comment.skillsDir,
254
271
  secrets: triggers.comment.secrets,
@@ -256,8 +273,10 @@ function routeComment(subset, triggers, knownFlows, targetType) {
256
273
  waitFor: triggers.comment.waitFor,
257
274
  instructions: triggers.comment.instructions,
258
275
  resume: triggers.comment.resume,
276
+ awaitInput: triggers.comment.awaitInput,
259
277
  replicas: triggers.comment.replicas,
260
278
  matched: { index: triggers.comment.index, type: "comment", phrase },
279
+ ...(parkedReply && { parkedReply: true }),
261
280
  // No author_association: Azure has none, and the authority that admitted this comment was resolved
262
281
  // from the graph, not read off the body.
263
282
  comment: { body },
@@ -282,7 +301,7 @@ function routePullRequest(subset, triggers, action) {
282
301
  ...(rule.command !== undefined ? { command: rule.command } : { flow: rule.flow }),
283
302
  repository: rule.repository,
284
303
  packages: rule.packages,
285
- image: rule.image, backend: rule.backend, excludeTools: rule.excludeTools,
304
+ image: rule.image, backend: rule.backend, excludeTools: rule.excludeTools, mcp: rule.mcp,
286
305
  provider: rule.provider, model: rule.model, maxTurns: rule.maxTurns, models: rule.models, maxCostUsd: rule.maxCostUsd,
287
306
  skillsDir: rule.skillsDir,
288
307
  secrets: rule.secrets,
@@ -290,6 +309,7 @@ function routePullRequest(subset, triggers, action) {
290
309
  waitFor: rule.waitFor,
291
310
  instructions: rule.instructions,
292
311
  resume: rule.resume,
312
+ awaitInput: rule.awaitInput,
293
313
  replicas: rule.replicas,
294
314
  matched: { index: rule.index, type: "pull_request", action },
295
315
  target: {
@@ -36,6 +36,7 @@
36
36
  import { escapeRegExp, firstMatchingRule, labelSet, matchedLabel, matchesRule } from "./predicate.mjs";
37
37
  import { findCloseRule } from "./close.mjs";
38
38
  import { isRecognizedAction, mapAction } from "./forgejo-subset.mjs";
39
+ import { hasParkedLabel } from "@edgehero/pi-dispatch/parked";
39
40
 
40
41
  const LABEL_ACTIONS = new Set(["opened", "labeled", "reopened"]);
41
42
  export const PR_ACTIONS = new Set(["labeled", "opened", "synchronize", "reopened"]);
@@ -131,6 +132,9 @@ export function filterForgejo(eventName, subset, triggers, knownFlows, selfId, a
131
132
  ...(resolved.backend !== undefined ? { backend: resolved.backend } : {}),
132
133
  // #291. A separate spread, filter.mjs's backend rule: a dropped exclusion runs the job WITH the tool.
133
134
  ...(resolved.excludeTools !== undefined ? { excludeTools: resolved.excludeTools } : {}),
135
+ // REQ-MCP-OPERATOR-SERVERS. Its own spread, the excludeTools rule above: a dropped list runs
136
+ // the job without its servers.
137
+ ...(resolved.mcp !== undefined ? { mcp: resolved.mcp } : {}),
134
138
  // #502. One spread PER FIELD, the excludeTools rule above: folded into one conditional, a trigger
135
139
  // that named only a model would have its provider's absence decide whether the model survives,
136
140
  // and a dropped model runs the job on the deployment default while the file reads as chosen.
@@ -154,6 +158,11 @@ export function filterForgejo(eventName, subset, triggers, knownFlows, selfId, a
154
158
  ...(resolved.waitFor !== undefined ? { waitFor: resolved.waitFor } : {}),
155
159
  ...(resolved.instructions !== undefined ? { instructions: resolved.instructions } : {}),
156
160
  ...(resolved.resume !== undefined ? { resume: resolved.resume } : {}),
161
+ // REQ-AWAIT-INPUT (issue #625): whether the job may end awaiting a person's answer. Present only when armed,
162
+ // resume's rule, so an unflagged job's data stays byte-identical.
163
+ ...(resolved.awaitInput === true ? { awaitInput: true } : {}),
164
+ // A phrase-free reply to a parked issue (issue #625): the worker gates it on the `parked` sidecar before any spend.
165
+ ...(resolved.parkedReply === true ? { parkedReply: true } : {}),
157
166
  // How many independent sandboxes race this flow (REQ-REPLICA-RUNS). At JOB level and conditional for the
158
167
  // reasons filter.mjs states in full: receiver.mjs reads this to decide how many times to enqueue, and an
159
168
  // unflagged job's data must stay byte-identical to today's.
@@ -183,7 +192,7 @@ function routeIssueLabel(subset, triggers) {
183
192
  // A command rule (issue #189) skips flow resolution entirely: the label match IS the dispatch.
184
193
  ...(rule.command !== undefined ? { command: rule.command } : { flow: rule.flow }),
185
194
  packages: rule.packages, // the MATCHED rule's fields -- rules in one file may differ on them
186
- image: rule.image, backend: rule.backend, excludeTools: rule.excludeTools,
195
+ image: rule.image, backend: rule.backend, excludeTools: rule.excludeTools, mcp: rule.mcp,
187
196
  provider: rule.provider, model: rule.model, maxTurns: rule.maxTurns, models: rule.models, maxCostUsd: rule.maxCostUsd,
188
197
  skillsDir: rule.skillsDir,
189
198
  secrets: rule.secrets,
@@ -191,6 +200,7 @@ function routeIssueLabel(subset, triggers) {
191
200
  waitFor: rule.waitFor,
192
201
  instructions: rule.instructions,
193
202
  resume: rule.resume,
203
+ awaitInput: rule.awaitInput,
194
204
  replicas: rule.replicas,
195
205
  matched: { index: rule.index, type: "label", label: matchedLabel(L, rule.predicate) },
196
206
  target: { type: "issue", number: subset.issue?.number, title: subset.issue?.title, body: subset.issue?.body },
@@ -201,7 +211,14 @@ function routeIssueLabel(subset, triggers) {
201
211
  function routeComment(subset, triggers, knownFlows) {
202
212
  const phrase = triggers.comment?.phrase;
203
213
  const body = subset.comment?.body;
204
- if (typeof phrase !== "string" || typeof body !== "string" || !body.includes(phrase)) {
214
+ const phraseHit = typeof phrase === "string" && typeof body === "string" && body.includes(phrase);
215
+ // A reply WITHOUT the phrase resumes a parked issue (issue #625, parked.mjs): only on an issue (a pull request's key
216
+ // needs its head branch, which costs a token to read, so its reply keeps the phrase), only when the comment
217
+ // trigger arms run.resume and is not a command, and only when the forge says the issue carries the marker. The
218
+ // author gate above already ran: this widens WHICH comments start a job, never WHO may. The marker is a hint; the
219
+ // worker refuses the job before any spend unless the key's `parked` sidecar exists.
220
+ const parkedReply = !phraseHit && typeof phrase === "string" && typeof body === "string" && triggers.comment?.resume === true && triggers.comment?.command === undefined && subset.isPull !== true && hasParkedLabel(subset.issue?.labels);
221
+ if (!phraseHit && !parkedReply) {
205
222
  return { enqueue: false, reason: "no-trigger-phrase" };
206
223
  }
207
224
  // On a COMMAND rule (issue #189) the flow-resolution block below -- default flow, the `<phrase> <flow>`
@@ -233,7 +250,7 @@ function routeComment(subset, triggers, knownFlows) {
233
250
  enqueue: true,
234
251
  ...(command !== undefined ? { command } : { flow }),
235
252
  packages: triggers.comment.packages,
236
- image: triggers.comment.image, backend: triggers.comment.backend, excludeTools: triggers.comment.excludeTools,
253
+ image: triggers.comment.image, backend: triggers.comment.backend, excludeTools: triggers.comment.excludeTools, mcp: triggers.comment.mcp,
237
254
  provider: triggers.comment.provider, model: triggers.comment.model, maxTurns: triggers.comment.maxTurns, models: triggers.comment.models, maxCostUsd: triggers.comment.maxCostUsd,
238
255
  skillsDir: triggers.comment.skillsDir,
239
256
  secrets: triggers.comment.secrets,
@@ -241,8 +258,10 @@ function routeComment(subset, triggers, knownFlows) {
241
258
  waitFor: triggers.comment.waitFor,
242
259
  instructions: triggers.comment.instructions,
243
260
  resume: triggers.comment.resume,
261
+ awaitInput: triggers.comment.awaitInput,
244
262
  replicas: triggers.comment.replicas,
245
263
  matched: { index: triggers.comment.index, type: "comment", phrase },
264
+ ...(parkedReply && { parkedReply: true }),
246
265
  // The invoking comment rides on the trigger. No author_association: Forgejo has none, and the
247
266
  // authority that admitted this comment was resolved from the API, not read off the body.
248
267
  comment: { body: subset.comment.body },
@@ -265,7 +284,7 @@ function routePullRequest(subset, triggers, action) {
265
284
  // A command rule (issue #189) skips flow resolution entirely: the rule match IS the dispatch.
266
285
  ...(rule.command !== undefined ? { command: rule.command } : { flow: rule.flow }),
267
286
  packages: rule.packages,
268
- image: rule.image, backend: rule.backend, excludeTools: rule.excludeTools,
287
+ image: rule.image, backend: rule.backend, excludeTools: rule.excludeTools, mcp: rule.mcp,
269
288
  provider: rule.provider, model: rule.model, maxTurns: rule.maxTurns, models: rule.models, maxCostUsd: rule.maxCostUsd,
270
289
  skillsDir: rule.skillsDir,
271
290
  secrets: rule.secrets,
@@ -273,6 +292,7 @@ function routePullRequest(subset, triggers, action) {
273
292
  waitFor: rule.waitFor,
274
293
  instructions: rule.instructions,
275
294
  resume: rule.resume,
295
+ awaitInput: rule.awaitInput,
276
296
  replicas: rule.replicas,
277
297
  matched: { index: rule.index, type: "pull_request", action },
278
298
  target: {
@@ -321,7 +341,7 @@ function routeIssueClose(subset, triggers, authorized) {
321
341
  enqueue: true,
322
342
  ...(rule.command !== undefined ? { command: rule.command } : { flow: rule.flow }),
323
343
  packages: rule.packages, // the MATCHED rule's fields -- rules in one file may differ on them
324
- image: rule.image, backend: rule.backend, excludeTools: rule.excludeTools,
344
+ image: rule.image, backend: rule.backend, excludeTools: rule.excludeTools, mcp: rule.mcp,
325
345
  provider: rule.provider, model: rule.model, maxTurns: rule.maxTurns, models: rule.models, maxCostUsd: rule.maxCostUsd,
326
346
  skillsDir: rule.skillsDir,
327
347
  secrets: rule.secrets,
@@ -329,6 +349,7 @@ function routeIssueClose(subset, triggers, authorized) {
329
349
  waitFor: rule.waitFor,
330
350
  instructions: rule.instructions,
331
351
  resume: rule.resume,
352
+ awaitInput: rule.awaitInput,
332
353
  replicas: rule.replicas,
333
354
  // `once: true` rides `matched` ONLY when armed (spreading false is a no-op), so the enqueue path
334
355
  // can see a disarm is owed without re-reading the rule table -- and a plain close rule's matched
@@ -358,7 +379,7 @@ function routePullRequestClose(subset, triggers, authorized) {
358
379
  enqueue: true,
359
380
  ...(rule.command !== undefined ? { command: rule.command } : { flow: rule.flow }),
360
381
  packages: rule.packages,
361
- image: rule.image, backend: rule.backend, excludeTools: rule.excludeTools,
382
+ image: rule.image, backend: rule.backend, excludeTools: rule.excludeTools, mcp: rule.mcp,
362
383
  provider: rule.provider, model: rule.model, maxTurns: rule.maxTurns, models: rule.models, maxCostUsd: rule.maxCostUsd,
363
384
  skillsDir: rule.skillsDir,
364
385
  secrets: rule.secrets,
@@ -366,6 +387,7 @@ function routePullRequestClose(subset, triggers, authorized) {
366
387
  waitFor: rule.waitFor,
367
388
  instructions: rule.instructions,
368
389
  resume: rule.resume,
390
+ awaitInput: rule.awaitInput,
369
391
  replicas: rule.replicas,
370
392
  // No `number` on a PR close's matched record -- parallel to routePullRequest's, where the item
371
393
  // number is the target's business; the issue route carries it because the github design does.
@@ -42,6 +42,7 @@
42
42
 
43
43
  import { firstMatchingRule, labelSet, matchedLabel } from "./predicate.mjs";
44
44
  import { findCloseRule } from "./close.mjs";
45
+ import { hasParkedLabel } from "@edgehero/pi-dispatch/parked";
45
46
 
46
47
  /**
47
48
  /** The merge-request actions a trigger may name, mirrored from the loader's gitlab vocabulary. */
@@ -134,6 +135,9 @@ export function filterGitLab(subset, triggers, knownFlows, selfId, authorized, d
134
135
  ...(resolved.backend !== undefined ? { backend: resolved.backend } : {}),
135
136
  // #291. A separate spread, filter.mjs's backend rule: a dropped exclusion runs the job WITH the tool.
136
137
  ...(resolved.excludeTools !== undefined ? { excludeTools: resolved.excludeTools } : {}),
138
+ // REQ-MCP-OPERATOR-SERVERS. Its own spread, the excludeTools rule above: a dropped list runs
139
+ // the job without its servers.
140
+ ...(resolved.mcp !== undefined ? { mcp: resolved.mcp } : {}),
137
141
  // #502. One spread PER FIELD, the excludeTools rule above: folded into one conditional, a trigger
138
142
  // that named only a model would have its provider's absence decide whether the model survives,
139
143
  // and a dropped model runs the job on the deployment default while the file reads as chosen.
@@ -159,6 +163,11 @@ export function filterGitLab(subset, triggers, knownFlows, selfId, authorized, d
159
163
  // Conditional like packages/image, and for the same reason: an unflagged job's data must stay
160
164
  // byte-identical to today's, so the key is absent rather than present-and-undefined.
161
165
  ...(resolved.resume !== undefined ? { resume: resolved.resume } : {}),
166
+ // REQ-AWAIT-INPUT (issue #625): whether the job may end awaiting a person's answer. Present only when armed,
167
+ // resume's rule, so an unflagged job's data stays byte-identical.
168
+ ...(resolved.awaitInput === true ? { awaitInput: true } : {}),
169
+ // A phrase-free reply to a parked issue (issue #625): the worker gates it on the `parked` sidecar before any spend.
170
+ ...(resolved.parkedReply === true ? { parkedReply: true } : {}),
162
171
  // How many independent sandboxes race this flow (REQ-REPLICA-RUNS). At JOB level and conditional for the
163
172
  // reasons filter.mjs states in full: receiver.mjs reads this to decide how many times to enqueue, and an
164
173
  // unflagged job's data must stay byte-identical to today's.
@@ -193,7 +202,7 @@ function routeLabel(subset, triggers, targetType) {
193
202
  // A command rule (issue #189) skips flow resolution entirely: the label match IS the dispatch.
194
203
  ...(rule.command !== undefined ? { command: rule.command } : { flow: rule.flow }),
195
204
  packages: rule.packages,
196
- image: rule.image, backend: rule.backend, excludeTools: rule.excludeTools,
205
+ image: rule.image, backend: rule.backend, excludeTools: rule.excludeTools, mcp: rule.mcp,
197
206
  provider: rule.provider, model: rule.model, maxTurns: rule.maxTurns, models: rule.models, maxCostUsd: rule.maxCostUsd,
198
207
  skillsDir: rule.skillsDir,
199
208
  secrets: rule.secrets,
@@ -201,6 +210,7 @@ function routeLabel(subset, triggers, targetType) {
201
210
  waitFor: rule.waitFor,
202
211
  instructions: rule.instructions,
203
212
  resume: rule.resume,
213
+ awaitInput: rule.awaitInput,
204
214
  replicas: rule.replicas,
205
215
  matched: { index: rule.index, type: "label", label: matchedLabel(added, rule.predicate) },
206
216
  target: buildTarget(subset, targetType),
@@ -258,7 +268,7 @@ function mrResult(subset, rule, matched) {
258
268
  // A command rule (issue #189) skips flow resolution entirely: the rule match IS the dispatch.
259
269
  ...(rule.command !== undefined ? { command: rule.command } : { flow: rule.flow }),
260
270
  packages: rule.packages,
261
- image: rule.image, backend: rule.backend, excludeTools: rule.excludeTools,
271
+ image: rule.image, backend: rule.backend, excludeTools: rule.excludeTools, mcp: rule.mcp,
262
272
  provider: rule.provider, model: rule.model, maxTurns: rule.maxTurns, models: rule.models, maxCostUsd: rule.maxCostUsd,
263
273
  skillsDir: rule.skillsDir,
264
274
  secrets: rule.secrets,
@@ -266,6 +276,7 @@ function mrResult(subset, rule, matched) {
266
276
  waitFor: rule.waitFor,
267
277
  instructions: rule.instructions,
268
278
  resume: rule.resume,
279
+ awaitInput: rule.awaitInput,
269
280
  replicas: rule.replicas,
270
281
  matched,
271
282
  target: buildTarget(subset, "pull_request"),
@@ -305,7 +316,7 @@ function routeIssueClose(subset, triggers, authorized) {
305
316
  enqueue: true,
306
317
  ...(rule.command !== undefined ? { command: rule.command } : { flow: rule.flow }),
307
318
  packages: rule.packages, // the MATCHED rule's fields -- rules in one file may differ on them
308
- image: rule.image, backend: rule.backend, excludeTools: rule.excludeTools,
319
+ image: rule.image, backend: rule.backend, excludeTools: rule.excludeTools, mcp: rule.mcp,
309
320
  provider: rule.provider, model: rule.model, maxTurns: rule.maxTurns, models: rule.models, maxCostUsd: rule.maxCostUsd,
310
321
  skillsDir: rule.skillsDir,
311
322
  secrets: rule.secrets,
@@ -313,6 +324,7 @@ function routeIssueClose(subset, triggers, authorized) {
313
324
  waitFor: rule.waitFor,
314
325
  instructions: rule.instructions,
315
326
  resume: rule.resume,
327
+ awaitInput: rule.awaitInput,
316
328
  replicas: rule.replicas,
317
329
  // `once: true` rides `matched` ONLY when armed (spreading false is a no-op), so the enqueue path
318
330
  // can see a disarm is owed without re-reading the rule table -- and a plain close rule's matched
@@ -340,7 +352,7 @@ function routeMergeRequestClose(subset, triggers, authorized) {
340
352
  enqueue: true,
341
353
  ...(rule.command !== undefined ? { command: rule.command } : { flow: rule.flow }),
342
354
  packages: rule.packages,
343
- image: rule.image, backend: rule.backend, excludeTools: rule.excludeTools,
355
+ image: rule.image, backend: rule.backend, excludeTools: rule.excludeTools, mcp: rule.mcp,
344
356
  provider: rule.provider, model: rule.model, maxTurns: rule.maxTurns, models: rule.models, maxCostUsd: rule.maxCostUsd,
345
357
  skillsDir: rule.skillsDir,
346
358
  secrets: rule.secrets,
@@ -348,6 +360,7 @@ function routeMergeRequestClose(subset, triggers, authorized) {
348
360
  waitFor: rule.waitFor,
349
361
  instructions: rule.instructions,
350
362
  resume: rule.resume,
363
+ awaitInput: rule.awaitInput,
351
364
  replicas: rule.replicas,
352
365
  // No `number` on an MR close's matched record -- parallel to routeMergeRequest's, where the item
353
366
  // number is the target's business; the issue route carries it because the github design does.
@@ -367,7 +380,14 @@ function routeNote(subset, triggers, knownFlows) {
367
380
 
368
381
  const phrase = triggers?.comment?.phrase;
369
382
  const body = subset.note;
370
- if (typeof phrase !== "string" || typeof body !== "string" || !body.includes(phrase)) {
383
+ const phraseHit = typeof phrase === "string" && typeof body === "string" && body.includes(phrase);
384
+ // A reply WITHOUT the phrase resumes a parked issue (issue #625, parked.mjs): only on an issue (a pull request's key
385
+ // needs its head branch, which costs a token to read, so its reply keeps the phrase), only when the comment
386
+ // trigger arms run.resume and is not a command, and only when the forge says the issue carries the marker. The
387
+ // author gate above already ran: this widens WHICH comments start a job, never WHO may. The marker is a hint; the
388
+ // worker refuses the job before any spend unless the key's `parked` sidecar exists.
389
+ const parkedReply = !phraseHit && typeof phrase === "string" && typeof body === "string" && triggers?.comment?.resume === true && triggers?.comment?.command === undefined && subset.noteableType !== "MergeRequest" && hasParkedLabel(subset.target?.labels);
390
+ if (!phraseHit && !parkedReply) {
371
391
  return { enqueue: false, reason: "no-trigger-phrase" };
372
392
  }
373
393
  // On a COMMAND rule (issue #189) the flow-resolution block below -- default flow, the `<phrase> <flow>`
@@ -393,7 +413,7 @@ function routeNote(subset, triggers, knownFlows) {
393
413
  enqueue: true,
394
414
  ...(command !== undefined ? { command } : { flow }),
395
415
  packages: triggers.comment.packages,
396
- image: triggers.comment.image, backend: triggers.comment.backend, excludeTools: triggers.comment.excludeTools,
416
+ image: triggers.comment.image, backend: triggers.comment.backend, excludeTools: triggers.comment.excludeTools, mcp: triggers.comment.mcp,
397
417
  provider: triggers.comment.provider, model: triggers.comment.model, maxTurns: triggers.comment.maxTurns, models: triggers.comment.models, maxCostUsd: triggers.comment.maxCostUsd,
398
418
  skillsDir: triggers.comment.skillsDir,
399
419
  secrets: triggers.comment.secrets,
@@ -401,8 +421,10 @@ function routeNote(subset, triggers, knownFlows) {
401
421
  waitFor: triggers.comment.waitFor,
402
422
  instructions: triggers.comment.instructions,
403
423
  resume: triggers.comment.resume,
424
+ awaitInput: triggers.comment.awaitInput,
404
425
  replicas: triggers.comment.replicas,
405
426
  matched: { index: triggers.comment.index, type: "comment", phrase },
427
+ ...(parkedReply && { parkedReply: true }),
406
428
  target: buildTarget(subset, targetType),
407
429
  // The invoking comment rides the job as DATA (CONST-ISSUE-TEXT-IS-DATA). No author_association
408
430
  // counterpart exists on GitLab: the approval is the resolved access level, which is a harness
package/src/filter.mjs CHANGED
@@ -47,6 +47,7 @@
47
47
  */
48
48
  import { findCloseRule } from "./close.mjs";
49
49
  import { escapeRegExp, firstMatchingRule, labelSet, matchedLabel, matchesRule } from "./predicate.mjs";
50
+ import { hasParkedLabel } from "@edgehero/pi-dispatch/parked";
50
51
 
51
52
  const AUTHOR_ALLOWLIST = new Set(["OWNER", "MEMBER", "COLLABORATOR"]);
52
53
  const LABEL_ACTIONS = new Set(["opened", "labeled", "reopened"]);
@@ -157,6 +158,9 @@ export function filter(eventName, subset, cfg, selfId, deliveryId, closerAuthori
157
158
  // dropped exclusion runs the job WITH the tool, the destructive absence the loader's own sweep
158
159
  // refuses a misspelling for, arriving through the plumbing instead of the spelling.
159
160
  ...(resolved.excludeTools !== undefined ? { excludeTools: resolved.excludeTools } : {}),
161
+ // REQ-MCP-OPERATOR-SERVERS. Its own spread, the excludeTools rule above: a dropped list runs
162
+ // the job without its servers.
163
+ ...(resolved.mcp !== undefined ? { mcp: resolved.mcp } : {}),
160
164
  // #502. One spread PER FIELD, the excludeTools rule above: folded into one conditional, a trigger
161
165
  // that named only a model would have its provider's absence decide whether the model survives,
162
166
  // and a dropped model runs the job on the deployment default while the file reads as chosen.
@@ -182,6 +186,11 @@ export function filter(eventName, subset, cfg, selfId, deliveryId, closerAuthori
182
186
  // Conditional like packages/image, and for the same reason: an unflagged job's data must stay
183
187
  // byte-identical to today's, so the key is absent rather than present-and-undefined.
184
188
  ...(resolved.resume !== undefined ? { resume: resolved.resume } : {}),
189
+ // REQ-AWAIT-INPUT (issue #625): whether the job may end awaiting a person's answer. Present only when armed,
190
+ // resume's rule, so an unflagged job's data stays byte-identical.
191
+ ...(resolved.awaitInput === true ? { awaitInput: true } : {}),
192
+ // A phrase-free reply to a parked issue (issue #625): the worker gates it on the `parked` sidecar before any spend.
193
+ ...(resolved.parkedReply === true ? { parkedReply: true } : {}),
185
194
  // How many independent sandboxes race this flow (REQ-REPLICA-RUNS). At JOB level like the rest, never
186
195
  // inside `trigger`, and for the sharpest version of that reason: this is the caller's fanout count --
187
196
  // receiver.mjs reads it to decide how many times to enqueue -- and a descriptive context object copied
@@ -221,7 +230,7 @@ function routeIssueLabel(subset, triggers) {
221
230
  // A command rule (issue #189) skips flow resolution entirely: the label match IS the dispatch.
222
231
  ...(rule.command !== undefined ? { command: rule.command } : { flow: rule.flow }),
223
232
  packages: rule.packages, // the MATCHED rule's fields -- rules in one file may differ on them
224
- image: rule.image, backend: rule.backend, excludeTools: rule.excludeTools,
233
+ image: rule.image, backend: rule.backend, excludeTools: rule.excludeTools, mcp: rule.mcp,
225
234
  provider: rule.provider, model: rule.model, maxTurns: rule.maxTurns, models: rule.models, maxCostUsd: rule.maxCostUsd,
226
235
  skillsDir: rule.skillsDir,
227
236
  secrets: rule.secrets,
@@ -229,6 +238,7 @@ function routeIssueLabel(subset, triggers) {
229
238
  waitFor: rule.waitFor,
230
239
  instructions: rule.instructions,
231
240
  resume: rule.resume,
241
+ awaitInput: rule.awaitInput,
232
242
  replicas: rule.replicas,
233
243
  matched: { index: rule.index, type: "label", label: matchedLabel(L, rule.predicate) },
234
244
  target: { type: "issue", number: subset.issue?.number, title: subset.issue?.title, body: subset.issue?.body },
@@ -247,7 +257,14 @@ function routeComment(subset, triggers, knownFlows) {
247
257
  }
248
258
  const phrase = triggers.comment?.phrase;
249
259
  const body = subset.comment?.body;
250
- if (typeof phrase !== "string" || typeof body !== "string" || !body.includes(phrase)) {
260
+ const phraseHit = typeof phrase === "string" && typeof body === "string" && body.includes(phrase);
261
+ // A reply WITHOUT the phrase resumes a parked issue (issue #625, parked.mjs): only on an issue (a pull request's key
262
+ // needs its head branch, which costs a token to read, so its reply keeps the phrase), only when the comment
263
+ // trigger arms run.resume and is not a command, and only when the forge says the issue carries the marker. The
264
+ // author gate above already ran: this widens WHICH comments start a job, never WHO may. The marker is a hint; the
265
+ // worker refuses the job before any spend unless the key's `parked` sidecar exists.
266
+ const parkedReply = !phraseHit && typeof phrase === "string" && typeof body === "string" && triggers.comment?.resume === true && triggers.comment?.command === undefined && subset.issue?.pull_request !== true && hasParkedLabel(subset.issue?.labels);
267
+ if (!phraseHit && !parkedReply) {
251
268
  return { enqueue: false, reason: "no-trigger-phrase" };
252
269
  }
253
270
  // On a COMMAND rule (issue #189) the whole flow-resolution block below -- default flow, the `<phrase>
@@ -282,7 +299,7 @@ function routeComment(subset, triggers, knownFlows) {
282
299
  // The single comment trigger IS the matched rule here, so its opt-in is the job's. A `<phrase>
283
300
  // <flow>` override changes WHICH flow runs, never which triggers.json entry authorized it.
284
301
  packages: triggers.comment.packages,
285
- image: triggers.comment.image, backend: triggers.comment.backend, excludeTools: triggers.comment.excludeTools,
302
+ image: triggers.comment.image, backend: triggers.comment.backend, excludeTools: triggers.comment.excludeTools, mcp: triggers.comment.mcp,
286
303
  provider: triggers.comment.provider, model: triggers.comment.model, maxTurns: triggers.comment.maxTurns, models: triggers.comment.models, maxCostUsd: triggers.comment.maxCostUsd,
287
304
  skillsDir: triggers.comment.skillsDir,
288
305
  secrets: triggers.comment.secrets,
@@ -290,8 +307,10 @@ function routeComment(subset, triggers, knownFlows) {
290
307
  waitFor: triggers.comment.waitFor,
291
308
  instructions: triggers.comment.instructions,
292
309
  resume: triggers.comment.resume,
310
+ awaitInput: triggers.comment.awaitInput,
293
311
  replicas: triggers.comment.replicas,
294
312
  matched: { index: triggers.comment.index, type: "comment", phrase },
313
+ ...(parkedReply && { parkedReply: true }),
295
314
  // The invoking comment rides on the trigger: body and author_association are both named by
296
315
  // INT-WEBHOOK-PAYLOAD-SUBSET, and the body stays DATA all the way down (CONST-ISSUE-TEXT-IS-DATA).
297
316
  comment: { body: subset.comment.body, author_association: subset.comment.author_association },
@@ -381,7 +400,7 @@ function routePullRequest(subset, triggers, action) {
381
400
  // A command rule (issue #189) skips flow resolution entirely: the rule match IS the dispatch.
382
401
  ...(rule.command !== undefined ? { command: rule.command } : { flow: rule.flow }),
383
402
  packages: rule.packages, // the MATCHED rule's fields -- rules in one file may differ on them
384
- image: rule.image, backend: rule.backend, excludeTools: rule.excludeTools,
403
+ image: rule.image, backend: rule.backend, excludeTools: rule.excludeTools, mcp: rule.mcp,
385
404
  provider: rule.provider, model: rule.model, maxTurns: rule.maxTurns, models: rule.models, maxCostUsd: rule.maxCostUsd,
386
405
  skillsDir: rule.skillsDir,
387
406
  secrets: rule.secrets,
@@ -389,6 +408,7 @@ function routePullRequest(subset, triggers, action) {
389
408
  waitFor: rule.waitFor,
390
409
  instructions: rule.instructions,
391
410
  resume: rule.resume,
411
+ awaitInput: rule.awaitInput,
392
412
  replicas: rule.replicas,
393
413
  matched: { index: rule.index, type: "pull_request", action },
394
414
  ...(isReview ? { review: { id: review.id, body: review.body, state: review.state, author_association: review.author_association } } : {}),
@@ -498,7 +518,7 @@ function routeClose(rules, number, closerAuthorized, matchedFor, targetFor) {
498
518
  // A command rule (issue #189) skips flow resolution entirely: the rule match IS the dispatch.
499
519
  ...(rule.command !== undefined ? { command: rule.command } : { flow: rule.flow }),
500
520
  packages: rule.packages, // the MATCHED rule's fields -- rules in one file may differ on them
501
- image: rule.image, backend: rule.backend, excludeTools: rule.excludeTools,
521
+ image: rule.image, backend: rule.backend, excludeTools: rule.excludeTools, mcp: rule.mcp,
502
522
  provider: rule.provider, model: rule.model, maxTurns: rule.maxTurns, models: rule.models, maxCostUsd: rule.maxCostUsd,
503
523
  skillsDir: rule.skillsDir,
504
524
  secrets: rule.secrets,
@@ -506,6 +526,7 @@ function routeClose(rules, number, closerAuthorized, matchedFor, targetFor) {
506
526
  waitFor: rule.waitFor,
507
527
  instructions: rule.instructions,
508
528
  resume: rule.resume,
529
+ awaitInput: rule.awaitInput,
509
530
  replicas: rule.replicas,
510
531
  matched: matchedFor(rule),
511
532
  target: targetFor(),