@edgehero/pi-dispatch-receiver 2.0.0 → 4.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@edgehero/pi-dispatch-receiver",
3
- "version": "2.0.0",
3
+ "version": "4.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": "^2.0.0",
51
+ "@edgehero/pi-dispatch": "^4.0.0",
52
52
  "bullmq": "5.80.4",
53
53
  "ioredis": "5.11.1"
54
54
  }
@@ -41,11 +41,14 @@
41
41
  * deadline and was skipped -- a forge that came back at t=50 was missed, and "retries for the
42
42
  * whole window" was false in the letter. The clamp is also what makes an uncancellable injected
43
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.
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.
45
46
  *
46
47
  * Total wall clock: the helper settles within `windowMs + attemptTimeoutMs`.
47
48
  */
48
49
 
50
+ import { githubFailureFields } from "@edgehero/pi-dispatch/octokit-log";
51
+
49
52
  // The first gap is what `RestartSec=5` gave, now in-process: a blip recovers exactly as fast as the
50
53
  // supervisor loop it replaces. It doubles to a cap borrowed from the worker's AUTH_RETRY_COOLDOWN_MS
51
54
  // rationale (worker/src/start.mjs): short enough to pick the forge up within one gap of it coming
@@ -127,13 +130,13 @@ export async function retryIdentity(
127
130
  if (err?.piDispatchConfig === true) throw err;
128
131
  const remainingMs = deadline - now();
129
132
  if (remainingMs <= 0) {
130
- log({ event: "identity_retry_exhausted", forge, attempts: attempt, windowMs, reason: err?.message });
133
+ log({ event: "identity_retry_exhausted", forge, attempts: attempt, windowMs, reason: err?.message, ...githubFailureFields(err) });
131
134
  throw err;
132
135
  }
133
136
  // Clamped, so the last gap ends exactly at the deadline and the final attempt runs at the
134
137
  // window's edge -- see the header for the measured 35s-of-60s failure the clamp removes.
135
138
  const gapMs = Math.min(delay, remainingMs);
136
- log({ event: "identity_retry", forge, attempt, reason: err?.message, delayMs: gapMs });
139
+ log({ event: "identity_retry", forge, attempt, reason: err?.message, ...githubFailureFields(err), delayMs: gapMs });
137
140
  await sleep(gapMs);
138
141
  delay = Math.min(delay * 2, delayCapMs);
139
142
  }
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, 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 });
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, 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
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, 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 });
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, 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 });
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, 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 });
343
343
  }
344
344
  }
345
345
 
@@ -117,6 +117,16 @@ export function filterAzure(subset, triggers, knownFlows, selfId, authorized, de
117
117
  ...(resolved.backend !== undefined ? { backend: resolved.backend } : {}),
118
118
  // #291. A separate spread, filter.mjs's backend rule: a dropped exclusion runs the job WITH the tool.
119
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 } : {}),
120
130
  // The trigger's injected skills dir (REQ-PER-TRIGGER-SKILLS), at JOB level beside image/packages and
121
131
  // NEVER inside `trigger`. That placement is sharpest here of all: `trigger` is carried into
122
132
  // /job/event.json, and a worker-host path in an agent-readable file is the leak prepare-local's
