forgedock 1.34.0 → 1.36.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.
Files changed (61) hide show
  1. package/README.md +78 -324
  2. package/bin/engine/events.mjs +152 -0
  3. package/bin/engine/events.test.mjs +128 -0
  4. package/bin/engine/telemetry-seam.mjs +4 -3
  5. package/bin/engine-cli.mjs +181 -26
  6. package/bin/experiment.mjs +55 -0
  7. package/bin/forgedock.mjs +19 -1
  8. package/bin/hooks/reread-nudge.mjs +44 -5
  9. package/bin/lean/batch.mjs +66 -3
  10. package/bin/lean/batch.test.mjs +176 -1
  11. package/bin/lean/budget.mjs +14 -3
  12. package/bin/lean/budget.test.mjs +11 -1
  13. package/bin/lean/conflictres.mjs +93 -0
  14. package/bin/lean/conflictres.test.mjs +93 -0
  15. package/bin/lean/conformance.mjs +30 -9
  16. package/bin/lean/conformance.test.mjs +51 -0
  17. package/bin/lean/effects.mjs +14 -3
  18. package/bin/lean/execution.mjs +2 -0
  19. package/bin/lean/i18n.mjs +65 -0
  20. package/bin/lean/i18n.test.mjs +47 -0
  21. package/bin/lean/lane.mjs +16 -0
  22. package/bin/lean/lean.mjs +147 -36
  23. package/bin/lean/lean.test.mjs +319 -8
  24. package/bin/lean/orchestrate.mjs +130 -13
  25. package/bin/lean/postdeploy-check.mjs +149 -0
  26. package/bin/lean/postdeploy-check.test.mjs +146 -0
  27. package/bin/lean/postdeploy-monitor.mjs +87 -0
  28. package/bin/lean/postdeploy-monitor.test.mjs +121 -0
  29. package/bin/lean/precheck.mjs +58 -8
  30. package/bin/lean/precheck.test.mjs +77 -3
  31. package/bin/lean/prefilter.mjs +29 -4
  32. package/bin/lean/prefilter.test.mjs +13 -5
  33. package/bin/lean/probe.mjs +2 -1
  34. package/bin/lean/prompts.mjs +36 -6
  35. package/bin/lean/sandbox-provider.contract.test.mjs +28 -0
  36. package/bin/lean/sandbox-remote.mjs +7 -0
  37. package/bin/lean/schedd.mjs +34 -5
  38. package/bin/lean/schedd.test.mjs +84 -0
  39. package/bin/lean/sequence.mjs +26 -0
  40. package/bin/lean/sequence.test.mjs +41 -2
  41. package/bin/lean/toolstate.mjs +77 -0
  42. package/bin/lean/toolstate.test.mjs +83 -0
  43. package/bin/lean/verify.mjs +18 -2
  44. package/bin/lean/verify.test.mjs +29 -3
  45. package/bin/stats/aggregate.mjs +1 -1
  46. package/bin/stats/experiment.mjs +44 -0
  47. package/bin/stats/github-source.mjs +2 -1
  48. package/bin/stats/index.mjs +42 -2
  49. package/bin/stats/release.mjs +75 -0
  50. package/bin/stats/release.test.mjs +68 -0
  51. package/commands/orchestrate.md +8 -3
  52. package/docs/CONFIG.md +61 -2
  53. package/docs/EVENTS.md +68 -0
  54. package/docs/STATS.md +31 -0
  55. package/docs/TELEMETRY.md +3 -1
  56. package/docs/site/install.md +89 -0
  57. package/package.json +1 -1
  58. package/scripts/bench-lightlane.mjs +2 -1
  59. package/scripts/eval-gate-scorecard.mjs +7 -0
  60. package/scripts/file-deferred-followups.sh +1 -0
  61. package/scripts/file-deferred-followups.test.sh +8 -0
