@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/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 repo poll. 35 days
79
- * deliberately exceeds the 31-day gh-* jobId retention (REQ-DEDUP-BY-DELIVERY-GUID): the cursor and
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
- // 35 days: strictly outlives the 31-day gh-* job retention -- see the header's cursor/TTL section.
122
- const CURSOR_TTL_SECONDS = 35 * 24 * 3600;
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
- out({ event: "poll_cycle", repos: repos.length, fetched: stats.fetched, enqueued: stats.enqueued, notModified: stats.notModified, ms: now() - startedAt });
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, forge
936
- * redelivers) has no analogue here -- the poller IS its own redelivery. So: bounded retry, then a
937
- * loud skip. Below the bound this THROWS, on purpose, into pollLabelEvents' scoped catch: the cycle
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
- ctx.out({ event: "dropped", delivery: deliveryId, reason: result.reason });
977
- return;
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
- const replicas = result.job.replicas ?? 1;
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 ? { ...result.job, replica: i } : result.job);
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: result.job.repo, target: `${result.job.target.type}#${result.job.target.number}`, flow: result.job.flow, jobId: r.jobId, survivingJobId: r.survivingJobId });
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: result.job.repo, target: `${result.job.target.type}#${result.job.target.number}`, flow: result.job.flow, replicas: created });
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
+ }