forgedock 1.33.0 → 1.35.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 (45) hide show
  1. package/bin/engine-cli.mjs +72 -12
  2. package/bin/forgedock.mjs +19 -1
  3. package/bin/hooks/reread-nudge.mjs +33 -5
  4. package/bin/lean/batch.mjs +24 -1
  5. package/bin/lean/batch.test.mjs +75 -0
  6. package/bin/lean/budget.mjs +14 -3
  7. package/bin/lean/budget.test.mjs +11 -1
  8. package/bin/lean/conflictres.mjs +93 -0
  9. package/bin/lean/conflictres.test.mjs +93 -0
  10. package/bin/lean/conformance.mjs +39 -6
  11. package/bin/lean/conformance.test.mjs +46 -0
  12. package/bin/lean/effects.mjs +11 -3
  13. package/bin/lean/lane.mjs +13 -0
  14. package/bin/lean/lean.mjs +111 -33
  15. package/bin/lean/lean.test.mjs +197 -5
  16. package/bin/lean/orchestrate.mjs +81 -8
  17. package/bin/lean/precheck.mjs +56 -8
  18. package/bin/lean/precheck.test.mjs +62 -3
  19. package/bin/lean/prompts.mjs +28 -6
  20. package/bin/lean/schedd.mjs +10 -1
  21. package/bin/lean/schedd.test.mjs +30 -0
  22. package/bin/lean/sequence.mjs +26 -0
  23. package/bin/lean/sequence.test.mjs +41 -2
  24. package/bin/lean/toolstate.mjs +77 -0
  25. package/bin/lean/toolstate.test.mjs +83 -0
  26. package/bin/lean/verify.mjs +18 -2
  27. package/bin/lean/verify.test.mjs +29 -3
  28. package/bin/review-staging.mjs +14 -2
  29. package/bin/stats/efficiency.mjs +103 -17
  30. package/bin/stats/efficiency.test.mjs +103 -4
  31. package/bin/stats/index.mjs +27 -5
  32. package/bin/stats/pool.mjs +2 -2
  33. package/commands/review-pr-agents/protocols.md +1 -1
  34. package/commands/review-pr-staging.md +1 -1
  35. package/docs/CONFIG.md +12 -2
  36. package/docs/STATS.md +29 -1
  37. package/package.json +1 -1
  38. package/scripts/review-finalize.sh +17 -1
  39. package/scripts/review-finalize.test.sh +41 -0
  40. package/scripts/review-pr-domain-map.test.sh +2 -2
  41. package/scripts/review-pr-staging-scope.test.sh +4 -2
  42. package/scripts/review-prepare.sh +62 -27
  43. package/scripts/review-prepare.test.sh +53 -0
  44. package/scripts/review-staging-pre.sh +29 -9
  45. package/scripts/review-staging-pre.test.sh +18 -0
