forgedock 1.24.5 → 1.25.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.
@@ -1,68 +1,268 @@
1
1
  // SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
2
2
  // SPDX-License-Identifier: AGPL-3.0-or-later
3
3
  /**
4
- * Thin orchestrate: run core work-on for several issues with bounded concurrency, honouring `**Depends on**: #X`
4
+ * Thin orchestrate: run core work-on for several issues with bounded concurrency, honouring `Depends on` / `Blocked by`
5
5
  * among the given issues (wait for the dependency to merge; skip when it ends anywhere but merged/invalid).
6
+ * A `dependency-open` run is requeued behind its dependencies, pulling an out-of-set one into the batch; a
7
+ * `decomposed` run adds its sub-issues and its dependents wait on them instead (#4124).
6
8
  * No investigation, synthesis or agent spawning. `runOne(issue)` and `readBody(issue)` are injected.
9
+ * Optional `prefilter(issue, lane)` (#4123) runs in the lane slot before `runOne`: when it closed the issue, no lane starts.
10
+ * Optional `resolveLane(issue)` (#4125) works out each issue's own lane (milestone branch or staging): an issue whose
11
+ * lane is refused ends `lane-refused` without a run, and a dependency edge between two lanes goes to `onCrossLane`.
12
+ *
13
+ * Durable batch runner (#4121): with a `store` every transition is persisted, so a restarted dispatcher continues the
14
+ * batch; lanes it launched that are still alive are re-attached through `adopt(issue, rec)` instead of relaunched.
15
+ * `deferred` is a scheduling fact, not an end: each typed code is requeued with its retry-at (`deferPolicy`), a usage
16
+ * limit holds all admissions until its reset, and non-host deferrals stop after `maxDeferrals`. An `engine-error` is
17
+ * relaunched once; `breaker` ({errors, window_min}) stops admissions after a burst and lets running lanes finish, the
18
+ * rest stay pending (outcome `held`) for a resume. `getParallel()` is read on every pass, so K can change live.
7
19
  */
8
- const DEP_RE = /^\*\*Depends on\*\*:[ \t]*#(\d+)\b/gm;
20
+ import { parseDependsOn } from "../engine/phases.mjs";
21
+
9
22
  // A dependency that finished like this no longer blocks its dependents.
10
23
  const SATISFIED = new Set(["merged", "invalid"]);
24
+ // Times one issue may be requeued behind a dependency before its dependency-open row is final.
25
+ export const MAX_REQUEUES = 2;
26
+ // Non-host deferrals (anything but host.busy) one issue may take before its deferred row is final.
27
+ export const DEFAULT_MAX_DEFERRALS = 3;
28
+ // Relaunches of an issue whose lane ended engine-error.
29
+ export const ENGINE_ERROR_RELAUNCHES = 1;
30
+ export const HOST_BUSY_RETRY_S = 60;
31
+ export const RATE_LIMIT_FALLBACK_S = 15 * 60;
32
+ export const DEFER_BACKOFF_S = 120;
33
+ export const DEFER_BACKOFF_MAX_S = 30 * 60;
34
+ // A dependency-open whose dependencies already finished satisfied (GitHub not yet closed): wait before relaunching.
35
+ export const DEP_REQUEUE_DELAY_S = 60;
11
36
 
37
+ /** Declared dependencies of an issue body; the same parser as the engine's admission (`openDependency`). */
12
38
  export function parseDeps(body, issue) {
13
- return [...new Set([...String(body ?? "").replace(/\r\n/g, "\n").matchAll(DEP_RE)].map((m) => Number(m[1])))].filter((n) => n > 0 && n !== issue);
39
+ return parseDependsOn(body, issue);
40
+ }
41
+
42
+ const posNum = (v) => { const n = Number(v); return Number.isFinite(n) && n > 0 ? n : null; };
43
+
44
+ /**
45
+ * When to run a `deferred` issue again. `deferrals` counts this issue's earlier non-host deferrals.
46
+ * @returns {{delayMs:number, host?:true, hold?:true} | {capped:true}}
47
+ */
48
+ export function deferPolicy(r, { now, deferrals = 0, maxDeferrals = DEFAULT_MAX_DEFERRALS }) {
49
+ const code = r?.code ?? null;
50
+ if (code === "host.busy") return { delayMs: (posNum(r?.retryInSeconds) ?? HOST_BUSY_RETRY_S) * 1000, host: true };
51
+ const k = deferrals + 1;
52
+ if (k > maxDeferrals) return { capped: true };
53
+ if (code === "rate_limited") {
54
+ const at = posNum(r?.resetAtEpochMs) && r.resetAtEpochMs > now ? r.resetAtEpochMs : now + (posNum(r?.retryInSeconds) ?? RATE_LIMIT_FALLBACK_S) * 1000;
55
+ return { delayMs: at - now, hold: true };
56
+ }
57
+ if (posNum(r?.retryInSeconds)) return { delayMs: r.retryInSeconds * 1000 };
58
+ // lease-held: "leased by X until <ISO>"
59
+ const until = /\buntil (\d{4}-\d\d-\d\dT[\d:.]+Z)/.exec(String(r?.detail ?? ""))?.[1];
60
+ const at = until ? Date.parse(until) : NaN;
61
+ if (Number.isFinite(at) && at > now) return { delayMs: Math.min(at - now + 5000, DEFER_BACKOFF_MAX_S * 1000) };
62
+ return { delayMs: Math.min(DEFER_BACKOFF_S * 2 ** (k - 1), DEFER_BACKOFF_MAX_S) * 1000 };
14
63
  }
