@edgehero/pi-dispatch-receiver 4.3.0 → 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 +45 -57
- 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 +43 -9
- 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 +31 -6
- 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/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
|
+
}
|
package/src/poller.mjs
CHANGED
|
@@ -75,8 +75,9 @@
|
|
|
75
75
|
* close event (issue #231). Deliberately OUTSIDE the touched
|
|
76
76
|
* family, with a short ~1-day TTL of its own: the counter must
|
|
77
77
|
* decay with the outage it measures, not live with the repo.
|
|
78
|
-
* All keys carry a ~35-day TTL and are refreshed TOGETHER after each successful
|
|
79
|
-
* deliberately exceeds the
|
|
78
|
+
* All keys carry a ~35-day TTL (`CURSOR_TTL_SECONDS`) and are refreshed TOGETHER after each successful
|
|
79
|
+
* repo poll. It deliberately exceeds the gh-* jobId retention (`FORGE_JOB_RETENTION_S`,
|
|
80
|
+
* REQ-DEDUP-BY-DELIVERY-GUID) by four days, and is derived from it: the cursor and
|
|
80
81
|
* the jobId are the poller's two dedup layers, and refreshing/expiring the cursor family as a unit
|
|
81
82
|
* means an actively polled repo always has a coherent live family, while a repo REMOVED from the set
|
|
82
83
|
* decays cleanly past both windows and re-arms from scratch if it ever returns. A partially expired
|
|
@@ -111,6 +112,9 @@ import { configError } from "@edgehero/pi-dispatch/config";
|
|
|
111
112
|
import { judgeValkeyAtStart, makeRedisClient, parseConnection, valkeyClientContext } from "@edgehero/pi-dispatch/connection";
|
|
112
113
|
import { makeGitHubAuth } from "@edgehero/pi-dispatch/get-token";
|
|
113
114
|
import { enqueueGitHubJob, makeQueue } from "@edgehero/pi-dispatch/queue";
|
|
115
|
+
import { FORGE_JOB_RETENTION_S } from "@edgehero/pi-dispatch/retention";
|
|
116
|
+
import { trySteer } from "./steer.mjs";
|
|
117
|
+
import { holdAfterDedup, holdBeforeLabel } from "./held.mjs";
|
|
114
118
|
import { filter, hasCloseTriggers, wantsCloserAuthority } from "./filter.mjs";
|
|
115
119
|
import { makeResolveGitHubAuthority } from "./github-members.mjs";
|
|
116
120
|
import { parseSubset } from "./receiver.mjs";
|
|
@@ -118,8 +122,10 @@ import { loadPollerConfig } from "./poller-config.mjs";
|
|
|
118
122
|
import { retryIdentity } from "./boot-retry.mjs";
|
|
119
123
|
|
|
120
124
|
const API_URL = "https://api.github.com";
|
|
121
|
-
//
|
|
122
|
-
|
|
125
|
+
// In SECONDS (`EX`): strictly outlives the gh-* job retention (`FORGE_JOB_RETENTION_S`) by four days, derived so that
|
|
126
|
+
// lengthening the retention cannot leave the cursor the shorter of the poller's two dedup layers -- see the header's
|
|
127
|
+
// cursor/TTL section.
|
|
128
|
+
export const CURSOR_TTL_SECONDS = FORGE_JOB_RETENTION_S + 4 * 86_400;
|
|
123
129
|
// Installation repo-list refresh cadence, in cycles (~10 minutes at the default interval): fresh
|
|
124
130
|
// enough that a newly installed repo starts polling soon, cheap enough to be invisible in the quota.
|
|
125
131
|
const DISCOVERY_EVERY = 10;
|
|
@@ -347,7 +353,9 @@ export async function startPoller(env = process.env, deps = {}) {
|
|
|
347
353
|
}
|
|
348
354
|
|
|
349
355
|
cycleNo++;
|
|
350
|
-
|
|
356
|
+
// `steered` (issue #646) only when a comment steered a run this cycle, so a cycle line without one is unchanged;
|
|
357
|
+
// `held` (issue #638) by the same rule, when a reply was held for a parking run.
|
|
358
|
+
out({ event: "poll_cycle", repos: repos.length, fetched: stats.fetched, enqueued: stats.enqueued, notModified: stats.notModified, ...(stats.steered > 0 ? { steered: stats.steered } : {}), ...(stats.held > 0 ? { held: stats.held } : {}), ms: now() - startedAt });
|
|
351
359
|
if (stopped) break;
|
|
352
360
|
|
|
353
361
|
// The delay: the configured interval, raised by any x-poll-interval hint seen this cycle, and
|
|
@@ -390,7 +398,7 @@ export async function startPoller(env = process.env, deps = {}) {
|
|
|
390
398
|
}
|
|
391
399
|
|
|
392
400
|
function emptyStats() {
|
|
393
|
-
return { fetched: 0, enqueued: 0, notModified: 0, minDelaySeconds: 0 };
|
|
401
|
+
return { fetched: 0, enqueued: 0, notModified: 0, minDelaySeconds: 0, steered: 0, held: 0 };
|
|
394
402
|
}
|
|
395
403
|
|
|
396
404
|
/** 1-30s of jitter for the post-rate-limit wake, spread by the injected `random`. */
|
|
@@ -932,9 +940,10 @@ async function pollReviews(ctx, api, repo, open, stats) {
|
|
|
932
940
|
* stranger's close is the filter's own `closer-not-allowed` drop and the events cursor advances
|
|
933
941
|
* past it -- an unauthorized close never wedges the feed.
|
|
934
942
|
*
|
|
935
|
-
* An INDETERMINATE lookup has no honest verdict, and the webhook arm's answer (503,
|
|
936
|
-
*
|
|
937
|
-
* loud skip. Below the bound this THROWS, on purpose, into
|
|
943
|
+
* An INDETERMINATE lookup has no honest verdict, and the webhook arm's answer (a 503, which GitHub
|
|
944
|
+
* never resends, so it waits for a hand redelivery) has no analogue here -- the poller IS its own
|
|
945
|
+
* retry. So: bounded retry, then a loud skip. Below the bound this THROWS, on purpose, into
|
|
946
|
+
* pollLabelEvents' scoped catch: the cycle
|
|
938
947
|
* logs `poll_close_gate_retry`, the events cursor is still sitting BEFORE this event (the caller
|
|
939
948
|
* advances it only after a handler returns), so the next cycle retries exactly this close -- and
|
|
940
949
|
* only the EVENTS feed ends for the cycle, because a monotone cursor cannot skip an entry and come
|
|
@@ -973,24 +982,62 @@ async function gate(ctx, eventName, payload, deliveryId, stats) {
|
|
|
973
982
|
|
|
974
983
|
const result = filter(eventName, subset, ctx.cfg, ctx.selfId, deliveryId, closerAuthorized);
|
|
975
984
|
if (!result.enqueue) {
|
|
976
|
-
|
|
977
|
-
|
|
985
|
+
// Issue #638, the webhook arms' `heldBeforeLabelOr` at the poller's seam: a phrase-free reply that missed only the
|
|
986
|
+
// parked marker is held for an awaitInput run parking on its target; otherwise today's drop.
|
|
987
|
+
let rescued = null;
|
|
988
|
+
if (result.heldCandidate !== undefined) {
|
|
989
|
+
const c = result.heldCandidate;
|
|
990
|
+
rescued = await holdBeforeLabel({ redis: ctx.redis, candidate: c, kind: "github", now: ctx.now, log: ctx.out, delivery: deliveryId, target: `${c.target.type}#${c.target.number}` });
|
|
991
|
+
if (rescued !== null && rescued.enqueue !== true) {
|
|
992
|
+
stats.held = (stats.held ?? 0) + 1;
|
|
993
|
+
return;
|
|
994
|
+
}
|
|
995
|
+
}
|
|
996
|
+
// A run just parked on the target (held.mjs): the reply is the parked reply it would have been, enqueued below.
|
|
997
|
+
if (rescued?.enqueue !== true) {
|
|
998
|
+
ctx.out({ event: "dropped", delivery: deliveryId, reason: result.reason });
|
|
999
|
+
return;
|
|
1000
|
+
}
|
|
978
1001
|
}
|
|
979
|
-
|
|
1002
|
+
// Comment steering (issue #646), the webhook arms' `steerOrEnqueue` at the poller's seam: the same `trySteer` over
|
|
1003
|
+
// the poller's own Valkey client, before any enqueue, only for a job the filter marked `steerMode`. A null falls
|
|
1004
|
+
// through to the enqueue below unchanged, minus the marker.
|
|
1005
|
+
let job = result.enqueue ? result.job : result.heldCandidate;
|
|
1006
|
+
if (job.steerMode !== undefined) {
|
|
1007
|
+
const { steerMode: mode, ...rest } = job;
|
|
1008
|
+
const steered = await trySteer({ redis: ctx.redis, job, kind: "github", now: ctx.now, log: ctx.out });
|
|
1009
|
+
// A retried cycle's comment steered before: trySteer logged `steer_redelivered`; nothing to enqueue.
|
|
1010
|
+
if (steered?.redelivered === true) return;
|
|
1011
|
+
if (steered !== null) {
|
|
1012
|
+
stats.steered = (stats.steered ?? 0) + 1;
|
|
1013
|
+
ctx.out({ event: "steered", delivery: deliveryId, repo: job.repo, target: `${job.target.type}#${job.target.number}`, mode, runs: steered.steered, runIds: steered.runIds });
|
|
1014
|
+
return;
|
|
1015
|
+
}
|
|
1016
|
+
job = rest;
|
|
1017
|
+
}
|
|
1018
|
+
const replicas = job.replicas ?? 1;
|
|
980
1019
|
let created = 0;
|
|
1020
|
+
const deduplicated = [];
|
|
981
1021
|
for (let i = 1; i <= replicas; i++) {
|
|
982
|
-
const r = await ctx.enqueue(replicas > 1 ? { ...
|
|
1022
|
+
const r = await ctx.enqueue(replicas > 1 ? { ...job, replica: i } : job);
|
|
983
1023
|
// Issue #289, the receiver arms' honesty rule at the poller's own seam: a semantic-window swallow
|
|
984
1024
|
// gets its own line with the surviving id, `enqueued` counts only what was CREATED, and a bare-id
|
|
985
1025
|
// return from an enqueue seam predating the shape counts as created -- the old behaviour exactly.
|
|
986
1026
|
if (r && typeof r === "object" && r.deduplicated === true) {
|
|
987
|
-
ctx.out({ event: "deduplicated", delivery: deliveryId, repo:
|
|
1027
|
+
ctx.out({ event: "deduplicated", delivery: deliveryId, repo: job.repo, target: `${job.target.type}#${job.target.number}`, flow: job.flow, jobId: r.jobId, survivingJobId: r.survivingJobId });
|
|
1028
|
+
deduplicated.push({ jobId: r.jobId, survivingJobId: r.survivingJobId });
|
|
988
1029
|
} else {
|
|
989
1030
|
created += 1;
|
|
990
1031
|
}
|
|
991
1032
|
}
|
|
1033
|
+
// Issue #638: a resume comment the window swallowed against an awaitInput run still going is held for that run's
|
|
1034
|
+
// settle (held.mjs asks nothing of Valkey unless the job and the swallow say so); `held` lines are its own.
|
|
1035
|
+
if (deduplicated.length > 0) {
|
|
1036
|
+
const held = await holdAfterDedup({ redis: ctx.redis, job, kind: "github", outcome: { created, deduplicated }, now: ctx.now, log: ctx.out, delivery: deliveryId, target: `${job.target.type}#${job.target.number}` });
|
|
1037
|
+
if (held !== null) stats.held = (stats.held ?? 0) + 1;
|
|
1038
|
+
}
|
|
992
1039
|
stats.enqueued += created;
|
|
993
|
-
if (created > 0) ctx.out({ event: "enqueued", delivery: deliveryId, repo:
|
|
1040
|
+
if (created > 0) ctx.out({ event: "enqueued", delivery: deliveryId, repo: job.repo, target: `${job.target.type}#${job.target.number}`, flow: job.flow, replicas: created });
|
|
994
1041
|
}
|
|
995
1042
|
|
|
996
1043
|
/**
|
package/src/predicate.mjs
CHANGED
|
@@ -60,3 +60,40 @@ export function matchesRule(L, rule) {
|
|
|
60
60
|
export function escapeRegExp(literal) {
|
|
61
61
|
return literal.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
62
62
|
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Which way a phrase comment steers a running job (issue #646): `"followUp"` when the word right after the FIRST
|
|
66
|
+
* occurrence of the phrase is `later` (lowercase, a whole word, separated from the phrase by spaces or tabs only),
|
|
67
|
+
* else `"steer"`. The caller has already established that the body holds the phrase.
|
|
68
|
+
*
|
|
69
|
+
* The first occurrence only, and the word immediately after it only: anywhere else `later` is ordinary prose ("fix it
|
|
70
|
+
* later today" further down must not turn a correction into a follow-up). A newline between the phrase and the word
|
|
71
|
+
* does not count, because a phrase on its own line followed by a paragraph that opens with "Later" or "later" is
|
|
72
|
+
* prose, not a grammar word. Case-sensitive for the same reason. The delivered text is always the whole body: the
|
|
73
|
+
* word chooses the mode, it is never stripped.
|
|
74
|
+
*/
|
|
75
|
+
export function steerModeOf(body, phrase) {
|
|
76
|
+
if (typeof body !== "string" || typeof phrase !== "string" || phrase === "") return "steer";
|
|
77
|
+
const at = body.indexOf(phrase);
|
|
78
|
+
if (at < 0) return "steer";
|
|
79
|
+
return /^[ \t]+later(?=\s|$)/.test(body.slice(at + phrase.length)) ? "followUp" : "steer";
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* A filter's drop result as the receiver sees it (issue #638, DES-AWAIT-INPUT-ENDS-THE-JOB). A comment route that
|
|
84
|
+
* dropped a phrase-free reply ONLY because the parked marker was not on the issue yet returns `heldResolved`, the route
|
|
85
|
+
* it would have taken with the marker; this turns that into `heldCandidate`, the JOB that route builds, through the
|
|
86
|
+
* filter's own `build`, and returns every other drop exactly as it came (no key added, so its bytes are today's). A
|
|
87
|
+
* candidate whose build refuses (an Azure work item with no repository) is no candidate.
|
|
88
|
+
*
|
|
89
|
+
* Why the filter says it and not the receiver: the agent posts its question itself and only then calls the tool, so a
|
|
90
|
+
* person can answer in the seconds before the run has parked and the marker is set. Whether that reply would have
|
|
91
|
+
* resumed the run is a question about the trigger file and the payload, which only the filter reads; whether a run is
|
|
92
|
+
* parking right now is a Valkey question, which only the receiver asks. Still a drop: the filter stays pure.
|
|
93
|
+
*/
|
|
94
|
+
export function heldCandidateOr(dropped, build) {
|
|
95
|
+
if (dropped?.heldResolved === undefined) return dropped;
|
|
96
|
+
const { heldResolved, ...drop } = dropped;
|
|
97
|
+
const built = build(heldResolved);
|
|
98
|
+
return built?.enqueue === true ? { ...drop, heldCandidate: built.job } : drop;
|
|
99
|
+
}
|