@@ -194,6 +204,7 @@ function matchLabelRules(subset, triggers, labels, action) {
194
204
  repository: rule.repository,
195
205
  packages: rule.packages,
196
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,
197
208
  skillsDir: rule.skillsDir,
198
209
  secrets: rule.secrets,
199
210
  secretsProfile: rule.secretsProfile,
@@ -238,6 +249,7 @@ function routeComment(subset, triggers, knownFlows, targetType) {
238
249
  repository: triggers.comment.repository,
239
250
  packages: triggers.comment.packages,
240
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,
241
253
  skillsDir: triggers.comment.skillsDir,
242
254
  secrets: triggers.comment.secrets,
243
255
  secretsProfile: triggers.comment.secretsProfile,
@@ -271,6 +283,7 @@ function routePullRequest(subset, triggers, action) {
271
283
  repository: rule.repository,
272
284
  packages: rule.packages,
273
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,
274
287
  skillsDir: rule.skillsDir,
275
288
  secrets: rule.secrets,
276
289
  secretsProfile: rule.secretsProfile,
@@ -131,6 +131,16 @@ export function filterForgejo(eventName, subset, triggers, knownFlows, selfId, a
131
131
  ...(resolved.backend !== undefined ? { backend: resolved.backend } : {}),
132
132
  // #291. A separate spread, filter.mjs's backend rule: a dropped exclusion runs the job WITH the tool.
133
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 } : {}),
134
144
  // The trigger's injected skills dir (REQ-PER-TRIGGER-SKILLS), at JOB level beside image/packages and
135
145
  // NEVER inside `trigger`. That placement is sharpest here of all: `trigger` is carried into
136
146
  // /job/event.json, and a worker-host path in an agent-readable file is the leak prepare-local's
@@ -174,6 +184,7 @@ function routeIssueLabel(subset, triggers) {
174
184
  ...(rule.command !== undefined ? { command: rule.command } : { flow: rule.flow }),
175
185
  packages: rule.packages, // the MATCHED rule's fields -- rules in one file may differ on them
176
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,
177
188
  skillsDir: rule.skillsDir,
178
189
  secrets: rule.secrets,
179
190
  secretsProfile: rule.secretsProfile,
@@ -223,6 +234,7 @@ function routeComment(subset, triggers, knownFlows) {
223
234
  ...(command !== undefined ? { command } : { flow }),
224
235
  packages: triggers.comment.packages,
225
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,
226
238
  skillsDir: triggers.comment.skillsDir,
227
239
  secrets: triggers.comment.secrets,
228
240
  secretsProfile: triggers.comment.secretsProfile,
@@ -254,6 +266,7 @@ function routePullRequest(subset, triggers, action) {
254
266
  ...(rule.command !== undefined ? { command: rule.command } : { flow: rule.flow }),
255
267
  packages: rule.packages,
256
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,
257
270
  skillsDir: rule.skillsDir,
258
271
  secrets: rule.secrets,
259
272
  secretsProfile: rule.secretsProfile,
@@ -309,6 +322,7 @@ function routeIssueClose(subset, triggers, authorized) {
309
322
  ...(rule.command !== undefined ? { command: rule.command } : { flow: rule.flow }),
310
323
  packages: rule.packages, // the MATCHED rule's fields -- rules in one file may differ on them
311
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,
312
326
  skillsDir: rule.skillsDir,
313
327
  secrets: rule.secrets,
314
328
  secretsProfile: rule.secretsProfile,
@@ -345,6 +359,7 @@ function routePullRequestClose(subset, triggers, authorized) {
345
359
  ...(rule.command !== undefined ? { command: rule.command } : { flow: rule.flow }),
346
360
  packages: rule.packages,
347
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,
348
363
  skillsDir: rule.skillsDir,
349
364
  secrets: rule.secrets,
350
365
  secretsProfile: rule.secretsProfile,
@@ -134,6 +134,16 @@ export function filterGitLab(subset, triggers, knownFlows, selfId, authorized, d
134
134
  ...(resolved.backend !== undefined ? { backend: resolved.backend } : {}),
135
135
  // #291. A separate spread, filter.mjs's backend rule: a dropped exclusion runs the job WITH the tool.
136
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 } : {}),
137
147
  // The trigger's injected skills dir (REQ-PER-TRIGGER-SKILLS), at JOB level beside image/packages and
138
148
  // NEVER inside `trigger`. That placement is sharpest here of all: `trigger` is carried into
139
149
  // /job/event.json, and a worker-host path in an agent-readable file is the leak prepare-local's
@@ -184,6 +194,7 @@ function routeLabel(subset, triggers, targetType) {
184
194
  ...(rule.command !== undefined ? { command: rule.command } : { flow: rule.flow }),
185
195
  packages: rule.packages,
186
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,
187
198
  skillsDir: rule.skillsDir,
188
199
  secrets: rule.secrets,
189
200
  secretsProfile: rule.secretsProfile,
@@ -248,6 +259,7 @@ function mrResult(subset, rule, matched) {
248
259
  ...(rule.command !== undefined ? { command: rule.command } : { flow: rule.flow }),
249
260
  packages: rule.packages,
250
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,
251
263
  skillsDir: rule.skillsDir,
252
264
  secrets: rule.secrets,
253
265
  secretsProfile: rule.secretsProfile,
@@ -294,6 +306,7 @@ function routeIssueClose(subset, triggers, authorized) {
294
306
  ...(rule.command !== undefined ? { command: rule.command } : { flow: rule.flow }),
295
307
  packages: rule.packages, // the MATCHED rule's fields -- rules in one file may differ on them
296
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,
297
310
  skillsDir: rule.skillsDir,
298
311
  secrets: rule.secrets,
299
312
  secretsProfile: rule.secretsProfile,
@@ -328,6 +341,7 @@ function routeMergeRequestClose(subset, triggers, authorized) {
328
341
  ...(rule.command !== undefined ? { command: rule.command } : { flow: rule.flow }),
329
342
  packages: rule.packages,
330
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,
331
345
  skillsDir: rule.skillsDir,
332
346
  secrets: rule.secrets,
333
347
  secretsProfile: rule.secretsProfile,
@@ -380,6 +394,7 @@ function routeNote(subset, triggers, knownFlows) {
380
394
  ...(command !== undefined ? { command } : { flow }),
381
395
  packages: triggers.comment.packages,
382
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,
383
398
  skillsDir: triggers.comment.skillsDir,
384
399
  secrets: triggers.comment.secrets,
385
400
  secretsProfile: triggers.comment.secretsProfile,
package/src/filter.mjs CHANGED
@@ -121,7 +121,8 @@ export function filter(eventName, subset, cfg, selfId, deliveryId, closerAuthori
121
121
  if (!resolved.enqueue) return resolved; // carries the drop reason
122
122
 
123
123
  // (3) Build the job from the INT-WEBHOOK-PAYLOAD-SUBSET fields only. No sender.login (not in the
124
- // subset), no provider/model/maxTurns (the worker fills defaults), no field outside the subset --
124
+ // subset), provider/model/maxTurns only when the matched rule named them (absent, the worker fills the
125
+ // deployment default, #502), no field outside the subset --
125
126
  // with two trigger-context additions (issue #49): `matched` is harness-computed, the filter's own
126
127
  // decision record naming the triggers.json entry that fired (not payload data at all); `comment`,
127
128
  // present only on the comment route, carries the invoking comment's body/author_association, both
@@ -156,6 +157,16 @@ export function filter(eventName, subset, cfg, selfId, deliveryId, closerAuthori
156
157
  // dropped exclusion runs the job WITH the tool, the destructive absence the loader's own sweep
157
158
  // refuses a misspelling for, arriving through the plumbing instead of the spelling.
158
159
  ...(resolved.excludeTools !== undefined ? { excludeTools: resolved.excludeTools } : {}),
160
+ // #502. One spread PER FIELD, the excludeTools rule above: folded into one conditional, a trigger
161
+ // that named only a model would have its provider's absence decide whether the model survives,
162
+ // and a dropped model runs the job on the deployment default while the file reads as chosen.
163
+ ...(resolved.provider !== undefined ? { provider: resolved.provider } : {}),
164
+ ...(resolved.model !== undefined ? { model: resolved.model } : {}),
165
+ ...(resolved.maxTurns !== undefined ? { maxTurns: resolved.maxTurns } : {}),
166
+ ...(resolved.models !== undefined ? { models: resolved.models } : {}),
167
+ // #501. Its own spread, the rule above: a dropped cap runs the job under the deployment's cap, or none,
168
+ // while the file reads as narrowed.
169
+ ...(resolved.maxCostUsd !== undefined ? { maxCostUsd: resolved.maxCostUsd } : {}),
159
170
  // The trigger's injected skills dir (REQ-PER-TRIGGER-SKILLS), at JOB level beside image/packages and
160
171
  // NEVER inside `trigger`. That placement is sharpest here of all: `trigger` is carried into
161
172
  // /job/event.json, and a worker-host path in an agent-readable file is the leak prepare-local's
@@ -211,6 +222,7 @@ function routeIssueLabel(subset, triggers) {
211
222
  ...(rule.command !== undefined ? { command: rule.command } : { flow: rule.flow }),
212
223
  packages: rule.packages, // the MATCHED rule's fields -- rules in one file may differ on them
213
224
  image: rule.image, backend: rule.backend, excludeTools: rule.excludeTools,
225
+ provider: rule.provider, model: rule.model, maxTurns: rule.maxTurns, models: rule.models, maxCostUsd: rule.maxCostUsd,
214
226
  skillsDir: rule.skillsDir,
215
227
  secrets: rule.secrets,
216
228
  secretsProfile: rule.secretsProfile,
@@ -271,6 +283,7 @@ function routeComment(subset, triggers, knownFlows) {
271
283
  // <flow>` override changes WHICH flow runs, never which triggers.json entry authorized it.
272
284
  packages: triggers.comment.packages,
273
285
  image: triggers.comment.image, backend: triggers.comment.backend, excludeTools: triggers.comment.excludeTools,
286
+ provider: triggers.comment.provider, model: triggers.comment.model, maxTurns: triggers.comment.maxTurns, models: triggers.comment.models, maxCostUsd: triggers.comment.maxCostUsd,
274
287
  skillsDir: triggers.comment.skillsDir,
275
288
  secrets: triggers.comment.secrets,
276
289
  secretsProfile: triggers.comment.secretsProfile,
@@ -369,6 +382,7 @@ function routePullRequest(subset, triggers, action) {
369
382
  ...(rule.command !== undefined ? { command: rule.command } : { flow: rule.flow }),
370
383
  packages: rule.packages, // the MATCHED rule's fields -- rules in one file may differ on them
371
384
  image: rule.image, backend: rule.backend, excludeTools: rule.excludeTools,
385
+ provider: rule.provider, model: rule.model, maxTurns: rule.maxTurns, models: rule.models, maxCostUsd: rule.maxCostUsd,
372
386
  skillsDir: rule.skillsDir,
373
387
  secrets: rule.secrets,
374
388
  secretsProfile: rule.secretsProfile,
@@ -485,6 +499,7 @@ function routeClose(rules, number, closerAuthorized, matchedFor, targetFor) {
485
499
  ...(rule.command !== undefined ? { command: rule.command } : { flow: rule.flow }),
486
500
  packages: rule.packages, // the MATCHED rule's fields -- rules in one file may differ on them
487
501
  image: rule.image, backend: rule.backend, excludeTools: rule.excludeTools,
502
+ provider: rule.provider, model: rule.model, maxTurns: rule.maxTurns, models: rule.models, maxCostUsd: rule.maxCostUsd,
488
503
  skillsDir: rule.skillsDir,
489
504
  secrets: rule.secrets,
490
505
  secretsProfile: rule.secretsProfile,
package/src/poller.mjs CHANGED
@@ -203,7 +203,8 @@ export async function startPoller(env = process.env, deps = {}) {
203
203
  // `auth ??= await ...` assigns only when the mint RESOLVES, so a retried getAuth re-invokes
204
204
  // makeAuth. Caching the PROMISE instead would memoize the first failure and turn the retry loop
205
205
  // into a rethrow spinner -- pinned by the poller's retry test.
206
- const getAuth = async () => (auth ??= await makeAuth(cfg.github));
206
+ // Issue #530: the GitHub client's own warnings go through this JSON log, never Octokit's plain console lines.
207
+ const getAuth = async () => (auth ??= await makeAuth(cfg.github, { log: (event, fields) => out({ event, ...fields }) }));
207
208
  const selfId = await retryIdentity(
208
209
  () => (selfIdFn ? selfIdFn(cfg.github) : getAuth().then((a) => a.selfId)),
209
210
  { forge: "github", windowMs: cfg.identityRetryWindowMs, log: out, now, sleep: sleepFn },
package/src/start.mjs CHANGED
@@ -49,6 +49,7 @@ import { makeQueue } from "@edgehero/pi-dispatch/queue";
49
49
  import { makeForgeRouter } from "./route.mjs";
50
50
  import { judgeValkeyAtStart, parseConnection, valkeyClientContext } from "@edgehero/pi-dispatch/connection";
51
51
  import { WATCH_DEBOUNCE_MS, changedWhileArming, makeWatchCloser, readBeforeArming } from "@edgehero/pi-dispatch/watch-closer";
52
+ import { githubFailureFields } from "@edgehero/pi-dispatch/octokit-log";
52
53
  import { retryIdentity } from "./boot-retry.mjs";
53
54
 
54
55
  /**
@@ -145,7 +146,8 @@ export async function startReceiver(
145
146
  // one credential decision -- an arm that resolved its identity is exactly the arm that can answer
146
147
  // a permission question. This is also why the github handler's missing-resolver 503 is unreachable
147
148
  // in a wired receiver: a boot that fails here mounts no `/` at all.
148
- const auth = await retryIdentity(() => makeAuth(cfg.github), { forge: "github", ...retryOpts });
149
+ // Issue #530: the GitHub client's own warnings go through this JSON log, never Octokit's plain console lines.
150
+ const auth = await retryIdentity(() => makeAuth(cfg.github, { log: (event, fields) => log({ event, ...fields }) }), { forge: "github", ...retryOpts });
149
151
  selfId = auth.selfId;
150
152
  log({ event: "self_identity", id: selfId, source: cfg.github.source });
151
153
  // The lookup token asks the mint to narrow to metadata:read -- the App path honors it GitHub-side,
@@ -372,7 +374,7 @@ if (isEntryModule(import.meta.url)) {
372
374
  // An unhandled rejection is printed as its message alone (PR #475's review), never Node's print of the whole reason.
373
375
  installRejectionPrinter();
374
376
  startReceiver(process.env).catch((err) => {
375
- process.stderr.write(`${JSON.stringify({ event: "receiver_start_failed", reason: err?.message })}\n`);
377
+ process.stderr.write(`${JSON.stringify({ event: "receiver_start_failed", reason: err?.message, ...githubFailureFields(err) })}\n`);
376
378
  // entryExitCode, NOT a bare 1. This file is what `receiver.service` execs -- cli.mjs is not on that
377
379
  // path -- so the mapping cli.mjs documents ("a supervisor restarting on exit 2 would loop on a config
378
380
  // that can never parse") only reaches a real deployment from here. A tagged config refusal exits 2