15
64
 
65
+ const cancellableSleep = (ms, signal) => new Promise((res) => {
66
+ const t = setTimeout(res, ms);
67
+ signal?.addEventListener("abort", () => { clearTimeout(t); res(); }, { once: true });
68
+ });
69
+
70
+ const toObj = (m) => Object.fromEntries([...m].map(([k, v]) => [String(k), v]));
71
+ const toMap = (o) => new Map(Object.entries(o ?? {}).map(([k, v]) => [Number(k), v]));
72
+
16
73
  /**
17
74
  * @returns {Promise<Array<{issue:number, outcome:string, pr:?number, cost_usd:number, minutes:number, detail?:string}>>}
18
75
  */
19
- export async function orchestrate({ issues, parallel = 3, runOne, readBody, now = () => Date.now(), onUpdate = () => {} }) {
20
- const set = new Set(issues);
21
- const deps = new Map();
22
- for (const n of issues) {
76
+ export async function orchestrate({ issues, parallel = 3, runOne, readBody, prefilter = null, resolveLane = null, now = () => Date.now(), onUpdate = () => {}, onCrossLane = () => {},
77
+ store = null, adopt = null, getParallel = null, breaker = null, maxDeferrals = DEFAULT_MAX_DEFERRALS, sleep = cancellableSleep, pollMs = 5000 }) {
78
+ const saved = store?.load?.() ?? null;
79
+ const order = saved ? [...saved.order] : [];
80
+ const set = new Set(order);
81
+ const deps = toMap(saved?.deps); // issue -> declared dependencies (any issue); only those in `set` are edges
82
+ const lanes = toMap(saved?.lanes); // issue -> resolveLane result
83
+ const crossSeen = new Set(saved?.crossSeen ?? []);
84
+ const results = toMap(saved?.results); // issue -> final row
85
+ const running = new Map(); // issue -> promise
86
+ const runRecs = toMap(saved?.running); // issue -> {startedAt, lane, pid?}: persisted so a restart can adopt the lane
87
+ const pending = [...(saved?.pending ?? [])];
88
+ const notBefore = toMap(saved?.notBefore);
89
+ const requeues = toMap(saved?.requeues);
90
+ const deferrals = toMap(saved?.deferrals);
91
+ const engineErrors = toMap(saved?.engineErrors);
92
+ const checked = toMap(saved?.prefilter); // issue -> pre-filter result
93
+ let errorTimes = [...(saved?.errorTimes ?? [])];
94
+ let holdUntil = saved?.holdUntil ?? null;
95
+ let tripped = saved?.breaker ?? null;
96
+
97
+ const save = () => {
98
+ if (!store) return;
99
+ store.save({ v: 1, order, deps: toObj(deps), lanes: toObj(lanes), crossSeen: [...crossSeen], results: toObj(results), running: toObj(runRecs),
100
+ pending, notBefore: toObj(notBefore), requeues: toObj(requeues), deferrals: toObj(deferrals), engineErrors: toObj(engineErrors),
101
+ prefilter: toObj(checked), errorTimes, holdUntil, breaker: tripped });
102
+ };
103
+ const update = (row) => { store?.log?.(row); onUpdate(row); };
104
+ const readDeps = async (n) => {
23
105
  let body = "";
24
106
  try { body = await readBody(n); } catch { /* unreadable: no ordering info, work-on's own check still applies */ }
25
- deps.set(n, parseDeps(body, n).filter((d) => set.has(d)));
26
- }
27
- const results = new Map(); // issue -> row
28
- const running = new Map(); // issue -> promise
29
- const pending = [...new Set(issues)];
107
+ deps.set(n, parseDeps(body, n));
108
+ if (resolveLane) { let l; try { l = await resolveLane(n); } catch (e) { l = { ok: false, code: "lane.unresolved", detail: e?.message ?? String(e) }; } lanes.set(n, l); }
109
+ };
110
+ const edges = (n) => (deps.get(n) ?? []).filter((d) => set.has(d));
111
+ // A milestone issue waiting on a staging fix (or the reverse) never sees that fix on its own base: surface the edge.
112
+ const noteCrossLane = () => {
113
+ for (const n of order) for (const d of edges(n)) {
114
+ const a = lanes.get(n), b = lanes.get(d), key = `${n}<${d}`;
115
+ if (!a?.ok || !b?.ok || a.lane === b.lane || crossSeen.has(key)) continue;
116
+ crossSeen.add(key);
117
+ onCrossLane({ issue: n, lane: a.lane, dependency: d, dependencyLane: b.lane });
118
+ }
119
+ };
120
+ const addIssues = async (ns) => {
121
+ const fresh = ns.filter((d) => !set.has(d));
122
+ for (const d of fresh) { set.add(d); order.push(d); }
123
+ for (const d of fresh) { await readDeps(d); pending.push(d); }
124
+ noteCrossLane();
125
+ };
126
+ // New issues (a fresh batch, or extra issues passed to a resume) join the queue.
127
+ for (const n of issues) if (!set.has(n)) { set.add(n); order.push(n); pending.push(n); }
128
+ for (const n of order) if (!deps.has(n)) await readDeps(n);
129
+ noteCrossLane();
130
+ save();
131
+
132
+ const skip = (n, detail) => { results.set(n, { issue: n, outcome: "skipped", pr: null, cost_usd: 0, minutes: 0, detail }); update(results.get(n)); };
133
+ const requeue = (n, delayMs) => { pending.push(n); if (delayMs > 0) notBefore.set(n, now() + delayMs); else notBefore.delete(n); };
134
+ const noteEngineError = () => {
135
+ if (!breaker) return;
136
+ const t = now(), windowMs = breaker.window_min * 60000;
137
+ errorTimes = [...errorTimes.filter((x) => t - x < windowMs), t];
138
+ if (!tripped && errorTimes.length >= breaker.errors) {
139
+ tripped = { at: t, detail: `circuit breaker: ${errorTimes.length} engine-errors within ${breaker.window_min} min; admissions stopped` };
140
+ update({ issue: null, outcome: "breaker", detail: tripped.detail });
141
+ }
142
+ };
30
143
 
31
- const skip = (n, detail) => { results.set(n, { issue: n, outcome: "skipped", pr: null, cost_usd: 0, minutes: 0, detail }); onUpdate(results.get(n)); };
144
+ // Apply one lane's result: requeue it (deferred, dependency-open, engine-error) or record its final row.
145
+ const settle = async (n, r, rec, pf) => {
146
+ const row = { issue: n, outcome: r?.terminalReason ?? "engine-error", pr: r?.pr ?? null, cost_usd: (r?.cost_usd ?? 0) + (pf?.cost_usd ?? 0), minutes: Math.round((now() - rec.startedAt) / 6000) / 10, detail: r?.detail, ...(r?.code ? { code: r.code } : {}), ...(pf ? { prefilter: pf } : {}) };
147
+ const depList = (Array.isArray(r?.dependencies) ? r.dependencies : []).filter((d) => Number.isSafeInteger(d) && d > 0 && d !== n);
148
+ const subs = (Array.isArray(r?.subissues) ? r.subissues : []).filter((d) => Number.isSafeInteger(d) && d > 0 && d !== n);
149
+ const done = () => { running.delete(n); runRecs.delete(n); };
150
+ if (row.outcome === "dependency-open" && depList.length && (requeues.get(n) ?? 0) < MAX_REQUEUES) {
151
+ // A scheduling fact, not a failure: add the edge, pull the dependency in, and run this issue again after it.
152
+ requeues.set(n, (requeues.get(n) ?? 0) + 1);
153
+ deps.set(n, [...new Set([...(deps.get(n) ?? []), ...depList])]);
154
+ await addIssues(depList);
155
+ // Dependencies already done but still open on GitHub: a delay instead of a hot relaunch loop.
156
+ const allDone = depList.every((d) => results.has(d) && SATISFIED.has(results.get(d).outcome));
157
+ requeue(n, allDone ? DEP_REQUEUE_DELAY_S * 1000 : 0); done(); save();
158
+ update({ ...row, detail: `requeued behind ${depList.map((d) => `#${d}`).join(", ")}` });
159
+ return;
160
+ }
161
+ if (row.outcome === "deferred") {
162
+ const pol = deferPolicy(r, { now: now(), deferrals: deferrals.get(n) ?? 0, maxDeferrals });
163
+ if (!pol.capped) {
164
+ if (!pol.host) deferrals.set(n, (deferrals.get(n) ?? 0) + 1);
165
+ if (pol.hold) holdUntil = Math.max(holdUntil ?? 0, now() + pol.delayMs);
166
+ requeue(n, pol.delayMs); done(); save();
167
+ update({ ...row, detail: `requeued in ${Math.round(pol.delayMs / 1000)}s${pol.hold ? " (admissions held)" : ""}: ${r?.code ?? "deferred"}${r?.detail ? ` ${r.detail}` : ""}` });
168
+ return;
169
+ }
170
+ row.detail = `gave up after ${deferrals.get(n) ?? 0} deferrals: ${r?.detail ?? r?.code ?? ""}`;
171
+ }
172
+ if (row.outcome === "engine-error") {
173
+ noteEngineError();
174
+ if ((engineErrors.get(n) ?? 0) < ENGINE_ERROR_RELAUNCHES) {
175
+ engineErrors.set(n, (engineErrors.get(n) ?? 0) + 1);
176
+ requeue(n, 0); done(); save();
177
+ update({ ...row, detail: `relaunching once: ${row.detail ?? ""}` });
178
+ return;
179
+ }
180
+ }
181
+ if (row.outcome === "decomposed" && subs.length) {
182
+ await addIssues(subs);
183
+ for (const [m, ds] of deps) if (ds.includes(n)) deps.set(m, [...new Set(ds.flatMap((d) => (d === n ? subs : [d])))]);
184
+ noteCrossLane();
185
+ }
186
+ results.set(n, row); done(); save(); update(row);
187
+ };
188
+
189
+ const track = (n, p) => { running.set(n, p.catch((e) => settle(n, { terminalReason: "engine-error", detail: e?.message ?? String(e) }, runRecs.get(n), null))); };
32
190
  const start = (n) => {
33
- const t0 = now();
34
- const p = (async () => {
191
+ const ln = lanes.get(n);
192
+ if (ln && !ln.ok) { // settled synchronously: nothing runs, no lease, no comment
193
+ const row = { issue: n, outcome: "lane-refused", pr: null, cost_usd: 0, minutes: 0, detail: `${ln.code}: ${ln.detail}`, code: ln.code };
194
+ results.set(n, row); save(); update(row);
195
+ return;
196
+ }
197
+ const lane = ln?.lane;
198
+ const rec = { startedAt: now(), ...(lane ? { lane } : {}) };
199
+ runRecs.set(n, rec); notBefore.delete(n); save();
200
+ track(n, (async () => {
201
+ // Pre-filter once per issue (a requeued issue is not re-checked); fail-open: an error means a normal lane.
202
+ if (prefilter && !checked.has(n)) { let pf = null; try { pf = await prefilter(n, lane); } catch { pf = null; } checked.set(n, pf); save(); }
203
+ const pf = checked.get(n) ?? null;
35
204
  let r;
36
- try { r = await runOne(n); } catch (e) { r = { terminalReason: "engine-error", detail: e?.message ?? String(e) }; }
37
- const row = { issue: n, outcome: r?.terminalReason ?? "engine-error", pr: r?.pr ?? null, cost_usd: r?.cost_usd ?? 0, minutes: Math.round((now() - t0) / 6000) / 10, detail: r?.detail };
38
- results.set(n, row); running.delete(n); onUpdate(row);
39
- })();
40
- running.set(n, p);
205
+ if (pf?.closed) r = { terminalReason: "invalid", cost_usd: 0, detail: `pre-filter: ${pf.detail}` };
206
+ else {
207
+ try { r = await runOne(n, lane, { onStart: (info) => { Object.assign(rec, info ?? {}); save(); } }); }
208
+ catch (e) { r = { terminalReason: "engine-error", detail: e?.message ?? String(e) }; }
209
+ }
210
+ await settle(n, r, rec, pf);
211
+ })());
41
212
  };
42
213
 
214
+ // Lanes launched by an earlier dispatcher: re-attach to the live ones, queue the rest again (no result was recorded).
215
+ for (const [n, rec] of [...runRecs]) {
216
+ let p = null;
217
+ if (adopt && rec?.pid) { try { p = await adopt(n, rec); } catch { p = null; } }
218
+ if (p) track(n, Promise.resolve(p).then((r) => settle(n, r, rec, checked.get(n) ?? null)));
219
+ else { runRecs.delete(n); if (!pending.includes(n) && !results.has(n)) pending.unshift(n); }
220
+ }
221
+ save();
222
+
223
+ const K = () => { let k = null; try { k = getParallel?.(); } catch { k = null; } return Number.isInteger(k) && k >= 1 ? k : parallel; };
43
224
  while (pending.length || running.size) {
44
225
  // Resolve skips first: a dependency that finished unsatisfied (or never can) skips its dependents.
45
226
  for (let i = pending.length - 1; i >= 0; i--) {
46
227
  const n = pending[i];
47
- const bad = deps.get(n).find((d) => results.has(d) && !SATISFIED.has(results.get(d).outcome));
228
+ if (results.has(n)) { pending.splice(i, 1); continue; }
229
+ const bad = edges(n).find((d) => results.has(d) && !SATISFIED.has(results.get(d).outcome));
48
230
  if (bad !== undefined) { pending.splice(i, 1); skip(n, `dependency #${bad} ended ${results.get(bad).outcome}`); }
49
231
  }
50
- for (let i = 0; i < pending.length && running.size < parallel;) {
51
- const n = pending[i];
52
- if (deps.get(n).every((d) => results.has(d) && SATISFIED.has(results.get(d).outcome))) { pending.splice(i, 1); start(n); }
53
- else i++;
232
+ const t = now();
233
+ if (holdUntil && t >= holdUntil) { holdUntil = null; save(); }
234
+ let started = 0;
235
+ if (!tripped && !holdUntil) {
236
+ for (let i = 0; i < pending.length && running.size < K();) {
237
+ const n = pending[i];
238
+ if ((notBefore.get(n) ?? 0) <= t && edges(n).every((d) => results.has(d) && SATISFIED.has(results.get(d).outcome))) { pending.splice(i, 1); start(n); started++; }
239
+ else i++;
240
+ }
54
241
  }
55
- if (running.size) await Promise.race(running.values());
56
- else if (pending.length) { // nothing runnable and nothing running: a dependency cycle
242
+ if (tripped && !running.size) break; // breaker: let running lanes finish, keep the rest pending for a resume
243
+ // Wake for a delayed issue, the end of a usage-limit hold, or (with a live K) a poll.
244
+ const waits = pending.map((n) => notBefore.get(n) ?? 0).filter((x) => x > t);
245
+ if (holdUntil) waits.push(holdUntil);
246
+ let wake = waits.length ? Math.max(0, Math.min(...waits) - t) : null;
247
+ if (getParallel && pending.length) wake = Math.min(wake ?? pollMs, pollMs);
248
+ if (tripped) wake = null;
249
+ if (running.size) {
250
+ if (wake === null) await Promise.race(running.values());
251
+ else { const ac = new AbortController(); await Promise.race([...running.values(), sleep(wake, ac.signal)]); ac.abort(); }
252
+ } else if (started) continue; // only lane-refused rows settled (synchronously): re-resolve skips first
253
+ else if (pending.length && waits.length) await sleep(Math.max(0, Math.min(...waits) - t));
254
+ else if (pending.length) { // nothing runnable, nothing running, nothing waiting on time: a dependency cycle
57
255
  for (const n of pending.splice(0)) skip(n, "dependency cycle");
256
+ save();
58
257
  }
59
258
  }
60
- return issues.filter((n, i) => issues.indexOf(n) === i).map((n) => results.get(n));
259
+ save();
260
+ return order.map((n) => results.get(n) ?? { issue: n, outcome: "held", pr: null, cost_usd: 0, minutes: 0, detail: tripped?.detail ?? "not run" });
61
261
  }
62
262
 
63
263
  export function formatTable(rows) {
64
264
  const head = ["issue", "outcome", "PR", "cost", "min"];
65
- const body = rows.map((r) => [`#${r.issue}`, r.outcome + (r.outcome === "skipped" && r.detail ? ` (${r.detail})` : ""), r.pr ? `#${r.pr}` : "-", `$${(r.cost_usd ?? 0).toFixed(2)}`, String(r.minutes ?? 0)]);
265
+ const body = rows.map((r) => [`#${r.issue}`, r.outcome + (["skipped", "held"].includes(r.outcome) && r.detail ? ` (${r.detail})` : r.prefilter?.closed ? " (pre-filter)" : ""), r.pr ? `#${r.pr}` : "-", `$${(r.cost_usd ?? 0).toFixed(2)}`, String(r.minutes ?? 0)]);
66
266
  const w = head.map((h, i) => Math.max(h.length, ...body.map((b) => b[i].length)));
67
267
  const line = (c) => c.map((x, i) => x.padEnd(w[i])).join(" ").trimEnd();
68
268
  return [line(head), line(w.map((n) => "-".repeat(n))), ...body.map(line)].join("\n");
@@ -0,0 +1,148 @@
1
+ // SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
2
+ // SPDX-License-Identifier: AGPL-3.0-or-later
3
+ /**
4
+ * Already-fixed pre-filter (#4123): cheap checks before any lane starts, so an issue that a merged PR or a later green
5
+ * CI run already resolved does not cost a worktree, a lease and a builder session.
6
+ * 1. merged PRs (and lane commits) that claim to fix #N -> candidate
7
+ * 2. auto-filed CI-failure issue whose workflow is green again on the failing branch -> closed with the run link
8
+ * 3. every repo path the issue names is gone from origin/<lane> -> candidate
9
+ * A candidate gets ONE read-only, single-turn validation on a small model in the main checkout (no worktree). Only an
10
+ * ALREADY-FIXED/INVALID verdict with evidence closes the issue; everything else, and every error, goes to the normal lane.
11
+ * All effects are injected (gh, git, runner, transitionLabel, projector?) so it is unit-testable.
12
+ */
13
+ import { capComment, sanitizeMd } from "./render.mjs";
14
+ import { PREFILTER_SYSTEM, prefilterPrompt, parsePrefilterVerdict } from "./prompts.mjs";
15
+
16
+ export const DEFAULT_PREFILTER_MODEL = "claude-haiku-5-5";
17
+ export const PREFILTER_MARKER = "FORGE:PREFILTER";
18
+ /** Spend avoided per pre-filter close when the batch has no full-lane invalid run to average (#4123: ~$13 / 21 lanes). */
19
+ export const DEFAULT_AVOIDED_USD = 0.62;
20
+
21
+ const esc = (s) => String(s).replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
22
+ /** `Fixes #N`, `closes owner/repo#N`, `resolved: #N`, or a lean-style title/subject ending `(#N)`. */
23
+ export function claimsFix(text, n) {
24
+ const s = String(text ?? "");
25
+ return new RegExp(`\\b(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?)\\b:?\\s+(?:[\\w.-]+/[\\w.-]+)?#${n}(?!\\d)`, "i").test(s)
26
+ || new RegExp(`\\(#${n}\\)\\s*$`, "m").test(s);
27
+ }
28
+
29
+ const RUN_URL_RE = /https:\/\/github\.com\/([\w.-]+\/[\w.-]+)\/actions\/runs\/(\d+)/;
30
+ const CI_FAIL_RE = /\b(?:fail(?:s|ed|ing|ure)?|broken|red)\b/i;
31
+ /** The failing Actions run of an auto-filed CI-failure issue: failure wording plus a run link in the title or body. */
32
+ export function ciFailureRun(iss) {
33
+ const text = `${iss.title ?? ""}\n${iss.body ?? ""}`;
34
+ const m = RUN_URL_RE.exec(text);
35
+ return m && CI_FAIL_RE.test(text) ? { repo: m[1], id: Number(m[2]) } : null;
36
+ }
37
+
38
+ const PATH_RE = /(?:^|[\s`'"(\[])((?:\.{0,2}\/)?(?:[\w@.-]+\/)+[\w@.-]+\.[A-Za-z0-9]{1,8})(?::\d+(?:-\d+)?)?(?=$|[\s`'"),.:;\]])/gm;
39
+ /** Repo-relative file paths the issue names (`dir/file.ext`, optionally `:line`); URLs and HTML comments are ignored. */
40
+ export function namedPaths(text) {
41
+ const s = sanitizeMd(text).replace(/<!--[\s\S]*?-->/g, " ").replace(/\b[a-z]+:\/\/\S+/gi, " ");
42
+ return [...new Set([...s.matchAll(PATH_RE)].map((m) => m[1].replace(/^\.\//, "")).filter((p) => !p.startsWith("/") && !p.startsWith("../")))];
43
+ }
44
+
45
+ /**
46
+ * @returns {Promise<{prs:object[], commits:object[], ci:?object, missingPaths:string[], namedPaths:string[]}>}
47
+ */
48
+ export async function collectSignals({ iss, repo, lane, io, cwd }) {
49
+ const n = iss.number;
50
+ const sig = { prs: [], commits: [], ci: null, missingPaths: [], namedPaths: [] };
51
+ try {
52
+ const found = JSON.parse(String(await io.gh(["search", "prs", `#${n}`, "--repo", repo, "--merged", "--json", "number,title,body,url", "--limit", "20"])) || "[]");
53
+ sig.prs = found.filter((p) => claimsFix(p.title, n) || claimsFix(p.body, n));
54
+ } catch { /* search unavailable: no PR signal */ }
55
+ try {
56
+ const log = String(await io.git(["log", `origin/${lane}`, "--max-count=50", "-E", `--grep=#${n}([^0-9]|$)`, "--format=%H%x09%s"], { cwd }));
57
+ sig.commits = log.split("\n").map((l) => l.split("\t")).filter(([h, s]) => h && s && !/^Merge /.test(s) && claimsFix(s, n)).map(([sha, subject]) => ({ sha, subject }));
58
+ } catch { /* no lane ref: no commit signal */ }
59
+ const run = ciFailureRun(iss);
60
+ if (run && run.repo.toLowerCase() === repo.toLowerCase()) {
61
+ try {
62
+ const failed = JSON.parse(String(await io.gh(["api", `repos/${repo}/actions/runs/${run.id}`])));
63
+ const branch = failed.head_branch || lane;
64
+ const latest = JSON.parse(String(await io.gh(["api", `repos/${repo}/actions/workflows/${failed.workflow_id}/runs?branch=${encodeURIComponent(branch)}&status=completed&per_page=1`])))?.workflow_runs?.[0];
65
+ if (latest && latest.id !== failed.id && latest.conclusion === "success" && Date.parse(latest.created_at) > Date.parse(failed.created_at)) {
66
+ sig.ci = { workflow: failed.name ?? latest.name ?? String(failed.workflow_id), branch, failedUrl: failed.html_url, greenUrl: latest.html_url, greenSha: latest.head_sha ?? "" };
67
+ }
68
+ } catch { /* run unreadable: no CI signal */ }
69
+ }
70
+ sig.namedPaths = namedPaths(iss.body);
71
+ if (sig.namedPaths.length) {
72
+ const missing = [];
73
+ for (const p of sig.namedPaths.slice(0, 20)) {
74
+ try { await io.git(["cat-file", "-e", `origin/${lane}:${p}`], { cwd }); } catch { missing.push(p); }
75
+ }
76
+ if (missing.length === Math.min(sig.namedPaths.length, 20)) sig.missingPaths = missing; // all gone: the code moved or was removed
77
+ }
78
+ return sig;
79
+ }
80
+
81
+ /**
82
+ * Pre-filter one issue. Never throws.
83
+ * @returns {Promise<{issue:number, closed:boolean, kind:?string, candidate:boolean, verdict:?string, cost_usd:number, detail:string}>}
84
+ */
85
+ export async function prefilterIssue({ issue, repo, lane, cwd, io, model = DEFAULT_PREFILTER_MODEL, dryRun = false, agentId = null, maxMinutes = 5 }) {
86
+ const out = { issue, closed: false, kind: null, candidate: false, verdict: null, cost_usd: 0, detail: "" };
87
+ try {
88
+ const iss = JSON.parse(String(await io.gh(["issue", "view", String(issue), "-R", repo, "--json", "number,title,body,state,labels"])));
89
+ if (String(iss.state).toUpperCase() !== "OPEN") return { ...out, detail: "not open" };
90
+ if ((iss.labels ?? []).some((l) => ["needs-human", "workflow:merged"].includes(l?.name ?? l))) return { ...out, detail: "labelled, left to the lane" };
91
+ if (io.projector) {
92
+ let st = null;
93
+ try { st = await io.projector.readState(issue); } catch { /* unreadable: proceed */ }
94
+ if (st?.lease && st.lease.until > Date.now() && st.lease.by !== agentId) return { ...out, detail: `leased by ${st.lease.by}` };
95
+ }
96
+ const sig = await collectSignals({ iss, repo, lane, io, cwd });
97
+ const close = async (kind, body, detail) => {
98
+ if (!dryRun) {
99
+ await io.gh(["issue", "comment", String(issue), "-R", repo, "--body", capComment(`<!-- ${PREFILTER_MARKER} -->\n<!-- FORGE:VALIDATE -->\n${body}`)]);
100
+ await io.gh(["issue", "close", String(issue), "-R", repo, "--reason", "not planned"]);
101
+ try { await io.transitionLabel?.(issue, "invalid"); } catch { /* labels are best effort */ }
102
+ }
103
+ return { ...out, closed: !dryRun, kind, detail };
104
+ };
105
+
106
+ if (sig.ci) {
107
+ out.candidate = true;
108
+ const c = sig.ci;
109
+ return await close("ci-green", `## Pre-filter: CI failure no longer reproduces\n**Workflow:** ${c.workflow} on \`${c.branch}\`\n**Failing run:** ${c.failedUrl}\n**Latest run (green):** ${c.greenUrl}${c.greenSha ? ` at \`${c.greenSha.slice(0, 12)}\`` : ""}\n\nClosed without a lane. Reopen if the failure comes back.`,
110
+ `CI green again: ${c.greenUrl}`);
111
+ }
112
+ if (!sig.prs.length && !sig.commits.length && !sig.missingPaths.length) return { ...out, detail: "no already-fixed signal" };
113
+ out.candidate = true;
114
+ const why = [...sig.prs.map((p) => `PR #${p.number}`), ...sig.commits.map((c) => c.sha.slice(0, 12)), ...(sig.missingPaths.length ? ["named paths missing"] : [])].join(", ");
115
+ if (dryRun) return { ...out, detail: `candidate: ${why}` };
116
+ let r;
117
+ try {
118
+ r = await io.runner({ role: "prefilter", issue, cwd, model, resume: null, system: PREFILTER_SYSTEM, readOnly: true,
119
+ prompt: prefilterPrompt({ issue: iss, lane, signals: sig }), maxMs: Math.round(maxMinutes * 60000) });
120
+ } catch (e) { return { ...out, detail: `validation failed (${String(e?.message ?? e).slice(0, 120)}): lane` }; }
121
+ out.cost_usd = Number.isFinite(r?.costUsd) ? r.costUsd : 0;
122
+ const v = parsePrefilterVerdict(r?.text);
123
+ out.verdict = v?.verdict ?? null;
124
+ if (!v || v.verdict === "CONFIRMED" || !v.evidence.length) return { ...out, detail: `candidate (${why}) ${v ? (v.verdict === "CONFIRMED" ? "confirmed" : "without evidence") : "unparseable verdict"}: lane` };
125
+ const ev = v.evidence.map((x) => `- ${sanitizeMd(x).replace(/\s+/g, " ").slice(0, 400)}`).join("\n");
126
+ return await close("validated", `## Pre-filter: ${v.verdict}\n**Signal:** ${why}\n**Summary:** ${sanitizeMd(v.summary).replace(/\s+/g, " ").slice(0, 600) || "not reported"}\n\n**Evidence:**\n${ev}\n\nValidated by a single read-only check (${model}), without a lane. Reopen if this is wrong.`,
127
+ `${v.verdict.toLowerCase()} (${why})`);
128
+ } catch (e) {
129
+ return { ...out, detail: `pre-filter error (${String(e?.message ?? e).slice(0, 120)}): lane` };
130
+ }
131
+ }
132
+
133
+ /** Per-issue hook for `orchestrate({ prefilter })`; `lane` is the issue's own resolved lane (#4125). */
134
+ export function makePrefilter(opts) {
135
+ return (issue, lane) => prefilterIssue({ ...opts, issue, ...(lane ? { lane } : {}) });
136
+ }
137
+
138
+ /** One report line: how many the pre-filter checked, flagged and closed, what validation cost, and the spend avoided. */
139
+ export function formatPrefilterSummary(rows) {
140
+ const pf = rows.filter((r) => r?.prefilter);
141
+ if (!pf.length) return "";
142
+ const closed = pf.filter((r) => r.prefilter.closed);
143
+ const lanes = rows.filter((r) => r && !r.prefilter?.closed && r.outcome === "invalid" && (r.cost_usd ?? 0) > 0);
144
+ const per = lanes.length ? lanes.reduce((a, r) => a + r.cost_usd, 0) / lanes.length : DEFAULT_AVOIDED_USD;
145
+ const spent = pf.reduce((a, r) => a + (r.prefilter.cost_usd ?? 0), 0);
146
+ const ci = closed.filter((r) => r.prefilter.kind === "ci-green").length;
147
+ return `pre-filter: ${pf.length} checked, ${pf.filter((r) => r.prefilter.candidate).length} candidate(s), ${closed.length} closed without a lane (${ci} CI green, ${closed.length - ci} validated); validation $${spent.toFixed(2)}, spend avoided ~$${Math.max(0, closed.length * per - spent).toFixed(2)} (${lanes.length ? "batch invalid-lane average" : "default"} $${per.toFixed(2)}/lane)`;
148
+ }