forgedock 1.30.0 → 1.31.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/outcome-codes.mjs +3 -2
- package/bin/engine/runlog.mjs +82 -12
- package/bin/engine/telemetry-seam.mjs +11 -4
- package/bin/engine/telemetry-seam.test.mjs +18 -0
- package/bin/engine-cli.mjs +362 -33
- package/bin/engine.mjs +47 -37
- package/bin/forgedock.mjs +35 -3
- package/bin/gc.mjs +50 -17
- package/bin/labels.json +5 -0
- package/bin/lean/apishape.mjs +170 -0
- package/bin/lean/apishape.test.mjs +187 -0
- package/bin/lean/batch.mjs +32 -0
- package/bin/lean/batch.test.mjs +294 -1
- package/bin/lean/budget.mjs +89 -0
- package/bin/lean/budget.test.mjs +106 -0
- package/bin/lean/checks.mjs +32 -9
- package/bin/lean/checks.test.mjs +78 -1
- package/bin/lean/conformance.e2e.test.mjs +27 -5
- package/bin/lean/conformance.mjs +150 -15
- package/bin/lean/conformance.test.mjs +175 -3
- package/bin/lean/context.mjs +116 -43
- package/bin/lean/context.test.mjs +139 -4
- package/bin/lean/decompose.mjs +90 -0
- package/bin/lean/decompose.test.mjs +105 -0
- package/bin/lean/depcache.mjs +11 -7
- package/bin/lean/depcache.test.mjs +81 -0
- package/bin/lean/e2e.mjs +102 -0
- package/bin/lean/e2e.test.mjs +86 -0
- package/bin/lean/effects.mjs +37 -15
- package/bin/lean/explore.mjs +47 -0
- package/bin/lean/explore.test.mjs +61 -0
- package/bin/lean/facts.mjs +84 -0
- package/bin/lean/facts.test.mjs +66 -0
- package/bin/lean/fixround.mjs +90 -0
- package/bin/lean/fixround.test.mjs +117 -0
- package/bin/lean/heavy.mjs +35 -2
- package/bin/lean/heavy.test.mjs +32 -2
- package/bin/lean/lean.mjs +650 -124
- package/bin/lean/lean.test.mjs +1450 -37
- package/bin/lean/ledger.mjs +5 -3
- package/bin/lean/ledger.test.mjs +18 -0
- package/bin/lean/light.mjs +135 -0
- package/bin/lean/light.test.mjs +173 -0
- package/bin/lean/limits.test.mjs +36 -1
- package/bin/lean/orchestrate.mjs +261 -38
- package/bin/lean/pitfalls.mjs +136 -0
- package/bin/lean/pitfalls.test.mjs +117 -0
- package/bin/lean/postmerge.mjs +85 -0
- package/bin/lean/postmerge.test.mjs +79 -0
- package/bin/lean/precheck.mjs +138 -13
- package/bin/lean/precheck.test.mjs +113 -1
- package/bin/lean/prefilter.mjs +159 -5
- package/bin/lean/prefilter.test.mjs +152 -3
- package/bin/lean/preflight.mjs +108 -3
- package/bin/lean/preflight.test.mjs +48 -1
- package/bin/lean/probe.mjs +297 -0
- package/bin/lean/probe.test.mjs +210 -0
- package/bin/lean/prompts.mjs +101 -13
- package/bin/lean/render.mjs +7 -3
- package/bin/lean/resume.mjs +67 -0
- package/bin/lean/resume.test.mjs +84 -0
- package/bin/lean/review-trigger.mjs +12 -0
- package/bin/lean/review-trigger.test.mjs +17 -1
- package/bin/lean/routing.mjs +74 -0
- package/bin/lean/routing.test.mjs +67 -0
- package/bin/lean/sched.test.mjs +127 -1
- package/bin/lean/schedd.mjs +55 -9
- package/bin/lean/schedd.test.mjs +149 -3
- package/bin/lean/selfreview.mjs +37 -0
- package/bin/lean/selfreview.test.mjs +51 -0
- package/bin/lean/seqlease.mjs +40 -0
- package/bin/lean/seqlease.test.mjs +101 -0
- package/bin/lean/sequence.mjs +296 -13
- package/bin/lean/sequence.test.mjs +263 -2
- package/bin/lean/toolchain.mjs +60 -15
- package/bin/lean/toolchain.test.mjs +120 -3
- package/bin/lean/toolstats.mjs +72 -0
- package/bin/lean/toolstats.test.mjs +47 -0
- package/bin/lean/verifier.mjs +232 -0
- package/bin/lean/verifier.test.mjs +163 -0
- package/bin/lean/verify.mjs +72 -10
- package/bin/lean/verify.test.mjs +68 -3
- package/bin/review-staging.mjs +533 -0
- package/bin/runner.mjs +82 -13
- package/bin/stats/aggregate.mjs +87 -2
- package/bin/stats/efficiency.mjs +319 -0
- package/bin/stats/efficiency.test.mjs +233 -0
- package/bin/stats/github-source.mjs +8 -3
- package/bin/stats/index.mjs +69 -6
- package/bin/stats/local-source.mjs +86 -3
- package/bin/stats/query.mjs +48 -0
- package/bin/stats/render.mjs +22 -0
- package/bin/stats/stats.test.mjs +224 -5
- package/commands/orchestrate.md +3 -1
- package/commands/review-pr-agents/protocols.md +1 -1
- package/commands/review-pr-staging.md +84 -831
- package/commands/review-pr.md +1 -1
- package/commands/work-on/build/implement.md +1 -0
- package/commands/work-on/build/validate.md +11 -1
- package/docs/CONFIG.md +80 -5
- package/docs/OUTCOME-CODES.md +2 -1
- package/docs/STATS.md +53 -1
- package/docs/TELEMETRY.md +1 -1
- package/docs/spec/review-protocol.md +1 -1
- package/package.json +1 -1
- package/scripts/ci-local/lib.mjs +20 -0
- package/scripts/ci-local/plan.mjs +2 -12
- package/scripts/ci-local/scoping.mjs +10 -1
- package/scripts/ci-local.test.sh +2 -0
- package/scripts/fixtures/apishape/other/go-change/base/x.go +3 -0
- package/scripts/fixtures/apishape/other/go-change/head/x.go +3 -0
- package/scripts/fixtures/apishape/python/generator-function/base/seq.py +3 -0
- package/scripts/fixtures/apishape/python/generator-function/head/seq.py +2 -0
- package/scripts/fixtures/apishape/python/ichunked/base/more.py +17 -0
- package/scripts/fixtures/apishape/python/ichunked/head/more.py +20 -0
- package/scripts/fixtures/apishape/python/private-edit/base/calc.py +6 -0
- package/scripts/fixtures/apishape/python/private-edit/head/calc.py +9 -0
- package/scripts/fixtures/apishape/python/removed/base/ops.py +6 -0
- package/scripts/fixtures/apishape/python/removed/head/ops.py +2 -0
- package/scripts/fixtures/apishape/python/renamed/base/text.py +2 -0
- package/scripts/fixtures/apishape/python/renamed/head/text.py +2 -0
- package/scripts/fixtures/apishape/python/signature/base/shapes.py +2 -0
- package/scripts/fixtures/apishape/python/signature/head/shapes.py +2 -0
- package/scripts/fixtures/apishape/python/sync-async/base/client.py +7 -0
- package/scripts/fixtures/apishape/python/sync-async/head/client.py +7 -0
- package/scripts/fixtures/apishape/ts/generator-function/base/seq.js +3 -0
- package/scripts/fixtures/apishape/ts/generator-function/head/seq.js +5 -0
- package/scripts/fixtures/apishape/ts/private-edit/base/calc.ts +7 -0
- package/scripts/fixtures/apishape/ts/private-edit/head/calc.ts +9 -0
- package/scripts/fixtures/apishape/ts/removed/base/ops.ts +7 -0
- package/scripts/fixtures/apishape/ts/removed/head/ops.ts +3 -0
- package/scripts/fixtures/apishape/ts/renamed/base/text.ts +3 -0
- package/scripts/fixtures/apishape/ts/renamed/head/text.ts +3 -0
- package/scripts/fixtures/apishape/ts/signature/base/shapes.ts +3 -0
- package/scripts/fixtures/apishape/ts/signature/head/shapes.ts +3 -0
- package/scripts/fixtures/apishape/ts/sync-async/base/client.mjs +5 -0
- package/scripts/fixtures/apishape/ts/sync-async/head/client.mjs +5 -0
- package/scripts/forge-root.test.sh +2 -2
- package/scripts/publish-staging-sync.test.sh +62 -4
- package/scripts/review-finalize.sh +17 -6
- package/scripts/review-finalize.test.sh +47 -0
- package/scripts/review-pr-arguments.test.sh +13 -1
- package/scripts/review-pr-delta-wiring.test.sh +2 -2
- package/scripts/review-pr-gate2.test.sh +6 -14
- package/scripts/review-pr-staging-finding-filter.test.sh +4 -4
- package/scripts/review-pr-staging-scope.test.sh +39 -33
- package/scripts/review-prepare.sh +73 -6
- package/scripts/review-prepare.test.sh +85 -0
- package/scripts/review-staging-post.sh +293 -0
- package/scripts/review-staging-post.test.sh +143 -0
- package/scripts/review-staging-pre.sh +401 -0
- package/scripts/review-staging-pre.test.sh +150 -0
- package/scripts/symbol-index.mjs +26 -7
- package/scripts/symbol-index.py +55 -9
- package/scripts/trusted-comments.test.sh +1 -1
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
|
|
2
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
3
|
+
import { describe, it } from "node:test";
|
|
4
|
+
import assert from "node:assert/strict";
|
|
5
|
+
import { buildResumeDigest, RESUME_DIGEST_BYTES } from "./resume.mjs";
|
|
6
|
+
import { normaliseFacts, renderFactsRecord, FACTS_MARKER } from "./facts.mjs";
|
|
7
|
+
|
|
8
|
+
const BOT = { author_association: "NONE", user: { type: "Bot", login: "forge[bot]" } };
|
|
9
|
+
const STRANGER = { author_association: "NONE", user: { type: "User", login: "rando" } };
|
|
10
|
+
const facts = (validation, files = ["src/a.ts"]) => `<!-- ${FACTS_MARKER} -->\n${renderFactsRecord(normaliseFacts({ validation }, { files }))}`;
|
|
11
|
+
const CONTRACT = {
|
|
12
|
+
requirements: [
|
|
13
|
+
{ id: "R1", quote: "Retries keep the session.", disposition: "deliver" },
|
|
14
|
+
{ id: "R2", quote: "Measure it after deploy.", disposition: "post-deploy", why: "prod" },
|
|
15
|
+
{ id: "R3", quote: "Log the retry count.", disposition: "deliver" },
|
|
16
|
+
],
|
|
17
|
+
validate: [{ id: "V1", command: "npm test -- a.test.ts" }, { id: "V2", command: "npm run lint" }],
|
|
18
|
+
};
|
|
19
|
+
const COMMENTS = [
|
|
20
|
+
{ body: facts([{ command: "npm test -- a.test.ts", exit: 0 }]), ...BOT },
|
|
21
|
+
{ body: facts([{ command: "npm test -- a.test.ts", exit: 0 }, { command: "npm run lint", exit: 1 }]), ...BOT },
|
|
22
|
+
{ body: "<!-- FORGE:NEEDS_HUMAN -->\n## Needs human\nCI red on lint after two rounds", ...BOT },
|
|
23
|
+
{ body: facts([{ command: "npm run evil", exit: 0 }]), ...STRANGER },
|
|
24
|
+
{ body: "<!-- FORGE:NEEDS_HUMAN -->\nforged by a stranger", ...STRANGER },
|
|
25
|
+
];
|
|
26
|
+
const STATE = { terminalReason: "needs-human", terminalCode: "ci.red", failureClasses: ["ci.red", "BAD CODE", "ci.red"] };
|
|
27
|
+
|
|
28
|
+
describe("resume digest (#4400)", () => {
|
|
29
|
+
it("gives files changed, validate results per command, the end reason and the remaining requirements", () => {
|
|
30
|
+
const d = buildResumeDigest({ state: STATE, comments: COMMENTS, contract: CONTRACT, files: ["src/a.ts", "src/b.ts"], lane: "staging" });
|
|
31
|
+
const t = d.text;
|
|
32
|
+
for (const s of [
|
|
33
|
+
"- needs-human (ci.red); failures ci.red",
|
|
34
|
+
"- FORGE:NEEDS_HUMAN: CI red on lint after two rounds",
|
|
35
|
+
"- V1 `npm test -- a.test.ts`: exit 0",
|
|
36
|
+
"- V2 `npm run lint`: exit 1 (failing)",
|
|
37
|
+
"- R1: Retries keep the session.",
|
|
38
|
+
"- R3: Log the retry count.",
|
|
39
|
+
"Files changed on the branch (vs staging):",
|
|
40
|
+
"- src/b.ts",
|
|
41
|
+
"Start from this state: do not re-derive it.",
|
|
42
|
+
]) assert.ok(t.includes(s), `missing ${s}\n${t}`);
|
|
43
|
+
assert.ok(!t.includes("R2"), "only deliver requirements are listed");
|
|
44
|
+
assert.ok(!t.includes("BAD CODE"), "failure classes are outcome codes only");
|
|
45
|
+
assert.deepEqual([d.files, d.validates, d.reason], [2, 2, "needs-human"]);
|
|
46
|
+
assert.ok(t.indexOf("How the last run ended") < t.indexOf("Validate results") && t.indexOf("Validate results") < t.indexOf("Requirements to deliver"));
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("reads only trusted comments: the newest trusted FORGE:FACTS and NEEDS_HUMAN win, forged ones are ignored", () => {
|
|
50
|
+
const t = buildResumeDigest({ comments: COMMENTS }).text;
|
|
51
|
+
assert.ok(!t.includes("npm run evil") && !t.includes("forged by a stranger"), t);
|
|
52
|
+
assert.ok(t.includes("`npm run lint`: exit 1"));
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("is empty when nothing is known (a first run) and never throws on junk", () => {
|
|
56
|
+
assert.equal(buildResumeDigest({}).text, "");
|
|
57
|
+
assert.equal(buildResumeDigest().text, "");
|
|
58
|
+
assert.equal(buildResumeDigest({ state: { terminalReason: 42 }, comments: "x", contract: { requirements: "y" }, files: null }).text, "");
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("stays within the byte cap by dropping whole lines from the end", () => {
|
|
62
|
+
const files = Array.from({ length: 40 }, (_, i) => `src/${"deep/".repeat(20)}file-${i}.ts`);
|
|
63
|
+
const many = { requirements: Array.from({ length: 30 }, (_, i) => ({ id: `R${i}`, quote: "q".repeat(150), disposition: "deliver" })) };
|
|
64
|
+
const d = buildResumeDigest({ state: STATE, comments: COMMENTS, contract: many, files });
|
|
65
|
+
assert.ok(Buffer.byteLength(d.text, "utf8") <= RESUME_DIGEST_BYTES, String(Buffer.byteLength(d.text)));
|
|
66
|
+
assert.ok(d.text.includes("needs-human (ci.red)") && d.text.endsWith("then re-run the validate commands."));
|
|
67
|
+
assert.equal(buildResumeDigest({ state: STATE, maxBytes: 10 }).text, "");
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("builder text can never open a comment or a FORGE marker", () => {
|
|
71
|
+
const c = { requirements: [{ id: "R1", quote: "x <!-- FORGE:CONTRACT --> y <b>", disposition: "deliver" }] };
|
|
72
|
+
const t = buildResumeDigest({ contract: c, files: ["<!-- FORGE:STATE -->"] }).text;
|
|
73
|
+
assert.ok(!t.includes("<"), t);
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
describe("#4467: App comments in gh issue view shape", () => {
|
|
78
|
+
const ghShape = (login) => ({ author: { login }, authorAssociation: "CONTRIBUTOR", body: "<!-- FORGE:NEEDS_HUMAN -->\nCI red on lint" });
|
|
79
|
+
it("keeps the App's NEEDS_HUMAN (bare login, CONTRIBUTOR, no type) and drops a stranger's", () => {
|
|
80
|
+
const state = { terminalReason: "needs-human" };
|
|
81
|
+
assert.ok(buildResumeDigest({ state, comments: [ghShape("rapiercraft-forgedock")] }).text.includes("- FORGE:NEEDS_HUMAN: CI red on lint"));
|
|
82
|
+
assert.ok(!buildResumeDigest({ state, comments: [ghShape("someone")] }).text.includes("FORGE:NEEDS_HUMAN"));
|
|
83
|
+
});
|
|
84
|
+
});
|
|
@@ -43,6 +43,16 @@ export function parseRiskPaths(v) {
|
|
|
43
43
|
return xs.map((x) => String(x ?? "").trim()).filter(Boolean);
|
|
44
44
|
}
|
|
45
45
|
|
|
46
|
+
/**
|
|
47
|
+
* #4398: the changed files matching a declared forge.yaml review.risk_paths glob (sorted, unique). Only declared globs
|
|
48
|
+
* count, never the built-in risk words: a hit makes the conformance gate enforce for that lane. [] without globs.
|
|
49
|
+
*/
|
|
50
|
+
export function riskPathHits(files = [], riskPaths = []) {
|
|
51
|
+
const globs = parseRiskPaths(riskPaths);
|
|
52
|
+
if (!globs.length) return [];
|
|
53
|
+
return [...new Set((files ?? []).map(String))].filter((f) => globs.some((g) => matchesGlob(f, g))).sort();
|
|
54
|
+
}
|
|
55
|
+
|
|
46
56
|
/**
|
|
47
57
|
* {would_skip, reasons:[{code, detail}]} from facts only. Codes: risk_path, package_edge, hotspot, gate.failed,
|
|
48
58
|
* gate.ambiguous. `hotspots` null/undefined means the facts were unavailable (ambiguous); {enabled:false} is an answer.
|
|
@@ -69,6 +79,8 @@ export function computeTrigger({ files = [], riskPaths = [], structure = null, h
|
|
|
69
79
|
if (gates.ciFellBack) add("gate.failed", "CI verification unavailable (local fallback)");
|
|
70
80
|
const tracked = (contract?.requirements ?? []).filter((r) => r?.disposition === "defer" || r?.disposition === "post-deploy").map((r) => r.id);
|
|
71
81
|
if (tracked.length) add("gate.ambiguous", `contract defers ${tracked.sort().join(", ")} (only the reviewer judges deferrals)`);
|
|
82
|
+
const oob = (contract?.requirements ?? []).filter((r) => r?.verify === "out_of_band").map((r) => r.id);
|
|
83
|
+
if (oob.length) add("gate.ambiguous", `contract verifies ${oob.sort().join(", ")} out of band (only the reviewer judges instrumentation)`);
|
|
72
84
|
reasons.sort((a, b) => a.code.localeCompare(b.code) || a.detail.localeCompare(b.detail));
|
|
73
85
|
return { would_skip: reasons.length === 0, reasons };
|
|
74
86
|
}
|
|
@@ -7,12 +7,21 @@ import { join } from "node:path";
|
|
|
7
7
|
import { spawnSync } from "node:child_process";
|
|
8
8
|
import { fileURLToPath } from "node:url";
|
|
9
9
|
import { tmpDir } from "../tests/_tmp.mjs";
|
|
10
|
-
import { computeTrigger, parseRiskPaths, riskClassOf, triggerReport, readRunLogs, formatReport } from "./review-trigger.mjs";
|
|
10
|
+
import { computeTrigger, riskPathHits, parseRiskPaths, riskClassOf, triggerReport, readRunLogs, formatReport } from "./review-trigger.mjs";
|
|
11
11
|
|
|
12
12
|
const HOT = { enabled: true, file_lines: 2000, growth_lines: 150, files: [] };
|
|
13
13
|
const clean = (patch = {}) => computeTrigger({ files: ["src/foo.js", "test/foo.test.js"], hotspots: HOT, ...patch });
|
|
14
14
|
const codes = (t) => t.reasons.map((r) => r.code);
|
|
15
15
|
|
|
16
|
+
describe("riskPathHits (#4398)", () => {
|
|
17
|
+
it("only declared review.risk_paths globs count, sorted and unique; built-in risk words never do", () => {
|
|
18
|
+
assert.deepEqual(riskPathHits(["src/auth/login.ts", "src/billing/b.ts", "src/billing/a.ts", "src/billing/a.ts"], ["src/billing/**"]), ["src/billing/a.ts", "src/billing/b.ts"]);
|
|
19
|
+
assert.deepEqual(riskPathHits(["src/auth/login.ts"], []), [], "no declared globs: nothing enforces");
|
|
20
|
+
assert.deepEqual(riskPathHits(["db/x.sql", "lib/a.js"], "db/**, *.sql"), ["db/x.sql"]);
|
|
21
|
+
assert.deepEqual(riskPathHits(undefined, ["**"]), []);
|
|
22
|
+
});
|
|
23
|
+
});
|
|
24
|
+
|
|
16
25
|
describe("computeTrigger (#4273)", () => {
|
|
17
26
|
it("a clean diff with passing gates would skip, with no reasons", () => {
|
|
18
27
|
assert.deepEqual(clean(), { would_skip: true, reasons: [] });
|
|
@@ -61,6 +70,13 @@ describe("computeTrigger (#4273)", () => {
|
|
|
61
70
|
assert.deepEqual(a, b);
|
|
62
71
|
assert.deepEqual(codes(a), ["gate.ambiguous", "gate.failed", "risk_path", "risk_path"]);
|
|
63
72
|
});
|
|
73
|
+
it("#4404 an out-of-band requirement forces the reviewer (only it judges instrumentation)", () => {
|
|
74
|
+
const t = clean({ contract: { requirements: [{ id: "R2", disposition: "deliver", verify: "out_of_band" }, { id: "R1", disposition: "deliver" }] } });
|
|
75
|
+
assert.equal(t.would_skip, false);
|
|
76
|
+
assert.deepEqual(t.reasons, [{ code: "gate.ambiguous", detail: "contract verifies R2 out of band (only the reviewer judges instrumentation)" }]);
|
|
77
|
+
assert.equal(clean({ contract: { requirements: [{ id: "R1", disposition: "deliver", verify: "in_lane" }] } }).would_skip, true);
|
|
78
|
+
});
|
|
79
|
+
|
|
64
80
|
it("parseRiskPaths takes a comma string or a list", () => {
|
|
65
81
|
assert.deepEqual(parseRiskPaths(" a/** , ,*.sql"), ["a/**", "*.sql"]);
|
|
66
82
|
assert.deepEqual(parseRiskPaths(["x", " ", "y "]), ["x", "y"]);
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
|
|
2
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
3
|
+
/**
|
|
4
|
+
* Builder model routing (#4394). The build runs on a mid-tier model when the frozen contract is small or touches only
|
|
5
|
+
* docs, config and tests, and on the top model (the configured builder model, else the CLI default) when it is large or
|
|
6
|
+
* hits a risk path (the built-in risk words or forge.yaml review.risk_paths). The route is computed once from the
|
|
7
|
+
* contract's deliverable paths; a failed validate round on the mid tier escalates the lane to the top model.
|
|
8
|
+
* Pure: no I/O, no model call. Repo-agnostic: paths are classified by extension and directory names only.
|
|
9
|
+
*/
|
|
10
|
+
import { riskClassOf, riskPathHits } from "./review-trigger.mjs";
|
|
11
|
+
|
|
12
|
+
export const ROUTING_MODES = Object.freeze(["on", "off"]);
|
|
13
|
+
export const DEFAULT_MID_MODEL = "claude-sonnet-5-5";
|
|
14
|
+
/** Deliverables of at most this many non-test files make a small change. */
|
|
15
|
+
export const SMALL_MAX_FILES = 2;
|
|
16
|
+
/** Fix-round triggers that are validate rounds: a failure on the mid tier escalates the lane to the top model. */
|
|
17
|
+
export const ESCALATE_TRIGGERS = Object.freeze(["precheck", "ci_red", "merge_ci"]);
|
|
18
|
+
|
|
19
|
+
const MODEL_RE = /^[A-Za-z0-9._:[\]-]+$/;
|
|
20
|
+
|
|
21
|
+
/** {mode, midModel}: an unknown mode is on, an invalid model id falls back to the default mid model. */
|
|
22
|
+
export function parseBuilderRouting({ mode, midModel } = {}) {
|
|
23
|
+
const m = String(mode ?? "").trim().toLowerCase();
|
|
24
|
+
const id = String(midModel ?? "").trim();
|
|
25
|
+
return { mode: ROUTING_MODES.includes(m) ? m : "on", midModel: MODEL_RE.test(id) ? id : DEFAULT_MID_MODEL };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const LOCKFILE_RE = /(^|\/)(package-lock\.json|npm-shrinkwrap\.json|yarn\.lock|pnpm-lock\.yaml|bun\.lockb?|Cargo\.lock|poetry\.lock|uv\.lock|Pipfile\.lock|Gemfile\.lock|composer\.lock|go\.sum|[^/]*\.lock)$/i;
|
|
29
|
+
const TEST_FILE_RE = /(\.(test|spec)\.[A-Za-z0-9]+$)|(_test\.(go|py|rb|exs?)$)|((^|\/)test_[^/]+\.py$)|(_spec\.rb$)|(Tests?\.(java|kt|cs|swift)$)/;
|
|
30
|
+
const TEST_DIR_RE = /(^|\/)(tests?|__tests__|spec|specs|fixtures|__snapshots__)\//i;
|
|
31
|
+
const DOCS_EXT_RE = /\.(md|mdx|markdown|rst|txt|adoc)$/i;
|
|
32
|
+
const DOCS_DIR_RE = /(^|\/)(docs?|devdocs)\//i;
|
|
33
|
+
const CONFIG_EXT_RE = /\.(json|jsonc|ya?ml|toml|ini|cfg|conf|properties|editorconfig)$/i;
|
|
34
|
+
const DOTFILE_RE = /(^|\/)\.[^/]+$/;
|
|
35
|
+
const ENV_EXAMPLE_RE = /(^|\/)\.env\.(example|sample|template)$/i;
|
|
36
|
+
|
|
37
|
+
/** 'test' | 'docs' | 'config' | 'code' for one repo-relative path. Lockfiles are code (they change what runs). */
|
|
38
|
+
export function classifyPath(p) {
|
|
39
|
+
const s = String(p ?? "").trim().replace(/^\.\//, "");
|
|
40
|
+
if (LOCKFILE_RE.test(s)) return "code";
|
|
41
|
+
if (TEST_FILE_RE.test(s) || TEST_DIR_RE.test(s)) return "test";
|
|
42
|
+
if (DOCS_EXT_RE.test(s) || DOCS_DIR_RE.test(s)) return "docs";
|
|
43
|
+
if (ENV_EXAMPLE_RE.test(s) || CONFIG_EXT_RE.test(s) || DOTFILE_RE.test(s)) return "config";
|
|
44
|
+
return "code";
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Deliverable paths of a frozen contract (trimmed, unique, in order). */
|
|
48
|
+
export function deliverablePaths(contract) {
|
|
49
|
+
const ds = Array.isArray(contract?.deliverables) ? contract.deliverables : [];
|
|
50
|
+
return [...new Set(ds.map((d) => String(d?.path ?? "").trim()).filter(Boolean))];
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* The route of one lane: {class, tier, model, risk}. class: risk | docs-config-test | small | large; tier: mid | top.
|
|
55
|
+
* `topModel` null means the CLI default. Mode off keeps every lane on the top model but still reports its class.
|
|
56
|
+
*/
|
|
57
|
+
export function routeBuilder({ contract = null, riskPaths = [], cfg = {}, topModel = null } = {}) {
|
|
58
|
+
const c = parseBuilderRouting(cfg);
|
|
59
|
+
const paths = deliverablePaths(contract);
|
|
60
|
+
const declared = new Set(riskPathHits(paths, riskPaths));
|
|
61
|
+
const risk = paths.filter((p) => riskClassOf(p) || declared.has(p));
|
|
62
|
+
const kinds = paths.map(classifyPath);
|
|
63
|
+
const cls = !paths.length ? "large"
|
|
64
|
+
: risk.length ? "risk"
|
|
65
|
+
: kinds.every((k) => k !== "code") ? "docs-config-test"
|
|
66
|
+
: kinds.filter((k) => k !== "test").length <= SMALL_MAX_FILES ? "small" : "large";
|
|
67
|
+
const tier = c.mode === "on" && (cls === "docs-config-test" || cls === "small") ? "mid" : "top";
|
|
68
|
+
return { mode: c.mode, class: cls, tier, model: tier === "mid" ? c.midModel : topModel, risk };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** True when a failed `trigger` round on the mid tier escalates the lane (once). */
|
|
72
|
+
export function shouldEscalate(route, escalated, trigger) {
|
|
73
|
+
return route?.tier === "mid" && !escalated && ESCALATE_TRIGGERS.includes(trigger);
|
|
74
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
|
|
2
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
3
|
+
import { describe, it } from "node:test";
|
|
4
|
+
import assert from "node:assert/strict";
|
|
5
|
+
import { parseBuilderRouting, classifyPath, routeBuilder, shouldEscalate, ESCALATE_TRIGGERS, DEFAULT_MID_MODEL } from "./routing.mjs";
|
|
6
|
+
|
|
7
|
+
const contract = (...paths) => ({ deliverables: paths.map((path, i) => ({ id: `D${i + 1}`, path, change: "x", requirements: ["R1"] })) });
|
|
8
|
+
|
|
9
|
+
describe("parseBuilderRouting (#4394)", () => {
|
|
10
|
+
it("defaults to on with the default mid model; unknown modes are on; invalid ids fall back", () => {
|
|
11
|
+
assert.deepEqual(parseBuilderRouting(), { mode: "on", midModel: DEFAULT_MID_MODEL });
|
|
12
|
+
assert.equal(DEFAULT_MID_MODEL, "claude-sonnet-5-5");
|
|
13
|
+
assert.equal(parseBuilderRouting({ mode: "OFF" }).mode, "off");
|
|
14
|
+
assert.equal(parseBuilderRouting({ mode: "shadow" }).mode, "on");
|
|
15
|
+
assert.equal(parseBuilderRouting({ midModel: "claude-haiku-5-5" }).midModel, "claude-haiku-5-5");
|
|
16
|
+
assert.equal(parseBuilderRouting({ midModel: "openai/gpt[1m]" }).midModel, DEFAULT_MID_MODEL);
|
|
17
|
+
assert.equal(parseBuilderRouting({ midModel: "sonnet; rm -rf /" }).midModel, DEFAULT_MID_MODEL);
|
|
18
|
+
assert.equal(parseBuilderRouting({ midModel: "opus[1m]" }).midModel, "opus[1m]");
|
|
19
|
+
});
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
describe("classifyPath (#4394)", () => {
|
|
23
|
+
it("classifies tests across ecosystems", () => {
|
|
24
|
+
for (const p of ["src/a.test.ts", "web/b.spec.tsx", "pkg/c_test.go", "tests/test_d.py", "app/test_e.py", "crates/x/tests/it.rs", "src/__tests__/f.js", "spec/models/user_spec.rb", "src/test/java/FooTest.java"]) {
|
|
25
|
+
assert.equal(classifyPath(p), "test", p);
|
|
26
|
+
}
|
|
27
|
+
});
|
|
28
|
+
it("classifies docs, config and code; lockfiles are code", () => {
|
|
29
|
+
for (const p of ["README.md", "docs/guide.mdx", "devdocs/modules/lean.md", "CHANGES.rst", "notes.txt", "docs/img/arch.svg"]) assert.equal(classifyPath(p), "docs", p);
|
|
30
|
+
for (const p of ["forge.yaml", ".github/workflows/ci.yml", "pyproject.toml", "setup.cfg", "tsconfig.json", ".env.example", ".eslintrc", "Cargo.toml"]) assert.equal(classifyPath(p), "config", p);
|
|
31
|
+
for (const p of ["package-lock.json", "pnpm-lock.yaml", "yarn.lock", "Cargo.lock", "poetry.lock", "go.sum", "src/main.rs", "lib/x.py", "cmd/main.go", "src/foo.ts", "Makefile"]) assert.equal(classifyPath(p), "code", p);
|
|
32
|
+
});
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
describe("routeBuilder (#4394)", () => {
|
|
36
|
+
it("risk: a built-in risk word or a declared review.risk_paths glob routes to the top model", () => {
|
|
37
|
+
const r = routeBuilder({ contract: contract("src/auth/session.ts", "README.md"), topModel: "opus" });
|
|
38
|
+
assert.deepEqual(r, { mode: "on", class: "risk", tier: "top", model: "opus", risk: ["src/auth/session.ts"] });
|
|
39
|
+
const g = routeBuilder({ contract: contract("detect/rules.py"), riskPaths: ["detect/**"] });
|
|
40
|
+
assert.deepEqual([g.class, g.tier, g.model, g.risk], ["risk", "top", null, ["detect/rules.py"]]);
|
|
41
|
+
assert.equal(routeBuilder({ contract: contract("detect/rules.py") }).class, "small", "detection is not a built-in risk word");
|
|
42
|
+
});
|
|
43
|
+
it("docs-config-test, small and large classes", () => {
|
|
44
|
+
assert.deepEqual(routeBuilder({ contract: contract("docs/a.md", "forge.yaml", "test/a.test.js"), topModel: "opus" }),
|
|
45
|
+
{ mode: "on", class: "docs-config-test", tier: "mid", model: DEFAULT_MID_MODEL, risk: [] });
|
|
46
|
+
const s = routeBuilder({ contract: contract("src/a.js", "src/b.js", "test/a.test.js", "test/b.test.js"), cfg: { midModel: "haiku" } });
|
|
47
|
+
assert.deepEqual([s.class, s.tier, s.model], ["small", "mid", "haiku"]);
|
|
48
|
+
const l = routeBuilder({ contract: contract("src/a.js", "src/b.js", "src/c.js"), topModel: "opus" });
|
|
49
|
+
assert.deepEqual([l.class, l.tier, l.model], ["large", "top", "opus"]);
|
|
50
|
+
const lock = routeBuilder({ contract: contract("package.json", "package-lock.json") });
|
|
51
|
+
assert.equal(lock.class, "small", "a lockfile change is code, not config");
|
|
52
|
+
});
|
|
53
|
+
it("no deliverables is large; mode off keeps the top tier but still reports the class", () => {
|
|
54
|
+
assert.deepEqual([routeBuilder({ contract: { deliverables: [] } }).class, routeBuilder({}).tier], ["large", "top"]);
|
|
55
|
+
const off = routeBuilder({ contract: contract("docs/a.md"), cfg: { mode: "off" }, topModel: "opus" });
|
|
56
|
+
assert.deepEqual([off.mode, off.class, off.tier, off.model], ["off", "docs-config-test", "top", "opus"]);
|
|
57
|
+
});
|
|
58
|
+
it("escalates once, on validate rounds only, from the mid tier", () => {
|
|
59
|
+
assert.deepEqual(ESCALATE_TRIGGERS, ["precheck", "ci_red", "merge_ci"]);
|
|
60
|
+
const mid = { tier: "mid" };
|
|
61
|
+
for (const t of ESCALATE_TRIGGERS) assert.equal(shouldEscalate(mid, false, t), true, t);
|
|
62
|
+
for (const t of ["conformance", "review", "build", "recovery"]) assert.equal(shouldEscalate(mid, false, t), false, t);
|
|
63
|
+
assert.equal(shouldEscalate(mid, true, "precheck"), false);
|
|
64
|
+
assert.equal(shouldEscalate({ tier: "top" }, false, "precheck"), false);
|
|
65
|
+
assert.equal(shouldEscalate(null, false, "precheck"), false);
|
|
66
|
+
});
|
|
67
|
+
});
|
package/bin/lean/sched.test.mjs
CHANGED
|
@@ -6,7 +6,7 @@ import { writeFileSync } from "node:fs";
|
|
|
6
6
|
import { join } from "node:path";
|
|
7
7
|
import { tmpDir } from "../tests/_tmp.mjs";
|
|
8
8
|
import { grantOrder, openSched, priorityFromLabels } from "./sched.mjs";
|
|
9
|
-
import { orchestrate } from "./orchestrate.mjs";
|
|
9
|
+
import { orchestrate, estimateLaneMinutes, laneHistory, formatEstimateSummary, EST_DEFAULT_MIN, POST_DEPLOY_EST_MIN } from "./orchestrate.mjs";
|
|
10
10
|
import { schedFromCli } from "../engine-cli.mjs";
|
|
11
11
|
|
|
12
12
|
const quiet = async (fn) => { const l = console.log; console.log = () => {}; try { return await fn(); } finally { console.log = l; } };
|
|
@@ -308,3 +308,129 @@ describe("cross-repo fairness weights (#4193)", () => {
|
|
|
308
308
|
assert.throws(() => a.setWeight("", 2), /non-empty/);
|
|
309
309
|
});
|
|
310
310
|
});
|
|
311
|
+
|
|
312
|
+
// #4409: a virtual clock. Lane durations and sleeps are timers in virtual ms; the clock jumps to the next timer once the
|
|
313
|
+
// event loop has drained every microtask, so the orchestrator's async chains settle before time moves.
|
|
314
|
+
const vclock = () => {
|
|
315
|
+
let t = 0, done = false;
|
|
316
|
+
const timers = [];
|
|
317
|
+
const at = (ms, signal) => new Promise((res) => {
|
|
318
|
+
const x = { when: t + ms, res };
|
|
319
|
+
timers.push(x);
|
|
320
|
+
signal?.addEventListener("abort", () => { const i = timers.indexOf(x); if (i >= 0) timers.splice(i, 1); res(); }, { once: true });
|
|
321
|
+
});
|
|
322
|
+
const tick = () => setImmediate(() => {
|
|
323
|
+
if (done) return;
|
|
324
|
+
if (timers.length) { timers.sort((a, b) => a.when - b.when); const x = timers.shift(); t = x.when; x.res(); }
|
|
325
|
+
tick();
|
|
326
|
+
});
|
|
327
|
+
tick();
|
|
328
|
+
return { now: () => t, at, sleep: (ms, signal) => at(ms, signal), stop: () => { done = true; } };
|
|
329
|
+
};
|
|
330
|
+
const MIN = 60000;
|
|
331
|
+
|
|
332
|
+
describe("longest-first admission (#4409)", () => {
|
|
333
|
+
it("estimateLaneMinutes: size label, unsized = M, post-deploy floor, history only with enough merged samples of the repo", () => {
|
|
334
|
+
assert.equal(estimateLaneMinutes({ labels: ["size:S"] }), EST_DEFAULT_MIN.S);
|
|
335
|
+
assert.equal(estimateLaneMinutes({ labels: [{ name: "size:L" }] }), EST_DEFAULT_MIN.L);
|
|
336
|
+
assert.equal(estimateLaneMinutes({}), EST_DEFAULT_MIN.M);
|
|
337
|
+
assert.equal(estimateLaneMinutes({ labels: ["size:L"], body: "- [ ] `R1: smoke` — post-deploy: needs prod" }), POST_DEPLOY_EST_MIN);
|
|
338
|
+
const run = (repo, size, min, reason = "merged") => ({ repo, size, reason, wallMs: min * MIN });
|
|
339
|
+
const h = laneHistory([run("o/r", "S", 4), run("o/r", "S", 6), run("o/r", "S", 8), run("o/r", "S", 100, "needs-human"), run("x/y", "S", 1),
|
|
340
|
+
run("o/r", "L", 90), run("o/r", "L", 70)], "o/r");
|
|
341
|
+
assert.deepEqual(h, { S: 6 }, "L has only 2 samples; other repos and unmerged runs never count");
|
|
342
|
+
assert.equal(estimateLaneMinutes({ labels: ["size:S"], history: h }), 6);
|
|
343
|
+
assert.equal(estimateLaneMinutes({ labels: ["size:L"], history: h }), EST_DEFAULT_MIN.L);
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
it("admits by estimate, descending, within a priority level; priority across levels is unchanged", async () => {
|
|
347
|
+
const est = { 1: 5, 2: 50, 3: 20 };
|
|
348
|
+
const order = [];
|
|
349
|
+
const rows = await orchestrate({ issues: [1, 2, 3], parallel: 1, readBody: async () => "", estimate: async (n) => est[n],
|
|
350
|
+
runOne: async (n) => { order.push(n); return { terminalReason: "merged" }; } });
|
|
351
|
+
assert.deepEqual(order, [2, 3, 1]);
|
|
352
|
+
assert.deepEqual(rows.map((r) => r.est_minutes), [5, 50, 20]);
|
|
353
|
+
const { handle } = world();
|
|
354
|
+
const order2 = [];
|
|
355
|
+
await orchestrate({ issues: [1, 2, 3], parallel: 1, readBody: async () => "", estimate: async (n) => est[n], pollMs: 2, batchId: "lpt",
|
|
356
|
+
sched: { registry: handle(7101), batch: "lpt", repo: "o/r", cap: 4 }, priorityOf: async (n) => (n === 1 ? 1 : 2),
|
|
357
|
+
runOne: async (n) => { order2.push(n); return { terminalReason: "merged" }; } });
|
|
358
|
+
assert.deepEqual(order2, [1, 2, 3], "the short P1 still precedes the long P2s");
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
it("decomposed sub-issues are admitted before same-priority FIFO items, with or without an estimator", async () => {
|
|
362
|
+
for (const estimate of [null, async () => 10]) {
|
|
363
|
+
const order = [];
|
|
364
|
+
let store = null;
|
|
365
|
+
await orchestrate({ issues: [1, 2, 3], parallel: 1, readBody: async () => "", estimate, store: { load: () => null, save: (x) => { store = x; } },
|
|
366
|
+
runOne: async (n) => { order.push(n); return n === 1 ? { terminalReason: "decomposed", subissues: [8, 9] } : { terminalReason: "merged" }; } });
|
|
367
|
+
assert.deepEqual(order, [1, 8, 9, 2, 3]);
|
|
368
|
+
assert.equal("estimates" in store, !!estimate, "no estimates key without an estimator");
|
|
369
|
+
if (estimate) assert.equal(store.estimates[8], EST_DEFAULT_MIN.M, "parent estimate ÷ N, floored at M");
|
|
370
|
+
}
|
|
371
|
+
});
|
|
372
|
+
|
|
373
|
+
it("starvation guard: an issue queued past starveMs is bumped ahead of a longer estimate", async () => {
|
|
374
|
+
// #2 runs 0-10 min; #3 (longer) is added at 1 min; at 10 min #1 has waited 10 min.
|
|
375
|
+
const run = async (starveMs) => {
|
|
376
|
+
const c = vclock();
|
|
377
|
+
const order = [];
|
|
378
|
+
try {
|
|
379
|
+
await orchestrate({ issues: [1, 2], parallel: 1, readBody: async () => "", now: c.now, sleep: c.sleep, pollMs: MIN, starveMs,
|
|
380
|
+
getAdded: () => (c.now() >= MIN ? [3] : []), estimate: async (n) => ({ 1: 5, 2: 30, 3: 40 })[n],
|
|
381
|
+
runOne: async (n) => { order.push(n); await c.at(10 * MIN); return { terminalReason: "merged" }; } });
|
|
382
|
+
} finally { c.stop(); }
|
|
383
|
+
return order;
|
|
384
|
+
};
|
|
385
|
+
assert.deepEqual(await run(8 * MIN), [2, 1, 3], "#1 waited past the limit");
|
|
386
|
+
assert.deepEqual(await run(30 * MIN), [2, 3, 1], "under the limit: longest first");
|
|
387
|
+
});
|
|
388
|
+
|
|
389
|
+
it("critical path: an issue with an in-batch dependent outranks an equal estimate without one", async () => {
|
|
390
|
+
const order = [];
|
|
391
|
+
await orchestrate({ issues: [1, 2, 3], parallel: 1, readBody: async (n) => (n === 3 ? "Depends on #2" : ""), estimate: async () => 10,
|
|
392
|
+
runOne: async (n) => { order.push(n); return { terminalReason: "merged" }; } });
|
|
393
|
+
assert.deepEqual(order, [2, 1, 3]);
|
|
394
|
+
});
|
|
395
|
+
|
|
396
|
+
it("replay of the 2026-10-22 batch: makespan is about the longest chain, not the FIFO tail", async () => {
|
|
397
|
+
// K=6; 41 short lanes; #41 (size:L, 40 min) is listed last; #3 decomposes 4 min in; its sub-issue #104 runs 48 min.
|
|
398
|
+
const c = vclock();
|
|
399
|
+
const issues = Array.from({ length: 41 }, (_, i) => i + 1);
|
|
400
|
+
const dur = (n) => (n === 41 ? 40 : n === 3 ? 4 : n === 104 ? 48 : 2) * MIN;
|
|
401
|
+
const start = new Map();
|
|
402
|
+
let rows;
|
|
403
|
+
try {
|
|
404
|
+
rows = await orchestrate({ issues, parallel: 6, readBody: async () => "", now: c.now, sleep: c.sleep,
|
|
405
|
+
estimate: async (n) => estimateLaneMinutes({ labels: [n === 41 ? "size:L" : "size:S"] }),
|
|
406
|
+
runOne: async (n) => {
|
|
407
|
+
start.set(n, c.now());
|
|
408
|
+
await c.at(dur(n));
|
|
409
|
+
return n === 3 ? { terminalReason: "decomposed", subissues: [101, 102, 103, 104] } : { terminalReason: "merged" };
|
|
410
|
+
} });
|
|
411
|
+
} finally { c.stop(); }
|
|
412
|
+
const makespan = c.now() / MIN;
|
|
413
|
+
assert.equal(rows.filter((r) => r.outcome === "merged").length, 44);
|
|
414
|
+
assert.equal(start.get(41), 0, "the long lane is admitted first");
|
|
415
|
+
assert.ok(start.get(104) <= 5 * MIN, `sub-issue #104 started at ${start.get(104) / MIN} min`);
|
|
416
|
+
assert.ok(makespan <= 56, `makespan ${makespan} min (longest chain 4 + 48)`);
|
|
417
|
+
});
|
|
418
|
+
|
|
419
|
+
it("formatEstimateSummary: estimated vs. actual over lanes that ran", () => {
|
|
420
|
+
assert.equal(formatEstimateSummary([{ issue: 1, outcome: "merged", minutes: 3 }]), null);
|
|
421
|
+
assert.equal(formatEstimateSummary([{ issue: 1, outcome: "merged", minutes: 12, est_minutes: 10 }, { issue: 2, outcome: "merged", minutes: 40, est_minutes: 25 },
|
|
422
|
+
{ issue: 3, outcome: "held", minutes: 0, est_minutes: 10 }]),
|
|
423
|
+
"estimates: 2 lanes, est 35.0 min vs actual 52.0 min; median |error| 8.5 min; worst #2 est 25 actual 40");
|
|
424
|
+
});
|
|
425
|
+
|
|
426
|
+
it("a resume keeps estimates, queue times and the head band from the store", async () => {
|
|
427
|
+
const saved = { v: 1, order: [1, 2, 3], deps: { 1: [], 2: [], 3: [] }, lanes: {}, crossSeen: [], results: {}, running: {}, pending: [2, 3, 1],
|
|
428
|
+
estimates: { 1: 50, 2: 5, 3: 20 }, enqueued: { 1: 0, 2: 0, 3: 0 }, heads: [3] };
|
|
429
|
+
const order = [];
|
|
430
|
+
let estCalls = 0;
|
|
431
|
+
await orchestrate({ issues: [], parallel: 1, readBody: async () => "", estimate: async () => { estCalls++; return 1; }, now: () => 0,
|
|
432
|
+
store: { load: () => saved, save: () => {} }, runOne: async (n) => { order.push(n); return { terminalReason: "merged" }; } });
|
|
433
|
+
assert.deepEqual(order, [3, 1, 2]);
|
|
434
|
+
assert.equal(estCalls, 0);
|
|
435
|
+
});
|
|
436
|
+
});
|
package/bin/lean/schedd.mjs
CHANGED
|
@@ -9,13 +9,22 @@
|
|
|
9
9
|
* Protocol: newline-delimited JSON, one request per connection.
|
|
10
10
|
* {op:"submit", argv, cwd, env} → {type:"batch", id, dir}, {type:"row", row}…, {type:"line", text}…, {type:"done", result, report}
|
|
11
11
|
* {op:"attach", id} → log.jsonl rows replayed, then live rows/lines and done; a finished batch replays report.json
|
|
12
|
-
* ({type:"done", replay:true}); a batch nobody hosts is resumed here
|
|
12
|
+
* ({type:"done", replay:true}) unless it has unrun added issues; a batch nobody hosts is resumed here
|
|
13
13
|
* {op:"set-parallel", id, k} → {type:"ok", parallel}
|
|
14
|
+
* {op:"add", id, issues} → {type:"ok", added, skipped, hosted} (#4408): merged into the batch's `add` file, which a
|
|
15
|
+
* hosted runner reads on its next pass and an unhosted batch on its next resume
|
|
16
|
+
* {op:"cancel", id} → {type:"ok", cancelled:true, hosted} (#4376): writes the batch's `cancel` file; that batch
|
|
17
|
+
* admits nothing more, its running local lanes get SIGTERM (their teardown saves the work)
|
|
18
|
+
* and the rest end held; no other batch and not the daemon. A finished, unhosted batch:
|
|
19
|
+
* {type:"ok", cancelled:false, finished:true}, nothing written
|
|
14
20
|
* {op:"status"} → {type:"status", pid, batches:[{id, running}]}
|
|
15
|
-
* Failures answer {type:"error", message}. A client closing its connection never cancels a batch, and stopping the daemon
|
|
21
|
+
* Failures answer {type:"error", message}. A client closing its connection never cancels a batch (only `cancel` stops one), and stopping the daemon
|
|
16
22
|
* never stops a lane (lanes are detached sessions). On start the daemon resumes every daemon-hosted batch without a
|
|
17
23
|
* report, so live lanes are re-adopted through the batch runner's `adopt`. It exits after `idleMs` with no batch and no client.
|
|
18
|
-
* `
|
|
24
|
+
* Shutdown (#4454): `close()` aborts every hosted batch's `signal` (its loop admits nothing more and leaves, lanes keep
|
|
25
|
+
* running, no report is written), waits for them up to `stopMs`, and only then releases `schedd.lock` and the socket, so a
|
|
26
|
+
* daemon started next never resumes a batch this one still dispatches. `exitOnSignals` exits 128+signo after that.
|
|
27
|
+
* `runBatch({argv, cwd, env, sink, keepBreaker, signal})` is injected (engine-cli `runBatch`); `sink` gets `batch(b)`, `row(r)`, `line(text)`.
|
|
19
28
|
*/
|
|
20
29
|
import { closeSync, existsSync, mkdirSync, openSync, readFileSync, rmSync, writeSync } from "node:fs";
|
|
21
30
|
import { createConnection, createServer } from "node:net";
|
|
@@ -25,6 +34,7 @@ import { schedDir, pidAlive } from "./sched.mjs";
|
|
|
25
34
|
import { batchRoot, listBatches, openBatch } from "./batch.mjs";
|
|
26
35
|
|
|
27
36
|
export const IDLE_MS = 10 * 60 * 1000;
|
|
37
|
+
export const STOP_MS = 10 * 1000;
|
|
28
38
|
|
|
29
39
|
export function scheddPaths(dir = schedDir()) {
|
|
30
40
|
return { dir, sock: join(dir, "schedd.sock"), lock: join(dir, "schedd.lock"), log: join(dir, "schedd.log") };
|
|
@@ -64,7 +74,7 @@ function takeLock(lock, pid, alive) {
|
|
|
64
74
|
* Start the daemon. @returns {Promise<{running:true, pid, close, closed:Promise} | {running:false, owner:number}>}
|
|
65
75
|
*/
|
|
66
76
|
export async function startDaemon({ dir = schedDir(), runBatch, idleMs = Number(process.env.FORGEDOCK_SCHEDD_IDLE_MS) || IDLE_MS,
|
|
67
|
-
root = batchRoot(), log = (m) => console.log(m), pid = process.pid, alive = pidAlive, resumeOnStart = true } = {}) {
|
|
77
|
+
root = batchRoot(), log = (m) => console.log(m), pid = process.pid, alive = pidAlive, resumeOnStart = true, stopMs = STOP_MS } = {}) {
|
|
68
78
|
const p = scheddPaths(dir);
|
|
69
79
|
mkdirSync(dir, { recursive: true });
|
|
70
80
|
if (!takeLock(p.lock, pid, alive)) {
|
|
@@ -77,11 +87,15 @@ export async function startDaemon({ dir = schedDir(), runBatch, idleMs = Number(
|
|
|
77
87
|
|
|
78
88
|
const hosted = new Map(); // batch id -> {id, dir, subs:Set<fn>}
|
|
79
89
|
const conns = new Set();
|
|
90
|
+
const runs = new Map(); // every runBatch in flight (hosted or not yet started) -> its AbortController (#4454)
|
|
91
|
+
let closing = null, resolveClosed;
|
|
80
92
|
let lastActive = Date.now();
|
|
81
93
|
const touch = () => { lastActive = Date.now(); };
|
|
82
94
|
|
|
83
95
|
/** Run one batch here. Resolves with {id, dir} once the batch exists, rejects when it fails before that. */
|
|
84
96
|
const host = ({ argv, cwd, env, keepBreaker = false }, first = null) => new Promise((resolve, reject) => {
|
|
97
|
+
if (closing) return reject(new Error("schedd is shutting down"));
|
|
98
|
+
const ac = new AbortController();
|
|
85
99
|
const ent = { id: null, dir: null, subs: new Set(first ? [first] : []) };
|
|
86
100
|
const emit = (m) => { for (const s of ent.subs) s(m); };
|
|
87
101
|
let started = false;
|
|
@@ -91,13 +105,15 @@ export async function startDaemon({ dir = schedDir(), runBatch, idleMs = Number(
|
|
|
91
105
|
line: (text) => emit({ type: "line", text }),
|
|
92
106
|
};
|
|
93
107
|
touch();
|
|
94
|
-
Promise.resolve().then(() => runBatch({ argv, cwd, env, sink, keepBreaker })).then((result) => {
|
|
108
|
+
const run = Promise.resolve().then(() => runBatch({ argv, cwd, env, sink, keepBreaker, signal: ac.signal })).then((result) => {
|
|
95
109
|
const report = ent.dir ? openBatch(root, ent.id).readReport()?.report ?? null : null;
|
|
96
110
|
emit({ type: "done", result, report });
|
|
97
111
|
}, (e) => {
|
|
98
112
|
const message = e?.message ?? String(e);
|
|
113
|
+
if (e?.code === "batch.stopped") { log(`schedd: batch ${ent.id ?? argv.join(" ")} stopped (lanes keep running; resumed by the next daemon)`); if (!started) reject(new Error(message)); return; }
|
|
99
114
|
if (!started) reject(new Error(message)); else { log(`schedd: batch ${ent.id} failed: ${message}`); emit({ type: "error", message }); }
|
|
100
|
-
}).finally(() => { if (ent.id) hosted.delete(ent.id); ent.subs.clear(); touch(); });
|
|
115
|
+
}).finally(() => { if (ent.id) hosted.delete(ent.id); ent.subs.clear(); runs.delete(run); touch(); });
|
|
116
|
+
runs.set(run, ac);
|
|
101
117
|
});
|
|
102
118
|
|
|
103
119
|
const handle = async (req, send) => {
|
|
@@ -108,6 +124,19 @@ export async function startDaemon({ dir = schedDir(), runBatch, idleMs = Number(
|
|
|
108
124
|
b.setParallel(Number(req.k));
|
|
109
125
|
return send({ type: "ok", parallel: Number(req.k) }, true);
|
|
110
126
|
}
|
|
127
|
+
if (req.op === "add") {
|
|
128
|
+
const b = openBatch(root, req.id);
|
|
129
|
+
if (!b.exists()) throw new Error(`no batch ${req.id} in ${root}`);
|
|
130
|
+
const { added, skipped } = b.addIssues(req.issues ?? []);
|
|
131
|
+
return send({ type: "ok", added, skipped, hosted: hosted.has(req.id) }, true);
|
|
132
|
+
}
|
|
133
|
+
if (req.op === "cancel") {
|
|
134
|
+
const b = openBatch(root, req.id);
|
|
135
|
+
if (!b.exists()) throw new Error(`no batch ${req.id} in ${root}`);
|
|
136
|
+
if (!hosted.has(req.id) && b.readReport()) return send({ type: "ok", cancelled: false, finished: true }, true);
|
|
137
|
+
b.requestCancel({ reason: "operator" });
|
|
138
|
+
return send({ type: "ok", cancelled: true, hosted: hosted.has(req.id) }, true);
|
|
139
|
+
}
|
|
111
140
|
const sub = (m) => send(m, m.type === "done" || m.type === "error");
|
|
112
141
|
if (req.op === "submit") {
|
|
113
142
|
const ent = await host({ argv: req.argv ?? [], cwd: req.cwd ?? process.cwd(), env: req.env ?? {} }, sub);
|
|
@@ -124,7 +153,7 @@ export async function startDaemon({ dir = schedDir(), runBatch, idleMs = Number(
|
|
|
124
153
|
const b = openBatch(root, req.id);
|
|
125
154
|
if (!b.exists()) throw new Error(`no batch ${req.id} in ${root}`);
|
|
126
155
|
const rep = b.readReport();
|
|
127
|
-
if (rep && !(rep.rows ?? []).some((r) => r.outcome === "held")) {
|
|
156
|
+
if (rep && !(rep.rows ?? []).some((r) => r.outcome === "held") && !b.unrunAdds().length) {
|
|
128
157
|
return send({ type: "done", replay: true, result: { rows: rep.rows ?? [], crossLane: rep.crossLane ?? [], batch: b.id, ok: !!rep.ok }, report: rep.report ?? null }, true);
|
|
129
158
|
}
|
|
130
159
|
const ent = await host({ argv: ["--resume", req.id], cwd: process.cwd(), env: {} }, sub);
|
|
@@ -158,13 +187,18 @@ export async function startDaemon({ dir = schedDir(), runBatch, idleMs = Number(
|
|
|
158
187
|
await new Promise((res, rej) => { server.once("error", rej); server.listen(p.sock, () => { server.off("error", rej); res(); }); });
|
|
159
188
|
log(`schedd: pid ${pid} listening on ${p.sock}`);
|
|
160
189
|
|
|
161
|
-
let closing = null, resolveClosed;
|
|
162
190
|
const closed = new Promise((r) => { resolveClosed = r; });
|
|
163
191
|
const close = () => {
|
|
164
192
|
if (closing) return closing;
|
|
165
193
|
clearInterval(idleTimer);
|
|
166
194
|
for (const c of conns) c.destroy();
|
|
167
|
-
|
|
195
|
+
const stopping = [...runs.keys()];
|
|
196
|
+
for (const ac of runs.values()) ac.abort();
|
|
197
|
+
let timer;
|
|
198
|
+
// #4454: the lock goes only once every hosted batch loop has stopped (or `stopMs` passed: the caller exits regardless)
|
|
199
|
+
const stopped = stopping.length ? Promise.race([Promise.allSettled(stopping).then(() => true), new Promise((r) => { timer = setTimeout(() => r(false), stopMs); })])
|
|
200
|
+
.then((ok) => { clearTimeout(timer); if (!ok) log(`schedd: ${runs.size} batch(es) did not stop within ${stopMs} ms`); }) : Promise.resolve();
|
|
201
|
+
closing = Promise.all([new Promise((r) => server.close(() => r())), stopped]).then(() => {
|
|
168
202
|
try { if (parseInt(readFileSync(p.lock, "utf8"), 10) === pid) rmSync(p.lock, { force: true }); } catch { /* gone */ }
|
|
169
203
|
rmSync(p.sock, { force: true });
|
|
170
204
|
resolveClosed();
|
|
@@ -188,6 +222,18 @@ export async function startDaemon({ dir = schedDir(), runBatch, idleMs = Number(
|
|
|
188
222
|
return { running: true, pid, sock: p.sock, close, closed };
|
|
189
223
|
}
|
|
190
224
|
|
|
225
|
+
/** #4454: on SIGTERM/SIGINT stop the daemon (`close()`: batch loops first, then lock and socket), then exit 128+signo, once. */
|
|
226
|
+
export function exitOnSignals(d, { on = (s, f) => process.once(s, f), exit = (c) => process.exit(c), signals = ["SIGTERM", "SIGINT"] } = {}) {
|
|
227
|
+
const SIGNO = { SIGINT: 2, SIGTERM: 15 };
|
|
228
|
+
let fired = false;
|
|
229
|
+
for (const sig of signals) on(sig, async () => {
|
|
230
|
+
if (fired) return;
|
|
231
|
+
fired = true;
|
|
232
|
+
try { await d.close(); } catch { /* exit anyway */ }
|
|
233
|
+
exit(128 + (SIGNO[sig] ?? 0));
|
|
234
|
+
});
|
|
235
|
+
}
|
|
236
|
+
|
|
191
237
|
/** Connect to the daemon, spawning it (`spawnDaemon()`) when nobody listens. @returns {Promise<import("node:net").Socket>} */
|
|
192
238
|
export async function connectDaemon({ dir = schedDir(), spawnDaemon = defaultSpawn, waitMs = 10_000, sleep = (ms) => new Promise((r) => setTimeout(r, ms)) } = {}) {
|
|
193
239
|
const { sock } = scheddPaths(dir);
|