@@ -0,0 +1,87 @@
1
+ // SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
2
+ // SPDX-License-Identifier: AGPL-3.0-or-later
3
+ /**
4
+ * Windowed post-deploy monitoring (#4627). A follow-up whose checks declare `for` (and `every`) stays open with the
5
+ * `post-deploy-monitoring` label after its first pass. `sweepDue` (run by schedd's timer and `forgedock probe --due`) re-runs
6
+ * each one when its cadence elapsed or `origin/<default>` moved (a release), with the command read from the default branch
7
+ * (never an issue). A pass is a FORGE:PROBE record; the window end closes the follow-up; a fail after a pass files one
8
+ * regression issue naming the fix commit and the last-passing and breaking release, then closes the follow-up. No model call.
9
+ */
10
+ import { readProbeConfig, resolveDefaultBranch, PROBE_MARKER, OUTPUT_TAIL } from "./probe.mjs";
11
+ import { MONITORING_LABEL, checksAt, itemCheckIds, monitorRecord, runNamedChecks, CHECKS_FILE } from "./postdeploy-check.mjs";
12
+ import { deferredFrom, isTrustedIssueComment } from "./prefilter.mjs";
13
+
14
+ export const REGRESSION_MARKER = "FORGE:REGRESSION";
15
+ const fenced = (s) => { const f = "`".repeat(Math.max(3, ...[...String(s).matchAll(/`+/g)].map((m) => m[0].length + 1))); return `${f}\n${String(s).trim() || "(no output)"}\n${f}`; };
16
+ const short = (s) => String(s ?? "unknown").slice(0, 12);
17
+
18
+ /** One monitored follow-up. Outcomes: skipped | unrunnable | not-due | due (dry run) | pass | window-ended | fail. */
19
+ export async function monitorOne({ issue, repo, def, defaultSha, cwd, io, now, dryRun, config, env, spawn }) {
20
+ const row = (outcome, detail = "", extra = {}) => ({ issue, outcome, detail, ...extra });
21
+ const iss = JSON.parse(String(await io.gh(["issue", "view", String(issue), "-R", repo, "--json", "number,title,body,state,labels,comments"])));
22
+ if (String(iss.state).toUpperCase() !== "OPEN") return row("skipped", "not open");
23
+ const recs = (iss.comments ?? []).filter((c) => isTrustedIssueComment(c)).map((c) => monitorRecord(c?.body)).filter(Boolean);
24
+ const last = recs[recs.length - 1];
25
+ const finish = async (comment) => {
26
+ if (comment) await io.gh(["issue", "comment", String(issue), "-R", repo, "--body", comment]);
27
+ await io.gh(["issue", "close", String(issue), "-R", repo, "--reason", "completed"]);
28
+ try { await io.gh(["issue", "edit", String(issue), "-R", repo, "--remove-label", MONITORING_LABEL]); } catch { /* label already gone */ }
29
+ };
30
+ if (!last || last.result !== "pass" || !last.until) return row("skipped", last?.result === "fail" ? "already failed (FORGE:PROBE)" : "no windowed pass record");
31
+ if (now >= last.until) {
32
+ if (!dryRun) await finish(`<!-- FORGE:MONITOR -->\nPost-deploy monitoring window ended ${new Date(last.until).toISOString()} with no failure; closed.`);
33
+ return row("window-ended", dryRun ? "dry run: not closed" : "closed", { fix: last.fix });
34
+ }
35
+ if (now - last.at < last.everyMs && defaultSha && defaultSha === last.defaultSha) return row("not-due", `next run after ${new Date(last.at + last.everyMs).toISOString()} or a new release`);
36
+ const ids = itemCheckIds(iss.body);
37
+ const checks = await checksAt(io.git, [`origin/${def}`], cwd);
38
+ if (!ids.length || ids.some((id) => !id || !checks[id])) return row("unrunnable", `no runnable check on ${def} (${CHECKS_FILE})`);
39
+ if (dryRun) return row("due", "dry run: not run", { fix: last.fix });
40
+ const rows = await runNamedChecks({ ids, checks, cwd, config, env, spawn });
41
+ const ok = rows.every((r) => r.result === "pass");
42
+ const rec = { fix: last.fix, default_sha: defaultSha, result: ok ? "pass" : "fail", at: new Date(now).toISOString(), ...(ok ? { window_until: new Date(last.until).toISOString(), every_ms: last.everyMs } : {}) };
43
+ const sum = rows.map((r) => ({ id: r.id, result: r.result, exit: r.exit, ms: r.ms }));
44
+ if (ok) {
45
+ await io.gh(["issue", "comment", String(issue), "-R", repo, "--body", `<!-- ${PROBE_MARKER} -->\n## Post-deploy check: pass\n${JSON.stringify({ ...rec, checks: sum })}\n\nRe-run on \`${def}\` at \`${short(defaultSha)}\`.`]);
46
+ return row("pass", `checks ${[...new Set(ids)].join(", ")} passed at ${short(defaultSha)}`, { fix: last.fix });
47
+ }
48
+ const bad = rows.filter((r) => r.result === "fail");
49
+ const parent = deferredFrom(iss.body);
50
+ const title = `Regression: post-deploy check ${bad.map((r) => r.id).join(", ")} failed on ${def}${parent ? ` (fix of #${parent})` : ""}`;
51
+ const body = [
52
+ `<!-- ${REGRESSION_MARKER} -->`, `Post-deploy check ${bad.map((r) => `\`${r.id}\``).join(", ")} passed after the fix and now fails.`, "",
53
+ `- Fix commit: \`${last.fix}\``, `- Last passing release (\`${def}\`): \`${last.defaultSha ?? "unknown"}\``, `- Breaking release (\`${def}\`): \`${defaultSha ?? "unknown"}\``,
54
+ `- Monitored follow-up: #${issue}${parent ? `; parent: #${parent}` : ""}`, "", ...bad.flatMap((r) => [`**${r.id}** (exit ${r.exit ?? "timeout"})`, fenced(r.output), ""]),
55
+ ].join("\n").slice(0, OUTPUT_TAIL * 4);
56
+ const created = String(await io.gh(["issue", "create", "-R", repo, "--title", title, "--body", body]));
57
+ const num = /\/issues\/(\d+)/.exec(created)?.[1] ?? null;
58
+ await finish(`<!-- ${PROBE_MARKER} -->\n## Post-deploy check: fail\n${JSON.stringify({ ...rec, regression: num ? Number(num) : null, checks: sum })}\n\nRegression issue ${num ? `#${num}` : "filed"}; monitoring stops.`);
59
+ return row("fail", `regression ${num ? `#${num}` : "filed"}: ${bad.map((r) => r.id).join(", ")} broke at ${short(defaultSha)} (fix ${short(last.fix)})`, { fix: last.fix, regression: num });
60
+ }
61
+
62
+ /** Re-run every due monitored follow-up of the repo. Sequential; an error is an `error` row, never a throw. */
63
+ export async function sweepDue({ repo, cwd = process.cwd(), io, dryRun = false, now = () => Date.now(), config = readProbeConfig(cwd), env = process.env, spawn, onResult = null } = {}) {
64
+ const def = await resolveDefaultBranch({ repo, io, cwd });
65
+ if (!def) throw new Error("no default branch");
66
+ const found = JSON.parse(String(await io.gh(["issue", "list", "-R", repo, "--state", "open", "--label", MONITORING_LABEL, "--limit", "100", "--json", "number"])));
67
+ const list = (Array.isArray(found) ? found : []).map((i) => i.number).filter(Number.isInteger);
68
+ if (!list.length) return [];
69
+ try { await io.git(["fetch", "origin", def], { cwd }); } catch { /* offline: the local ref */ }
70
+ let defaultSha = null;
71
+ try { defaultSha = String(await io.git(["rev-parse", `origin/${def}`], { cwd })).trim() || null; } catch { /* unknown release */ }
72
+ const rows = [];
73
+ for (const issue of list) {
74
+ try { rows.push(await monitorOne({ issue, repo, def, defaultSha, cwd, io, now: now(), dryRun, config, env, spawn })); } catch (e) {
75
+ rows.push({ issue, outcome: "error", detail: String(e?.message ?? e).slice(0, 160) });
76
+ }
77
+ try { if (!dryRun) await onResult?.(rows[rows.length - 1]); } catch { /* an event sink never fails the sweep */ }
78
+ }
79
+ return rows;
80
+ }
81
+
82
+ /** One line, e.g. `monitoring: pass #1; regression #2; closed #3`; "" when nothing is monitored. */
83
+ export function formatDueSummary(rows) {
84
+ const g = [["pass", "pass"], ["fail", "regression"], ["window-ended", "window ended"], ["due", "due"], ["not-due", "not due"], ["unrunnable", "unrunnable"], ["error", "error"]];
85
+ const parts = g.map(([k, l]) => [l, (rows ?? []).filter((r) => r.outcome === k).map((r) => `#${r.issue}`)]).filter(([, a]) => a.length).map(([l, a]) => `${l} ${a.join(" ")}`);
86
+ return parts.length ? `monitoring: ${parts.join("; ")}` : "";
87
+ }
@@ -0,0 +1,121 @@
1
+ // SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
2
+ // SPDX-License-Identifier: AGPL-3.0-or-later
3
+ import { describe, it } from "node:test";
4
+ import assert from "node:assert/strict";
5
+ import { sweepDue, formatDueSummary } from "./postdeploy-monitor.mjs";
6
+ import { PROBE_MARKER } from "./probe.mjs";
7
+ import { isTrustedIssueComment } from "./prefilter.mjs";
8
+
9
+ const T0 = Date.parse("2026-10-26T00:00:00Z");
10
+ const H = 3600e3;
11
+ const CHECKS = JSON.stringify({ soak: { run: "echo ok", for: "7d", every: "6h" } });
12
+ const BODY = "## Acceptance Criteria\n\n- [ ] `R1: health row exists` — post-deploy: [check:soak] soak\n\n<!-- FORGE:DEFERRED_FROM: #40 -->\n";
13
+ const rec = (o) => ({ authorAssociation: "OWNER", author: { login: "bot" }, body: `<!-- ${PROBE_MARKER} -->\n${JSON.stringify({ fix: "fix1234567890", default_sha: "rel1", result: "pass", at: new Date(T0).toISOString(), window_until: new Date(T0 + 7 * 24 * H).toISOString(), every_ms: 6 * H, ...o })}` });
14
+
15
+ function world({ comments = [rec({})], head = "rel1", file = CHECKS, code = 0, state = "OPEN" } = {}) {
16
+ const w = { gh: [], git: [], spawned: [] };
17
+ w.io = {
18
+ gh: async (a) => {
19
+ w.gh.push(a);
20
+ const k = a.slice(0, 2).join(" ");
21
+ if (k === "repo view") return "main\n";
22
+ if (k === "issue list") return JSON.stringify([{ number: 41 }]);
23
+ if (k === "issue view") return JSON.stringify({ number: 41, state, title: "", body: BODY, labels: [{ name: "post-deploy-monitoring" }], comments });
24
+ if (k === "issue create") return "https://github.com/o/r/issues/77\n";
25
+ return "";
26
+ },
27
+ git: async (a) => { w.git.push(a); if (a[0] === "show") { if (file == null) throw new Error("missing"); return file; } if (a[0] === "rev-parse") return `${head}\n`; return ""; },
28
+ };
29
+ w.spawn = async (o) => { w.spawned.push(o.run); return { code, stdout: code ? "broke" : "ok", stderr: "" }; };
30
+ return w;
31
+ }
32
+ const sweep = (w, o = {}) => sweepDue({ repo: "o/r", cwd: "/repo", io: w.io, now: () => T0 + H, config: { env: [], timeoutS: 5 }, env: {}, spawn: w.spawn, ...o });
33
+ const calls = (w, k) => w.gh.filter((a) => a.slice(0, 2).join(" ") === k);
34
+
35
+ const trustedOk = isTrustedIssueComment(rec({}));
36
+
37
+ describe("post-deploy windowed monitoring (#4627)", () => {
38
+ it("fixture comments count as trusted", () => assert.ok(trustedOk));
39
+
40
+ it("not due: cadence not elapsed and no new release", async () => {
41
+ const w = world();
42
+ const rows = await sweep(w);
43
+ assert.equal(rows[0].outcome, "not-due"); assert.deepEqual(w.spawned, []);
44
+ });
45
+
46
+ it("due by cadence: re-runs the default-branch command and records a pass; the follow-up stays open", async () => {
47
+ const w = world();
48
+ const rows = await sweep(w, { now: () => T0 + 7 * H });
49
+ assert.equal(rows[0].outcome, "pass"); assert.deepEqual(w.spawned, ["echo ok"]);
50
+ assert.ok(w.git.some((a) => a.join(" ") === "show origin/main:.forge/post-deploy-checks.json"));
51
+ assert.equal(calls(w, "issue comment").length, 1);
52
+ assert.equal(calls(w, "issue close").length, 0); assert.equal(calls(w, "issue create").length, 0);
53
+ });
54
+
55
+ it("due by a new release (origin/main moved) well before the cadence", async () => {
56
+ const w = world({ head: "rel2" });
57
+ const rows = await sweep(w);
58
+ assert.equal(rows[0].outcome, "pass"); assert.deepEqual(w.spawned, ["echo ok"]);
59
+ });
60
+
61
+ it("window end closes the follow-up and drops the label without running", async () => {
62
+ const w = world();
63
+ const rows = await sweep(w, { now: () => T0 + 8 * 24 * H });
64
+ assert.equal(rows[0].outcome, "window-ended"); assert.deepEqual(w.spawned, []);
65
+ assert.equal(calls(w, "issue close").length, 1);
66
+ assert.ok(calls(w, "issue edit").some((a) => a.includes("--remove-label")));
67
+ });
68
+
69
+ it("pass then fail files one regression naming the fix commit and both releases, then closes the follow-up", async () => {
70
+ const w = world({ head: "rel2", code: 1 });
71
+ const rows = await sweep(w);
72
+ assert.equal(rows[0].outcome, "fail"); assert.equal(rows[0].regression, "77");
73
+ const [create] = calls(w, "issue create");
74
+ const text = create[create.indexOf("--body") + 1];
75
+ assert.match(create[create.indexOf("--title") + 1], /Regression: post-deploy check soak failed on main \(fix of #40\)/);
76
+ assert.match(text, /FORGE:REGRESSION/); assert.match(text, /fix1234567890/); assert.match(text, /`rel1`/); assert.match(text, /`rel2`/); assert.match(text, /broke/);
77
+ assert.equal(calls(w, "issue close").length, 1);
78
+ assert.ok(!w.gh.some((a) => a[1] === "reopen"));
79
+ const cm = calls(w, "issue comment")[0];
80
+ assert.match(cm[cm.indexOf("--body") + 1], /"result":"fail".*"regression":77/s);
81
+ });
82
+
83
+ it("a follow-up whose latest record is a fail is never re-run or re-filed", async () => {
84
+ const w = world({ head: "rel3", code: 1, comments: [rec({}), rec({ result: "fail", default_sha: "rel2", regression: 77 })] });
85
+ const rows = await sweep(w);
86
+ assert.equal(rows[0].outcome, "skipped"); assert.deepEqual(w.spawned, []); assert.equal(calls(w, "issue create").length, 0);
87
+ });
88
+
89
+ it("an untrusted record never counts", async () => {
90
+ const w = world({ comments: [{ authorAssociation: "NONE", author: { login: "rando" }, body: rec({}).body }] });
91
+ assert.equal((await sweep(w, { now: () => T0 + 9 * H }))[0].outcome, "skipped");
92
+ assert.deepEqual(w.spawned, []);
93
+ });
94
+
95
+ it("a check missing on the default branch is unrunnable and never run", async () => {
96
+ for (const file of [null, JSON.stringify({ other: "echo hi" })]) {
97
+ const w = world({ file, head: "rel2" });
98
+ assert.equal((await sweep(w))[0].outcome, "unrunnable"); assert.deepEqual(w.spawned, []);
99
+ }
100
+ });
101
+
102
+ it("dry run runs nothing and writes nothing", async () => {
103
+ const w = world({ head: "rel2" });
104
+ assert.equal((await sweep(w, { dryRun: true }))[0].outcome, "due");
105
+ assert.deepEqual(w.spawned, []);
106
+ assert.equal(calls(w, "issue comment").length + calls(w, "issue close").length + calls(w, "issue create").length, 0);
107
+ });
108
+
109
+ it("no monitored follow-up: no git, no run; the summary names outcomes", async () => {
110
+ const w = world();
111
+ w.io.gh = async (a) => (a[0] === "repo" ? "main\n" : "[]");
112
+ assert.deepEqual(await sweep(w), []); assert.equal(w.git.length, 0);
113
+ assert.equal(formatDueSummary([{ issue: 1, outcome: "pass" }, { issue: 2, outcome: "fail" }]), "monitoring: pass #1; regression #2");
114
+ });
115
+
116
+ it("reports sink rows for pass and fail only when not a dry run", async () => {
117
+ const seen = [];
118
+ await sweep(world({ head: "rel2", code: 1 }), { onResult: (r) => seen.push(r.outcome) });
119
+ assert.deepEqual(seen, ["fail"]);
120
+ });
121
+ });
@@ -10,7 +10,8 @@
10
10
  * which the builder runs in-session (`selfCheck`); `worktreeFingerprint` tells whether that run saw the final tree.
11
11
  */
12
12
  import { createHash } from "node:crypto";
13
- import { scopeChecks, selectCommands } from "./testmap.mjs";
13
+ import { loadavg, cpus } from "node:os";
14
+ import { scopeChecks, selectCommands, mergeChecks } from "./testmap.mjs";
14
15
  import { LOG_CAP } from "./verify.mjs";
15
16
 
16
17
  /** #4364: the engine's check plan for `forgedock check` (written beside contract.json) and the CLI's last run record. */
@@ -22,6 +23,45 @@ export const CHECK_LOG_DIR = ".forge-lean/logs";
22
23
  /** rc of a command killed by its timeout (effects.mjs runCheckCommand). */
23
24
  const TIMEOUT_RC = 124;
24
25
 
26
+ /** Ceiling of a load-scaled retry timeout. */
27
+ const MAX_RETRY_MS = 3600000;
28
+
29
+ /** Host load per core, from the OS; the default `load` of `runWithTimeoutRetry`. */
30
+ const hostLoad = () => ({ load1: loadavg()[0], cores: cpus().length || 1 });
31
+
32
+ /**
33
+ * #4589: the timeout of the one retry after a command hit its own timeout: `perMs * max(2, ceil(load1 / cores))`, at most
34
+ * 3600 s and never below `perMs`. Deterministic; a timed-out command has no measured duration, so load scales it.
35
+ */
36
+ export function retryTimeoutMs({ perMs, load1, cores }) {
37
+ const per = Number(perMs);
38
+ if (!(per > 0) || !Number.isFinite(per)) return per;
39
+ const factor = Math.max(2, Math.ceil((Number(load1) || 0) / (Number(cores) > 0 ? Number(cores) : 1)));
40
+ return Math.max(per, Math.min(per * factor, MAX_RETRY_MS));
41
+ }
42
+
43
+ /**
44
+ * #4589: a check timeout (rc 124) is a busy host, not a failing command: run `run(timeoutMs)`, and when it timed out on its
45
+ * own, once more with `retryTimeoutMs`. `left` (ms still available in a round budget) caps the retry; an exhausted budget, or a retry the budget cut
46
+ * short, is `cut` (left to CI); a budget with room for no longer retry leaves the first rc 124 a failure (#4620). A repeated timeout keeps rc 124 and says it was infrastructure. Other rcs are never retried.
47
+ * @param {(timeoutMs:number) => Promise<{rc:number, output?:string}>} run
48
+ * @returns {Promise<{r:{rc:number, output?:string}, retried:boolean, cut:boolean}>}
49
+ */
50
+ export async function runWithTimeoutRetry(run, timeoutMs, { left = () => Infinity, load = hostLoad } = {}) {
51
+ const r = await run(timeoutMs);
52
+ if (r?.rc !== TIMEOUT_RC || !(timeoutMs > 0) || !Number.isFinite(timeoutMs)) return { r, retried: false, cut: false };
53
+ let want = timeoutMs;
54
+ try { want = retryTimeoutMs({ perMs: timeoutMs, ...load() }); } catch { /* no load reading: the minimum factor */ want = timeoutMs * 2; }
55
+ const room = left();
56
+ if (!(room > 0)) return { r, retried: false, cut: true }; // the budget is spent: CI runs it
57
+ const t2 = Math.min(want, room);
58
+ if (!(t2 > timeoutMs)) return { r, retried: false, cut: false }; // #4620: no room for a longer retry; its own timeout stays a failure
59
+ const r2 = await run(t2);
60
+ const cut = r2?.rc === TIMEOUT_RC && t2 < want;
61
+ if (r2?.rc === TIMEOUT_RC && !cut) return { r: { ...r2, output: `${String(r2.output ?? "")}\n(infra timeout: host load, not a code failure; retried once with ${Math.round(t2 / 1000)}s)` }, retried: true, cut };
62
+ return { r: r2, retried: true, cut };
63
+ }
64
+
25
65
  /**
26
66
  * @param {{commands:string[], parallelism?:number, budgetMs:number, perCommandMs:number, now?:() => number,
27
67
  * acquire?:() => Promise<{release?:Function}|null>,
@@ -30,7 +70,7 @@ const TIMEOUT_RC = 124;
30
70
  * @returns {Promise<{results:{command:string, rc:number, output:string, ms:number}[], deferred:string[], ms:number}>}
31
71
  * results in input order (commands that ran and were not cut by the budget); deferred in input order
32
72
  */
33
- export async function runChecks({ commands, parallelism = 1, budgetMs, perCommandMs, now = () => Date.now(), acquire, run }) {
73
+ export async function runChecks({ commands, parallelism = 1, budgetMs, perCommandMs, now = () => Date.now(), acquire, run, load }) {
34
74
  const list = Array.isArray(commands) ? commands : [];
35
75
  const workers = Math.max(1, Math.floor(Number(parallelism)) || 1);
36
76
  const k = Math.max(1, Math.min(workers, list.length)), share = Math.max(1, Math.floor(workers / k));
@@ -44,7 +84,13 @@ export async function runChecks({ commands, parallelism = 1, budgetMs, perComman
44
84
  if (left <= 0) return { deferred: true };
45
85
  const timeoutMs = Math.min(per, left), t = now();
46
86
  let r;
47
- try { r = await run({ command, timeoutMs, workers: share }); } catch (e) { r = { rc: 127, output: String(e?.message ?? e) }; }
87
+ try {
88
+ if (timeoutMs >= per) { // #4589: its own timeout, not the budget's: one longer retry
89
+ const x = await runWithTimeoutRetry((ms) => run({ command, timeoutMs: ms, workers: share }), timeoutMs, { left: () => deadline - now(), ...(load ? { load } : {}) });
90
+ if (x.cut) return { deferred: true };
91
+ r = x.r;
92
+ } else r = await run({ command, timeoutMs, workers: share });
93
+ } catch (e) { r = { rc: 127, output: String(e?.message ?? e) }; }
48
94
  const rc = Number.isInteger(r?.rc) ? r.rc : 1;
49
95
  // the budget, not the command's own timeout, cut it short: CI runs it
50
96
  if (rc === TIMEOUT_RC && timeoutMs < per) return { deferred: true };
@@ -80,15 +126,19 @@ export function formatPrecheckSummary(rows) {
80
126
  /**
81
127
  * #4364: the pre-PR commands and budget for a round: `scopeChecks` over the trimmed learned commands (a mapped learned
82
128
  * command runs only when its globs match a changed file); budget lean.precheck.max_minutes, else
83
- * verify.precheck_total_seconds (default 600 s); per command verify.precheck_timeout_seconds.
84
- * @returns {{commands:string[], source:string, added:number, dropped:string[], selected:number, budgetMs:number, perCommandMs:number}}
129
+ * verify.precheck_total_seconds (default 600 s); per command verify.precheck_timeout_seconds. #4585: `invariants`
130
+ * (verify.invariants) are appended unscoped after the selected commands, deduped; the test map never drops them.
131
+ * @returns {{commands:string[], source:string, added:number, dropped:string[], selected:number, invariants?:number, budgetMs:number, perCommandMs:number}}
85
132
  */
86
- export function planPrecheck({ testMap = null, files = [], learned = [], maxMinutes, verify = {} }) {
133
+ export function planPrecheck({ testMap = null, files = [], learned = [], maxMinutes, verify = {}, invariants = verify?.invariants }) {
87
134
  const list = (Array.isArray(learned) ? learned : []).map((c) => String(c ?? "").trim()).filter(Boolean);
88
- const m = scopeChecks(testMap, files, list);
135
+ const s = scopeChecks(testMap, files, list);
136
+ const inv = (Array.isArray(invariants) ? invariants : []).map((c) => String(c ?? "").trim()).filter(Boolean);
137
+ const withInv = mergeChecks(inv, s.commands);
138
+ const m = inv.length ? { ...s, commands: withInv.commands } : s;
89
139
  const max = Number(maxMinutes);
90
140
  const budgetMs = (max > 0 ? max * 60 : Number(verify.precheckTotalSeconds) || 600) * 1000;
91
- return { ...m, selected: testMap ? selectCommands(testMap, files).length : 0, budgetMs, perCommandMs: (Number(verify.precheckTimeoutSeconds) || 300) * 1000 };
141
+ return { ...m, ...(inv.length ? { invariants: inv.length } : {}), selected: testMap ? selectCommands(testMap, files).length : 0, budgetMs, perCommandMs: (Number(verify.precheckTimeoutSeconds) || 300) * 1000 };
92
142
  }
93
143
 
94
144
  const lines = (out) => String(out ?? "").split("\n").map((x) => x.trim()).filter(Boolean);
@@ -6,7 +6,7 @@ import { writeFileSync } from "node:fs";
6
6
  import { join } from "node:path";
7
7
  import { tmpDir } from "../tests/_tmp.mjs";
8
8
  import { formatFirstPassSummary } from "./fixround.mjs";
9
- import { runChecks, formatPrecheckSummary, planPrecheck, selfCheck, selfCheckState, formatCheckTable, worktreeFingerprint, quietFailure } from "./precheck.mjs";
9
+ import { runChecks, retryTimeoutMs, runWithTimeoutRetry, formatPrecheckSummary, planPrecheck, selfCheck, selfCheckState, formatCheckTable, worktreeFingerprint, quietFailure } from "./precheck.mjs";
10
10
  import { execFileSync } from "node:child_process";
11
11
  import { readPrecheckBudget } from "../engine-cli.mjs";
12
12
  import { orchestrate } from "./orchestrate.mjs";
@@ -52,9 +52,9 @@ describe("runChecks (#4331)", () => {
52
52
  assert.deepEqual(r.deferred, ["b", "c"]);
53
53
  });
54
54
 
55
- it("a command's own timeout (not the budget) stays a failure; a throw is rc 127", async () => {
55
+ it("a command's own timeout (not the budget) stays a failure once the retry also times out; a throw is rc 127", async () => {
56
56
  const h = harness({ cost: { slow: 5000 } });
57
- const r = await runChecks({ commands: ["slow", "boom", "bad"], parallelism: 2, budgetMs: 600000, perCommandMs: 2000, now: h.now,
57
+ const r = await runChecks({ commands: ["slow", "boom", "bad"], parallelism: 2, budgetMs: 600000, perCommandMs: 2000, now: h.now, load: () => ({ load1: 0, cores: 4 }),
58
58
  run: (a) => (a.command === "boom" ? Promise.reject(new Error("spawn failed")) : a.command === "bad" ? Promise.resolve({ rc: 2, output: "lint" }) : h.run(a)) });
59
59
  assert.deepEqual(r.results.map((x) => [x.command, x.rc]), [["slow", 124], ["boom", 127], ["bad", 2]]);
60
60
  assert.match(r.results[1].output, /spawn failed/);
@@ -145,6 +145,16 @@ describe("readPrecheckBudget: forge.yaml lean.precheck.max_minutes (#4331)", ()
145
145
  describe("forgedock check core (#4364)", () => {
146
146
  const MAP = { version: 1, entries: [{ globs: ["src/**"], command: "npm test", cwd: "." }, { globs: ["docs/**"], command: "make docs", cwd: "." }] };
147
147
 
148
+ it("planPrecheck #4585: invariants are appended unscoped, deduped, and kept when the map matches nothing", () => {
149
+ const p = planPrecheck({ testMap: MAP, files: ["README.md"], learned: ["make lint"], verify: { invariants: ["bash scripts/inv.sh", " make lint ", ""] } });
150
+ assert.deepEqual(p.commands.at(-1), "bash scripts/inv.sh");
151
+ assert.equal(p.commands.filter((c) => c === "make lint").length, 1);
152
+ assert.equal(p.invariants, 2);
153
+ const only = planPrecheck({ files: [], learned: [], verify: { invariants: ["bash scripts/inv.sh"] } });
154
+ assert.deepEqual(only.commands, ["bash scripts/inv.sh"]);
155
+ assert.deepEqual(planPrecheck({ testMap: MAP, files: ["README.md"], learned: ["make lint"] }).commands, ["make lint"], "no invariants: unchanged");
156
+ });
157
+
148
158
  it("planPrecheck: the engine's selection and budget rule (max_minutes wins, else precheck_total_seconds, else 600 s)", () => {
149
159
  const p = planPrecheck({ testMap: MAP, files: ["src/a.js"], learned: [" make lint ", "", "make docs"], verify: { precheckTotalSeconds: 120, precheckTimeoutSeconds: 30 } });
150
160
  assert.deepEqual(p.commands, ["make lint", "npm test"]); assert.deepEqual(p.dropped, ["make docs"]);
@@ -208,3 +218,67 @@ describe("forgedock check core (#4364)", () => {
208
218
  assert.notEqual(await fp(), dirty);
209
219
  });
210
220
  });
221
+
222
+ describe("check timeout is infrastructure (#4589)", () => {
223
+ const idle = () => ({ load1: 0, cores: 4 });
224
+ it("retryTimeoutMs doubles at least, scales with load per core, caps at 3600 s and never shrinks", () => {
225
+ assert.equal(retryTimeoutMs({ perMs: 300000, load1: 0, cores: 4 }), 600000);
226
+ assert.equal(retryTimeoutMs({ perMs: 300000, load1: 13, cores: 4 }), 1200000);
227
+ assert.equal(retryTimeoutMs({ perMs: 2000000, load1: 40, cores: 4 }), 3600000);
228
+ assert.equal(retryTimeoutMs({ perMs: 4000000, load1: 0, cores: 4 }), 4000000);
229
+ });
230
+ it("runChecks retries a command's own timeout once with a longer one and passes", async () => {
231
+ const h = harness({ cost: { slow: 400000 } });
232
+ const r = await runChecks({ commands: ["slow"], budgetMs: Infinity, perCommandMs: 300000, now: h.now, run: h.run, load: idle });
233
+ assert.deepEqual(h.calls, [["slow", 300000], ["slow", 600000]]);
234
+ assert.equal(r.results[0].rc, 0);
235
+ assert.deepEqual(r.deferred, []);
236
+ });
237
+ it("a repeated timeout stays rc 124 and is labelled infrastructure", async () => {
238
+ const h = harness({ cost: { slow: 9e9 } });
239
+ const r = await runChecks({ commands: ["slow"], budgetMs: Infinity, perCommandMs: 1000, now: h.now, run: h.run, load: idle });
240
+ assert.equal(h.calls.length, 2);
241
+ assert.equal(r.results[0].rc, 124);
242
+ assert.match(r.results[0].output, /infra timeout/);
243
+ });
244
+ it("selfCheck is ok when the retry passes", async () => {
245
+ const h = harness({ cost: { slow: 5000 } });
246
+ const r = await selfCheck({ contract: { validate: [{ id: "V1", command: "slow" }] }, plan: { commands: [], budgetMs: 0, perCommandMs: 3000 }, now: h.now, run: h.run });
247
+ assert.equal(r.ok, true);
248
+ });
249
+ it("a budget-cut timeout is still deferred, never retried; a non-124 failure is never retried", async () => {
250
+ const h = harness({ cost: { slow: 9e9 }, rc: { bad: 1 } });
251
+ const r = await runChecks({ commands: ["slow"], budgetMs: 2000, perCommandMs: 300000, now: h.now, run: h.run, load: idle });
252
+ assert.equal(h.calls.length, 1);
253
+ assert.deepEqual(r.deferred, ["slow"]);
254
+ const h2 = harness({ rc: { bad: 1 } });
255
+ const r2 = await runChecks({ commands: ["bad"], budgetMs: Infinity, perCommandMs: 1000, now: h2.now, run: h2.run, load: idle });
256
+ assert.equal(h2.calls.length, 1);
257
+ assert.equal(r2.results[0].rc, 1);
258
+ });
259
+ it("an exhausted budget defers a command that hit its own timeout", async () => {
260
+ const h = harness({ cost: { slow: 9e9 } });
261
+ const r = await runChecks({ commands: ["slow"], budgetMs: 1000, perCommandMs: 1000, now: h.now, run: h.run, load: idle });
262
+ assert.equal(h.calls.length, 1);
263
+ assert.deepEqual(r.deferred, ["slow"]);
264
+ });
265
+ it("default budget: a check hitting its own timeout is reported, not deferred (#4620)", async () => {
266
+ const plan = planPrecheck({ verify: {}, learned: ["npm test"] });
267
+ const h = harness({ cost: { "npm test": 9e9 } });
268
+ const r = await runChecks({ commands: plan.commands, budgetMs: plan.budgetMs, perCommandMs: plan.perCommandMs, now: h.now, run: h.run, load: idle });
269
+ assert.equal(r.results.length, 1);
270
+ assert.equal(r.results[0].rc, 124);
271
+ assert.deepEqual(r.deferred, []);
272
+ });
273
+ it("a budget with room left but no longer retry reports the timeout as a failure", async () => {
274
+ const h = harness({ cost: { slow: 9e9 } });
275
+ const r = await runChecks({ commands: ["slow"], budgetMs: 1500, perCommandMs: 1000, now: h.now, run: h.run, load: idle });
276
+ assert.equal(h.calls.length, 1);
277
+ assert.deepEqual([r.results.map((x) => x.rc), r.deferred], [[124], []]);
278
+ });
279
+ it("runWithTimeoutRetry reports retried", async () => {
280
+ let n = 0;
281
+ const x = await runWithTimeoutRetry(async (ms) => (++n === 1 ? { rc: 124, output: "t" } : { rc: 0, output: String(ms) }), 1000, { load: idle });
282
+ assert.deepEqual([x.retried, x.r.rc, x.r.output], [true, 0, "2000"]);
283
+ });
284
+ });
@@ -15,6 +15,8 @@
15
15
  * of origin/<default>, or while forge.yaml `post_deploy.precondition_cmd` exits 1. With `post_deploy.min_age` (#4425) it also
16
16
  * stays parked (`awaiting-min-age`) until that long (or the longest window an item's why states) after the fix landed on
17
17
  * the default branch. Anything unresolvable fails open.
18
+ * Named checks (#4626): once ready, the items' `[check:<id>]` checks run from the default branch's checks file (no model
19
+ * call): pass closes the follow-up, fail reopens the parent. An item without a runnable check parks `awaiting-measurement`.
18
20
  * All effects are injected (gh, git, runner, transitionLabel, projector?, execCode?) so it is unit-testable.
19
21
  */
20
22
  import { execFile } from "node:child_process";
@@ -23,6 +25,7 @@ import { PREFILTER_SYSTEM, prefilterPrompt, parsePrefilterVerdict } from "./prom
23
25
  import { isTrustedComment } from "../engine/phases.mjs";
24
26
  import { APP_SLUG } from "../init-repo.mjs";
25
27
  import { probeResultOf } from "./probe.mjs";
28
+ import { AWAITING_MEASUREMENT_LABEL, MONITORING_LABEL, itemCheckIds, measureFollowup } from "./postdeploy-check.mjs";
26
29
 
27
30
  export const DEFAULT_PREFILTER_MODEL = "claude-haiku-5-5";
28
31
  export const PREFILTER_MARKER = "FORGE:PREFILTER";
@@ -300,16 +303,20 @@ export function lazyDefaultBranch({ io, repo, cwd, configured = null }) {
300
303
  }
301
304
  /** #4389: label and explain a parked issue once (the comment only when the label was not there yet); best effort. */
302
305
  async function markParked({ issue, repo, io, had, gate }) {
303
- const edit = () => io.gh(["issue", "edit", String(issue), "-R", repo, "--add-label", AWAITING_PROMOTION_LABEL]);
306
+ const measure = gate.state === "awaiting-measurement"; // #4626
307
+ const label = measure ? AWAITING_MEASUREMENT_LABEL : AWAITING_PROMOTION_LABEL;
308
+ const edit = () => io.gh(["issue", "edit", String(issue), "-R", repo, "--add-label", label]);
304
309
  try { await edit(); } catch {
305
310
  try {
306
- await io.gh(["label", "create", AWAITING_PROMOTION_LABEL, "-R", repo, "--force", "--color", "C5DEF5", "--description", "Post-deploy follow-up parked until its parent fix reaches the default branch. Managed by ForgeDock."]);
311
+ await io.gh(["label", "create", label, "-R", repo, "--force", "--color", "C5DEF5", "--description", measure ? "Post-deploy follow-up with no runnable default-branch check; never given a lane. Managed by ForgeDock." : "Post-deploy follow-up parked until its parent fix reaches the default branch. Managed by ForgeDock."]);
307
312
  await edit();
308
313
  } catch { /* the comment and the report still show it */ }
309
314
  }
310
315
  if (had) return;
311
- const what = gate.state === "awaiting-promotion" ? "awaiting promotion" : gate.state === "awaiting-min-age" ? "awaiting its post-deploy soak window" : "awaiting its post-deploy precondition";
312
- const body = `<!-- ${PRECONDITION_MARKER} -->\n## Parked: ${what}\n${sanitizeMd(gate.detail)}.\n\nEvery item here is a post-deploy check, so no lane was started. The next orchestrate or triage run checks again and launches this issue once the precondition holds.`;
316
+ const what = measure ? "awaiting measurement" : gate.state === "awaiting-promotion" ? "awaiting promotion" : gate.state === "awaiting-min-age" ? "awaiting its post-deploy soak window" : "awaiting its post-deploy precondition";
317
+ const tail = measure ? "Every item here is a post-deploy check, and at least one names no runnable check, so no lane was started and none will be. Name each one with `[check:<id>]` and add `<id>` to the checks file on the default branch, or measure it by hand."
318
+ : "Every item here is a post-deploy check, so no lane was started. The next orchestrate or triage run checks again and launches this issue once the precondition holds.";
319
+ const body = `<!-- ${PRECONDITION_MARKER} -->\n## Parked: ${what}\n${sanitizeMd(gate.detail)}.\n\n${tail}`;
313
320
  try { await io.gh(["issue", "comment", String(issue), "-R", repo, "--body", capComment(body)]); } catch { /* the label still shows it */ }
314
321
  }
315
322
 
@@ -328,6 +335,12 @@ export function formatParkedLine(rows) {
328
335
  const defs = uniq(soak.map((r) => r.prefilter.default));
329
336
  out.push(`awaiting min age: ${soak.map((r) => `#${r.issue}`).join(" ")} (fix on ${defs.join(", ") || "the default branch"}, soak window not elapsed)`);
330
337
  }
338
+ const meas = parked.filter((r) => r.prefilter.kind === "awaiting-measurement"); // #4626
339
+ if (meas.length) out.push(`awaiting measurement: ${meas.map((r) => `#${r.issue}`).join(" ")} (no runnable default-branch check; no lane)`);
340
+ const failed = parked.filter((r) => r.prefilter.kind === "check-failed");
341
+ if (failed.length) out.push(`check failed: ${failed.map((r) => `#${r.issue}`).join(" ")} (parent reopened; waiting for a new fix)`);
342
+ const mon = parked.filter((r) => r.prefilter.kind === "monitoring"); // #4627
343
+ if (mon.length) out.push(`monitoring: ${mon.map((r) => `#${r.issue}`).join(" ")} (windowed post-deploy checks re-run on later releases)`);
331
344
  const pre = parked.filter((r) => r.prefilter.kind === "awaiting-precondition");
332
345
  if (pre.length) out.push(`awaiting precondition: ${pre.map((r) => `#${r.issue}`).join(" ")} (post_deploy.precondition_cmd not ready)`);
333
346
  return out.join("\n");
@@ -346,6 +359,8 @@ export async function prefilterIssue({ issue, repo, lane, cwd, io, model = DEFAU
346
359
  const labelled = names.some((l) => ["needs-human", "workflow:merged"].includes(l));
347
360
  // #4389: a post-deploy follow-up is gated even when an earlier lane escalated it (needs-human); nothing else changes.
348
361
  const gated = !!defaultBranch && !names.includes("workflow:merged") && deferredFrom(iss.body) != null && postDeployOnly(iss.body);
362
+ // #4627: a follow-up under windowed monitoring belongs to `sweepDue`, never to a lane or a second measurement
363
+ if (names.includes(MONITORING_LABEL)) return { ...out, parked: true, kind: "monitoring", detail: "windowed post-deploy checks re-run on later releases" };
349
364
  if (labelled && !gated) return { ...out, detail: "labelled, left to the lane" };
350
365
  if (io.projector) {
351
366
  let st = null;
@@ -362,6 +377,16 @@ export async function prefilterIssue({ issue, repo, lane, cwd, io, model = DEFAU
362
377
  if (gate.state === "ready" && had && !dryRun) {
363
378
  try { await io.gh(["issue", "edit", String(issue), "-R", repo, "--remove-label", AWAITING_PROMOTION_LABEL]); } catch { /* best effort */ }
364
379
  }
380
+ if (gate.state === "ready") {
381
+ const m = await measureFollowup({ iss, repo, parent: gate.parent, def: gate.default, shas: gate.shas, cwd, io, dryRun, isTrusted: isTrustedIssueComment, ...(io.spawnCheck ? { spawn: io.spawnCheck } : {}) });
382
+ if (m.state === "awaiting-measurement") {
383
+ if (!dryRun) await markParked({ issue, repo, io, had: names.includes(AWAITING_MEASUREMENT_LABEL), gate: { ...gate, state: m.state, detail: m.detail } });
384
+ return { ...out, parked: true, kind: m.state, parent: gate.parent, lane, default: gate.default, detail: m.detail };
385
+ }
386
+ if (m.state === "monitoring") return { ...out, parked: true, kind: "monitoring", parent: gate.parent, lane, default: gate.default, detail: m.detail };
387
+ if (m.state === "pass") return { ...out, closed: !dryRun, kind: "post-deploy-check", parent: gate.parent, lane, default: gate.default, detail: m.detail };
388
+ if (m.state === "fail" || m.state === "recorded-fail") return { ...out, parked: true, kind: "check-failed", parent: gate.parent, lane, default: gate.default, detail: m.detail };
389
+ }
365
390
  if (labelled) return { ...out, detail: `labelled, left to the lane (post-deploy gate: ${gate.state}${gate.detail ? `, ${gate.detail}` : ""})` };
366
391
  }
367
392
  // #4399: an issue reopened by a failed acceptance probe goes to a lane, never back to an already-fixed close
@@ -397,14 +397,22 @@ describe("post-deploy precondition gate (#4389)", () => {
397
397
  assert.deepEqual(w.edits, []); assert.deepEqual(w.comments, []);
398
398
  });
399
399
 
400
- it("an ancestor (after promotion): not parked, the label is removed, and the issue goes on as before", async () => {
400
+ it("an ancestor (after promotion): the promotion label is removed; with no runnable check it parks awaiting-measurement, never a lane (#4626)", async () => {
401
401
  const w = world({ issues: { 41: { body: followup(40, [item("R2", "post-deploy")]), labels: [{ name: "awaiting-promotion" }] } }, mergedPrs: parentPr, ancestry: { [MERGE]: 0 } });
402
402
  const r = await gated(w, 41);
403
- assert.ok(!r.parked); assert.equal(r.detail, "no already-fixed signal");
404
- assert.deepEqual(w.edits, [[41, "--remove-label", "awaiting-promotion"]]);
403
+ assert.equal(r.parked, true); assert.equal(r.kind, "awaiting-measurement");
404
+ assert.deepEqual(w.edits, [[41, "--remove-label", "awaiting-promotion"], [41, "--add-label", "awaiting-measurement"]]);
405
+ assert.equal(w.runner.length, 0);
405
406
  const rows = await orchestrate({ issues: [41], readBody: async () => "", prefilter: makePrefilter({ repo: "o/r", cwd: "/repo", io: w.io, defaultBranch: "main" }),
406
- runOne: async () => ({ terminalReason: "merged", pr: 7, cost_usd: 1 }) });
407
- assert.deepEqual(rows.map((x) => x.outcome), ["merged"]);
407
+ runOne: async () => { throw new Error("no lane"); } });
408
+ assert.deepEqual(rows.map((x) => x.outcome), ["awaiting-measurement"]);
409
+ });
410
+
411
+ it("a follow-up under windowed monitoring is parked, never a lane or a second measurement (#4627)", async () => {
412
+ const w = world({ issues: { 41: { body: followup(40, [item("R2", "[check:smoke] post-deploy")]), labels: [{ name: "post-deploy-monitoring" }] } }, mergedPrs: parentPr, ancestry: { [MERGE]: 0 } });
413
+ const r = await gated(w, 41);
414
+ assert.equal(r.parked, true); assert.equal(r.kind, "monitoring"); assert.equal(r.closed, false);
415
+ assert.deepEqual(w.edits, []); assert.deepEqual(w.comments, []); assert.equal(w.runner.length, 0);
408
416
  });
409
417
 
410
418
  it("an unresolvable parent (no merged PR, no trajectory) or an unknown SHA falls through to the lane", async () => {
@@ -267,7 +267,7 @@ export async function resolveDefaultBranch({ repo, io, cwd = process.cwd() }) {
267
267
  * Probe `issues`, or (none given) the issues closed within `sweepDays` that mention `forge-probe`, capped at `limit`.
268
268
  * Sequential: probes hit production. Never throws per issue: an error is an `error` row.
269
269
  */
270
- export async function sweepProbes({ repo, cwd = process.cwd(), io, issues = null, limit = 50, sinceDays = null, dryRun = false, config = readProbeConfig(cwd), now = () => Date.now(), env = process.env, spawn } = {}) {
270
+ export async function sweepProbes({ repo, cwd = process.cwd(), io, issues = null, limit = 50, sinceDays = null, dryRun = false, config = readProbeConfig(cwd), now = () => Date.now(), env = process.env, spawn, onResult = null } = {}) {
271
271
  if (!config.enabled && !issues?.length) return [];
272
272
  const defaultBranch = await resolveDefaultBranch({ repo, io, cwd });
273
273
  if (!defaultBranch) throw new Error("no default branch");
@@ -284,6 +284,7 @@ export async function sweepProbes({ repo, cwd = process.cwd(), io, issues = null
284
284
  try { rows.push(await probeIssue({ issue, repo, cwd, io, defaultBranch, dryRun, config, env, spawn })); } catch (e) {
285
285
  rows.push({ issue, outcome: "error", detail: String(e?.message ?? e).slice(0, 160) });
286
286
  }
287
+ try { if (!dryRun) await onResult?.(rows[rows.length - 1]); } catch { /* an event sink never fails the sweep (#4630) */ }
287
288
  }
288
289
  return rows;
289
290
  }