forgedock 1.34.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 +16 -6
- package/bin/lean/conformance.test.mjs +25 -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 +27 -5
- 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/docs/CONFIG.md +12 -2
- package/package.json +1 -1
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
|
|
2
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
3
|
+
// #4588: deterministic base-merge conflict resolution, on real temp git repos.
|
|
4
|
+
import { describe, it } from "node:test";
|
|
5
|
+
import assert from "node:assert/strict";
|
|
6
|
+
import { execFileSync } from "node:child_process";
|
|
7
|
+
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
8
|
+
import { dirname, join } from "node:path";
|
|
9
|
+
import { tmpDir } from "../tests/_tmp.mjs";
|
|
10
|
+
import { readBaseMerge } from "../engine-cli.mjs";
|
|
11
|
+
import { resolveBaseConflicts, jsonUnionText } from "./conflictres.mjs";
|
|
12
|
+
|
|
13
|
+
const sh = (cwd, ...a) => execFileSync("git", ["-c", "user.name=t", "-c", "user.email=t@t", ...a], { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] });
|
|
14
|
+
const put = (root, files) => { for (const [p, t] of Object.entries(files)) { mkdirSync(dirname(join(root, p)), { recursive: true }); writeFileSync(join(root, p), t); } };
|
|
15
|
+
const commit = (d, m) => { sh(d, "add", "-A"); sh(d, "commit", "-qm", m); };
|
|
16
|
+
|
|
17
|
+
/** A repo whose lane and main both edited `files`; the merge of main into the lane is left in progress. */
|
|
18
|
+
function conflicted(root, start, lane, main) {
|
|
19
|
+
sh(root, "init", "-q", "-b", "main"); put(root, start); commit(root, "base");
|
|
20
|
+
sh(root, "checkout", "-qb", "lane"); put(root, lane); commit(root, "lane");
|
|
21
|
+
sh(root, "checkout", "-q", "main"); put(root, main); commit(root, "main");
|
|
22
|
+
sh(root, "checkout", "-q", "lane");
|
|
23
|
+
try { sh(root, "merge", "--no-edit", "main"); } catch { /* conflicts */ }
|
|
24
|
+
return Object.keys(start).filter((f) => sh(root, "diff", "--name-only", "--diff-filter=U").split("\n").includes(f));
|
|
25
|
+
}
|
|
26
|
+
const env = (root) => ({ git: async (a) => sh(root, ...a), cwd: root });
|
|
27
|
+
|
|
28
|
+
describe("resolveBaseConflicts (#4588)", () => {
|
|
29
|
+
it("union: two lanes appending to one barrel keep both lines", async () => {
|
|
30
|
+
const root = tmpDir("cr-union"); const files = conflicted(root, { "index.ts": "a\n" }, { "index.ts": "a\nlane\n" }, { "index.ts": "a\nmain\n" });
|
|
31
|
+
const r = await resolveBaseConflicts({ ...env(root), files, cfg: { unionGlobs: ["index.ts"] } });
|
|
32
|
+
assert.deepEqual(r, { resolved: ["index.ts"], remaining: [] });
|
|
33
|
+
assert.equal(readFileSync(join(root, "index.ts"), "utf8"), "a\nlane\nmain\n");
|
|
34
|
+
sh(root, "commit", "--no-edit"); // all staged: the merge commits
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("union: a hunk that edits a base line is refused and stays conflicted", async () => {
|
|
38
|
+
const root = tmpDir("cr-union-edit"); const files = conflicted(root, { "index.ts": "a\n" }, { "index.ts": "x\n" }, { "index.ts": "y\n" });
|
|
39
|
+
assert.deepEqual(await resolveBaseConflicts({ ...env(root), files, cfg: { unionGlobs: ["index.ts"] } }), { resolved: [], remaining: ["index.ts"] });
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("json: key union of a status map; the lane's indent is kept", async () => {
|
|
43
|
+
const root = tmpDir("cr-json");
|
|
44
|
+
const files = conflicted(root, { "status.json": '{\n "a": 1\n}\n' }, { "status.json": '{\n "a": 1,\n "lane": 2\n}\n' }, { "status.json": '{\n "a": 1,\n "main": 3\n}\n' });
|
|
45
|
+
const r = await resolveBaseConflicts({ ...env(root), files, cfg: { jsonGlobs: ["status.json"] } });
|
|
46
|
+
assert.deepEqual(r.resolved, ["status.json"]);
|
|
47
|
+
assert.equal(readFileSync(join(root, "status.json"), "utf8"), '{\n "a": 1,\n "lane": 2,\n "main": 3\n}\n');
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("json: the same key changed differently on both sides is refused", async () => {
|
|
51
|
+
const root = tmpDir("cr-json-clash");
|
|
52
|
+
const files = conflicted(root, { "status.json": '{"a":1}\n' }, { "status.json": '{"a":2}\n' }, { "status.json": '{"a":3}\n' });
|
|
53
|
+
assert.deepEqual(await resolveBaseConflicts({ ...env(root), files, cfg: { jsonGlobs: ["status.json"] } }), { resolved: [], remaining: ["status.json"] });
|
|
54
|
+
assert.throws(() => jsonUnionText('[1]', '[2]', '[3]'));
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("generated: the base side is taken and the command regenerates the file", async () => {
|
|
58
|
+
const root = tmpDir("cr-gen");
|
|
59
|
+
const files = conflicted(root, { "gen/out.txt": "v0\n", "src.txt": "s\n" }, { "gen/out.txt": "lane\n" }, { "gen/out.txt": "main\n" });
|
|
60
|
+
const r = await resolveBaseConflicts({ ...env(root), files, cfg: { generatedGlobs: ["gen/*"], generatedCommand: "echo regenerated > gen/out.txt" } });
|
|
61
|
+
assert.deepEqual(r, { resolved: ["gen/out.txt"], remaining: [] });
|
|
62
|
+
assert.equal(readFileSync(join(root, "gen/out.txt"), "utf8"), "regenerated\n");
|
|
63
|
+
assert.equal(sh(root, "diff", "--name-only", "--diff-filter=U"), "");
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it("generated: a failing command leaves the file conflicted", async () => {
|
|
67
|
+
const root = tmpDir("cr-gen-fail");
|
|
68
|
+
const files = conflicted(root, { "gen/out.txt": "v0\n" }, { "gen/out.txt": "lane\n" }, { "gen/out.txt": "main\n" });
|
|
69
|
+
const r = await resolveBaseConflicts({ ...env(root), files, cfg: { generatedGlobs: ["gen/*"], generatedCommand: "exit 3" } });
|
|
70
|
+
assert.deepEqual(r.resolved, []);
|
|
71
|
+
assert.deepEqual(r.remaining, ["gen/out.txt"]);
|
|
72
|
+
assert.match(r.note, /generated command failed/);
|
|
73
|
+
assert.match(readFileSync(join(root, "gen/out.txt"), "utf8"), /<<<<<<</);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("sequence files and unconfigured files are left alone", async () => {
|
|
77
|
+
const root = tmpDir("cr-skip");
|
|
78
|
+
const files = conflicted(root, { "db/meta/_journal.json": '{"a":1}\n', "x.ts": "a\n" }, { "db/meta/_journal.json": '{"a":1,"l":1}\n', "x.ts": "a\nl\n" }, { "db/meta/_journal.json": '{"a":1,"m":1}\n', "x.ts": "a\nm\n" });
|
|
79
|
+
const r = await resolveBaseConflicts({ ...env(root), files, cfg: { jsonGlobs: ["*.json"] } });
|
|
80
|
+
assert.deepEqual(r.resolved, []);
|
|
81
|
+
assert.deepEqual(r.remaining.sort(), ["db/meta/_journal.json", "x.ts"]);
|
|
82
|
+
assert.deepEqual(await resolveBaseConflicts({ ...env(root), files, cfg: {} }), { resolved: [], remaining: files });
|
|
83
|
+
});
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
describe("readBaseMerge (#4588)", () => {
|
|
87
|
+
it("reads lean.base_merge_* and is undefined when no glob is set", () => {
|
|
88
|
+
const root = tmpDir("cr-cfg");
|
|
89
|
+
assert.equal(readBaseMerge(root), undefined);
|
|
90
|
+
writeFileSync(join(root, "forge.yaml"), 'lean:\n base_merge_union_globs: "index.ts, **/messages.ts"\n base_merge_json_globs: "status.json"\n base_merge_generated_globs: "gen/**"\n base_merge_generated_command: "pnpm gen"\n');
|
|
91
|
+
assert.deepEqual(readBaseMerge(root), { unionGlobs: ["index.ts", "**/messages.ts"], jsonGlobs: ["status.json"], generatedGlobs: ["gen/**"], generatedCommand: "pnpm gen" });
|
|
92
|
+
});
|
|
93
|
+
});
|
package/bin/lean/conformance.mjs
CHANGED
|
@@ -57,11 +57,13 @@ export function scopeText(iss, trustedComments = []) {
|
|
|
57
57
|
/** #4265: failures whose detail starts with a contract requirement id also quote that requirement, so a needs-human names what is unmet. */
|
|
58
58
|
export function explainFailures(failures, contract) {
|
|
59
59
|
const reqs = new Map((contract?.requirements ?? []).map((r) => [String(r?.id ?? ""), String(r?.quote ?? "")]));
|
|
60
|
+
const amended = new Map((contract?.requirements ?? []).filter((r) => r?.amendment?.text).map((r) => [String(r.id), String(r.amendment.text)]));
|
|
60
61
|
return (failures ?? []).map((f) => {
|
|
61
62
|
const id = /^([A-Za-z][\w-]*)\b/.exec(String(f?.detail ?? ""))?.[1];
|
|
62
63
|
const q = id && reqs.get(id);
|
|
63
64
|
if (!q) return f;
|
|
64
|
-
|
|
65
|
+
const am = amended.get(id);
|
|
66
|
+
return { ...f, detail: `${f.detail} ("${q.length > 160 ? `${q.slice(0, 157)}...` : q}"${am ? `, amended to "${am.length > 160 ? `${am.slice(0, 157)}...` : am}"` : ""})` };
|
|
65
67
|
});
|
|
66
68
|
}
|
|
67
69
|
export const isTestPath = (p) => TEST_RE.test(String(p));
|
|
@@ -264,7 +266,7 @@ export function validateContract(c, issueText, { repro = false } = {}) {
|
|
|
264
266
|
/** forge#4519: a shell pattern as in scripts/hotspots.sh `case` (`*` crosses `/`, `?` one character), anchored. */
|
|
265
267
|
const shellGlobRe = (g) => new RegExp(`^${String(g).replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*").replace(/\?/g, ".")}$`);
|
|
266
268
|
|
|
267
|
-
const AMEND_KINDS = ["add-deliverable", "add-requirement", ...TRACKED];
|
|
269
|
+
const AMEND_KINDS = ["add-deliverable", "add-requirement", ...TRACKED, "amend-requirement"];
|
|
268
270
|
const AMEND_SLIPS = { "amend-requirement": "defer", limitation: "defer" };
|
|
269
271
|
|
|
270
272
|
/** forge#4574: common builder slips: `type` for `kind`, amend-requirement/limitation as defer, an `R#` in `id` as the requirement. */
|
|
@@ -272,7 +274,8 @@ function normalizeAmendment(a) {
|
|
|
272
274
|
if (!a || typeof a !== "object" || Array.isArray(a)) return a;
|
|
273
275
|
const n = { ...a };
|
|
274
276
|
if (blank(n.kind) && !blank(n.type)) n.kind = n.type;
|
|
275
|
-
|
|
277
|
+
// #4594: amend-requirement with a revised `text` is a real amendment; without one it stays the slip for defer
|
|
278
|
+
if (Object.hasOwn(AMEND_SLIPS, n.kind) && !(n.kind === "amend-requirement" && !blank(n.text))) n.kind = AMEND_SLIPS[n.kind];
|
|
276
279
|
if (TRACKED.includes(n.kind) && blank(n.requirement) && /^R\d+$/.test(String(n.id ?? ""))) n.requirement = n.id;
|
|
277
280
|
return n;
|
|
278
281
|
}
|
|
@@ -281,7 +284,7 @@ function normalizeAmendment(a) {
|
|
|
281
284
|
function amendmentProblem(a, tag) {
|
|
282
285
|
const kind = a?.kind;
|
|
283
286
|
const need = kind === "add-deliverable" ? "needs a path" : kind === "add-requirement" ? "needs requirement {id, quote}"
|
|
284
|
-
: TRACKED.includes(kind) ? "needs requirement: \"R#\"" : null;
|
|
287
|
+
: TRACKED.includes(kind) ? "needs requirement: \"R#\"" : kind === "amend-requirement" ? "needs requirement: \"R#\" and text (the revised wording)" : null;
|
|
285
288
|
const why = need ? `amendment kind '${kind}' ${need}` : `unknown kind '${kind ?? ""}'; valid kinds are ${AMEND_KINDS.join(", ")}`;
|
|
286
289
|
return `unknown or incomplete amendment: ${why}: ${tag}`;
|
|
287
290
|
}
|
|
@@ -320,6 +323,11 @@ export function applyAmendments(frozen, amendments, issueText = null, { generate
|
|
|
320
323
|
if (tagsOutOfBand(r)) r.verify = "out_of_band"; // #4404: same freeze-time tag as the planned requirements
|
|
321
324
|
c.requirements.push(r);
|
|
322
325
|
if (TRACKED.includes(r.disposition)) toTrack.push(r);
|
|
326
|
+
} else if (a.kind === "amend-requirement" && !blank(a.requirement)) {
|
|
327
|
+
// #4594: the requirement stays deliver and keeps its verbatim quote; the reviewer judges it against amendment.text
|
|
328
|
+
const r = c.requirements.find((x) => x.id === a.requirement);
|
|
329
|
+
if (!r) { problems.push(`amendment names unknown requirement ${a.requirement}`); continue; }
|
|
330
|
+
r.amendment = { text: String(a.text).trim(), why: String(a.why).trim() }; r.amended = true;
|
|
323
331
|
} else if (TRACKED.includes(a.kind) && a.requirement) {
|
|
324
332
|
const r = c.requirements.find((x) => x.id === a.requirement);
|
|
325
333
|
if (!r) { problems.push(`amendment names unknown requirement ${a.requirement}`); continue; }
|
|
@@ -625,14 +633,16 @@ export function decide(raw, ctx) {
|
|
|
625
633
|
const comp = checkCompleteness(rv, ctx);
|
|
626
634
|
if (!comp.complete) return { state: "review.incomplete", failures: comp.problems.map((d) => ({ code: C.REVIEW_INCOMPLETE, detail: d })), missing: comp.missing, review: rv };
|
|
627
635
|
const f = [];
|
|
628
|
-
|
|
636
|
+
const amendedOf = (ids) => (ctx.contract.requirements ?? []).find((r) => r.amendment?.text && (ids ?? []).includes(r.id));
|
|
637
|
+
const notAccepted = (r) => (r ? `amendment not accepted (${r.id} amended to "${String(r.amendment.text).slice(0, 200)}"): ` : "");
|
|
638
|
+
for (const c of rv.issue_coverage) if (c.status !== "covered") f.push({ code: C.CONFORMANCE_OMISSION, detail: `${notAccepted(amendedOf(c.contract_ids))}issue requirement ${c.status}: "${c.quote}"${c.note ? ` (${c.note})` : ""}` });
|
|
629
639
|
for (const r of ctx.contract.requirements) {
|
|
630
640
|
const row = rv.requirements.find((x) => x.id === r.id);
|
|
631
641
|
if (r.disposition === "deliver" && isOutOfBand(r)) {
|
|
632
642
|
// #4404: the measured result lives in the named job; only the instrumentation is judged here
|
|
633
643
|
if (row.instrumented !== true) f.push({ code: C.CONFORMANCE_UNIMPLEMENTED, detail: `${r.id} verified out of band${r.evidence ? ` by ${r.evidence}` : ""}: instrumentation missing${row.note ? `: ${row.note}` : ""}${row.evidence ? ` (${row.evidence})` : ""}` });
|
|
634
644
|
} else if (r.disposition === "deliver" && row.status !== "implemented") {
|
|
635
|
-
f.push({ code: ["deferred", "post-deploy"].includes(row.status) ? C.CONFORMANCE_UNTRACKED_DEFERRAL : C.CONFORMANCE_UNIMPLEMENTED, detail: `${r.id} ${row.status}${row.note ? `: ${row.note}` : ""}${row.evidence ? ` (${row.evidence})` : ""}` });
|
|
645
|
+
f.push({ code: ["deferred", "post-deploy"].includes(row.status) ? C.CONFORMANCE_UNTRACKED_DEFERRAL : C.CONFORMANCE_UNIMPLEMENTED, detail: `${r.amendment?.text ? `${r.id} amendment not accepted (amended to "${String(r.amendment.text).slice(0, 200)}"): ` : `${r.id} `}${row.status}${row.note ? `: ${row.note}` : ""}${row.evidence ? ` (${row.evidence})` : ""}` });
|
|
636
646
|
}
|
|
637
647
|
if (TRACKED.includes(r.disposition) && row.deferral_ok === false) f.push({ code: C.CONFORMANCE_UNJUSTIFIED_DEFERRAL, detail: `${r.id} ${r.disposition} is not justified${row.note ? `: ${row.note}` : ""}; implement it` });
|
|
638
648
|
}
|
|
@@ -645,3 +645,28 @@ describe("#4404 verified out of band: a requirement measured outside the lane is
|
|
|
645
645
|
assert.deepEqual(outOfBandRequirements(tagged), [{ id: "R3", quote: LH, job: "lighthouse-nightly" }]);
|
|
646
646
|
});
|
|
647
647
|
});
|
|
648
|
+
|
|
649
|
+
describe("#4594 amend-requirement", () => {
|
|
650
|
+
const am = { kind: "amend-requirement", requirement: "R1", text: "regenerate and reveal the feed URL once", why: "token stored hash-only" };
|
|
651
|
+
it("with text it keeps deliver, tracks nothing and records the amendment", () => {
|
|
652
|
+
const a = applyAmendments(C, [am]);
|
|
653
|
+
assert.deepEqual(a.problems, []); assert.deepEqual(a.toTrack, []);
|
|
654
|
+
const r = a.contract.requirements.find((x) => x.id === "R1");
|
|
655
|
+
assert.equal(r.disposition, "deliver"); assert.equal(r.amendment.text, am.text); assert.equal(r.amendment.why, am.why); assert.equal(r.quote, C.requirements[0].quote);
|
|
656
|
+
});
|
|
657
|
+
it("an unknown requirement is a problem; the frozen contract is untouched", () => {
|
|
658
|
+
assert.match(applyAmendments(C, [{ ...am, requirement: "R9" }]).problems[0], /unknown requirement R9/);
|
|
659
|
+
assert.equal(C.requirements[0].amendment, undefined);
|
|
660
|
+
});
|
|
661
|
+
it("decide: an amended requirement the reviewer rejects fails as 'amendment not accepted'; accepted conforms", () => {
|
|
662
|
+
const k = { ...ctx, contract: applyAmendments(C, [am]).contract };
|
|
663
|
+
assert.equal(decide(good, k).state, "conforms");
|
|
664
|
+
const d = decide({ ...row("R1", { status: "partial", note: "amendment: still never shows the URL" }), issue_coverage: good.issue_coverage.map((c) => (c.quote === Q1 ? { ...c, contract_ids: ["R1"], status: "narrowed" } : c)) }, k);
|
|
665
|
+
assert.equal(d.state, "nonconforming");
|
|
666
|
+
assert.ok(d.failures.length >= 2 && d.failures.every((f) => /amendment not accepted \(.*amended to "regenerate/.test(f.detail)));
|
|
667
|
+
});
|
|
668
|
+
it("explainFailures shows the amended text", () => {
|
|
669
|
+
const c = applyAmendments(C, [am]).contract;
|
|
670
|
+
assert.match(explainFailures([{ code: "x", detail: "R1 partial" }], c)[0].detail, /amended to "regenerate and reveal/);
|
|
671
|
+
});
|
|
672
|
+
});
|
package/bin/lean/effects.mjs
CHANGED
|
@@ -17,7 +17,8 @@ import { makeHeavyIo } from "./heavy.mjs";
|
|
|
17
17
|
import { gatherContextFacts } from "./context.mjs";
|
|
18
18
|
import { makePitfallStore } from "./pitfalls.mjs";
|
|
19
19
|
import { enumeratingTests, missingEntries } from "./enumerating.mjs";
|
|
20
|
-
import {
|
|
20
|
+
import { resolveBaseConflicts } from "./conflictres.mjs";
|
|
21
|
+
import { sequenceCollisions, snapshotLane, verifyRenumber, renumberLane, resolveSequenceConflicts, generatorProblems } from "./sequence.mjs";
|
|
21
22
|
import { duplicateExports } from "./postmerge.mjs";
|
|
22
23
|
import { scopedChecks } from "./checks.mjs";
|
|
23
24
|
import { makeSequenceLeaseIo } from "./seqlease.mjs";
|
|
@@ -63,8 +64,11 @@ export const noToolFlags = (caps) => (caps?.strictMcp ? ["--tools", ""]
|
|
|
63
64
|
|
|
64
65
|
/** #4456: `--settings` registering the re-read nudge (bin/hooks/reread-nudge.mjs) as a PostToolUse hook on Read and Bash. */
|
|
65
66
|
export const REREAD_HOOK = fileURLToPath(new URL("../hooks/reread-nudge.mjs", import.meta.url));
|
|
66
|
-
export const rereadNudgeFlags = (node = process.execPath, hook = REREAD_HOOK) =>
|
|
67
|
-
hooks
|
|
67
|
+
export const rereadNudgeFlags = (node = process.execPath, hook = REREAD_HOOK) => {
|
|
68
|
+
const hooks = [{ type: "command", command: `${JSON.stringify(node)} ${JSON.stringify(hook)}` }];
|
|
69
|
+
// #4580: the same script denies a full-file Read of an unchanged path already read twice (PreToolUse)
|
|
70
|
+
return ["--settings", JSON.stringify({ hooks: { PostToolUse: [{ matcher: "Read|Bash", hooks }], PreToolUse: [{ matcher: "Read", hooks }] } })];
|
|
71
|
+
};
|
|
68
72
|
|
|
69
73
|
export function makeLeanRunner({ runCli = runCliBackendAsync, probe = systemdRunAvailable } = {}) {
|
|
70
74
|
return async ({ role, issue, cwd, model, resume, system, prompt, readOnly = false, noTools = false, rereadNudge = false, maxTurns = null, agents = null, env = null, idleMs = 0, maxMs = 0, laneLimits = null, control = null, onSpawn = null, contextMax = 0, turnMax = 0, busy = null, busyMs = 0, onIdleExtend = null }) => {
|
|
@@ -372,7 +376,11 @@ export function makeLeanIo({ repo }) {
|
|
|
372
376
|
/** #4386: the deterministic renumber, sequence-only base-sync resolution, and the host sequence lease at the merge gate. */
|
|
373
377
|
sequenceRenumber: ({ cwd, base, laneRef, regenerate, reserve }) => renumberLane({ git: (a) => run("git")(a, { cwd }), base, cwd, laneRef, regenerate, reserve }),
|
|
374
378
|
sequenceResolve: ({ cwd, files }) => resolveSequenceConflicts({ git: (a) => run("git")(a, { cwd }), files }),
|
|
379
|
+
/** #4588: deterministic base-merge resolution of union / JSON-map / generated files (bin/lean/conflictres.mjs). */
|
|
380
|
+
conflictResolve: ({ cwd, files, cfg }) => resolveBaseConflicts({ git: (a) => run("git")(a, { cwd }), cwd, files, cfg }),
|
|
375
381
|
sequenceLease: makeSequenceLeaseIo(),
|
|
382
|
+
/** #4591: lane-added migrations the repo's generator did not produce (no journal entry or snapshot); [] on failure. */
|
|
383
|
+
migrationGenerator: ({ cwd, base }) => generatorProblems({ git: (a) => run("git")(a, { cwd }), base }),
|
|
376
384
|
/** #4406: the lane's scoped checks after a base merge (bin/lean/checks.mjs; [] on failure), and same-app duplicate exports (bin/lean/postmerge.mjs). */
|
|
377
385
|
postMergeChecks: async ({ cwd, base }) => { try { return scopedChecks({ repo: cwd, base }); } catch { return []; } },
|
|
378
386
|
duplicateExports: ({ cwd, base }) => duplicateExports({ git: (a) => run("git")(a, { cwd }), cwd, base }),
|
package/bin/lean/lane.mjs
CHANGED
|
@@ -83,3 +83,16 @@ export async function resolveLane({ issue, repo, io, cwd, branches = {}, request
|
|
|
83
83
|
if (!out.trim()) return refuse(OUTCOME_CODES.LANE_BRANCH_MISSING, `lane branch ${lane} (${why}) does not exist on origin; create it first (e.g. /milestone start) or fix the milestone title`);
|
|
84
84
|
return { ok: true, lane, expected, milestone, overridden, defaults };
|
|
85
85
|
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* #4593: true when `lane` is a non-staging branch fully merged into `staging` (a shipped milestone branch).
|
|
89
|
+
* Fail closed: any git error (no such ref, offline) is "not shipped".
|
|
90
|
+
*/
|
|
91
|
+
export async function isShippedLane({ lane, staging = DEFAULT_STAGING_BRANCH, io, cwd }) {
|
|
92
|
+
if (!lane || lane === staging) return false;
|
|
93
|
+
try {
|
|
94
|
+
try { await io.git(["fetch", "origin", lane, staging], { cwd }); } catch { /* offline: use the local refs */ }
|
|
95
|
+
await io.git(["merge-base", "--is-ancestor", `origin/${lane}`, `origin/${staging}`], { cwd });
|
|
96
|
+
return true;
|
|
97
|
+
} catch { return false; }
|
|
98
|
+
}
|
package/bin/lean/lean.mjs
CHANGED
|
@@ -17,28 +17,28 @@ import { TRACKED, isBugIssue, scopeText, explainFailures, inIssue, validateContr
|
|
|
17
17
|
import { checkAdmission, hostParallelism, ADMISSION_RETRY_SECONDS } from "./admission.mjs";
|
|
18
18
|
import { namedPaths, isTrustedIssueComment } from "./prefilter.mjs";
|
|
19
19
|
import { preflightProbes, preflightProblems, unsetEnvVars, runPreflight, knownToContract } from "./preflight.mjs";
|
|
20
|
-
import { resolveLane, isDefaultLane } from "./lane.mjs";
|
|
20
|
+
import { resolveLane, isDefaultLane, isShippedLane, DEFAULT_STAGING_BRANCH } from "./lane.mjs";
|
|
21
21
|
import { splitGate, rationaleLine } from "./decompose.mjs";
|
|
22
22
|
import { dockerLabels, LEDGER_TTL_MS } from "./ledger.mjs";
|
|
23
23
|
import { repoKey } from "../runner.mjs";
|
|
24
24
|
import { prepareToolchain } from "./toolchain.mjs";
|
|
25
25
|
import { toolStats } from "./toolstats.mjs";
|
|
26
|
-
import { readTestMap, readTestMapAtRef, factsPath, TEST_MAP_FILE } from "./testmap.mjs";
|
|
27
|
-
import { runChecks, planPrecheck, worktreeFingerprint, selfCheckState, quietFailure, CHECK_PLAN_FILE, SELF_CHECK_FILE, CHECK_LOG_DIR } from "./precheck.mjs";
|
|
26
|
+
import { readTestMap, readTestMapAtRef, mergeChecks, factsPath, TEST_MAP_FILE } from "./testmap.mjs";
|
|
27
|
+
import { runChecks, runWithTimeoutRetry, planPrecheck, worktreeFingerprint, selfCheckState, quietFailure, CHECK_PLAN_FILE, SELF_CHECK_FILE, CHECK_LOG_DIR } from "./precheck.mjs";
|
|
28
28
|
import { retrying, ghCacheRetry, PUSH_TRANSIENT_RE, PUSH_PERMANENT_RE, GH_TRANSIENT_RE, MERGE_TRANSIENT_RE, MERGE_CONFLICT_RE } from "./retry.mjs";
|
|
29
29
|
import { renderInvestigation, renderImplementation, renderReview, renderPrBody, renderTrajectory, renderContract, renderAmendments, renderShadowReview, parseContractComment, contractJson, capComment, sanitizeMd, MAX_COMMENT_CHARS, recoveryEvidence } from "./render.mjs";
|
|
30
30
|
import { parseVerifierConfig, pickVerifierModel, changedSymbols, callersOf, verifierBudget, verifierPrompt, parseVerifierFindings, findingsToFailures, renderVerifierNote, VERIFIER_SYSTEM } from "./verifier.mjs";
|
|
31
31
|
import { computeTrigger, riskPathHits, TRIGGER_MODES } from "./review-trigger.mjs";
|
|
32
32
|
import { hostCapabilityBlock } from "./execution.mjs";
|
|
33
|
-
import { REVIEWER_SYSTEM, LEAN_DIR, RESULT_FILE, INVESTIGATION_FILE, IMPLEMENTATION_FILE, CONTRACT_FILE, builderPlanPrompt, builderImplementPrompt, contractCorrectionPrompt, conformanceResumePrompt, reviewerPrompt, reviewerRepairPrompt, REVIEW_DIFF_MAX_BYTES, blockCorrectionPrompt, HUMAN_BLOCK_REASONS, structurePrompt, hotspotPlanNote, hotspotBuildRule, reproPlanNote, contextPackNote, docsPrompt, ciFailurePrompt, ciUnavailablePrompt, precheckFailurePrompt, contextRestartPrompt, turnBudgetWrapupPrompt, syncChecks, GROUPING_SYSTEM, enumeratingPlanNote, enumeratingBuildRule, missingEntriesPrompt, sequencePrompt, sequenceSyncNote, fixSessionPrompt, postMergeFailurePrompt, duplicateExportsPrompt } from "./prompts.mjs";
|
|
33
|
+
import { REVIEWER_SYSTEM, LEAN_DIR, RESULT_FILE, INVESTIGATION_FILE, IMPLEMENTATION_FILE, CONTRACT_FILE, builderPlanPrompt, builderImplementPrompt, contractCorrectionPrompt, conformanceResumePrompt, reviewerPrompt, reviewerRepairPrompt, REVIEW_DIFF_MAX_BYTES, blockCorrectionPrompt, HUMAN_BLOCK_REASONS, structurePrompt, hotspotPlanNote, hotspotBuildRule, reproPlanNote, contextPackNote, docsPrompt, ciFailurePrompt, ciUnavailablePrompt, precheckFailurePrompt, contextRestartPrompt, turnBudgetWrapupPrompt, syncChecks, GROUPING_SYSTEM, enumeratingPlanNote, enumeratingBuildRule, invariantsPlanNote, invariantsBuildRule, missingEntriesPrompt, sequencePrompt, sequenceSyncNote, fixSessionPrompt, postMergeFailurePrompt, duplicateExportsPrompt, migrationGeneratorPrompt } from "./prompts.mjs";
|
|
34
34
|
import { postMergeCommands } from "./postmerge.mjs";
|
|
35
35
|
import { freshFix, issueSummary, firstPassOf, sessionCostDelta } from "./fixround.mjs";
|
|
36
36
|
import { parseBuilderRouting, routeBuilder, shouldEscalate } from "./routing.mjs";
|
|
37
|
-
import { makeTurnBudget, laneSize, recoverySize, wrapupGrant, budgetDigest } from "./budget.mjs";
|
|
37
|
+
import { makeTurnBudget, TURN_BUDGET_DEFAULTS, scaledExtra, laneSize, recoverySize, wrapupGrant, budgetDigest } from "./budget.mjs";
|
|
38
38
|
import { pendingPitfall, pitfallRecord, foldPitfalls, selectPitfalls, failureLog } from "./pitfalls.mjs";
|
|
39
39
|
import { unjustified } from "./enumerating.mjs";
|
|
40
40
|
import { addLaneWorktree, DEFAULT_SPARSE_MAX_KB } from "./worktree.mjs";
|
|
41
|
-
import { parseVerify, runCiVerify, collectFailedLogs, capLogs, VERIFY_MODES, VERIFY_CHECK_NAME, verifyReport, ciGate, rerunFailedOnce, rerunTarget } from "./verify.mjs";
|
|
41
|
+
import { parseVerify, runCiVerify, collectFailedLogs, capLogs, VERIFY_MODES, VERIFY_CHECK_NAME, verifyReport, ciGate, rerunFailedOnce, rerunTarget, splitFlaky } from "./verify.mjs";
|
|
42
42
|
import { e2eWanted, ownFailures, runE2eGate, NO_TRIGGER } from "./e2e.mjs";
|
|
43
43
|
import { laneBackendId } from "./limits.mjs";
|
|
44
44
|
import { SPLIT_LABEL } from "./refactor.mjs";
|
|
@@ -50,6 +50,7 @@ import { parseContextPackConfig, contextArm, contextPackFor, renderContextRecord
|
|
|
50
50
|
import { isExternalKill } from "./hostkill.mjs";
|
|
51
51
|
import { buildResumeDigest } from "./resume.mjs";
|
|
52
52
|
import { lightEligibility } from "./light.mjs";
|
|
53
|
+
import { untrackToolState, unstageToolState } from "./toolstate.mjs";
|
|
53
54
|
import { runLightStep, runLightTests, lightBuildPrompt, upgradeNote, renderLiteReceipt } from "./lightlane.mjs";
|
|
54
55
|
|
|
55
56
|
/**
|
|
@@ -168,6 +169,7 @@ export function isProtectedLaneBranch(b, { issue, lane = null, branches = {}, de
|
|
|
168
169
|
* @param {string} [o.fixModel] model of fresh fix-round sessions (#4363, forge.yaml lean.fix_model); default the reviewer model
|
|
169
170
|
* @param {object} [o.builderRouting] forge.yaml lean.builder_routing/builder_mid_model (#4394): {mode: on (default)|off, midModel}
|
|
170
171
|
* @param {string} [o.sequenceRegenerate] forge.yaml lean.sequence.regenerate (#4386): snapshot generator for a schema-changing renumbered migration
|
|
172
|
+
* @param {object} [o.baseMerge] forge.yaml lean.base_merge_* (#4588): {unionGlobs, jsonGlobs, generatedGlobs, generatedCommand} for deterministic base-merge conflict resolution
|
|
171
173
|
* @param {number} [o.ciMaxMinutes] remote-CI wait budget (default 360); exhaustion is NOT needs-human
|
|
172
174
|
* @param {"shadow"|"enforce"} [o.conformance] conformance gate mode (default shadow)
|
|
173
175
|
* @param {"needs-human"|"degraded"} [o.reviewIncomplete] shadow only: what an incomplete review after the re-ask does (default needs-human)
|
|
@@ -577,6 +579,7 @@ export async function runCore(o) {
|
|
|
577
579
|
|
|
578
580
|
/** git push with transient retry (#4114): 4 attempts; permission/protected-branch rejections fail at once. */
|
|
579
581
|
const gitPush = async (args) => {
|
|
582
|
+
if (!args.includes("--delete")) await untrackToolState({ git, base, ev }); // #4590: never push .forge-lean/ or tool caches this branch added
|
|
580
583
|
try { return await retrying(() => git(args), { transient: PUSH_TRANSIENT_RE, permanent: PUSH_PERMANENT_RE, attempts: 4, sleep: sleepFn, random }); }
|
|
581
584
|
catch (e) {
|
|
582
585
|
if (e?.transientExhausted) { e.deferCode = OUTCOME_CODES.PUSH_TRANSIENT; e.deferDetail = `git push kept failing transiently after 4 attempts: ${String(e?.stderr ?? e?.message ?? e).slice(0, 300)}`; }
|
|
@@ -589,7 +592,7 @@ export async function runCore(o) {
|
|
|
589
592
|
async function checkpoint(reason) {
|
|
590
593
|
try {
|
|
591
594
|
const dirty = String(await git(["status", "--porcelain"])).trim();
|
|
592
|
-
if (dirty) { await git(["add", "-A"]); await git(["commit", "--no-verify", "-m", `wip(forge): checkpoint after ${reason}`]); }
|
|
595
|
+
if (dirty) { await git(["add", "-A"]); if (await unstageToolState(git)) await git(["commit", "--no-verify", "-m", `wip(forge): checkpoint after ${reason}`]); } // #4590: tool state is never staged
|
|
593
596
|
const ahead = String(await git(["diff", "--name-only", `${base}...HEAD`])).trim();
|
|
594
597
|
if (ahead) await gitPush(["push", "-u", "origin", branch]);
|
|
595
598
|
ev({ event: "CHECKPOINT", reason, dirty: Boolean(dirty), pushed: Boolean(ahead) });
|
|
@@ -633,7 +636,7 @@ export async function runCore(o) {
|
|
|
633
636
|
let hotFacts = null; // forge#4195: scripts/hotspots.sh facts for the files the issue names, read once before the plan call
|
|
634
637
|
let enumList = []; // #4322: tests enumerating the members of the paths the issue names, read once before the plan call
|
|
635
638
|
let resumeNote = ""; // #4400: the resume digest of the last run (resume.mjs), "" on a first run or with context_pack off
|
|
636
|
-
const implementRestart = () => builderImplementPrompt(vcfg.mode, tcOpts()) + hotspotBuildRule(hotFacts) + enumeratingBuildRule(enumList) + exploreBuildRule(exploreCfg) + CONTINUE_NOTE.replace("BASE", lane) + resumeNote;
|
|
639
|
+
const implementRestart = () => builderImplementPrompt(vcfg.mode, tcOpts()) + hotspotBuildRule(hotFacts) + enumeratingBuildRule(enumList) + invariantsBuildRule(vcfg.invariants) + exploreBuildRule(exploreCfg) + CONTINUE_NOTE.replace("BASE", lane) + resumeNote;
|
|
637
640
|
/** #4390: builder calls stopped at the context ceiling restart fresh; planning (and its corrections) run uncapped. */
|
|
638
641
|
const UNCEILED = ["plan", "contract_correction", "block_correction"], CONTEXT_RESTARTS_MAX = 3;
|
|
639
642
|
async function builder(n, prompt, resume, live = false, restart = implementRestart, opts = {}) {
|
|
@@ -943,6 +946,17 @@ export async function runCore(o) {
|
|
|
943
946
|
if (prs.length || remote) resumed = { pr: prs[0]?.number ?? null, base: prs[0]?.baseRefName ?? null };
|
|
944
947
|
} catch { /* no resume info: start fresh */ }
|
|
945
948
|
// An open PR on another base (a hand-retargeted PR, or one opened before lane resolution) is never merged (#4125).
|
|
949
|
+
// #4593: a shipped milestone lane (fully merged into staging) cannot take new work; follow the open PR's live base.
|
|
950
|
+
if (resumed?.base && resumed.base !== lane && !isDefaultLane(resumed.base, defaults)) {
|
|
951
|
+
const staging = branches.staging || DEFAULT_STAGING_BRANCH;
|
|
952
|
+
let baseOnOrigin = false;
|
|
953
|
+
try { baseOnOrigin = !!String(await io.git(["ls-remote", "--heads", "origin", `refs/heads/${resumed.base}`], { cwd })).trim(); } catch { /* fail closed */ }
|
|
954
|
+
if (baseOnOrigin && await isShippedLane({ lane, staging, io, cwd }) && !(await isShippedLane({ lane: resumed.base, staging, io, cwd }))) {
|
|
955
|
+
T.decisions.push(`Lane ${lane} has shipped (merged into ${staging}); following open PR #${resumed.pr} base ${resumed.base}`);
|
|
956
|
+
ev({ event: "LANE_FOLLOWED_PR_BASE", from: lane, to: resumed.base, pr: resumed.pr });
|
|
957
|
+
lane = resumed.base; base = `origin/${lane}`;
|
|
958
|
+
}
|
|
959
|
+
}
|
|
946
960
|
if (resumed?.base && resumed.base !== lane) {
|
|
947
961
|
return await needsHuman(`Open PR #${resumed.pr} targets \`${resumed.base}\`, but #${issue}'s lane is \`${lane}\`; retarget or close it before rerunning.`,
|
|
948
962
|
isDefaultLane(resumed.base, defaults) ? OUTCOME_CODES.LANE_DEFAULT_BRANCH : OUTCOME_CODES.LANE_MISMATCH);
|
|
@@ -1393,15 +1407,15 @@ export async function runCore(o) {
|
|
|
1393
1407
|
enumList = io.enumeratingTests ? ((await io.enumeratingTests({ cwd: wt, paths: namedPaths(iss.body ?? "") }).catch(() => null)) ?? []) : [];
|
|
1394
1408
|
facts = await factsPath([wt, cwd], io.fs?.exists);
|
|
1395
1409
|
let ctxPack = frozen || lightTry ? null : await contextPackStep();
|
|
1396
|
-
const planPrompt = () => builderPlanPrompt(issue, tcOpts()) + hotspotPlanNote(hotFacts) + enumeratingPlanNote(enumList) + reproPlanNote(reproOn()) + explorePlanRule(exploreCfg) + (ctxCfg.mode === "on" ? contextPackNote(ctxPack) : "");
|
|
1410
|
+
const planPrompt = () => builderPlanPrompt(issue, tcOpts()) + hotspotPlanNote(hotFacts) + enumeratingPlanNote(enumList) + invariantsPlanNote(vcfg.invariants) + reproPlanNote(reproOn()) + explorePlanRule(exploreCfg) + (ctxCfg.mode === "on" ? contextPackNote(ctxPack) : "");
|
|
1397
1411
|
let lightDone = false, lightCommits = false;
|
|
1398
1412
|
if (lightTry) {
|
|
1399
1413
|
liveKeys = ["implementation"]; stage = "implement";
|
|
1400
1414
|
tb.setSize(recoverySize(laneSize({ labels: (iss?.labels ?? []).map((l) => l?.name ?? l) }), recover)); // #4505
|
|
1401
1415
|
const lr = await runLightStep({ title: iss.title, issueText, riskPaths, predictedFiles: namedPaths(iss.body ?? ""), cfg: o.light ?? {}, validate: (c) => validateContract(c, issueText, { repro: reproOn() }),
|
|
1402
|
-
build: () => builder(++attemptN, lightBuildPrompt(issue, tcOpts()) + hotspotBuildRule(hotFacts) + enumeratingBuildRule(enumList), null, true, undefined, { trigger: "build" }),
|
|
1416
|
+
build: () => builder(++attemptN, lightBuildPrompt(issue, tcOpts()) + hotspotBuildRule(hotFacts) + enumeratingBuildRule(enumList) + invariantsBuildRule(vcfg.invariants), null, true, undefined, { trigger: "build" }),
|
|
1403
1417
|
readResult, checkpoint: () => checkpoint("light build"), changedFiles, reproOf, numstat: () => git(["diff", "--numstat", `${base}...HEAD`]),
|
|
1404
|
-
runTests: async (cmds) => (io.runCommand ? runLightTests({ commands: cmds, parallelism: Number(verifyParallelism()) || 1, now, timeoutMs: vcfg.precheckTimeoutSeconds * 1000, acquire: () => heavyAcquire("precheck"), // #4451: host-capped
|
|
1418
|
+
runTests: async (cmds) => (io.runCommand ? runLightTests({ commands: mergeChecks(vcfg.invariants ?? [], cmds).commands, parallelism: Number(verifyParallelism()) || 1, now, timeoutMs: vcfg.precheckTimeoutSeconds * 1000, acquire: () => heavyAcquire("precheck"), // #4451: host-capped
|
|
1405
1419
|
run: ({ command, timeoutMs, workers }) => { const env = laneEnv(); return io.runCommand({ command, cwd: wt, timeoutMs, env: env.FORGE_HOST_PARALLELISM ? { ...env, FORGE_HOST_PARALLELISM: String(workers) } : env }); } }) : null),
|
|
1406
1420
|
fixTests: (failed) => builder(++attemptN, precheckFailurePrompt(capLogs(failed.map((f) => ({ ...f, log: f.output }))).map((f) => ({ command: f.command, rc: f.rc, output: f.log })), vcfg.mode), sessionId, false, undefined, { mode: "resume", trigger: "precheck" }),
|
|
1407
1421
|
apiShape: () => (io.apiShape ? io.apiShape({ cwd: wt, base }).catch(() => null) : null) });
|
|
@@ -1498,17 +1512,20 @@ export async function runCore(o) {
|
|
|
1498
1512
|
T.validate = lightDone ? "light lane (no plan session)" : "resumed (existing branch)";
|
|
1499
1513
|
}
|
|
1500
1514
|
T.plan = [`contract ${hash}${lightDone ? " (derived, light lane)" : ""}`, `${frozen.requirements.length} requirement(s)`];
|
|
1501
|
-
|
|
1515
|
+
{ // #4392, #4505: next size on a budget recovery; #4587: a big L contract and an L recovery get extra turns
|
|
1516
|
+
const sz = recoverySize(laneSize({ contract: frozen, labels: (iss?.labels ?? []).map((l) => l?.name ?? l) }), recover);
|
|
1517
|
+
tb.setSize(sz, scaledExtra({ size: sz, contract: frozen, recover, base: (turnBudgets ?? {}).L ?? TURN_BUDGET_DEFAULTS.L }));
|
|
1518
|
+
}
|
|
1502
1519
|
if (io.fs.write) { try { await io.fs.write(`${wt}/${CONTRACT_FILE}`, `${contractJson(frozen)}\n`); } catch { /* the prompt still names it */ } }
|
|
1503
1520
|
// #4364: what `forgedock check` needs from the operator's forge.yaml (gitignored, so not in the worktree)
|
|
1504
|
-
if (io.fs.write) { try { await io.fs.write(`${wt}/${CHECK_PLAN_FILE}`, `${JSON.stringify({ v: 1, base, mode: vcfg.mode, learned: learnedCommands, maxMinutes: Number(o.precheck?.maxMinutes) > 0 ? Number(o.precheck.maxMinutes) : null, precheckTotalSeconds: vcfg.precheckTotalSeconds, precheckTimeoutSeconds: vcfg.precheckTimeoutSeconds })}\n`); } catch { /* the CLI falls back to forge.yaml */ } }
|
|
1521
|
+
if (io.fs.write) { try { await io.fs.write(`${wt}/${CHECK_PLAN_FILE}`, `${JSON.stringify({ v: 1, base, mode: vcfg.mode, learned: learnedCommands, invariants: vcfg.invariants ?? [], maxMinutes: Number(o.precheck?.maxMinutes) > 0 ? Number(o.precheck.maxMinutes) : null, precheckTotalSeconds: vcfg.precheckTotalSeconds, precheckTimeoutSeconds: vcfg.precheckTimeoutSeconds })}\n`); } catch { /* the CLI falls back to forge.yaml */ } }
|
|
1505
1522
|
route = routeBuilder({ contract: frozen, riskPaths, cfg: routingCfg, topModel: builderModel }); // #4394
|
|
1506
1523
|
{
|
|
1507
1524
|
const rec = { event: "BUILDER_ROUTE", mode: route.mode, class: route.class, tier: route.tier, model: route.model ?? "default", risk: route.risk };
|
|
1508
1525
|
ev(rec); await emit(rec);
|
|
1509
1526
|
T.decisions.push(`Builder routed to the ${route.tier} tier (${route.class}; ${route.model ?? "CLI default"})`);
|
|
1510
1527
|
}
|
|
1511
|
-
const implementPrompt = builderImplementPrompt(vcfg.mode, tcOpts()) + hotspotBuildRule(hotFacts) + enumeratingBuildRule(enumList) + exploreBuildRule(exploreCfg) + (planned ? "" : (fromCheckpoint ? CONTINUE_NOTE : RERUN_NOTE).replace("BASE", lane)) + (salvagedRef ? SALVAGE_NOTE.replaceAll("REF", salvagedRef) : "") + recoverNote() + (planned ? "" : resumeNote);
|
|
1528
|
+
const implementPrompt = builderImplementPrompt(vcfg.mode, tcOpts()) + hotspotBuildRule(hotFacts) + enumeratingBuildRule(enumList) + invariantsBuildRule(vcfg.invariants) + exploreBuildRule(exploreCfg) + (planned ? "" : (fromCheckpoint ? CONTINUE_NOTE : RERUN_NOTE).replace("BASE", lane)) + (salvagedRef ? SALVAGE_NOTE.replaceAll("REF", salvagedRef) : "") + recoverNote() + (planned ? "" : resumeNote);
|
|
1512
1529
|
liveKeys = ["implementation"];
|
|
1513
1530
|
stage = "implement";
|
|
1514
1531
|
if (!lightDone) await builder(++attemptN, implementPrompt, planned ? sessionId : null, true, undefined, { trigger: "build" });
|
|
@@ -1713,6 +1730,24 @@ export async function runCore(o) {
|
|
|
1713
1730
|
return false;
|
|
1714
1731
|
}
|
|
1715
1732
|
await duplicateGate("hand-off");
|
|
1733
|
+
/** #4591: lane-added migrations the repo's generator did not produce: one builder round (stage "sequence"), then an anomaly. */
|
|
1734
|
+
async function migrationGate(trigger) {
|
|
1735
|
+
if (!io.migrationGenerator || isSplit) return;
|
|
1736
|
+
const found = async () => (await io.migrationGenerator({ cwd: wt, base }).catch(() => null)) ?? [];
|
|
1737
|
+
let bad = await found();
|
|
1738
|
+
if (!bad.length) return;
|
|
1739
|
+
ev({ event: "MIGRATION_HAND_NAMED", trigger, count: bad.length });
|
|
1740
|
+
if (sessionId) {
|
|
1741
|
+
T.decisions.push(`Hand-named migration(s) (${trigger}): ${bad.slice(0, 5).join("; ")}; builder resumed once`);
|
|
1742
|
+
await fixRound("sequence", migrationGeneratorPrompt(bad, vcfg.mode), [], "sequence", bad.join("\n"));
|
|
1743
|
+
result = (await readResult()) ?? result;
|
|
1744
|
+
files = await changedFiles();
|
|
1745
|
+
bad = await found();
|
|
1746
|
+
}
|
|
1747
|
+
if (bad.length) T.anomalies.push(`Hand-named migration(s) remain (${trigger}): ${bad.slice(0, 5).join("; ")}`);
|
|
1748
|
+
else await gatePassed("sequence");
|
|
1749
|
+
}
|
|
1750
|
+
await migrationGate("hand-off");
|
|
1716
1751
|
/** #4406: verify.post_merge (else the scoped typecheck) on the merged tree before the base-sync push; fix rounds up to verify.max_rounds. */
|
|
1717
1752
|
async function postMergeGate(trigger) {
|
|
1718
1753
|
const commands = io.runCommand && !isSplit ? postMergeCommands({ configured: vcfg.postMerge, scoped: (await io.postMergeChecks?.({ cwd: wt, base }).catch(() => [])) ?? [], mode: vcfg.mode }) : [];
|
|
@@ -1721,7 +1756,7 @@ export async function runCore(o) {
|
|
|
1721
1756
|
await renew();
|
|
1722
1757
|
results = [];
|
|
1723
1758
|
for (const command of commands) {
|
|
1724
|
-
let x; try { x = await heavyStep("precheck", () => io.runCommand({ command, cwd: wt, timeoutMs
|
|
1759
|
+
let x; try { x = await heavyStep("precheck", async () => (await runWithTimeoutRetry((timeoutMs) => io.runCommand({ command, cwd: wt, timeoutMs, env: laneEnv() }), vcfg.precheckTimeoutSeconds * 1000)).r); } catch (e) { x = { rc: 127, output: String(e?.message ?? e) }; }
|
|
1725
1760
|
results.push({ command, rc: Number(x?.rc ?? 1), output: String(x?.output ?? "") });
|
|
1726
1761
|
}
|
|
1727
1762
|
ev({ event: "POST_MERGE_CHECKS", trigger, round, results: results.map(({ command, rc }) => ({ command, rc })) });
|
|
@@ -1745,6 +1780,7 @@ export async function runCore(o) {
|
|
|
1745
1780
|
// cannot run falls back once to local validation. A head verified here lets the precheck accept validate ids left to CI.
|
|
1746
1781
|
let ciVerifiedHead = null, ciFellBack = false, ciRounds = 0;
|
|
1747
1782
|
const ciSeenRuns = [];
|
|
1783
|
+
const ciReruns = new Map(); // #4586: head -> {rr} of the one rerun of its failed jobs, spent before any fix round
|
|
1748
1784
|
let ciGateNames; // #4387: checks that gate CI verification, read once per lane
|
|
1749
1785
|
/** Verifies the current head; returns {done} with a terminal result, or {fixes} (builder CI-fix rounds that moved the head). */
|
|
1750
1786
|
async function ciVerify(trigger) {
|
|
@@ -1757,7 +1793,7 @@ export async function runCore(o) {
|
|
|
1757
1793
|
const verifyArgs = { io, branch, head, changedFiles: files, verify: vcfg, ciMaxMinutes, now, sleep, random, exclude: ciSeenRuns, gate: ciGateNames,
|
|
1758
1794
|
push: () => gitPush(["push", "-u", "origin", branch]),
|
|
1759
1795
|
onPoll: async () => { await renew(); onProgress({ event: "phase_paused", phase: "verify", detail: `CI verification pending on ${branch}` }); } };
|
|
1760
|
-
|
|
1796
|
+
let v = await runCiVerify(verifyArgs);
|
|
1761
1797
|
if (v.runId != null) ciSeenRuns.push(v.runId);
|
|
1762
1798
|
const rec = { event: "CI_VERIFY", trigger, mode: vcfg.mode, round: ciRounds, result: v.result, skipped: v.result === "skipped", run_url: v.runUrl ?? null, failures: v.failures.map((f) => f.job) };
|
|
1763
1799
|
ev(rec); await emit({ ...rec, phase: "verify" });
|
|
@@ -1785,28 +1821,44 @@ export async function runCore(o) {
|
|
|
1785
1821
|
return { fixes: fixes + 1 };
|
|
1786
1822
|
}
|
|
1787
1823
|
// fail
|
|
1788
|
-
|
|
1824
|
+
// #4586: `verify.flaky` failures are reported, never block; the rest are rerun once before any round is counted.
|
|
1825
|
+
let sp = splitFlaky(v.failures, vcfg.flaky);
|
|
1826
|
+
const greenNow = (note) => { ciVerifiedHead = head; row(`CI green at ${head.slice(0, 12)}${note}`); return gatePassed("ci_red", "merge_ci"); };
|
|
1827
|
+
const reportFlaky = () => { if (!sp.flaky.length) return; const n = sp.flaky.map((f) => f.job); T.anomalies.push(`Known-flaky CI failure(s) reported, not blocking (verify.flaky): ${n.join(", ")}`); ev({ event: "CI_FLAKY_REPORTED", trigger, head, jobs: n }); };
|
|
1828
|
+
reportFlaky();
|
|
1829
|
+
if (!sp.own.length) { await greenNow(" (only verify.flaky failures)"); return { fixes }; }
|
|
1830
|
+
if (!isSplit && !ciReruns.has(head)) {
|
|
1831
|
+
const own = new Set(sp.own.map((f) => f.job)), vOwn = { ...v, failures: sp.own, failedChecks: v.failedChecks?.filter((c) => own.has(c.name)) };
|
|
1832
|
+
const rr = await rerunFailedOnce({ io, v: vOwn });
|
|
1833
|
+
ciReruns.set(head, { rr });
|
|
1834
|
+
const v2 = rr.ok ? await runCiVerify({ ...verifyArgs, rerunOf: rerunTarget(vOwn) }) : null;
|
|
1835
|
+
if (rr.detail !== "no rerun seam") ev({ event: "CI_FLAKE_RERUN", trigger, head, runs: rr.runs, jobs: rr.jobs, result: v2?.result ?? "rerun-failed", detail: v2?.detail ?? rr.detail });
|
|
1836
|
+
if (v2?.result === "pass") {
|
|
1837
|
+
await greenNow(" after a rerun of the failed jobs");
|
|
1838
|
+
T.decisions.push(`CI verification red (${sp.own.map((f) => f.job).join(", ")}); green after one rerun of the failed jobs (flake), no fix round spent`);
|
|
1839
|
+
return { fixes };
|
|
1840
|
+
}
|
|
1841
|
+
if (v2?.result === "timeout") return { done: await finish("ci-pending", `CI rerun of the failed jobs on ${branch} still pending after ${ciMaxMinutes} min; rerun resumes the branch`) };
|
|
1842
|
+
if (v2?.result === "fail") {
|
|
1843
|
+
v = v2; if (v.runId != null) ciSeenRuns.push(v.runId);
|
|
1844
|
+
sp = splitFlaky(v.failures, vcfg.flaky); reportFlaky();
|
|
1845
|
+
if (!sp.own.length) { await greenNow(" (only verify.flaky failures)"); return { fixes }; }
|
|
1846
|
+
}
|
|
1847
|
+
}
|
|
1848
|
+
const jobs = sp.own.map((f) => f.job).join(", ") || "unknown job";
|
|
1789
1849
|
row(`CI red (${jobs})`);
|
|
1790
1850
|
if (ciRounds >= vcfg.maxRounds || !sessionId) {
|
|
1791
|
-
return { done: await needsHuman(`CI verification failed on \`${branch}\` at \`${head.slice(0, 12)}\` after ${ciRounds} fix round(s); nothing merged:\n${
|
|
1851
|
+
return { done: await needsHuman(`CI verification failed on \`${branch}\` at \`${head.slice(0, 12)}\` after ${ciRounds} fix round(s); nothing merged:\n${sp.own.slice(0, 10).map((f) => `- ${f.job}${f.url ? ` (${f.url})` : ""}`).join("\n")}`, OUTCOME_CODES.CI_RED) };
|
|
1792
1852
|
}
|
|
1793
1853
|
ciRounds++; fixes++;
|
|
1794
1854
|
T.decisions.push(`CI verification red (${jobs}); builder resumed with the failed logs (round ${ciRounds}/${vcfg.maxRounds})`);
|
|
1795
|
-
ev({ event: "CI_FIX_ROUND", trigger, round: ciRounds, jobs:
|
|
1796
|
-
await fixRound("ci", ciFailurePrompt(
|
|
1855
|
+
ev({ event: "CI_FIX_ROUND", trigger, round: ciRounds, jobs: sp.own.map((f) => f.job) });
|
|
1856
|
+
await fixRound("ci", ciFailurePrompt(sp.own, vcfg.mode), [], "ci_red", failureLog(sp.own));
|
|
1797
1857
|
await upsertStage("implementation");
|
|
1798
1858
|
result = (await readResult()) ?? result;
|
|
1799
1859
|
if (String(await git(["rev-parse", "HEAD"])).trim() === head) {
|
|
1800
|
-
// #4387:
|
|
1801
|
-
const rr =
|
|
1802
|
-
const v2 = rr.ok ? await runCiVerify({ ...verifyArgs, rerunOf: rerunTarget(v) }) : null;
|
|
1803
|
-
if (rr.detail !== "no rerun seam") ev({ event: "CI_FLAKE_RERUN", trigger, head, runs: rr.runs, jobs: rr.jobs, result: v2?.result ?? "rerun-failed", detail: v2?.detail ?? rr.detail });
|
|
1804
|
-
if (v2?.result === "pass") {
|
|
1805
|
-
ciVerifiedHead = head; await gatePassed("ci_red", "merge_ci"); row(`CI green at ${head.slice(0, 12)} after a rerun of the failed jobs`);
|
|
1806
|
-
T.decisions.push(`CI verification red (${jobs}), no fix commit; green after one rerun of the failed jobs (flake)`);
|
|
1807
|
-
return { fixes };
|
|
1808
|
-
}
|
|
1809
|
-
if (v2?.result === "timeout") return { done: await finish("ci-pending", `CI rerun of the failed jobs on ${branch} still pending after ${ciMaxMinutes} min; rerun resumes the branch`) };
|
|
1860
|
+
// #4387/#4586: no commit says the failure is not the change's; the failed jobs were already rerun once on this head (before the round).
|
|
1861
|
+
const rr = ciReruns.get(head)?.rr ?? { ok: false, detail: isSplit ? "refactor:split" : "no rerun seam" };
|
|
1810
1862
|
const how = !rr.ok ? (rr.detail === "no rerun seam" ? "" : ` (rerun of the failed jobs not possible: ${rr.detail})`) : " and stayed red after one rerun of the failed jobs";
|
|
1811
1863
|
return { done: await needsHuman(`CI verification failed on \`${branch}\` (${jobs}) and the builder made no new commit for it${how}; nothing merged.`, OUTCOME_CODES.CI_RED) };
|
|
1812
1864
|
}
|
|
@@ -1921,7 +1973,7 @@ export async function runCore(o) {
|
|
|
1921
1973
|
const r = runs.find((x) => x?.id === v.id);
|
|
1922
1974
|
if (!r || r.exit === undefined || r.exit === 0) continue;
|
|
1923
1975
|
let x;
|
|
1924
|
-
try { x = await heavyStep("precheck", () => io.runCommand({ command: String(v.command), cwd: wt, timeoutMs
|
|
1976
|
+
try { x = await heavyStep("precheck", async () => (await runWithTimeoutRetry((timeoutMs) => io.runCommand({ command: String(v.command), cwd: wt, timeoutMs, env }), vcfg.precheckTimeoutSeconds * 1000)).r); } catch (e) { x = { rc: 127, output: String(e?.message ?? e) }; }
|
|
1925
1977
|
out[v.id] = { rc: Number.isInteger(x?.rc) ? x.rc : 1, output: String(x?.output ?? "") };
|
|
1926
1978
|
}
|
|
1927
1979
|
if (Object.keys(out).length) ev({ event: "VALIDATE_RERUN", runs: Object.entries(out).map(([id, x]) => ({ id, rc: x.rc })) });
|
|
@@ -2197,10 +2249,20 @@ export async function runCore(o) {
|
|
|
2197
2249
|
}
|
|
2198
2250
|
let how, tests = "not required (clean merge)", rereview = "not required (conflict-free base merge)", detResolved = false;
|
|
2199
2251
|
// #4386: conflicts only in sequence files (numbered, journal, snapshot, their index) resolve without a builder
|
|
2200
|
-
|
|
2252
|
+
// #4588: union / JSON-map / generated files resolve first; only the files left go to the sequence resolver
|
|
2253
|
+
const bm = o.baseMerge, cr = !clean && io.conflictResolve && bm && Object.values(bm).some((v) => v?.length) ? await io.conflictResolve({ cwd: wt, files, cfg: bm }).catch(() => null) : null;
|
|
2254
|
+
let seqFiles = cr?.resolved?.length ? cr.remaining : files, crDone = false;
|
|
2255
|
+
if (cr?.resolved?.length && !seqFiles.length) { try { await git(["commit", "--no-edit"]); crDone = true; } catch { /* the builder finishes the merge */ } }
|
|
2256
|
+
if (cr?.note) T.decisions.push(`Base-sync ${syncs}: ${cr.note}`);
|
|
2257
|
+
const rs = !clean && !crDone && seqFiles.length && io.sequenceResolve ? ((await io.sequenceResolve({ cwd: wt, files: seqFiles }).catch((e) => ({ unsupported: String(e?.message ?? e) }))) ?? {}) : null;
|
|
2201
2258
|
if (rs?.unsupported) T.decisions.push(`Base-sync ${syncs}: no deterministic resolution (${rs.unsupported})`);
|
|
2202
2259
|
if (clean) {
|
|
2203
2260
|
how = "clean merge of the base into the branch";
|
|
2261
|
+
} else if (crDone) {
|
|
2262
|
+
detResolved = true;
|
|
2263
|
+
T.decisions.push(`Base-sync ${syncs}: conflicts only in union / JSON-map / generated files (${files.join(", ")}); resolved by the engine (no builder)`);
|
|
2264
|
+
how = `deterministic: ${files.length} union / JSON-map / generated file(s) resolved`;
|
|
2265
|
+
tests = "not required (deterministic base-merge resolution)";
|
|
2204
2266
|
} else if (rs?.ok) {
|
|
2205
2267
|
detResolved = true;
|
|
2206
2268
|
T.decisions.push(`Base-sync ${syncs}: conflicts only in sequence files (${files.join(", ")}); base side taken, lane files renumbered by the engine (no builder)`);
|
|
@@ -2273,7 +2335,21 @@ export async function runCore(o) {
|
|
|
2273
2335
|
* Returns null to wait again, or a terminal result (rounds exhausted, no session, no new commit, review failure).
|
|
2274
2336
|
*/
|
|
2275
2337
|
async function mergeFixRound(checks) {
|
|
2338
|
+
// #4586: the failed runs are rerun once per head before a round is counted; the red check is waited out until the rerun reports (bounded).
|
|
2339
|
+
const rkey = `merge:${localHead}`, rerun = ciReruns.get(rkey);
|
|
2340
|
+
if (!rerun && !isSplit) {
|
|
2341
|
+
const rr = await rerunFailedOnce({ io, v: { failedChecks: checks, failures: checks.map((c) => ({ job: c.name })) } });
|
|
2342
|
+
ciReruns.set(rkey, { rr, urls: new Map(checks.map((c) => [c.name, c.url ?? null])), waits: 0 });
|
|
2343
|
+
ev({ event: "CI_FLAKE_RERUN", trigger: "merge", head: localHead, runs: rr.runs, jobs: rr.jobs, result: rr.ok ? "rerun-started" : "rerun-failed", detail: rr.detail });
|
|
2344
|
+
if (rr.ok) { await sleep(20000); return null; }
|
|
2345
|
+
} else if (rerun?.rr.ok && rerun.waits < 20 && checks.every((c) => (c.url ?? null) === rerun.urls.get(c.name))) {
|
|
2346
|
+
rerun.waits++; await sleep(20000); return null; // still the attempt that was rerun
|
|
2347
|
+
}
|
|
2348
|
+
const flakyNames = checks.filter((c) => vcfg.flaky?.includes(c.name)).map((c) => c.name);
|
|
2349
|
+
if (flakyNames.length) { T.anomalies.push(`Known-flaky required check(s) reported (verify.flaky): ${flakyNames.join(", ")}`); ev({ event: "CI_FLAKY_REPORTED", trigger: "merge", head: localHead, jobs: flakyNames }); }
|
|
2350
|
+
checks = checks.filter((c) => !flakyNames.includes(c.name));
|
|
2276
2351
|
const jobs = checks.map((c) => c.name);
|
|
2352
|
+
if (!checks.length) return await needsHuman(`Required check(s) ${flakyNames.join(", ")} (verify.flaky) still failed on PR #${pr} after one rerun; a required check blocks the merge whatever verify.flaky says; nothing merged.`, OUTCOME_CODES.CI_RED);
|
|
2277
2353
|
if (ciRounds >= vcfg.maxRounds || !sessionId) {
|
|
2278
2354
|
return await needsHuman(`Required check(s) failed on PR #${pr}: ${jobs.join(", ")}${ciRounds ? ` (after ${ciRounds} CI fix round(s))` : ""}`, OUTCOME_CODES.CI_RED);
|
|
2279
2355
|
}
|
|
@@ -2357,7 +2433,9 @@ export async function runCore(o) {
|
|
|
2357
2433
|
T.decisions.push(`e2e (${want.reason}): ${v.result} at ${localHead.slice(0, 12)}${v.detail ? ` (${v.detail})` : ""}`);
|
|
2358
2434
|
if (v.result === "timeout") return { done: await finish("ci-pending", `PR #${pr} e2e still pending after ${ciMaxMinutes} min`, { pr }) };
|
|
2359
2435
|
if (v.result === "unavailable") return { done: await needsHuman(`e2e verification unavailable on PR #${pr} (${v.detail}); nothing merged.`) };
|
|
2360
|
-
|
|
2436
|
+
const ownReal = own.filter((f) => !vcfg.flaky?.includes(f.job));
|
|
2437
|
+
if (own.length > ownReal.length) { T.anomalies.push(`Known-flaky e2e failure(s) reported, not blocking (verify.flaky): ${own.filter((f) => !ownReal.includes(f)).map((f) => f.job).join(", ")}`); }
|
|
2438
|
+
if (ownReal.length) { const r = await mergeFixRound(ownReal.map((f) => ({ name: f.job, url: f.url }))); return r ? { done: r } : { again: true }; }
|
|
2361
2439
|
if (v.result === "fail") T.anomalies.push(`Pre-existing e2e failure(s) reported, not blocking: ${v.failures.map((f) => f.job).join(", ")}`);
|
|
2362
2440
|
e2eHead = localHead;
|
|
2363
2441
|
return null;
|