@@ -118,6 +118,8 @@ function world(o = {}) {
118
118
  if (a[0] === "write-tree") return w.salvageTrees.length ? w.salvageTrees.shift() : "";
119
119
  if (a[0] === "rev-parse" && a[1] === "HEAD^{tree}") return "0".repeat(40);
120
120
  if (a.includes("commit-tree")) return "c".repeat(40);
121
+ if (a[0] === "diff" && a.includes("--diff-filter=A")) return (w.added ?? []).join("\0"); // #4590: files the branch added vs the base
122
+ if (a[0] === "rm" && a[1] === "--cached" && w.added) w.added = w.added.filter((f) => !a.includes(f));
121
123
  if (a[0] === "diff" && a.includes("--diff-filter=U")) return w.conflictFiles.join("\n");
122
124
  if (a[0] === "diff" && a[1] === "--check") return "";
123
125
  if (a[0] === "grep") return w.markers ? "src/foo.js:3:<<<<<<< HEAD" : "";
@@ -129,6 +131,7 @@ function world(o = {}) {
129
131
  if (a[0] === "diff" && a[1] === "--numstat") return w.numstat ?? "";
130
132
  if (a[0] === "ls-files") return [...new Set(["src/foo.js", "src/bar.js", "test/foo.test.js", ...w.changed])].join("\n");
131
133
  if (a[0] === "diff") return "diff --git a/src/foo.js b/src/foo.js\n+fix";
134
+ if (a[0] === "merge-base" && a[1] === "--is-ancestor") { if ((o.shipped ?? []).includes(String(a[2]).replace(/^origin\//, ""))) return ""; throw new Error("not an ancestor"); } // #4593
132
135
  if (a[0] === "ls-remote" && String(a.at(-1)).startsWith("refs/heads/")) { const b = a.at(-1).slice("refs/heads/".length); return (o.branchesOnOrigin ?? ["staging", "main"]).includes(b) ? `abc\trefs/heads/${b}\n` : ""; }
133
136
  if (a[0] === "ls-remote") return w.remote;
134
137
  if (a[0] === "show" && w.baseFiles) { if (!(a[1] in w.baseFiles)) throw new Error(`fatal: path not in ${a[1]}`); return w.baseFiles[a[1]]; }
@@ -1618,9 +1621,9 @@ describe("hotspot prevention (forge#4195): plan facts, build rule, unexempted gr
1618
1621
  assert.equal(hotspotBuildRule(facts([], { file_lines: 1000, growth_lines: 50 })).trim(), "Don't grow a file over 1000 lines by more than 50 lines. Put new code next to it.");
1619
1622
  assert.equal(hotspotBuildRule(null), ""); assert.equal(hotspotBuildRule(facts([], { enabled: false })), "");
1620
1623
  const plan = builderPlanPrompt(7) + hotspotPlanNote(facts([HOT, { ...HOT, path: "a/b.py" }, { ...HOT, path: "c/d.py" }]));
1621
- // 1850: #4404 (verify out_of_band) and #4396 (split size rule) grew the plan prompt to ~1803 tokens on CI paths.
1622
- assert.ok(approxTokens(plan) <= 1850, `plan prompt with hotspots is ${approxTokens(plan)} tokens`);
1623
- assert.ok(approxTokens(builderImplementPrompt() + hotspotBuildRule(facts([]))) <= 1500);
1624
+ // 1850: #4404 (verify out_of_band) and #4396 (split size rule) grew the plan prompt to ~1803 tokens on CI paths; 1900: #4589 (check timeout is not a block, one block-rule line).
1625
+ assert.ok(approxTokens(plan) <= 1900, `plan prompt with hotspots is ${approxTokens(plan)} tokens`);
1626
+ assert.ok(approxTokens(builderImplementPrompt() + hotspotBuildRule(facts([]))) <= 1550); // 1550: #4591 (never hand-name migration files, one rule line)
1624
1627
  });
1625
1628
 
1626
1629
  it("#4519 generated_paths: both prompts name the globs (even with the gate off), and an amendment adding one blocks", async () => {
@@ -2103,6 +2106,9 @@ describe("prompts", () => {
2103
2106
  const p = reviewerPrompt({ issue: { number: 7, title: "t", body: "b" }, comments: [{ body: "please also cover bar" }, { body: "<!-- FORGE:STARTED -->\nx" }], contract: CONTRACT, result: { status: "fixed" }, files: ["src/foo.js"], diff: "x".repeat(2000) });
2104
2107
  for (const t of ["<issue_body>", "<issue_comments>\nplease also cover bar\n</issue_comments>", "<contract>", '"R1"', "<builder_result>", "<changed_files>\nsrc/foo.js", "<diff>"]) assert.ok(p.includes(t), t);
2105
2108
  assert.doesNotMatch(p, /FORGE:STARTED/);
2109
+ const am = reviewerPrompt({ issue: { number: 7, title: "t", body: "b" }, contract: { ...CONTRACT, requirements: [{ ...CONTRACT.requirements[0], amendment: { text: "reveal once", why: "hash-only token" } }] }, diff: "" });
2110
+ assert.ok(am.includes("reveal once") && am.includes("hash-only token"));
2111
+ assert.match(REVIEWER_SYSTEM, /amendment\.text/);
2106
2112
  assert.ok(approxTokens(REVIEWER_SYSTEM) + approxTokens(reviewerPrompt({ issue: { number: 7, title: "t", body: "b" }, contract: CONTRACT, diff: "" })) <= 1800);
2107
2113
  });
2108
2114
 
@@ -2243,6 +2249,51 @@ describe("orchestrate", () => {
2243
2249
  assert.deepEqual(rows.map((r) => [r.issue, r.outcome, r.detail]), [[9, "needs-human", "ci.red"], [1, "skipped", "dependency #9 needs-human"]]);
2244
2250
  });
2245
2251
 
2252
+ it("#4595 a dependent skipped for a needs-human blocker is re-admitted when the blocker merges meanwhile; its dependents follow", async () => {
2253
+ const log = [];
2254
+ let merged = false;
2255
+ const rows = await orchestrate({ issues: [9, 1, 2], parallel: 1,
2256
+ readBody: async (n) => ({ 1: "**Depends on**: #9", 2: "**Depends on**: #1" })[n] ?? "",
2257
+ dispatchCheck: async (n) => (n === 9 && merged ? { outcome: "merged", pr: 90, detail: "PR #90 already merged" } : null),
2258
+ runOne: async (n) => { log.push(n); if (n === 9) { merged = true; return { terminalReason: "needs-human", detail: "ci.red" }; } return { terminalReason: "merged", pr: 100 + n }; } });
2259
+ assert.deepEqual(log, [9, 1, 2]);
2260
+ assert.deepEqual(rows.map((r) => [r.issue, r.outcome]), [[9, "merged"], [1, "merged"], [2, "merged"]]);
2261
+ assert.equal(rows[0].pr, 90);
2262
+ });
2263
+
2264
+ it("#4595 an out-of-set parked blocker that closes re-admits the skipped issue", async () => {
2265
+ const log = [];
2266
+ let closed = false;
2267
+ const rows = await orchestrate({ issues: [1], parallel: 1, readBody: async () => "",
2268
+ issueLabels: async () => ["needs-human"],
2269
+ dispatchCheck: async (n) => (n === 9 && closed ? { outcome: "invalid", detail: "issue closed" } : null),
2270
+ runOne: async (n) => { log.push(n); if (log.length === 1) { closed = true; return { terminalReason: "dependency-open", dependencies: [9] }; } return { terminalReason: "merged" }; } });
2271
+ assert.deepEqual(log, [1, 1]);
2272
+ assert.deepEqual(rows.map((r) => [r.issue, r.outcome]), [[1, "merged"]]);
2273
+ });
2274
+
2275
+ it("#4614 an out-of-set blocker that dispatchCheck reports merged but the lane still sees open re-admits once, then stays skipped", async () => {
2276
+ const log = [];
2277
+ let checks = 0;
2278
+ const rows = await orchestrate({ issues: [20], parallel: 1, readBody: async () => "",
2279
+ issueLabels: async () => ["needs-human"],
2280
+ dispatchCheck: async (n) => { checks++; return n === 10 ? { outcome: "merged", pr: 77, detail: "PR #77 already merged" } : null; },
2281
+ runOne: async (n) => { log.push(n); if (log.length > 5) throw new Error("re-admission loop"); return { terminalReason: "dependency-open", dependencies: [10] }; } });
2282
+ assert.deepEqual(log, [20, 20], "one re-admission, no loop");
2283
+ assert.ok(checks <= 3, `bounded dispatch checks (${checks})`);
2284
+ assert.deepEqual(rows.map((r) => [r.issue, r.outcome]), [[20, "skipped"]]);
2285
+ });
2286
+
2287
+ it("#4595 an unreadable or still-open blocker leaves the dependent skipped; no dispatchCheck changes nothing", async () => {
2288
+ for (const dispatchCheck of [async () => { throw new Error("gh down"); }, async () => null, undefined]) {
2289
+ const log = [];
2290
+ const rows = await orchestrate({ issues: [9, 1], parallel: 1, readBody: async (n) => (n === 1 ? "**Depends on**: #9" : ""), ...(dispatchCheck ? { dispatchCheck } : {}),
2291
+ runOne: async (n) => { log.push(n); return { terminalReason: "needs-human", detail: "ci.red" }; } });
2292
+ assert.deepEqual(log, [9]);
2293
+ assert.deepEqual(rows.map((r) => [r.issue, r.outcome]), [[9, "needs-human"], [1, "skipped"]]);
2294
+ }
2295
+ });
2296
+
2246
2297
  it("#4124 a repeated dependency-open stops after the requeue cap", async () => {
2247
2298
  const h = harness({}, { 1: "dependency-open" });
2248
2299
  const clock = { t: 0 }, starts = [];
@@ -3724,6 +3775,28 @@ describe("lane resolution in core work-on (#4125)", () => {
3724
3775
  });
3725
3776
  });
3726
3777
 
3778
+ describe("shipped lane follows the open PR base (#4593)", () => {
3779
+ const pr = (base) => [{ number: 77, headRefOid: "aaaa1111aaaa1111", baseRefName: base }];
3780
+ const go = (o) => run(world({ milestone: "Old", branchesOnOrigin: ["staging", "milestone/old", "milestone/new"], remote: "abc\trefs/heads/feat/issue-7-lean", ...o }), { lane: undefined });
3781
+ it("proceeds on the PR base when the issue's lane is shipped", async () => {
3782
+ const w = world({ milestone: "Old", branchesOnOrigin: ["staging", "milestone/old", "milestone/new"], shipped: ["milestone/old"], prList: pr("milestone/new"), remote: "abc\trefs/heads/feat/issue-7-lean" });
3783
+ const r = await run(w, { lane: undefined });
3784
+ assert.notEqual(r.terminalReason, "needs-human");
3785
+ assert.equal(r.terminalReason, "merged");
3786
+ assert.ok(w.calls.some((c) => c[0] === "git" && c.includes("origin/milestone/new..refs/heads/feat/issue-7-lean")), "work is compared against the PR base");
3787
+ });
3788
+ it("stays needs-human when the lane is not shipped, the base is shipped, missing, or the default branch", async () => {
3789
+ for (const [o, code] of [
3790
+ [{ prList: pr("milestone/new") }, "lane.mismatch"],
3791
+ [{ shipped: ["milestone/old", "milestone/new"], prList: pr("milestone/new") }, "lane.mismatch"],
3792
+ [{ shipped: ["milestone/old"], prList: pr("milestone/gone") }, "lane.mismatch"],
3793
+ [{ shipped: ["milestone/old"], prList: pr("main") }, "lane.default_branch"]]) {
3794
+ const r = await go(o);
3795
+ assert.deepEqual([r.terminalReason, r.code], ["needs-human", code], JSON.stringify(o));
3796
+ }
3797
+ });
3798
+ });
3799
+
3727
3800
  describe("lane entry points (#4125)", () => {
3728
3801
  const quiet = async (fn) => { const l = console.log; console.log = () => {}; try { return await fn(); } finally { console.log = l; } };
3729
3802
  const deps = () => ({ dir: tmpDir("core-cli-"), emitTelemetry: async () => {}, leanIo: {}, io: { gh: async (a) => (a.includes("defaultBranchRef") ? "main\n" : "o/r\n") },
@@ -3964,7 +4037,7 @@ describe("verify-by-CI (#4167): ci mode runs the heavy checks on CI and feeds fa
3964
4037
  assert.equal(readVerify(d), undefined);
3965
4038
  writeFileSync(`${d}/forge.yaml`, "verify:\n mode: ci\n workflow: verify.yml\n skip_paths: \"*.md,docs/**\"\n max_rounds: 3\n precheck_timeout_seconds: 60\n e2e_label: full-ci\n e2e_checks: \"e2e (1),e2e (2)\"\n");
3966
4039
  const e2e = { mode: "auto", label: "full-ci", workflow: null, pathsInput: null, checks: ["e2e (1)", "e2e (2)"], paths: ["e2e/**", "**/e2e/**", "*.e2e.*", "*.e2e-spec.*", "cypress/**", "playwright/**"], blocking: "changed" };
3967
- assert.deepEqual(readVerify(d), { mode: "ci", workflow: "verify.yml", pathsInput: null, skipPaths: ["*.md", "docs/**"], maxRounds: 3, precheckTimeoutSeconds: 60, precheckTotalSeconds: 600, checkRun: false, checks: null, postMerge: null, duplicateExports: true, e2e });
4040
+ assert.deepEqual(readVerify(d), { mode: "ci", workflow: "verify.yml", pathsInput: null, skipPaths: ["*.md", "docs/**"], maxRounds: 3, precheckTimeoutSeconds: 60, precheckTotalSeconds: 600, checkRun: false, checks: null, postMerge: null, flaky: [], duplicateExports: true, invariants: [], e2e });
3968
4041
  });
3969
4042
 
3970
4043
  describe("#4387 a no-commit CI fix round reruns the failed jobs once", () => {
@@ -4003,6 +4076,31 @@ describe("verify-by-CI (#4167): ci mode runs the heavy checks on CI and feeds fa
4003
4076
  assert.deepEqual(w.reran, ["77"]); assert.equal(w.merges.length, 0);
4004
4077
  assert.deepEqual(flakeEvents(dir).map((e) => e.result), ["fail"]);
4005
4078
  });
4079
+
4080
+ /** #4586: count the builder CI-fix prompts of a world. */
4081
+ const fixPrompts = (w) => { const n = { n: 0 }; const inner = w.io.runner; w.io.runner = async (c) => { if (/CI failed on the pushed branch/.test(c.prompt)) n.n++; return inner(c); }; return n; };
4082
+
4083
+ it("#4586 a flake that goes green on the rerun spends no fix round and never resumes the builder", async () => {
4084
+ const w = flaky("success"), n = fixPrompts(w), dir = tmpDir("core-ci-");
4085
+ const r = await run(w, { verify: PUSH, dir });
4086
+ assert.equal(r.terminalReason, "merged"); assert.equal(n.n, 0); assert.deepEqual(w.reran, ["77"]);
4087
+ });
4088
+
4089
+ it("#4586 a failure that repeats on the same head after the rerun counts one round and reaches the builder", async () => {
4090
+ const w = flaky("failure"), n = fixPrompts(w), dir = tmpDir("core-ci-");
4091
+ const r = await run(w, { verify: PUSH, dir });
4092
+ assert.equal(r.code, "ci.red"); assert.ok(n.n >= 1);
4093
+ assert.equal(readLog(dir, 7).filter((e) => e.event === "CI_FIX_ROUND").length, 1, "one counted round");
4094
+ assert.deepEqual(w.reran, ["77"], "one rerun per head, none after the round");
4095
+ });
4096
+
4097
+ it("#4586 verify.flaky: a listed failing job is reported, never reruns, never feeds the builder, and the lane merges", async () => {
4098
+ const w = flaky("failure"), n = fixPrompts(w), dir = tmpDir("core-ci-");
4099
+ w.io.checkStates = async () => [{ name: "test", state: "success", url: RUN, head: w.head }];
4100
+ const r = await run(w, { verify: { ...PUSH, flaky: ["test"] }, dir });
4101
+ assert.equal(r.terminalReason, "merged"); assert.equal(n.n, 0); assert.deepEqual(w.reran, []);
4102
+ assert.deepEqual(readLog(dir, 7).filter((e) => e.event === "CI_FLAKY_REPORTED").map((e) => e.jobs), [["test"]]);
4103
+ });
4006
4104
  });
4007
4105
  });
4008
4106
 
@@ -4392,6 +4490,25 @@ describe("local-mode CI fix rounds at merge and declared pre-PR check commands (
4392
4490
  assert.ok(log.findIndex((e) => e.event === "PRECHECK_COMMANDS") < log.findIndex((e) => e.event === "PR_OPENED"));
4393
4491
  });
4394
4492
 
4493
+ it("#4585 verify.invariants run in the pre-PR round even with no learned commands, and the prompts carry the checklist", async () => {
4494
+ const w = world(); const ran = [];
4495
+ w.io.runCommand = async ({ command }) => { ran.push(command); return { rc: 0, output: "" }; };
4496
+ const r = await run(w, { verify: { invariants: ["bash scripts/inv.sh"] } });
4497
+ assert.equal(r.terminalReason, "merged");
4498
+ assert.deepEqual(ran, ["bash scripts/inv.sh"]);
4499
+ const b = builders(w);
4500
+ assert.match(b[0][4], /Repo-wide invariants[\s\S]*bash scripts\/inv\.sh/); assert.match(b[1][4], /Invariants checklist[\s\S]*bash scripts\/inv\.sh/);
4501
+ const w2 = world(); let n = 0; w2.io.runCommand = async () => { n++; return { rc: 0, output: "" }; };
4502
+ await run(w2); assert.equal(n, 0, "no invariants: nothing runs");
4503
+ });
4504
+
4505
+ it("#4585 a failing invariant resumes the builder like a failed check command", async () => {
4506
+ const w = world(); let n = 0;
4507
+ w.io.runCommand = async () => (n++ ? { rc: 0, output: "" } : { rc: 1, output: "registry missing table x" });
4508
+ await run(w, { verify: { invariants: ["bash scripts/inv.sh"] } });
4509
+ assert.ok(n >= 2, "the invariant is rerun after the builder fix round");
4510
+ });
4511
+
4395
4512
  it("pre-PR: a passing set adds no builder turn; no commands configured runs nothing", async () => {
4396
4513
  const w = world(); let n = 0;
4397
4514
  w.io.runCommand = async () => { n++; return { rc: 0, output: "" }; };
@@ -4430,7 +4547,7 @@ describe("#4364 in-session forgedock check: plan hand-off and self-check record"
4430
4547
  it("writes check-plan.json beside the contract and the implement prompt asks for forgedock check", async () => {
4431
4548
  const w = world(); w.io.runCommand = async () => ({ rc: 0, output: "" });
4432
4549
  assert.equal((await run(w, { precheck: { commands: [" make lint ", ""], maxMinutes: 5 }, verify: { mode: "hybrid" } })).terminalReason, "merged");
4433
- assert.deepEqual(JSON.parse(w.files[`${WT}/.forge-lean/check-plan.json`]), { v: 1, base: "origin/staging", mode: "hybrid", learned: ["make lint"], maxMinutes: 5, precheckTotalSeconds: 600, precheckTimeoutSeconds: 300 });
4550
+ assert.deepEqual(JSON.parse(w.files[`${WT}/.forge-lean/check-plan.json`]), { v: 1, base: "origin/staging", mode: "hybrid", learned: ["make lint"], invariants: [], maxMinutes: 5, precheckTotalSeconds: 600, precheckTimeoutSeconds: 300 });
4434
4551
  assert.match(builders(w)[1][4], /forgedock\.mjs check` in the worktree[\s\S]*report the problem as blocked/);
4435
4552
  });
4436
4553
 
@@ -5082,6 +5199,23 @@ describe("enumerating tests + sequence numbers at hand-off (#4322)", () => {
5082
5199
  assert.equal(builders(w).length, 2);
5083
5200
  });
5084
5201
 
5202
+ const HAND = ["db/2026_x.sql: not produced by the migration generator (no journal entry added for it)"];
5203
+ it("#4591 a hand-named migration resumes the builder once with the generator rule; a remainder is an anomaly; clean costs no round", async () => {
5204
+ const w = hworld(); const d = [HAND, []];
5205
+ w.io.migrationGenerator = async (x) => { w.genArgs = x; return d.length > 1 ? d.shift() : d[0]; };
5206
+ assert.equal((await run(w)).terminalReason, "merged");
5207
+ const b = builders(w);
5208
+ assert.equal(b.length, 3);
5209
+ assert.match(b[2][4], /db\/2026_x\.sql[\s\S]*Never name or number migration files by hand/);
5210
+ assert.equal(w.genArgs.cwd, WT);
5211
+ const w2 = hworld(); w2.io.migrationGenerator = async () => HAND;
5212
+ assert.equal((await run(w2)).terminalReason, "merged");
5213
+ assert.match(traj(w2)[0], /Hand-named migration\(s\) remain \(hand-off\)/);
5214
+ const w3 = hworld(); w3.io.migrationGenerator = async () => [];
5215
+ await run(w3);
5216
+ assert.equal(builders(w3).length, 2);
5217
+ });
5218
+
5085
5219
  it("no enumerating tests and no collisions: no new plan step, no extra builder call (prompts unchanged)", async () => {
5086
5220
  const w = hworld();
5087
5221
  assert.equal((await run(w)).terminalReason, "merged");
@@ -5184,6 +5318,31 @@ describe("enumerating tests + sequence numbers at hand-off (#4322)", () => {
5184
5318
  assert.match(w.comments.find((c) => c.includes("<!-- FORGE:BASE_SYNC -->")), /deterministic: base side taken[\s\S]*renumbered deterministically[\s\S]*not required \(deterministic renumber \+ base merge\)/);
5185
5319
  });
5186
5320
 
5321
+ it("#4588: base-sync conflicts the engine resolves (union / JSON map / generated) need no builder, re-review or sequence resolver", async () => {
5322
+ const w = dworld({ seq: [[], []], prView: [CONFLICTING, OKSTATE], mergeResults: ["conflict"], conflictFiles: ["src/index.ts", "i18n/status.json"] }), asked = [];
5323
+ w.io.conflictResolve = async (x) => { asked.push(x); w.head = "eeee0000eeee0000"; w.conflictFiles = []; w.mergeInProgress = false; return { resolved: x.files, remaining: [] }; };
5324
+ assert.equal((await run(w, { baseMerge: { unionGlobs: ["src/index.ts"], jsonGlobs: ["i18n/status.json"], generatedGlobs: [], generatedCommand: "" } })).terminalReason, "merged");
5325
+ assert.deepEqual(asked[0].files, ["src/index.ts", "i18n/status.json"]);
5326
+ assert.equal(asked[0].cfg.jsonGlobs[0], "i18n/status.json");
5327
+ assert.equal(w.resolveArgs, undefined, "sequence resolver not asked");
5328
+ assert.equal(builders(w).length, 2, "plan + implement only");
5329
+ assert.equal(reviewers(w).length, 1, "no re-review");
5330
+ assert.match(w.comments.find((c) => c.includes("<!-- FORGE:BASE_SYNC -->")), /deterministic: 2 union \/ JSON-map \/ generated file/);
5331
+ });
5332
+
5333
+ it("#4588: files the engine cannot resolve go to the sequence resolver, and the builder when that fails; without config nothing changes", async () => {
5334
+ const w = dworld({ prView: [CONFLICTING, OKSTATE], mergeResults: ["conflict"], conflictFiles: ["src/index.ts", "db/meta/_journal.json"], resolve: { unsupported: "x" } });
5335
+ w.io.conflictResolve = async () => ({ resolved: ["src/index.ts"], remaining: ["db/meta/_journal.json"] });
5336
+ assert.equal((await run(w, { baseMerge: { unionGlobs: ["src/index.ts"] } })).terminalReason, "merged");
5337
+ assert.deepEqual(w.resolveArgs.files, ["db/meta/_journal.json"]);
5338
+ assert.ok(builders(w).length >= 3, "the builder finishes the merge");
5339
+ const n = dworld({ prView: [CONFLICTING, OKSTATE], mergeResults: ["conflict"], conflictFiles: ["db/meta/_journal.json"] });
5340
+ let called = false; n.io.conflictResolve = async () => { called = true; return { resolved: [], remaining: [] }; };
5341
+ await run(n);
5342
+ assert.equal(called, false, "no baseMerge config: resolver not consulted");
5343
+ assert.deepEqual(n.resolveArgs.files, ["db/meta/_journal.json"]);
5344
+ });
5345
+
5187
5346
  it("a schema-changing migration without lean.sequence.regenerate falls back to the builder renumber and the trajectory records why", async () => {
5188
5347
  const w = dworld({ seq: [COLL, []], det: [{ unsupported: "schema-changing migration db/0007_usage.sql needs lean.sequence.regenerate (its snapshot cannot be cloned)" }] });
5189
5348
  assert.equal((await run(w, { sequenceRegenerate: null })).terminalReason, "merged");
@@ -6222,3 +6381,36 @@ describe("#4450 light lane: one builder session, derived contract, upgrade to th
6222
6381
  assert.equal(events(dir, "LIGHT_DONE").length + events(dir, "LIGHT_UPGRADE").length, 0);
6223
6382
  });
6224
6383
  });
6384
+
6385
+ describe("tool state never rides the lane branch (#4590)", () => {
6386
+ const untracks = (w) => w.calls.filter((c) => c[0] === "git" && c[1] === "rm" && c[2] === "--cached");
6387
+ const subjects = (w) => w.calls.filter((c) => c[0] === "git" && c[1] === "commit" && c.some((x) => /untrack lane tool state/.test(x)));
6388
+
6389
+ it("a branch that added .forge-lean/ files and tool caches is untracked and committed before the PR push", async () => {
6390
+ const dir = tmpDir("toolstate-");
6391
+ const w = world({}); w.added = ["src/foo.js", ".forge-lean/result.json", "apps/worker/.eslintcache", "packages/a/tsconfig.tsbuildinfo"];
6392
+ assert.equal((await run(w, { dir })).terminalReason, "merged");
6393
+ const rm = untracks(w);
6394
+ assert.ok(rm.length >= 1, "git rm --cached ran");
6395
+ for (const f of [".forge-lean/result.json", "apps/worker/.eslintcache", "packages/a/tsconfig.tsbuildinfo"]) assert.ok(rm[0].includes(f), f);
6396
+ assert.ok(!rm[0].includes("src/foo.js"), "product files stay");
6397
+ assert.equal(subjects(w).length, 1, "one untrack commit; the next push finds the branch clean");
6398
+ const firstPush = w.calls.findIndex((c) => c[0] === "git" && c[1] === "push");
6399
+ assert.ok(w.calls.findIndex((c) => c === subjects(w)[0]) < firstPush, "committed before the push");
6400
+ assert.ok(readLog(dir, 7).some((e) => e.event === "TOOLSTATE_UNTRACKED"));
6401
+ });
6402
+
6403
+ it("a clean branch makes no untrack commit", async () => {
6404
+ const w = world({}); w.added = ["src/foo.js"];
6405
+ assert.equal((await run(w)).terminalReason, "merged");
6406
+ assert.equal(untracks(w).length, 0);
6407
+ assert.equal(subjects(w).length, 0);
6408
+ });
6409
+ });
6410
+
6411
+ describe("block rules: a check timeout is not a block (#4589)", () => {
6412
+ it("tells the builder rc 124 is host load, never a human block", async () => {
6413
+ const { blockCorrectionPrompt } = await import("./prompts.mjs");
6414
+ assert.match(blockCorrectionPrompt("x"), /times out \(rc 124\) is host load, never a block/);
6415
+ });
6416
+ });
@@ -7,6 +7,7 @@
7
7
  * `decomposed` run adds its sub-issues and its dependents wait on them instead (#4124). A dependency that is parked
8
8
  * (ended needs-human in this batch, or an out-of-set issue labelled needs-human per `issueLabels(n)`) is never pulled in:
9
9
  * the issue ends `skipped` (`dependency #N needs-human`) and its dependents with it (#4387).
10
+ * A skipped dependent whose blocker later closes or merges outside its lane is re-admitted (#4595, needs `dispatchCheck`).
10
11
  * No investigation, synthesis or agent spawning. `runOne(issue)` and `readBody(issue)` are injected.
11
12
  * Optional `prefilter(issue, lane)` (#4123) runs in the lane slot before `runOne`: when it closed the issue, no lane starts.
12
13
  * When it parked the issue (#4389, `parked: true`), no lane starts either and the row ends with the parked kind.
@@ -28,6 +29,8 @@
28
29
  * `getAdded()` (#4408, `forgedock orchestrate --add`) is read on every pass too: issues it returns that are not in the batch
29
30
  * join it through `addIssues` (the still-pending issues' `Depends on` are re-read first, so edges hold both ways), with one
30
31
  * batch decision and one `added` row; while it is set, lanes running with an empty queue still wake every `pollMs`.
32
+ * `getHeld()` (#4592, `--hold`/`--release`) is read on every pass: the issues it returns are never admitted (a running lane is not stopped);
33
+ * when only held issues (and their dependents) remain and nothing runs, the batch ends with them `held` ("held by operator"), and a `--resume <id> <issue...>` naming them (or `--release`) re-admits them.
31
34
  *
32
35
  * Maintenance budget (#4197), off unless `maintenance: {share, whenIdle}` is passed: `classify(issue)` gives each issue
33
36
  * `{maintenance, priority}` (persisted as `classes`) and admission follows `pickNext`: P0/P1 features, then a maintenance
@@ -104,6 +107,7 @@ import { laneSize, SIZES } from "./budget.mjs";
104
107
 
105
108
  // A dependency that finished like this no longer blocks its dependents.
106
109
  const SATISFIED = new Set(["merged", "invalid"]);
110
+ const WATCH_EVERY_MS = 60_000; // #4595
107
111
  // Times one issue may be requeued behind a dependency before its dependency-open row is final.
108
112
  export const MAX_REQUEUES = 2;
109
113
  // Deferrals other than a busy resource (UNCAPPED_DEFERRALS) one issue may take before its deferred row is final.
@@ -232,7 +236,7 @@ const toMap = (o) => new Map(Object.entries(o ?? {}).map(([k, v]) => [Number(k),
232
236
  export async function orchestrate({ issues, parallel = 3, runOne, readBody, prefilter = null, resolveLane = null, now = () => Date.now(), onUpdate = () => {}, onCrossLane = () => {},
233
237
  store = null, adopt = null, getParallel = null, getAdded = null, breaker = null, maxDeferrals = DEFAULT_MAX_DEFERRALS, sleep = cancellableSleep, pollMs = 5000,
234
238
  classify = null, maintenance = null, limits = null, identity = () => "default", batchId = null, drift = null, sched = null, priorityOf = null, remote = null, beforeAdmit = null,
235
- recovery = null, decide = () => {}, conflicting = null, relabelNeedsHuman = null, onExit = null, issueLabels = null, getCancelled = null, stopLane = null,
239
+ recovery = null, decide = () => {}, conflicting = null, relabelNeedsHuman = null, onExit = null, issueLabels = null, getCancelled = null, getHeld = null, stopLane = null,
236
240
  estimate = null, starveMs = DEFAULT_STARVE_MS, signal = null, dispatchCheck = null, syncParked = null, syncEveryMs = DEFAULT_SYNC_EVERY_MS }) {
237
241
  const saved = store?.load?.() ?? null;
238
242
  const order = saved ? [...saved.order] : [];
@@ -257,6 +261,8 @@ export async function orchestrate({ issues, parallel = 3, runOne, readBody, pref
257
261
  const holds = limits ?? memoryLimits(now);
258
262
  const heldUntil = () => { try { const id = identity(); const u = holds.holdUntil(id); return Number.isFinite(u) && u > now() ? u : null; } catch { return null; } };
259
263
  let tripped = saved?.breaker ?? null;
264
+ let opHeld = new Set(); // #4592: operator-held issues this pass (the batch `hold` file is the authority)
265
+ const opHeldHit = new Set(); // held issues that were pending this pass
260
266
  // #4376: operator cancel (the batch `cancel` file through `getCancelled`); persisted, but the file stays the authority.
261
267
  let cancelled = getCancelled ? (saved?.cancelled ?? null) : null;
262
268
  const budget = maintenance && Number(maintenance.share) > 0 ? { share: Number(maintenance.share), whenIdle: !!maintenance.whenIdle } : null;
@@ -736,7 +742,7 @@ export async function orchestrate({ issues, parallel = 3, runOne, readBody, pref
736
742
  }
737
743
  save();
738
744
 
739
- const heldDetail = (n) => cancelled ? "cancelled by operator" : tripped?.detail ?? (driftHeld.has(n) ? `drift: ${driftHeld.get(n)} flagged` : budgetStop ? `execution budget: ${budgetStop}` : budgetHeld ? "maintenance share" : "not run");
745
+ const heldDetail = (n) => cancelled ? "cancelled by operator" : opHeld.has(n) ? "held by operator" : tripped?.detail ?? (driftHeld.has(n) ? `drift: ${driftHeld.get(n)} flagged` : budgetStop ? `execution budget: ${budgetStop}` : budgetHeld ? "maintenance share" : "not run");
740
746
  // #4315: a recovery queued but never launched (the batch stopped: breaker, budget, hold; or a resume that did not relaunch a
741
747
  // lost run) left the issue without needs-human, since a non-final attempt does not relabel. Relabel it once, with the full reason.
742
748
  // #4318: also on a throw out of the loop and on a signal (`onExit`); `cause` overrides the held reason. Runs once per process.
@@ -783,6 +789,24 @@ export async function orchestrate({ issues, parallel = 3, runOne, readBody, pref
783
789
  stopSent.add(n);
784
790
  try { Promise.resolve(stopLane(n, rec)).catch(() => {}); } catch { /* best effort */ }
785
791
  }
792
+ // #4592: re-read the hold set; one decision and one row when it changes.
793
+ const refreshHeld = () => {
794
+ if (!getHeld) return;
795
+ let got = [];
796
+ try { got = getHeld() ?? []; } catch { got = opHeld.size ? [...opHeld] : []; }
797
+ const next = new Set((Array.isArray(got) ? got : []).filter((n) => Number.isSafeInteger(n) && n > 0));
798
+ const fmt = (ns) => ns.map((n) => `#${n}`).join(", ");
799
+ const held = [...next].filter((n) => !opHeld.has(n)), freed = [...opHeld].filter((n) => !next.has(n));
800
+ opHeld = next;
801
+ if (held.length) {
802
+ decided({ issue: null, what: `held ${fmt(held)} (forgedock orchestrate --hold)`, why: "operator request", alternative: "cancel the batch" });
803
+ update({ issue: null, outcome: "hold", detail: fmt(held) });
804
+ }
805
+ if (freed.length) {
806
+ decided({ issue: null, what: `released ${fmt(freed)} (forgedock orchestrate --release)`, why: "operator request", alternative: "resume the batch" });
807
+ update({ issue: null, outcome: "release", detail: fmt(freed) });
808
+ }
809
+ };
786
810
  const checkCancel = () => {
787
811
  if (!getCancelled) return;
788
812
  let c = null;
@@ -811,25 +835,71 @@ export async function orchestrate({ issues, parallel = 3, runOne, readBody, pref
811
835
  halted = true;
812
836
  return Object.assign(new Error("batch stopped: schedd shutting down"), { code: "batch.stopped" });
813
837
  };
838
+ // #4595: a skipped row whose blocker (`dependency #B ...`) was finished outside the lane is re-admitted: B is re-checked
839
+ // through `dispatchCheck` (at most every WATCH_EVERY_MS, and once before the batch would end); fail-open.
840
+ let lastWatch = null;
841
+ const readmitted = new Set(); // "n:b": re-admit n on blocker b once; a lane that re-skips on b stays skipped
842
+ const blockerOf = (n) => { const r = results.get(n); const m = r?.outcome === "skipped" ? /^dependency #(\d+) /.exec(r.detail ?? "") : null; return m ? Number(m[1]) : null; };
843
+ const watchable = () => !!dispatchCheck && [...results.keys()].some((n) => blockerOf(n) !== null);
844
+ const watchBlockers = async (final) => {
845
+ if (!watchable() || cancelled || tripped) return;
846
+ const t0 = now();
847
+ if (!final && lastWatch !== null && t0 - lastWatch < WATCH_EVERY_MS) return;
848
+ lastWatch = t0;
849
+ const resolved = new Set();
850
+ const roots = new Set([...results.keys()].filter((n) => !readmitted.has(`${n}:${blockerOf(n)}`)).map(blockerOf).filter((b) => b !== null && blockerOf(b) === null));
851
+ for (const b of roots) {
852
+ let g = null;
853
+ try { g = (await dispatchCheck(b)) ?? null; } catch { g = null; }
854
+ if (!g || !SATISFIED.has(g.outcome)) continue;
855
+ resolved.add(b);
856
+ const row = results.get(b);
857
+ if (row) { results.set(b, { ...row, outcome: g.outcome, pr: Number.isInteger(g.pr) ? g.pr : row.pr, detail: g.detail ?? row.detail }); update(results.get(b)); }
858
+ }
859
+ const back = new Set();
860
+ for (let grew = true; grew;) {
861
+ grew = false;
862
+ for (const n of results.keys()) {
863
+ const b = blockerOf(n);
864
+ if (b !== null && !back.has(n) && !readmitted.has(`${n}:${b}`) && (resolved.has(b) || back.has(b))) { back.add(n); grew = true; }
865
+ }
866
+ }
867
+ for (const n of back) {
868
+ const b = blockerOf(n);
869
+ readmitted.add(`${n}:${b}`);
870
+ results.delete(n);
871
+ pending.push(n);
872
+ decided({ issue: n, what: `re-admitted: dependency #${b} merged or closed after this issue was skipped`, why: "the blocker was finished outside this batch lane",
873
+ alternative: "leave it skipped until the next batch" });
874
+ }
875
+ if (back.size) save();
876
+ };
814
877
  const K = () => { let k = null; try { k = getParallel?.(); } catch { k = null; } return Number.isInteger(k) && k >= 1 ? k : parallel; };
815
878
  try {
816
879
  for (;;) {
817
880
  if (signal?.aborted) throw halt();
818
881
  await drainAdds();
819
882
  checkCancel();
883
+ await watchBlockers(!pending.length && !running.size);
820
884
  if (!pending.length && !running.size) break;
821
885
  // Resolve skips first: a dependency that finished unsatisfied (or never can) skips its dependents.
822
- for (let i = pending.length - 1; i >= 0; i--) {
823
- const n = pending[i];
824
- if (results.has(n)) { pending.splice(i, 1); continue; }
825
- const bad = edges(n).find((d) => results.has(d) && !SATISFIED.has(results.get(d).outcome));
826
- if (bad !== undefined) { pending.splice(i, 1); skip(n, `dependency #${bad} ended ${results.get(bad).outcome}`); }
886
+ for (let again = true; again;) { // #4595: to a fixpoint, so a chain's tail is skipped on its blocker, not as a "cycle"
887
+ again = false;
888
+ for (let i = pending.length - 1; i >= 0; i--) {
889
+ const n = pending[i];
890
+ if (results.has(n)) { pending.splice(i, 1); continue; }
891
+ const bad = edges(n).find((d) => results.has(d) && !SATISFIED.has(results.get(d).outcome));
892
+ if (bad !== undefined) { pending.splice(i, 1); skip(n, `dependency #${bad} ended ${results.get(bad).outcome}`); again = true; }
893
+ }
827
894
  }
828
895
  const t = now();
829
896
  const holdUntil = heldUntil();
830
897
  let started = 0;
831
898
  driftHeld.clear();
899
+ opHeldHit.clear();
900
+ refreshHeld();
832
901
  const isReady = (n) => {
902
+ if (opHeld.has(n)) { opHeldHit.add(n); return false; }
833
903
  if (!((notBefore.get(n) ?? 0) <= t && edges(n).every((d) => results.has(d) && SATISFIED.has(results.get(d).outcome)))) return false;
834
904
  if (!drift?.holds) return true;
835
905
  let pkg = null;
@@ -898,10 +968,12 @@ export async function orchestrate({ issues, parallel = 3, runOne, readBody, pref
898
968
  if (holdUntil) waits.push(holdUntil);
899
969
  let wake = waits.length ? Math.max(0, Math.min(...waits) - t) : null;
900
970
  if (holdUntil && pending.length) wake = Math.min(wake ?? pollMs, pollMs); // #4446: a cleared hold (forgedock limits clear) admits on the next poll
901
- if (getParallel && pending.length) wake = Math.min(wake ?? pollMs, pollMs);
971
+ if ((getParallel || getHeld) && pending.length) wake = Math.min(wake ?? pollMs, pollMs);
902
972
  if (offered.size && pending.length) wake = Math.min(wake ?? pollMs, pollMs); // a slot another batch frees
973
+ if (getHeld && running.size) wake = Math.min(wake ?? pollMs, pollMs); // #4592: a release while only lanes run
903
974
  if (getAdded && running.size) wake = Math.min(wake ?? pollMs, pollMs); // #4408: an add while only lanes run
904
975
  if (syncParked && running.size && parkedPrs().length) wake = Math.min(wake ?? syncEveryMs, Math.max(0, syncEveryMs - (t - (lastParkedSync ?? t)))); // #4525
976
+ if (running.size && watchable()) wake = Math.min(wake ?? WATCH_EVERY_MS, WATCH_EVERY_MS); // #4595: re-check the blockers of skipped rows
905
977
  if (tripped) wake = null;
906
978
  if (getCancelled && running.size && !cancelled) wake = Math.min(wake ?? pollMs, pollMs); // #4376: notice a cancel while lanes run
907
979
  if (running.size) {
@@ -911,6 +983,7 @@ export async function orchestrate({ issues, parallel = 3, runOne, readBody, pref
911
983
  else if (pending.length && offered.size) await nap(wake); // waiting for a host slot
912
984
  else if (pending.length && waits.length) await nap(Math.min(wake, getCancelled ? pollMs : Infinity)); // wake is poll-capped by a hold or a live K; #4454: nap wakes on a stop
913
985
  else if (pending.length && budgetHeld) { if (await drainAdds()) continue; break; } // maintenance the budget does not admit now: held for a later batch
986
+ else if (pending.length && opHeldHit.size && !pending.some((n) => !opHeldHit.has(n) && isReady(n))) { if (await drainAdds()) continue; break; } // #4592: held by the operator: pending for a --resume
914
987
  else if (pending.length && driftHeld.size) { if (await drainAdds()) continue; break; } // held by a drift flag (block_dispatch): pending for a later batch
915
988
  else if (pending.length) { // nothing runnable, nothing running, nothing waiting on time: a dependency cycle
916
989
  for (const n of pending.splice(0)) skip(n, "dependency cycle");
@@ -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,43 @@ 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; a retry the budget cut
46
+ * short is `cut` (left to CI). 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 t2 = Math.min(want, left());
56
+ if (!(t2 > timeoutMs)) return { r, retried: false, cut: t2 < want }; // no budget left to retry in: CI runs it
57
+ const r2 = await run(t2);
58
+ const cut = r2?.rc === TIMEOUT_RC && t2 < want;
59
+ 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 };
60
+ return { r: r2, retried: true, cut };
61
+ }
62
+
25
63
  /**
26
64
  * @param {{commands:string[], parallelism?:number, budgetMs:number, perCommandMs:number, now?:() => number,
27
65
  * acquire?:() => Promise<{release?:Function}|null>,
@@ -30,7 +68,7 @@ const TIMEOUT_RC = 124;
30
68
  * @returns {Promise<{results:{command:string, rc:number, output:string, ms:number}[], deferred:string[], ms:number}>}
31
69
  * results in input order (commands that ran and were not cut by the budget); deferred in input order
32
70
  */
33
- export async function runChecks({ commands, parallelism = 1, budgetMs, perCommandMs, now = () => Date.now(), acquire, run }) {
71
+ export async function runChecks({ commands, parallelism = 1, budgetMs, perCommandMs, now = () => Date.now(), acquire, run, load }) {
34
72
  const list = Array.isArray(commands) ? commands : [];
35
73
  const workers = Math.max(1, Math.floor(Number(parallelism)) || 1);
36
74
  const k = Math.max(1, Math.min(workers, list.length)), share = Math.max(1, Math.floor(workers / k));
@@ -44,7 +82,13 @@ export async function runChecks({ commands, parallelism = 1, budgetMs, perComman
44
82
  if (left <= 0) return { deferred: true };
45
83
  const timeoutMs = Math.min(per, left), t = now();
46
84
  let r;
47
- try { r = await run({ command, timeoutMs, workers: share }); } catch (e) { r = { rc: 127, output: String(e?.message ?? e) }; }
85
+ try {
86
+ if (timeoutMs >= per) { // #4589: its own timeout, not the budget's: one longer retry
87
+ const x = await runWithTimeoutRetry((ms) => run({ command, timeoutMs: ms, workers: share }), timeoutMs, { left: () => deadline - now(), ...(load ? { load } : {}) });
88
+ if (x.cut) return { deferred: true };
89
+ r = x.r;
90
+ } else r = await run({ command, timeoutMs, workers: share });
91
+ } catch (e) { r = { rc: 127, output: String(e?.message ?? e) }; }
48
92
  const rc = Number.isInteger(r?.rc) ? r.rc : 1;
49
93
  // the budget, not the command's own timeout, cut it short: CI runs it
50
94
  if (rc === TIMEOUT_RC && timeoutMs < per) return { deferred: true };
@@ -80,15 +124,19 @@ export function formatPrecheckSummary(rows) {
80
124
  /**
81
125
  * #4364: the pre-PR commands and budget for a round: `scopeChecks` over the trimmed learned commands (a mapped learned
82
126
  * 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}}
127
+ * verify.precheck_total_seconds (default 600 s); per command verify.precheck_timeout_seconds. #4585: `invariants`
128
+ * (verify.invariants) are appended unscoped after the selected commands, deduped; the test map never drops them.
129
+ * @returns {{commands:string[], source:string, added:number, dropped:string[], selected:number, invariants?:number, budgetMs:number, perCommandMs:number}}
85
130
  */
86
- export function planPrecheck({ testMap = null, files = [], learned = [], maxMinutes, verify = {} }) {
131
+ export function planPrecheck({ testMap = null, files = [], learned = [], maxMinutes, verify = {}, invariants = verify?.invariants }) {
87
132
  const list = (Array.isArray(learned) ? learned : []).map((c) => String(c ?? "").trim()).filter(Boolean);
88
- const m = scopeChecks(testMap, files, list);
133
+ const s = scopeChecks(testMap, files, list);
134
+ const inv = (Array.isArray(invariants) ? invariants : []).map((c) => String(c ?? "").trim()).filter(Boolean);
135
+ const withInv = mergeChecks(inv, s.commands);
136
+ const m = inv.length ? { ...s, commands: withInv.commands } : s;
89
137
  const max = Number(maxMinutes);
90
138
  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 };
139
+ return { ...m, ...(inv.length ? { invariants: inv.length } : {}), selected: testMap ? selectCommands(testMap, files).length : 0, budgetMs, perCommandMs: (Number(verify.precheckTimeoutSeconds) || 300) * 1000 };
92
140
  }
93
141
 
94
142
  const lines = (out) => String(out ?? "").split("\n").map((x) => x.trim()).filter(Boolean);