@edgehero/pi-dispatch-receiver 4.3.1 → 5.1.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/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
+ }