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.
- package/bin/engine-cli.mjs +72 -12
- package/bin/forgedock.mjs +19 -1
- package/bin/hooks/reread-nudge.mjs +33 -5
- package/bin/lean/batch.mjs +24 -1
- package/bin/lean/batch.test.mjs +75 -0
- package/bin/lean/budget.mjs +14 -3
- package/bin/lean/budget.test.mjs +11 -1
- package/bin/lean/conflictres.mjs +93 -0
- package/bin/lean/conflictres.test.mjs +93 -0
- package/bin/lean/conformance.mjs +39 -6
- package/bin/lean/conformance.test.mjs +46 -0
- package/bin/lean/effects.mjs +11 -3
- package/bin/lean/lane.mjs +13 -0
- package/bin/lean/lean.mjs +111 -33
- package/bin/lean/lean.test.mjs +197 -5
- package/bin/lean/orchestrate.mjs +81 -8
- package/bin/lean/precheck.mjs +56 -8
- package/bin/lean/precheck.test.mjs +62 -3
- package/bin/lean/prompts.mjs +28 -6
- package/bin/lean/schedd.mjs +10 -1
- package/bin/lean/schedd.test.mjs +30 -0
- package/bin/lean/sequence.mjs +26 -0
- package/bin/lean/sequence.test.mjs +41 -2
- package/bin/lean/toolstate.mjs +77 -0
- package/bin/lean/toolstate.test.mjs +83 -0
- package/bin/lean/verify.mjs +18 -2
- package/bin/lean/verify.test.mjs +29 -3
- package/bin/review-staging.mjs +14 -2
- package/bin/stats/efficiency.mjs +103 -17
- package/bin/stats/efficiency.test.mjs +103 -4
- package/bin/stats/index.mjs +27 -5
- package/bin/stats/pool.mjs +2 -2
- package/commands/review-pr-agents/protocols.md +1 -1
- package/commands/review-pr-staging.md +1 -1
- package/docs/CONFIG.md +12 -2
- package/docs/STATS.md +29 -1
- package/package.json +1 -1
- package/scripts/review-finalize.sh +17 -1
- package/scripts/review-finalize.test.sh +41 -0
- package/scripts/review-pr-domain-map.test.sh +2 -2
- package/scripts/review-pr-staging-scope.test.sh +4 -2
- package/scripts/review-prepare.sh +62 -27
- package/scripts/review-prepare.test.sh +53 -0
- package/scripts/review-staging-pre.sh +29 -9
- package/scripts/review-staging-pre.test.sh +18 -0
package/bin/lean/lean.test.mjs
CHANGED
|
@@ -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) <=
|
|
1623
|
-
assert.ok(approxTokens(builderImplementPrompt() + hotspotBuildRule(facts([]))) <=
|
|
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
|
+
});
|
package/bin/lean/orchestrate.mjs
CHANGED
|
@@ -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
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
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");
|
package/bin/lean/precheck.mjs
CHANGED
|
@@ -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 {
|
|
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 {
|
|
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
|
-
*
|
|
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
|
|
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);
|