@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 +12 -2
- package/package.json +2 -2
- package/src/azure-members.mjs +6 -3
- package/src/boot-retry.mjs +1 -1
- package/src/cli.mjs +9 -1
- package/src/config.mjs +26 -56
- package/src/filter-azure.mjs +150 -99
- package/src/filter-forgejo.mjs +148 -88
- package/src/filter-gitlab.mjs +149 -90
- package/src/filter.mjs +168 -113
- package/src/forgejo-members.mjs +4 -3
- package/src/github-members.mjs +2 -1
- package/src/gitlab-members.mjs +2 -1
- package/src/held.mjs +120 -0
- package/src/poller.mjs +62 -15
- package/src/predicate.mjs +37 -0
- package/src/receiver.mjs +290 -60
- package/src/start.mjs +26 -5
- package/src/steer.mjs +221 -0
- package/src/trigger-groups.mjs +57 -0
- package/src/verify-azure.mjs +1 -1
- package/src/verify-gitlab.mjs +5 -4
- package/src/verify-watch.mjs +40 -0
- package/src/verify.mjs +1 -1
- package/src/watch-pull.mjs +94 -0
- package/src/watch-puller.mjs +82 -0
- package/src/watch.mjs +60 -0
- package/src/http-body.mjs +0 -73
package/src/filter.mjs
CHANGED
|
@@ -46,7 +46,7 @@
|
|
|
46
46
|
* user); `deliveryId` is the `X-GitHub-Delivery` GUID, carried into the job for downstream dedup.
|
|
47
47
|
*/
|
|
48
48
|
import { findCloseRule } from "./close.mjs";
|
|
49
|
-
import { escapeRegExp, firstMatchingRule, labelSet, matchedLabel, matchesRule } from "./predicate.mjs";
|
|
49
|
+
import { escapeRegExp, firstMatchingRule, heldCandidateOr, labelSet, matchedLabel, matchesRule, steerModeOf } from "./predicate.mjs";
|
|
50
50
|
import { hasParkedLabel } from "@edgehero/pi-dispatch/parked";
|
|
51
51
|
|
|
52
52
|
const AUTHOR_ALLOWLIST = new Set(["OWNER", "MEMBER", "COLLABORATOR"]);
|
|
@@ -119,103 +119,128 @@ export function filter(eventName, subset, cfg, selfId, deliveryId, closerAuthori
|
|
|
119
119
|
return { enqueue: false, reason: "unhandled-event" };
|
|
120
120
|
}
|
|
121
121
|
|
|
122
|
-
|
|
122
|
+
// A drop is returned as it is, except a phrase-free reply that failed ONLY for the missing parked marker (issue #638,
|
|
123
|
+
// DES-AWAIT-INPUT-ENDS-THE-JOB): that one also carries `heldCandidate`, the job the parked-reply route would have
|
|
124
|
+
// built, through the SAME `build` below, so the receiver can hold it for a run that is parking right now and the job
|
|
125
|
+
// that run starts is byte for byte the one the marker would have produced. Still a drop: nothing here enqueues it.
|
|
126
|
+
if (!resolved.enqueue) return heldCandidateOr(resolved, build);
|
|
127
|
+
return build(resolved);
|
|
123
128
|
|
|
124
|
-
//
|
|
125
|
-
//
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
...(resolved.
|
|
206
|
-
//
|
|
207
|
-
//
|
|
208
|
-
//
|
|
209
|
-
//
|
|
210
|
-
|
|
211
|
-
//
|
|
212
|
-
|
|
213
|
-
//
|
|
214
|
-
//
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
129
|
+
// The job from a resolved route. One function for every route and for the held candidate (issue #638), declared
|
|
130
|
+
// here so it closes over this delivery's subset and ids.
|
|
131
|
+
function build(resolved) {
|
|
132
|
+
// (3) Build the job from the INT-WEBHOOK-PAYLOAD-SUBSET fields only. No sender.login (not in the
|
|
133
|
+
// subset), provider/model/maxTurns only when the matched rule named them (absent, the worker fills the
|
|
134
|
+
// deployment default, #502), no field outside the subset --
|
|
135
|
+
// with two trigger-context additions (issue #49): `matched` is harness-computed, the filter's own
|
|
136
|
+
// decision record naming the triggers.json entry that fired (not payload data at all); `comment`,
|
|
137
|
+
// present only on the comment route, carries the invoking comment's body/author_association, both
|
|
138
|
+
// fields INT-WEBHOOK-PAYLOAD-SUBSET already names.
|
|
139
|
+
//
|
|
140
|
+
// `packages` and `image` are harness-computed EXECUTION knobs read off the matched triggers.json entry
|
|
141
|
+
// (like `matched`), never payload data -- REQ-GLOBAL-PI-OVERLAY's per-trigger control over loading the
|
|
142
|
+
// operator-staged pi packages, and INT-TRIGGERS-FILE-CONTRACT's per-trigger container image. Both sit at
|
|
143
|
+
// JOB level, NOT inside `trigger`: `trigger` is the descriptive context object carried verbatim into
|
|
144
|
+
// /job/event.json (INT-CONTAINER-JOB-INPUTS) and must stay descriptive, so an execution switch has no
|
|
145
|
+
// business there. Both are omitted entirely when the matched rule did not set them, so an unflagged
|
|
146
|
+
// trigger's job literal is byte-identical to today's.
|
|
147
|
+
const job = {
|
|
148
|
+
repo: subset.repository?.full_name,
|
|
149
|
+
target: resolved.target,
|
|
150
|
+
// EXACTLY ONE of flow/command, decided by the matched rule (issue #189; the shared parser enforces
|
|
151
|
+
// the exclusivity at load). A command rule dispatches a registered pi extension command instead of
|
|
152
|
+
// resolving a flow, and its job must carry NO flow key at all -- a present-and-undefined flow would
|
|
153
|
+
// change the enqueued bytes for every consumer that serializes the job. The spread keeps a flow
|
|
154
|
+
// rule's literal byte-identical to what it always was.
|
|
155
|
+
...(resolved.command !== undefined ? { command: resolved.command } : { flow: resolved.flow }),
|
|
156
|
+
...(resolved.packages !== undefined ? { packages: resolved.packages } : {}),
|
|
157
|
+
...(resolved.image !== undefined ? { image: resolved.image } : {}),
|
|
158
|
+
// #227. A SEPARATE spread, and this is the whole of the bug it replaces: folded into the `image`
|
|
159
|
+
// conditional above, a trigger that named a venue but no image had its venue silently dropped here --
|
|
160
|
+
// it loaded, validated, reached this function on the rule, and then ran on the default. That is
|
|
161
|
+
// exactly the destructive absence `validateBackend`'s near-miss sweep refuses a misspelling for,
|
|
162
|
+
// arriving through the plumbing instead of the spelling, one file downstream of the guard.
|
|
163
|
+
...(resolved.backend !== undefined ? { backend: resolved.backend } : {}),
|
|
164
|
+
// #291. A separate spread, the backend line's rule one field over: folded into a neighbour's
|
|
165
|
+
// conditional, a trigger that named only exclusions would have them silently dropped here -- and a
|
|
166
|
+
// dropped exclusion runs the job WITH the tool, the destructive absence the loader's own sweep
|
|
167
|
+
// refuses a misspelling for, arriving through the plumbing instead of the spelling.
|
|
168
|
+
...(resolved.excludeTools !== undefined ? { excludeTools: resolved.excludeTools } : {}),
|
|
169
|
+
// Issue #800, each its own spread by the same rule: a dropped list runs the job with what the file takes away.
|
|
170
|
+
...(resolved.excludeExtensions !== undefined ? { excludeExtensions: resolved.excludeExtensions } : {}),
|
|
171
|
+
...(resolved.excludeExtensionTools !== undefined ? { excludeExtensionTools: resolved.excludeExtensionTools } : {}),
|
|
172
|
+
// REQ-MCP-OPERATOR-SERVERS. Its own spread, the excludeTools rule above: a dropped list runs
|
|
173
|
+
// the job without its servers.
|
|
174
|
+
...(resolved.mcp !== undefined ? { mcp: resolved.mcp } : {}),
|
|
175
|
+
// #502. One spread PER FIELD, the excludeTools rule above: folded into one conditional, a trigger
|
|
176
|
+
// that named only a model would have its provider's absence decide whether the model survives,
|
|
177
|
+
// and a dropped model runs the job on the deployment default while the file reads as chosen.
|
|
178
|
+
...(resolved.provider !== undefined ? { provider: resolved.provider } : {}),
|
|
179
|
+
...(resolved.model !== undefined ? { model: resolved.model } : {}),
|
|
180
|
+
...(resolved.maxTurns !== undefined ? { maxTurns: resolved.maxTurns } : {}),
|
|
181
|
+
...(resolved.models !== undefined ? { models: resolved.models } : {}), ...(resolved.fallbackModels !== undefined ? { fallbackModels: resolved.fallbackModels } : {}),
|
|
182
|
+
// #501. Its own spread, the rule above: a dropped cap runs the job under the deployment's cap, or none,
|
|
183
|
+
// while the file reads as narrowed.
|
|
184
|
+
...(resolved.maxCostUsd !== undefined ? { maxCostUsd: resolved.maxCostUsd } : {}),
|
|
185
|
+
// The trigger's injected skills dir (REQ-PER-TRIGGER-SKILLS), at JOB level beside image/packages and
|
|
186
|
+
// NEVER inside `trigger`. That placement is sharpest here of all: `trigger` is carried into
|
|
187
|
+
// /job/event.json, and a worker-host path in an agent-readable file is the leak prepare-local's
|
|
188
|
+
// basename(folder) restraint already exists to prevent.
|
|
189
|
+
...(resolved.skillsDir !== undefined ? { skillsDir: resolved.skillsDir } : {}),
|
|
190
|
+
// REQ-TRIGGER-SECRETS. Conditional exactly like skillsDir above, so a trigger that binds no
|
|
191
|
+
// secret enqueues the job data it always has. The map holds REFERENCES, never values: the
|
|
192
|
+
// receiver has no resolver and reaches no vault -- the worker resolves them pre-spend.
|
|
193
|
+
...(resolved.secrets !== undefined ? { secrets: resolved.secrets } : {}),
|
|
194
|
+
...(resolved.secretsProfile !== undefined ? { secretsProfile: resolved.secretsProfile } : {}),
|
|
195
|
+
...(resolved.waitFor !== undefined ? { waitFor: resolved.waitFor } : {}),
|
|
196
|
+
...(resolved.instructions !== undefined ? { instructions: resolved.instructions } : {}),
|
|
197
|
+
// Conditional like packages/image, and for the same reason: an unflagged job's data must stay
|
|
198
|
+
// byte-identical to today's, so the key is absent rather than present-and-undefined.
|
|
199
|
+
...(resolved.resume !== undefined ? { resume: resolved.resume } : {}),
|
|
200
|
+
// REQ-AWAIT-INPUT (issue #625): whether the job may end awaiting a person's answer. Present only when armed,
|
|
201
|
+
// resume's rule, so an unflagged job's data stays byte-identical.
|
|
202
|
+
...(resolved.awaitInput === true ? { awaitInput: true } : {}),
|
|
203
|
+
...(resolved.gates !== undefined ? { gates: resolved.gates, ...(resolved.gateTimeout !== undefined ? { gateTimeout: resolved.gateTimeout } : {}), ...(resolved.gateBudgetRaiseUsd !== undefined ? { gateBudgetRaiseUsd: resolved.gateBudgetRaiseUsd } : {}) } : {}),
|
|
204
|
+
// Issue #644: the progress tool and the forge status comment, each present only when armed, awaitInput's rule.
|
|
205
|
+
...(resolved.progress === true ? { progress: true } : {}),
|
|
206
|
+
...(resolved.statusComment === true ? { statusComment: true } : {}),
|
|
207
|
+
// Issue #645: durable runs, present only when armed, the same rule.
|
|
208
|
+
...(resolved.recover === true ? { recover: true } : {}),
|
|
209
|
+
// Issue #646: steering a running job, present only when armed, the same rule.
|
|
210
|
+
...(resolved.steer === true ? { steer: true } : {}),
|
|
211
|
+
// Issue #646: how this phrase comment steers a job of its trigger that is already running, set only on the comment
|
|
212
|
+
// route of a steer-armed trigger (never a parked reply, never a command). The receiver reads it to try the run's
|
|
213
|
+
// inbox before it enqueues, and `enqueueForgeJob` names its fields, so it never reaches a queue. At JOB level, never
|
|
214
|
+
// inside `trigger`, which is copied verbatim into /job/event.json.
|
|
215
|
+
...(resolved.steerMode !== undefined ? { steerMode: resolved.steerMode } : {}),
|
|
216
|
+
// A phrase-free reply to a parked issue (issue #625): the worker gates it on the `parked` sidecar before any spend.
|
|
217
|
+
...(resolved.parkedReply === true ? { parkedReply: true } : {}),
|
|
218
|
+
// How many independent sandboxes race this flow (REQ-REPLICA-RUNS). At JOB level like the rest, never
|
|
219
|
+
// inside `trigger`, and for the sharpest version of that reason: this is the caller's fanout count --
|
|
220
|
+
// receiver.mjs reads it to decide how many times to enqueue -- and a descriptive context object copied
|
|
221
|
+
// into /job/event.json is no place for the number of jobs to create.
|
|
222
|
+
...(resolved.replicas !== undefined ? { replicas: resolved.replicas } : {}),
|
|
223
|
+
trigger: {
|
|
224
|
+
event: eventName,
|
|
225
|
+
action,
|
|
226
|
+
deliveryId,
|
|
227
|
+
sender: { id: subset.sender.id },
|
|
228
|
+
matched: resolved.matched,
|
|
229
|
+
...(resolved.comment ? { comment: resolved.comment } : {}),
|
|
230
|
+
// The invoking review, present only on the review route (issue #66), sibling of `comment` and
|
|
231
|
+
// carried for the same reason: without it a "Request changes: rename the helper" review starts a
|
|
232
|
+
// job that cannot know what was asked. All four fields are named by INT-WEBHOOK-PAYLOAD-SUBSET and
|
|
233
|
+
// the body stays DATA all the way down (CONST-ISSUE-TEXT-IS-DATA).
|
|
234
|
+
//
|
|
235
|
+
// NOTE the pair above: `event` is `pull_request_review` and `action` is the raw `submitted`,
|
|
236
|
+
// byte-for-byte what GitHub sent. `matched.action` is the canonical `review_submitted`, because
|
|
237
|
+
// `matched` names the triggers.json entry that fired rather than the payload. This is the first
|
|
238
|
+
// GitHub case where the two differ, and INT-CONTAINER-JOB-INPUTS says so.
|
|
239
|
+
...(resolved.review ? { review: resolved.review } : {}),
|
|
240
|
+
},
|
|
241
|
+
};
|
|
242
|
+
return { enqueue: true, job };
|
|
243
|
+
}
|
|
219
244
|
}
|
|
220
245
|
|
|
221
246
|
/** Issue label path: the label allowlist IS the human approval gate -- only collaborators can label. */
|
|
@@ -230,15 +255,19 @@ function routeIssueLabel(subset, triggers) {
|
|
|
230
255
|
// A command rule (issue #189) skips flow resolution entirely: the label match IS the dispatch.
|
|
231
256
|
...(rule.command !== undefined ? { command: rule.command } : { flow: rule.flow }),
|
|
232
257
|
packages: rule.packages, // the MATCHED rule's fields -- rules in one file may differ on them
|
|
233
|
-
image: rule.image, backend: rule.backend, excludeTools: rule.excludeTools, mcp: rule.mcp,
|
|
234
|
-
provider: rule.provider, model: rule.model, maxTurns: rule.maxTurns, models: rule.models, maxCostUsd: rule.maxCostUsd,
|
|
258
|
+
image: rule.image, backend: rule.backend, excludeTools: rule.excludeTools, excludeExtensions: rule.excludeExtensions, excludeExtensionTools: rule.excludeExtensionTools, mcp: rule.mcp,
|
|
259
|
+
provider: rule.provider, model: rule.model, maxTurns: rule.maxTurns, models: rule.models, fallbackModels: rule.fallbackModels, maxCostUsd: rule.maxCostUsd,
|
|
235
260
|
skillsDir: rule.skillsDir,
|
|
236
261
|
secrets: rule.secrets,
|
|
237
262
|
secretsProfile: rule.secretsProfile,
|
|
238
263
|
waitFor: rule.waitFor,
|
|
239
264
|
instructions: rule.instructions,
|
|
240
265
|
resume: rule.resume,
|
|
241
|
-
awaitInput: rule.awaitInput,
|
|
266
|
+
awaitInput: rule.awaitInput, gates: rule.gates, gateTimeout: rule.gateTimeout, gateBudgetRaiseUsd: rule.gateBudgetRaiseUsd,
|
|
267
|
+
progress: rule.progress,
|
|
268
|
+
statusComment: rule.statusComment,
|
|
269
|
+
recover: rule.recover,
|
|
270
|
+
steer: rule.steer,
|
|
242
271
|
replicas: rule.replicas,
|
|
243
272
|
matched: { index: rule.index, type: "label", label: matchedLabel(L, rule.predicate) },
|
|
244
273
|
target: { type: "issue", number: subset.issue?.number, title: subset.issue?.title, body: subset.issue?.body },
|
|
@@ -251,7 +280,7 @@ function routeIssueLabel(subset, triggers) {
|
|
|
251
280
|
* `knownFlows` is passed separately because it is NOT a per-forge rule -- it is the whole file's flow
|
|
252
281
|
* vocabulary, and it bounds which names a comment may summon rather than which rules may match.
|
|
253
282
|
*/
|
|
254
|
-
function routeComment(subset, triggers, knownFlows) {
|
|
283
|
+
function routeComment(subset, triggers, knownFlows, assumeParked = false) {
|
|
255
284
|
if (!AUTHOR_ALLOWLIST.has(subset.comment?.author_association)) {
|
|
256
285
|
return { enqueue: false, reason: "author-not-allowed" };
|
|
257
286
|
}
|
|
@@ -263,8 +292,17 @@ function routeComment(subset, triggers, knownFlows) {
|
|
|
263
292
|
// trigger arms run.resume and is not a command, and only when the forge says the issue carries the marker. The
|
|
264
293
|
// author gate above already ran: this widens WHICH comments start a job, never WHO may. The marker is a hint; the
|
|
265
294
|
// worker refuses the job before any spend unless the key's `parked` sidecar exists.
|
|
266
|
-
const
|
|
295
|
+
const parkedCandidate = !phraseHit && typeof phrase === "string" && typeof body === "string" && triggers.comment?.resume === true && triggers.comment?.command === undefined && subset.issue?.pull_request !== true;
|
|
296
|
+
const parkedReply = parkedCandidate && (assumeParked || hasParkedLabel(subset.issue?.labels));
|
|
267
297
|
if (!phraseHit && !parkedReply) {
|
|
298
|
+
// Issue #638: a reply that fails ONLY for the missing marker (the run asked its question and is still parking, so
|
|
299
|
+
// the label is not on yet) is still a drop, but it carries the route the marker would have taken, built by this
|
|
300
|
+
// same function with the marker assumed (so every other rule above still decided it). The receiver holds it for a
|
|
301
|
+
// run that is parking on this target, or drops it exactly as today.
|
|
302
|
+
if (parkedCandidate) {
|
|
303
|
+
const held = routeComment(subset, triggers, knownFlows, true);
|
|
304
|
+
if (held.enqueue) return { enqueue: false, reason: "no-trigger-phrase", heldResolved: held };
|
|
305
|
+
}
|
|
268
306
|
return { enqueue: false, reason: "no-trigger-phrase" };
|
|
269
307
|
}
|
|
270
308
|
// On a COMMAND rule (issue #189) the whole flow-resolution block below -- default flow, the `<phrase>
|
|
@@ -299,18 +337,27 @@ function routeComment(subset, triggers, knownFlows) {
|
|
|
299
337
|
// The single comment trigger IS the matched rule here, so its opt-in is the job's. A `<phrase>
|
|
300
338
|
// <flow>` override changes WHICH flow runs, never which triggers.json entry authorized it.
|
|
301
339
|
packages: triggers.comment.packages,
|
|
302
|
-
image: triggers.comment.image, backend: triggers.comment.backend, excludeTools: triggers.comment.excludeTools, mcp: triggers.comment.mcp,
|
|
303
|
-
provider: triggers.comment.provider, model: triggers.comment.model, maxTurns: triggers.comment.maxTurns, models: triggers.comment.models, maxCostUsd: triggers.comment.maxCostUsd,
|
|
340
|
+
image: triggers.comment.image, backend: triggers.comment.backend, excludeTools: triggers.comment.excludeTools, excludeExtensions: triggers.comment.excludeExtensions, excludeExtensionTools: triggers.comment.excludeExtensionTools, mcp: triggers.comment.mcp,
|
|
341
|
+
provider: triggers.comment.provider, model: triggers.comment.model, maxTurns: triggers.comment.maxTurns, models: triggers.comment.models, fallbackModels: triggers.comment.fallbackModels, maxCostUsd: triggers.comment.maxCostUsd,
|
|
304
342
|
skillsDir: triggers.comment.skillsDir,
|
|
305
343
|
secrets: triggers.comment.secrets,
|
|
306
344
|
secretsProfile: triggers.comment.secretsProfile,
|
|
307
345
|
waitFor: triggers.comment.waitFor,
|
|
308
346
|
instructions: triggers.comment.instructions,
|
|
309
347
|
resume: triggers.comment.resume,
|
|
310
|
-
awaitInput: triggers.comment.awaitInput,
|
|
348
|
+
awaitInput: triggers.comment.awaitInput, gates: triggers.comment.gates, gateTimeout: triggers.comment.gateTimeout, gateBudgetRaiseUsd: triggers.comment.gateBudgetRaiseUsd,
|
|
349
|
+
progress: triggers.comment.progress,
|
|
350
|
+
statusComment: triggers.comment.statusComment,
|
|
351
|
+
recover: triggers.comment.recover,
|
|
352
|
+
steer: triggers.comment.steer,
|
|
311
353
|
replicas: triggers.comment.replicas,
|
|
312
354
|
matched: { index: triggers.comment.index, type: "comment", phrase },
|
|
313
355
|
...(parkedReply && { parkedReply: true }),
|
|
356
|
+
// Issue #646: a phrase comment on a steer-armed trigger may steer a run of it that is already going. Only with the
|
|
357
|
+
// phrase (a parked reply is an answer to a parked run, never a steer) and never on a command rule (the loader refuses
|
|
358
|
+
// `steer` beside `command`; checked again here so a drifted file cannot reach a run). The author gate above already
|
|
359
|
+
// ran: this changes where an admitted comment goes, never who may send one. The `later` grammar is `steerModeOf`'s.
|
|
360
|
+
...(phraseHit && command === undefined && triggers.comment.steer === true && { steerMode: steerModeOf(body, phrase) }),
|
|
314
361
|
// The invoking comment rides on the trigger: body and author_association are both named by
|
|
315
362
|
// INT-WEBHOOK-PAYLOAD-SUBSET, and the body stays DATA all the way down (CONST-ISSUE-TEXT-IS-DATA).
|
|
316
363
|
comment: { body: subset.comment.body, author_association: subset.comment.author_association },
|
|
@@ -400,15 +447,19 @@ function routePullRequest(subset, triggers, action) {
|
|
|
400
447
|
// A command rule (issue #189) skips flow resolution entirely: the rule match IS the dispatch.
|
|
401
448
|
...(rule.command !== undefined ? { command: rule.command } : { flow: rule.flow }),
|
|
402
449
|
packages: rule.packages, // the MATCHED rule's fields -- rules in one file may differ on them
|
|
403
|
-
image: rule.image, backend: rule.backend, excludeTools: rule.excludeTools, mcp: rule.mcp,
|
|
404
|
-
provider: rule.provider, model: rule.model, maxTurns: rule.maxTurns, models: rule.models, maxCostUsd: rule.maxCostUsd,
|
|
450
|
+
image: rule.image, backend: rule.backend, excludeTools: rule.excludeTools, excludeExtensions: rule.excludeExtensions, excludeExtensionTools: rule.excludeExtensionTools, mcp: rule.mcp,
|
|
451
|
+
provider: rule.provider, model: rule.model, maxTurns: rule.maxTurns, models: rule.models, fallbackModels: rule.fallbackModels, maxCostUsd: rule.maxCostUsd,
|
|
405
452
|
skillsDir: rule.skillsDir,
|
|
406
453
|
secrets: rule.secrets,
|
|
407
454
|
secretsProfile: rule.secretsProfile,
|
|
408
455
|
waitFor: rule.waitFor,
|
|
409
456
|
instructions: rule.instructions,
|
|
410
457
|
resume: rule.resume,
|
|
411
|
-
awaitInput: rule.awaitInput,
|
|
458
|
+
awaitInput: rule.awaitInput, gates: rule.gates, gateTimeout: rule.gateTimeout, gateBudgetRaiseUsd: rule.gateBudgetRaiseUsd,
|
|
459
|
+
progress: rule.progress,
|
|
460
|
+
statusComment: rule.statusComment,
|
|
461
|
+
recover: rule.recover,
|
|
462
|
+
steer: rule.steer,
|
|
412
463
|
replicas: rule.replicas,
|
|
413
464
|
matched: { index: rule.index, type: "pull_request", action },
|
|
414
465
|
...(isReview ? { review: { id: review.id, body: review.body, state: review.state, author_association: review.author_association } } : {}),
|
|
@@ -518,15 +569,19 @@ function routeClose(rules, number, closerAuthorized, matchedFor, targetFor) {
|
|
|
518
569
|
// A command rule (issue #189) skips flow resolution entirely: the rule match IS the dispatch.
|
|
519
570
|
...(rule.command !== undefined ? { command: rule.command } : { flow: rule.flow }),
|
|
520
571
|
packages: rule.packages, // the MATCHED rule's fields -- rules in one file may differ on them
|
|
521
|
-
image: rule.image, backend: rule.backend, excludeTools: rule.excludeTools, mcp: rule.mcp,
|
|
522
|
-
provider: rule.provider, model: rule.model, maxTurns: rule.maxTurns, models: rule.models, maxCostUsd: rule.maxCostUsd,
|
|
572
|
+
image: rule.image, backend: rule.backend, excludeTools: rule.excludeTools, excludeExtensions: rule.excludeExtensions, excludeExtensionTools: rule.excludeExtensionTools, mcp: rule.mcp,
|
|
573
|
+
provider: rule.provider, model: rule.model, maxTurns: rule.maxTurns, models: rule.models, fallbackModels: rule.fallbackModels, maxCostUsd: rule.maxCostUsd,
|
|
523
574
|
skillsDir: rule.skillsDir,
|
|
524
575
|
secrets: rule.secrets,
|
|
525
576
|
secretsProfile: rule.secretsProfile,
|
|
526
577
|
waitFor: rule.waitFor,
|
|
527
578
|
instructions: rule.instructions,
|
|
528
579
|
resume: rule.resume,
|
|
529
|
-
awaitInput: rule.awaitInput,
|
|
580
|
+
awaitInput: rule.awaitInput, gates: rule.gates, gateTimeout: rule.gateTimeout, gateBudgetRaiseUsd: rule.gateBudgetRaiseUsd,
|
|
581
|
+
progress: rule.progress,
|
|
582
|
+
statusComment: rule.statusComment,
|
|
583
|
+
recover: rule.recover,
|
|
584
|
+
steer: rule.steer,
|
|
530
585
|
replicas: rule.replicas,
|
|
531
586
|
matched: matchedFor(rule),
|
|
532
587
|
target: targetFor(),
|
|
@@ -553,8 +608,8 @@ export function wantsCloserAuthority(eventName, subset, group, selfId) {
|
|
|
553
608
|
// Filter's own step-0/1 guards, replicated FIRST and in the same order (fail-closed identity, then
|
|
554
609
|
// the unconditional bot-loop guard): the harness closing its own issue -- the natural last act of
|
|
555
610
|
// the very flow a close trigger arms -- must not cost a lookup, and an INDETERMINATE lookup on a
|
|
556
|
-
// self-delivery would 503 (
|
|
557
|
-
// `self`.
|
|
611
|
+
// self-delivery would answer 503 (a failed delivery in the hook's history and a `lookup-failed` count on the
|
|
612
|
+
// Connections page) for traffic the filter would only ever drop as `self`.
|
|
558
613
|
if (typeof subset?.sender?.id !== "number") return false;
|
|
559
614
|
if (subset.sender.id === selfId) return false;
|
|
560
615
|
if (eventName === "issues" && subset.action === CLOSE_ACTION) {
|
package/src/forgejo-members.mjs
CHANGED
|
@@ -19,7 +19,8 @@
|
|
|
19
19
|
* - it runs AFTER verification, so an unauthenticated flood cannot make this project call Forgejo;
|
|
20
20
|
* - it returns a two-armed verdict, because "not a collaborator" and "could not tell" are different
|
|
21
21
|
* answers. A 404 is determinate and refuses; anything else is INDETERMINATE and the receiver answers
|
|
22
|
-
* 503
|
|
22
|
+
* 503. Forgejo never resends that on its own, and its replay is a new delivery id (receiver.mjs, WHAT A
|
|
23
|
+
* 503 MEANS), so a person replays what still needs to run. Collapsing
|
|
23
24
|
* indeterminate to "deny" would drop real work during an outage behind a 204 that looks exactly like a
|
|
24
25
|
* stranger being correctly refused.
|
|
25
26
|
*
|
|
@@ -91,8 +92,8 @@ export function makeResolveForgejoAuthority({ apiUrl, token, tokenVar = "FORGEJO
|
|
|
91
92
|
// collaborators can query only their own"}; the same account raised to repo admin gets 200). The
|
|
92
93
|
// other 403s this route can give are the token's scope (`token does not have at least one of
|
|
93
94
|
// required scope(s)`, `token scope is limited to public repos`), so the dedicated token's reason names its scope too.
|
|
94
|
-
// Still indeterminate, and that is a decision, not an oversight. A
|
|
95
|
-
//
|
|
95
|
+
// Still indeterminate, and that is a decision, not an oversight. A replay cannot heal it, but the
|
|
96
|
+
// replay costs nothing (no token is minted and nothing spends before this answer), while the
|
|
96
97
|
// alternative is worse in the way this file's header names: a determinate refusal answers 204, which
|
|
97
98
|
// Forgejo records as a successful delivery, so the one place the operator looks would show every
|
|
98
99
|
// trigger "delivered" while nothing ran. A 503 marks each delivery failed there, the log line below
|
package/src/github-members.mjs
CHANGED
|
@@ -20,7 +20,8 @@
|
|
|
20
20
|
* payload-only and lookup-free, and an unauthenticated flood cannot make this project call GitHub;
|
|
21
21
|
* - it returns a two-armed verdict, because "not a collaborator" and "could not tell" are different
|
|
22
22
|
* answers. A 404 is determinate and refuses; anything unrecognised is INDETERMINATE and the receiver
|
|
23
|
-
* answers 503
|
|
23
|
+
* answers 503. GitHub never resends that on its own (receiver.mjs, WHAT A 503 MEANS): a redelivery by
|
|
24
|
+
* hand within 3 days keeps the `X-GitHub-Delivery` GUID, so it queues at most once.
|
|
24
25
|
* Collapsing indeterminate to "deny" would drop real work during an outage behind a 204 that looks
|
|
25
26
|
* exactly like a stranger being correctly refused.
|
|
26
27
|
*
|
package/src/gitlab-members.mjs
CHANGED
|
@@ -13,7 +13,8 @@
|
|
|
13
13
|
* API calls on an attacker's behalf.
|
|
14
14
|
* - It distinguishes "not a member" from "could not tell", and those are different answers. A 404 is
|
|
15
15
|
* determinate and yields `authorized: false`, which the gate refuses. Anything else -- a 5xx, a dead
|
|
16
|
-
* socket, a revoked token -- is INDETERMINATE, and the receiver answers 503
|
|
16
|
+
* socket, a revoked token -- is INDETERMINATE, and the receiver answers 503. GitLab never resends that on
|
|
17
|
+
* its own (receiver.mjs, WHAT A 503 MEANS); a "Resend Request" keeps the id, so it queues at most once.
|
|
17
18
|
* Collapsing indeterminate to "deny" would silently drop legitimate work during an outage, with a 204
|
|
18
19
|
* that reads exactly like a correctly-refused stranger.
|
|
19
20
|
*
|
package/src/held.mjs
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Held replies, the receiver's side (issue #638, DES-AWAIT-INPUT-ENDS-THE-JOB; keys and scripts in the worker's
|
|
3
|
+
* held-replies.mjs): an answer to a run that is parking right now is held for that run's settle instead of being lost.
|
|
4
|
+
*
|
|
5
|
+
* TWO WAYS A REPLY USED TO BE LOST, two entry points here, both shared by the four webhook arms and the poller so no
|
|
6
|
+
* forge can weaken one alone (the four-copies doctrine of receiver.mjs):
|
|
7
|
+
* (a) `holdAfterDedup`: a resume comment for an awaitInput trigger that the semantic window deduplicated against a
|
|
8
|
+
* run still going (the parked run finishing, or a resumed run). Held under the SURVIVING job's id, which is the
|
|
9
|
+
* run id the worker registered: the window is keyed per target and flow, so the survivor is that run.
|
|
10
|
+
* (b) `holdBeforeLabel`: a phrase-free reply on an issue that the filter dropped ONLY because the parked marker was
|
|
11
|
+
* not set yet (the filter's `heldCandidate`). Held under every awaitInput run live on the target.
|
|
12
|
+
* A run is live only while its worker's keeper refreshes it (held-settle.mjs), and the hold script refuses once the run
|
|
13
|
+
* has settled, so `ok` or `dup` means the run's settle will see the reply; anything else (no live run, `gone`,
|
|
14
|
+
* `closed`, `full`, a refusal, a Valkey fault) returns null and the caller answers EXACTLY as it did before this
|
|
15
|
+
* feature: a hold is a rescue for a reply that was going to be lost, never a way to lose one that was not.
|
|
16
|
+
*
|
|
17
|
+
* WHAT THE HELD REPLY BECOMES is the worker's call at the run's final outcome: the first one started as a resumed job if
|
|
18
|
+
* the run parked, dropped (today's coalescing) if it did not. The fallback stored is `fallbackJobOf`, the object the
|
|
19
|
+
* late steer stores, so "the job this comment would have been" has one spelling.
|
|
20
|
+
*
|
|
21
|
+
* Not the steer inbox, deliberately, and never consulted by `trySteer`: see held-replies.mjs for why the keys are apart.
|
|
22
|
+
* Logs carry the delivery, the kind, the target in the arm's grammar and run ids, never the comment. Never throws.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import { awaitTargetKeyOf, holdReply, liveAwaitRunsForTarget, recentlyParked } from "@edgehero/pi-dispatch/held-replies";
|
|
26
|
+
import { forgeMessageId } from "@edgehero/pi-dispatch/steer-inbox";
|
|
27
|
+
import { fallbackJobOf } from "./steer.mjs";
|
|
28
|
+
|
|
29
|
+
/** At most this many live awaitInput runs of one target take a pre-label reply: the steer fanout's bound. */
|
|
30
|
+
export const HELD_FANOUT_LIMIT = 4;
|
|
31
|
+
|
|
32
|
+
const nowOf = (now) => (typeof now === "function" ? now() : now);
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Whether a fanout outcome may hold anything: a COMMENT-matched job armed with both `run.resume` (it continues the
|
|
36
|
+
* parked session) and `run.awaitInput` (the trigger whose runs park), at least one replica of which the window
|
|
37
|
+
* swallowed. Free, so the caller asks it before it asks for a Valkey client.
|
|
38
|
+
*/
|
|
39
|
+
export function wantsHoldAfterDedup(job, outcome) {
|
|
40
|
+
return job?.trigger?.matched?.type === "comment" && job.resume === true && job.awaitInput === true && Array.isArray(outcome?.deduplicated) && outcome.deduplicated.length > 0;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async function holdUnder({ redis, runIds, job, kind, route, now, log, delivery, target }) {
|
|
44
|
+
const id = forgeMessageId(job?.trigger?.deliveryId);
|
|
45
|
+
if (id === null) {
|
|
46
|
+
log({ event: "held_fell_through", delivery, kind, route, reason: "no-delivery-id" });
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
const entry = { id, at: nowOf(now), route, fallback: fallbackJobOf(job, kind) };
|
|
50
|
+
const held = [];
|
|
51
|
+
const outcomes = {};
|
|
52
|
+
for (const runId of runIds) {
|
|
53
|
+
const r = await holdReply({ redis, runId, entry, now });
|
|
54
|
+
const outcome = typeof r === "string" ? r : (r?.error ?? "error");
|
|
55
|
+
outcomes[outcome] = (outcomes[outcome] ?? 0) + 1;
|
|
56
|
+
if (r === "ok" || r === "dup") {
|
|
57
|
+
held.push(runId);
|
|
58
|
+
log({ event: "held", delivery, repo: job.repo, target, route, runId });
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
if (held.length > 0) return { held: held.length, runIds: held };
|
|
62
|
+
log({ event: "held_fell_through", delivery, kind, route, reason: Object.keys(outcomes).length === 1 ? Object.keys(outcomes)[0] : "mixed", outcomes });
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* (a) `{ held, runIds }` when at least one surviving run took the reply, else null. `outcome` is `fanout`'s, `job` the
|
|
68
|
+
* job that was enqueued (no `steerMode`), `target` the arm's own grammar for the log.
|
|
69
|
+
*/
|
|
70
|
+
export async function holdAfterDedup({ redis, job, kind, outcome, now = Date.now, log = () => {}, delivery, target } = {}) {
|
|
71
|
+
try {
|
|
72
|
+
if (!wantsHoldAfterDedup(job, outcome)) return null;
|
|
73
|
+
if (!redis) {
|
|
74
|
+
log({ event: "held_fell_through", delivery, kind, route: "dedup", reason: "no-client" });
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
const runIds = [...new Set(outcome.deduplicated.map((d) => d?.survivingJobId).filter((r) => typeof r === "string" && r !== ""))];
|
|
78
|
+
if (runIds.length === 0) return null;
|
|
79
|
+
return await holdUnder({ redis, runIds, job, kind, route: "dedup", now, log, delivery, target });
|
|
80
|
+
} catch {
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* (b) `{ held, runIds }` when at least one live awaitInput run on the candidate's target took the reply; `{ enqueue:
|
|
87
|
+
* true }` when none did but a run PARKED on the target within the last minute (its take raised the parked marker,
|
|
88
|
+
* held-replies.mjs): the reply was posted before the label and delivered after the take, and is enqueued as the ordinary
|
|
89
|
+
* parked reply it would have been a moment later (it coalesces against the taken job, or resumes the parked session),
|
|
90
|
+
* never dropped for the label it raced (review round 2); else null. `candidate` is the filter's `heldCandidate` (the
|
|
91
|
+
* parked-reply job, `parkedReply: true`).
|
|
92
|
+
*/
|
|
93
|
+
export async function holdBeforeLabel({ redis, candidate, kind, now = Date.now, log = () => {}, delivery, target } = {}) {
|
|
94
|
+
try {
|
|
95
|
+
if (!candidate || typeof candidate !== "object" || candidate.parkedReply !== true) return null;
|
|
96
|
+
const targetKey = awaitTargetKeyOf({ ...candidate, kind });
|
|
97
|
+
if (targetKey === null) return null;
|
|
98
|
+
if (!redis) {
|
|
99
|
+
log({ event: "held_fell_through", delivery, kind, route: "pre-label", reason: "no-client" });
|
|
100
|
+
return null;
|
|
101
|
+
}
|
|
102
|
+
const runs = await liveAwaitRunsForTarget({ redis, targetKey, now, limit: HELD_FANOUT_LIMIT });
|
|
103
|
+
if (!Array.isArray(runs)) {
|
|
104
|
+
log({ event: "held_fell_through", delivery, kind, route: "pre-label", reason: runs?.error ?? "error" });
|
|
105
|
+
return null;
|
|
106
|
+
}
|
|
107
|
+
const held = runs.length > 0 ? await holdUnder({ redis, runIds: runs, job: candidate, kind, route: "pre-label", now, log, delivery, target }) : null;
|
|
108
|
+
if (held !== null) return held;
|
|
109
|
+
const parked = await recentlyParked({ redis, targetKey });
|
|
110
|
+
if (parked === true) {
|
|
111
|
+
log({ event: "held_after_park", delivery, kind, repo: candidate.repo, target });
|
|
112
|
+
return { enqueue: true };
|
|
113
|
+
}
|
|
114
|
+
// No live run and no park is the ordinary case (a phrase-free comment on an issue nothing is parking on), so it
|
|
115
|
+
// logs nothing: the caller's `dropped` line is exactly today's.
|
|
116
|
+
return null;
|
|
117
|
+
} catch {
|
|
118
|
+
return null;
|
|
119
|
+
}
|
|
120
|
+
}
|