forgedock 1.32.0 → 1.33.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 +7 -3
- package/bin/engine/phases.mjs +29 -8
- package/bin/engine-cli.mjs +84 -24
- package/bin/forgedock.mjs +21 -1
- package/bin/lean/basesync.mjs +98 -0
- package/bin/lean/basesync.test.mjs +151 -0
- package/bin/lean/batch.mjs +9 -3
- package/bin/lean/batch.test.mjs +134 -3
- package/bin/lean/budget.mjs +7 -1
- package/bin/lean/budget.test.mjs +9 -1
- package/bin/lean/conformance.mjs +20 -7
- package/bin/lean/conformance.test.mjs +29 -2
- package/bin/lean/depcache.mjs +23 -3
- package/bin/lean/depcache.test.mjs +42 -1
- package/bin/lean/effects.mjs +29 -3
- package/bin/lean/execution.mjs +106 -14
- package/bin/lean/execution.test.mjs +101 -1
- package/bin/lean/hostkill.mjs +8 -0
- package/bin/lean/lean.mjs +65 -24
- package/bin/lean/lean.test.mjs +221 -10
- package/bin/lean/lightlane.mjs +38 -13
- package/bin/lean/lightlane.test.mjs +35 -1
- package/bin/lean/orchestrate.mjs +93 -26
- package/bin/lean/prompts.mjs +17 -7
- package/bin/lean/recovery.test.mjs +26 -0
- package/bin/lean/retry.mjs +15 -1
- package/bin/lean/sandbox-provider.contract.test.mjs +74 -1
- package/bin/lean/sandbox-remote.mjs +40 -7
- package/bin/lean/schedd.mjs +50 -14
- package/bin/lean/schedd.test.mjs +133 -21
- package/bin/lean/seqlease.mjs +49 -2
- package/bin/lean/seqlease.test.mjs +14 -0
- package/bin/lean/sequence.mjs +54 -6
- package/bin/lean/sequence.test.mjs +83 -1
- package/bin/pool/agent.mjs +435 -0
- package/bin/pool/cli.mjs +110 -0
- package/bin/pool/docker.mjs +56 -0
- package/bin/pool/http.mjs +55 -0
- package/bin/pool/router.mjs +299 -0
- package/bin/pool/store.mjs +25 -0
- package/bin/pool/version.mjs +44 -0
- package/bin/runner.mjs +1 -0
- package/bin/stats/aggregate.mjs +6 -0
- package/bin/stats/index.mjs +14 -2
- package/bin/stats/local-source.mjs +1 -1
- package/bin/stats/pool.mjs +51 -0
- package/bin/stats/pool.test.mjs +39 -0
- package/bin/stats/query.mjs +2 -2
- package/bin/stats/stats.test.mjs +15 -1
- package/commands/orchestrate.md +3 -3
- package/docs/CONFIG.md +23 -9
- package/docs/OUTCOME-CODES.md +5 -3
- package/docs/STATS.md +7 -0
- package/package.json +2 -1
- package/pool/README.md +13 -0
- package/pool/image/Dockerfile +46 -0
- package/pool/image/claude-shim +8 -0
- package/pool/image/cs-cred +12 -0
- package/pool/image/entrypoint.sh +61 -0
- package/pool/image/gh-shim +6 -0
- package/pool/install/install-agent-linux.sh +53 -0
- package/pool/install/install-agent.ps1 +225 -0
- package/pool/install/install-agent.sh +101 -0
- package/pool/install/install-router.sh +67 -0
- package/pool/install/keepalive.ps1 +36 -0
- package/pool/install/uninstall-agent.ps1 +20 -0
- package/scripts/hotspots.sh +33 -21
- package/scripts/hotspots.test.sh +22 -3
- package/scripts/refresh-bot-token.test.sh +56 -0
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
|
|
2
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
3
|
+
// #4525: parked-PR base sync. Unit tests for the union resolver, and a real temp git repo (bare origin + clone).
|
|
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 { readOrchestration } from "../engine-cli.mjs";
|
|
11
|
+
import { orchestrate } from "./orchestrate.mjs";
|
|
12
|
+
import { unionResolve, matchesGlobs, isSequenceFile, syncParked } from "./basesync.mjs";
|
|
13
|
+
|
|
14
|
+
const sh = (cwd, ...a) => execFileSync("git", a, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], env: { ...process.env, GIT_AUTHOR_NAME: "t", GIT_AUTHOR_EMAIL: "t@t", GIT_COMMITTER_NAME: "t", GIT_COMMITTER_EMAIL: "t@t" } });
|
|
15
|
+
const put = (root, files) => { for (const [p, t] of Object.entries(files)) { mkdirSync(dirname(join(root, p)), { recursive: true }); writeFileSync(join(root, p), t); } };
|
|
16
|
+
const commit = (d, m) => { sh(d, "add", "-A"); sh(d, "commit", "-qm", m); };
|
|
17
|
+
|
|
18
|
+
const hunk = (ours, base, theirs) => `top\n<<<<<<< HEAD\n${ours}||||||| base\n${base}=======\n${theirs}>>>>>>> origin/main\nbottom\n`;
|
|
19
|
+
|
|
20
|
+
describe("unionResolve (#4525)", () => {
|
|
21
|
+
it("keeps both sides when both only added lines", () => {
|
|
22
|
+
assert.equal(unionResolve(hunk("a\n", "", "b\n")), "top\na\nb\nbottom\n");
|
|
23
|
+
});
|
|
24
|
+
it("refuses a hunk that modified or deleted base lines", () => {
|
|
25
|
+
assert.equal(unionResolve(hunk("a\n", "x\n", "b\n")), null);
|
|
26
|
+
assert.equal(unionResolve(hunk("", "x\n", "b\n")), null);
|
|
27
|
+
});
|
|
28
|
+
it("refuses text with no base section or no conflict", () => {
|
|
29
|
+
assert.equal(unionResolve("top\n<<<<<<< HEAD\na\n=======\nb\n>>>>>>> x\n"), null);
|
|
30
|
+
assert.equal(unionResolve("plain\n"), null);
|
|
31
|
+
});
|
|
32
|
+
it("refuses a file when any one hunk is not a pure addition", () => {
|
|
33
|
+
assert.equal(unionResolve(`${hunk("a\n", "", "b\n")}${hunk("c\n", "y\n", "d\n")}`), null);
|
|
34
|
+
});
|
|
35
|
+
it("matches globs and excludes sequence files", () => {
|
|
36
|
+
assert.ok(matchesGlobs("src/registry/index.ts", ["src/registry/*.ts"]));
|
|
37
|
+
assert.ok(matchesGlobs("a/b/CHANGELOG.md", ["CHANGELOG.md"]));
|
|
38
|
+
assert.ok(matchesGlobs("a/b/c.txt", ["a/**"]));
|
|
39
|
+
assert.ok(!matchesGlobs("a/b/c.txt", ["a/*.txt"]));
|
|
40
|
+
assert.ok(!matchesGlobs("x", []));
|
|
41
|
+
assert.ok(isSequenceFile("db/drizzle/meta/_journal.json"));
|
|
42
|
+
assert.ok(isSequenceFile("db/drizzle/0007_usage.sql"));
|
|
43
|
+
assert.ok(!isSequenceFile("src/registry.ts"));
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
/** origin (bare) + clone `d` on main with registry.txt, other.txt; branch `lane` from main. Returns helpers. */
|
|
48
|
+
function fixture() {
|
|
49
|
+
const root = tmpDir("basesync-");
|
|
50
|
+
sh(root, "init", "-q", "--bare", "-b", "main", "origin.git");
|
|
51
|
+
const d = join(root, "work");
|
|
52
|
+
sh(root, "clone", "-q", "origin.git", "work");
|
|
53
|
+
sh(d, "checkout", "-qb", "main");
|
|
54
|
+
put(d, { "reg/registry.txt": "a\nb\n", "other.txt": "one\ntwo\n", "db/_journal.json": "{}\n" });
|
|
55
|
+
commit(d, "init"); sh(d, "push", "-q", "origin", "main");
|
|
56
|
+
sh(d, "checkout", "-qb", "lane");
|
|
57
|
+
const onLane = (files) => { sh(d, "checkout", "-q", "lane"); put(d, files); commit(d, "lane"); sh(d, "push", "-q", "origin", "lane"); };
|
|
58
|
+
const onMain = (files) => { sh(d, "checkout", "-q", "main"); put(d, files); commit(d, "base"); sh(d, "push", "-q", "origin", "main"); };
|
|
59
|
+
const git = async (a, o = {}) => sh(o.cwd ?? d, ...a);
|
|
60
|
+
const tip = (b) => sh(root, "--git-dir", "origin.git", "rev-parse", b).trim();
|
|
61
|
+
const show = (b, f) => sh(root, "--git-dir", "origin.git", "show", `${b}:${f}`);
|
|
62
|
+
return { d, onLane, onMain, git, tip, show, tmp: root };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
describe("syncParked (#4525)", () => {
|
|
66
|
+
it("pushes a clean merge of the base into the branch", async () => {
|
|
67
|
+
const f = fixture();
|
|
68
|
+
f.onLane({ "lane.txt": "x\n" });
|
|
69
|
+
f.onMain({ "other.txt": "one\ntwo\nthree\n" });
|
|
70
|
+
const r = await syncParked({ git: f.git, base: "main", branch: "lane", tmp: f.tmp });
|
|
71
|
+
assert.equal(r.status, "merged");
|
|
72
|
+
assert.equal(f.show("lane", "other.txt"), "one\ntwo\nthree\n");
|
|
73
|
+
assert.equal(f.show("lane", "lane.txt"), "x\n");
|
|
74
|
+
});
|
|
75
|
+
it("is up-to-date when the base is already merged, and unchanged for a handled base sha", async () => {
|
|
76
|
+
const f = fixture();
|
|
77
|
+
f.onLane({ "lane.txt": "x\n" });
|
|
78
|
+
const r = await syncParked({ git: f.git, base: "main", branch: "lane", tmp: f.tmp });
|
|
79
|
+
assert.equal(r.status, "up-to-date");
|
|
80
|
+
f.onMain({ "m.txt": "m\n" });
|
|
81
|
+
const r2 = await syncParked({ git: f.git, base: "main", branch: "lane", tmp: f.tmp, last: f.tip("main") });
|
|
82
|
+
assert.equal(r2.status, "unchanged");
|
|
83
|
+
});
|
|
84
|
+
it("union-resolves a two-sided list addition in a matching file and pushes", async () => {
|
|
85
|
+
const f = fixture();
|
|
86
|
+
f.onLane({ "reg/registry.txt": "a\nb\nlane\n" });
|
|
87
|
+
f.onMain({ "reg/registry.txt": "a\nb\nbase\n" });
|
|
88
|
+
const r = await syncParked({ git: f.git, base: "main", branch: "lane", unionGlobs: ["reg/*.txt"], tmp: f.tmp });
|
|
89
|
+
assert.equal(r.status, "merged");
|
|
90
|
+
assert.equal(f.show("lane", "reg/registry.txt"), "a\nb\nlane\nbase\n");
|
|
91
|
+
});
|
|
92
|
+
it("reports other conflicts and leaves the branch alone", async () => {
|
|
93
|
+
const f = fixture();
|
|
94
|
+
f.onLane({ "other.txt": "one\nLANE\n" });
|
|
95
|
+
f.onMain({ "other.txt": "one\nBASE\n" });
|
|
96
|
+
const before = f.tip("lane");
|
|
97
|
+
const r = await syncParked({ git: f.git, base: "main", branch: "lane", unionGlobs: ["reg/*.txt"], tmp: f.tmp });
|
|
98
|
+
assert.equal(r.status, "conflict");
|
|
99
|
+
assert.deepEqual(r.paths, ["other.txt"]);
|
|
100
|
+
assert.equal(f.tip("lane"), before);
|
|
101
|
+
});
|
|
102
|
+
it("does not union-resolve a file outside the globs, or a _journal.json even when matched", async () => {
|
|
103
|
+
const f = fixture();
|
|
104
|
+
f.onLane({ "reg/registry.txt": "a\nb\nlane\n", "db/_journal.json": "{\"l\":1}\n" });
|
|
105
|
+
f.onMain({ "reg/registry.txt": "a\nb\nbase\n", "db/_journal.json": "{\"b\":1}\n" });
|
|
106
|
+
const r = await syncParked({ git: f.git, base: "main", branch: "lane", unionGlobs: ["**/*.json"], tmp: f.tmp });
|
|
107
|
+
assert.equal(r.status, "conflict");
|
|
108
|
+
assert.deepEqual(r.paths.sort(), ["db/_journal.json", "reg/registry.txt"]);
|
|
109
|
+
assert.equal(readFileSync(join(f.d, "other.txt"), "utf8").length > 0, true);
|
|
110
|
+
});
|
|
111
|
+
it("returns an error (never throws) when git fails", async () => {
|
|
112
|
+
const r = await syncParked({ git: async () => { throw new Error("boom\nmore"); }, base: "main", branch: "lane" });
|
|
113
|
+
assert.deepEqual(r, { status: "error", detail: "boom" });
|
|
114
|
+
});
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
describe("orchestrate parked-PR sync seam (#4525)", () => {
|
|
118
|
+
it("syncs a parked needs-human PR while the batch runs; comments conflicting paths once per base sha", async () => {
|
|
119
|
+
const calls = [];
|
|
120
|
+
let release; const gate = new Promise((r) => { release = r; });
|
|
121
|
+
const decisions = [];
|
|
122
|
+
const seam = async (a) => { calls.push(a); if (calls.length >= 3) release(); return a.last ? { status: "unchanged", base: a.last } : { status: "conflict", base: "b".repeat(40), paths: ["x.ts", "y.ts"] }; };
|
|
123
|
+
const rows = await orchestrate({ issues: [1, 2], parallel: 2, readBody: async () => "", pollMs: 5, syncEveryMs: 10, syncParked: seam,
|
|
124
|
+
runOne: async (n) => { if (n === 1) return { terminalReason: "needs-human", pr: 101, detail: "stuck" }; await gate; return { terminalReason: "merged", pr: 102 }; },
|
|
125
|
+
decide: (d) => { decisions.push(d); } });
|
|
126
|
+
assert.equal(rows.find((r) => r.issue === 1).outcome, "needs-human");
|
|
127
|
+
assert.ok(calls.length >= 3 && calls.every((c) => c.issue === 1 && c.pr === 101));
|
|
128
|
+
assert.equal(calls[0].last, null);
|
|
129
|
+
assert.equal(calls[1].last, "b".repeat(40));
|
|
130
|
+
const mine = decisions.filter((d) => d.issue === 1 && /conflicts with its base/.test(d.what));
|
|
131
|
+
assert.equal(mine.length, 1);
|
|
132
|
+
assert.match(mine[0].what, /x\.ts, y\.ts/);
|
|
133
|
+
});
|
|
134
|
+
it("without the seam nothing is synced and rows are unchanged", async () => {
|
|
135
|
+
const decisions = [];
|
|
136
|
+
const rows = await orchestrate({ issues: [1, 2], parallel: 2, readBody: async () => "", syncEveryMs: 1, decide: (d) => { decisions.push(d); },
|
|
137
|
+
runOne: async (n) => (n === 1 ? { terminalReason: "needs-human", pr: 101, detail: "stuck" } : { terminalReason: "merged", pr: 102 }) });
|
|
138
|
+
assert.deepEqual(rows.map((r) => r.outcome), ["needs-human", "merged"]);
|
|
139
|
+
assert.equal(decisions.length, 0);
|
|
140
|
+
});
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
describe("readOrchestration base_sync (#4525)", () => {
|
|
144
|
+
const read = (yaml) => { const d = tmpDir("basesync-cfg-"); writeFileSync(join(d, "forge.yaml"), yaml); return readOrchestration(d).baseSync; };
|
|
145
|
+
it("defaults on with no globs; parses enabled and union_globs; false opts out", () => {
|
|
146
|
+
assert.deepEqual(read("project:\n name: x\n"), { enabled: true, unionGlobs: [] });
|
|
147
|
+
assert.deepEqual(read("orchestration:\n base_sync:\n enabled: true\n union_globs: \"reg/*.ts, CHANGELOG.md\"\n"), { enabled: true, unionGlobs: ["reg/*.ts", "CHANGELOG.md"] });
|
|
148
|
+
assert.equal(read("orchestration:\n base_sync:\n enabled: false\n").enabled, false);
|
|
149
|
+
assert.equal(read("orchestration:\n base_sync_enabled: false\n").enabled, false);
|
|
150
|
+
});
|
|
151
|
+
});
|
package/bin/lean/batch.mjs
CHANGED
|
@@ -181,7 +181,10 @@ export async function waitLane({ batch, issue, pid, alive = isLaneAlive, sleep =
|
|
|
181
181
|
}
|
|
182
182
|
}
|
|
183
183
|
|
|
184
|
-
/**
|
|
184
|
+
/**
|
|
185
|
+
* Lane seams for `orchestrate()`: `runOne` launches a detached lane, `adopt` re-attaches to a live one (else null). A lane
|
|
186
|
+
* that already ended resolves at once with its result file; a live one calls `ctx.onLive()` before it is awaited (#4504).
|
|
187
|
+
*/
|
|
185
188
|
export function detachedLanes({ batch, laneArgs, script, cwd, env, bin, alive = isLaneAlive, pollMs, spawnFn }) {
|
|
186
189
|
return {
|
|
187
190
|
runOne: async (n, lane, ctx = {}) => {
|
|
@@ -189,9 +192,12 @@ export function detachedLanes({ batch, laneArgs, script, cwd, env, bin, alive =
|
|
|
189
192
|
ctx.onStart?.({ pid });
|
|
190
193
|
return waitLane({ batch, issue: n, pid, alive, pollMs });
|
|
191
194
|
},
|
|
192
|
-
adopt: async (n, rec) => {
|
|
195
|
+
adopt: async (n, rec, ctx = {}) => {
|
|
193
196
|
const rf = batch.resultFile(n);
|
|
194
|
-
if (!existsSync(rf)
|
|
197
|
+
if (!existsSync(rf)) {
|
|
198
|
+
if (!alive(rec.pid, rf)) return null;
|
|
199
|
+
ctx.onLive?.();
|
|
200
|
+
}
|
|
195
201
|
return waitLane({ batch, issue: n, pid: rec.pid, alive, pollMs });
|
|
196
202
|
},
|
|
197
203
|
};
|
package/bin/lean/batch.test.mjs
CHANGED
|
@@ -7,7 +7,7 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
|
7
7
|
import { join } from "node:path";
|
|
8
8
|
import { tmpDir } from "../tests/_tmp.mjs";
|
|
9
9
|
import { orchestrate, deferPolicy, DEFER_BACKOFF_S } from "./orchestrate.mjs";
|
|
10
|
-
import { openBatch, fsTypeOf, assertDiskPath, isLaneAlive, writeAtomic, launchLane } from "./batch.mjs";
|
|
10
|
+
import { openBatch, fsTypeOf, assertDiskPath, isLaneAlive, writeAtomic, launchLane, detachedLanes } from "./batch.mjs";
|
|
11
11
|
import { orchestrateFromCli, runFromCli, readOrchestration, makeRelabelNeedsHuman, makeDispatchCheck } from "../engine-cli.mjs";
|
|
12
12
|
|
|
13
13
|
// orchestrateFromCli opens the host lane registry (#4191): keep it in a temp dir, also for spawned dispatchers.
|
|
@@ -120,6 +120,16 @@ await orchestrateFromCli(["1", "2", "3", "--parallel", "2", "--repo", "o/r", "--
|
|
|
120
120
|
assert.equal(starts[1].length, 2);
|
|
121
121
|
});
|
|
122
122
|
|
|
123
|
+
it("#4518 cli.host_killed deferrals are requeued and never trip the breaker", async () => {
|
|
124
|
+
const { now, sleep } = fakeClock();
|
|
125
|
+
const updates = []; let calls = 0;
|
|
126
|
+
const rows = await orchestrate({ issues: [1], readBody: async () => "", now, sleep, maxDeferrals: 3, breaker: { errors: 1, window_min: 30 },
|
|
127
|
+
onUpdate: (r) => updates.push(r),
|
|
128
|
+
runOne: async () => (++calls <= 2 ? { terminalReason: "deferred", code: "cli.host_killed", retryInSeconds: 60, detail: "builder host kill" } : { terminalReason: "merged", pr: 5 }) });
|
|
129
|
+
assert.equal(rows[0].outcome, "merged");
|
|
130
|
+
assert.ok(!updates.some((u) => u.outcome === "breaker" || u.outcome === "engine-error"));
|
|
131
|
+
});
|
|
132
|
+
|
|
123
133
|
it("the breaker stops admissions after N engine-errors in M minutes and lets running lanes finish", async () => {
|
|
124
134
|
const started = [];
|
|
125
135
|
let releaseSlow;
|
|
@@ -203,9 +213,9 @@ await orchestrateFromCli(["1", "2", "3", "--parallel", "2", "--repo", "o/r", "--
|
|
|
203
213
|
assert.doesNotThrow(() => assertDiskPath("/home/u/.forge/batches", mounts));
|
|
204
214
|
assert.equal(isLaneAlive(process.pid, "--definitely-not-in-my-cmdline"), false);
|
|
205
215
|
assert.equal(isLaneAlive(2 ** 22 + 12345, null), false);
|
|
206
|
-
assert.deepEqual(readOrchestration(root), { breaker: { errors: 3, window_min: 30 }, maxDeferrals: 3, globalParallel: null, recovery: { maxAttempts: 1 } });
|
|
216
|
+
assert.deepEqual(readOrchestration(root), { breaker: { errors: 3, window_min: 30 }, maxDeferrals: 3, globalParallel: null, recovery: { maxAttempts: 1 }, baseSync: { enabled: true, unionGlobs: [] } });
|
|
207
217
|
writeFileSync(join(root, "forge.yaml"), "orchestration:\n breaker:\n errors: 5\n window_min: 10\n max_deferrals: 2\n global_parallel: 4\n");
|
|
208
|
-
assert.deepEqual(readOrchestration(root), { breaker: { errors: 5, window_min: 10 }, maxDeferrals: 2, globalParallel: 4, recovery: { maxAttempts: 1 } });
|
|
218
|
+
assert.deepEqual(readOrchestration(root), { breaker: { errors: 5, window_min: 10 }, maxDeferrals: 2, globalParallel: 4, recovery: { maxAttempts: 1 }, baseSync: { enabled: true, unionGlobs: [] } });
|
|
209
219
|
});
|
|
210
220
|
|
|
211
221
|
it("run-issue --result-file writes the lane result, also when the run throws", async () => {
|
|
@@ -629,3 +639,124 @@ describe("#4481: a closed issue or a merged lane PR is skipped at dispatch", ()
|
|
|
629
639
|
assert.equal(await mk(new Error("x"), new Error("y"))(9), null);
|
|
630
640
|
});
|
|
631
641
|
});
|
|
642
|
+
|
|
643
|
+
describe("#4504: a resume settles lanes that ended while no dispatcher ran", () => {
|
|
644
|
+
it("#4504: a resume settles a dead lane's result and requeues a dead lane without one before scheduling, while an earlier live lane still runs", async () => {
|
|
645
|
+
const dir = tmpDir("batch-4504-");
|
|
646
|
+
const batch = { resultFile: (n) => join(dir, `${n}.json`), logFile: (n) => join(dir, `${n}.log`) };
|
|
647
|
+
writeFileSync(batch.resultFile(2), JSON.stringify({ terminalReason: "merged", pr: 102 }));
|
|
648
|
+
const { adopt } = detachedLanes({ batch, laneArgs: () => [], script: "x", alive: (pid) => pid === 11, pollMs: 5 });
|
|
649
|
+
let saved = { v: 1, order: [1, 2, 3, 4], deps: { 1: [], 2: [], 3: [], 4: [] }, lanes: {}, crossSeen: [], results: {},
|
|
650
|
+
running: { 1: { startedAt: 1, pid: 11 }, 2: { startedAt: 1, pid: 22 }, 3: { startedAt: 1, pid: 33 } }, pending: [4],
|
|
651
|
+
notBefore: {}, requeues: {}, deferrals: {}, engineErrors: {}, prefilter: {}, errorTimes: [], breaker: null };
|
|
652
|
+
const store = { load: () => saved, save: (s) => { saved = s; }, log: () => {} };
|
|
653
|
+
const launched = [];
|
|
654
|
+
const done = orchestrate({ issues: [], parallel: 3, pollMs: 5, readBody: async () => "", store, adopt,
|
|
655
|
+
runOne: async (n) => { launched.push(n); return { terminalReason: "merged", pr: 100 + n }; } });
|
|
656
|
+
let rows;
|
|
657
|
+
try {
|
|
658
|
+
await until(() => launched.includes(3) && launched.includes(4) && saved.results?.["2"], 3000);
|
|
659
|
+
assert.equal(saved.results["2"].outcome, "merged", "the dead lane's result is settled");
|
|
660
|
+
assert.equal(saved.results["2"].pr, 102);
|
|
661
|
+
assert.ok(saved.running["1"], "the live lane is still running");
|
|
662
|
+
assert.equal(saved.running["2"], undefined, "the settled lane left running");
|
|
663
|
+
assert.ok(!launched.includes(1) && !launched.includes(2), "neither adopted lane is relaunched");
|
|
664
|
+
} finally {
|
|
665
|
+
writeFileSync(batch.resultFile(1), JSON.stringify({ terminalReason: "merged", pr: 101 })); // the live lane ends
|
|
666
|
+
rows = await done;
|
|
667
|
+
}
|
|
668
|
+
assert.deepEqual(rows.map((r) => [r.issue, r.outcome, r.pr]), [[1, "merged", 101], [2, "merged", 102], [3, "merged", 103], [4, "merged", 104]]);
|
|
669
|
+
assert.deepEqual(launched.sort(), [3, 4], "the dead lane without a result is requeued and run once");
|
|
670
|
+
});
|
|
671
|
+
});
|
|
672
|
+
|
|
673
|
+
describe("#4528: a full sandbox pool (sandbox.capacity) is never capped", () => {
|
|
674
|
+
it("sandbox.capacity is uncapped and honours retryInSeconds; other codes still cap at maxDeferrals", () => {
|
|
675
|
+
assert.deepEqual(deferPolicy({ code: "sandbox.capacity" }, { now: 0, deferrals: 99 }), { delayMs: 60000, host: true });
|
|
676
|
+
assert.deepEqual(deferPolicy({ code: "sandbox.capacity", retryInSeconds: 30 }, { now: 0, deferrals: 99, maxDeferrals: 3 }), { delayMs: 30000, host: true });
|
|
677
|
+
for (const code of ["merge.transient", "gh.unavailable", "cli.idle", "rate_limited", "sandbox.start_failed"]) {
|
|
678
|
+
assert.deepEqual(deferPolicy({ code, retryInSeconds: 60 }, { now: 0, deferrals: 3, maxDeferrals: 3 }), { capped: true }, code);
|
|
679
|
+
}
|
|
680
|
+
});
|
|
681
|
+
|
|
682
|
+
it("with K above the pool size every issue eventually runs and none is skipped for capacity", async () => {
|
|
683
|
+
const c = { t: 1_000_000 };
|
|
684
|
+
const now = () => c.t;
|
|
685
|
+
// Fake clock that advances after a macrotask, so running lanes progress while deferred ones wait.
|
|
686
|
+
const sleep = async (ms) => { await new Promise((r) => setImmediate(r)); c.t += ms; };
|
|
687
|
+
const POOL = 2;
|
|
688
|
+
let inUse = 0;
|
|
689
|
+
const capacityDefers = new Map();
|
|
690
|
+
const rows = await orchestrate({ issues: [1, 2, 3, 4, 5, 6], parallel: 4, maxDeferrals: 3, now, sleep, readBody: async () => "",
|
|
691
|
+
runOne: async (n) => {
|
|
692
|
+
if (inUse >= POOL) {
|
|
693
|
+
capacityDefers.set(n, (capacityDefers.get(n) ?? 0) + 1);
|
|
694
|
+
return { terminalReason: "deferred", code: "sandbox.capacity", retryInSeconds: 60, detail: "sandbox launch: HTTP 503" };
|
|
695
|
+
}
|
|
696
|
+
inUse++;
|
|
697
|
+
try { await new Promise((r) => setTimeout(r, 20)); } finally { inUse--; }
|
|
698
|
+
return { terminalReason: "merged", pr: 100 + n };
|
|
699
|
+
} });
|
|
700
|
+
assert.deepEqual(rows.map((r) => [r.issue, r.outcome]).sort((a, b) => a[0] - b[0]), [1, 2, 3, 4, 5, 6].map((n) => [n, "merged"]));
|
|
701
|
+
assert.ok(!rows.some((r) => /gave up/.test(r.detail ?? "")));
|
|
702
|
+
assert.ok(Math.max(...capacityDefers.values()) > 3, "an issue was deferred for capacity more than maxDeferrals times");
|
|
703
|
+
});
|
|
704
|
+
});
|
|
705
|
+
|
|
706
|
+
describe("#4540: remote admissions are capped to the provider's free capacity", () => {
|
|
707
|
+
const run = async (capacity) => {
|
|
708
|
+
const c = { t: 1_000_000 };
|
|
709
|
+
const sleep = async (ms) => { await new Promise((r) => setImmediate(r)); c.t += ms; };
|
|
710
|
+
let live = 0, peak = 0, bounced = 0;
|
|
711
|
+
const POOL = 2;
|
|
712
|
+
const rows = await orchestrate({ issues: [1, 2, 3, 4, 5], parallel: 5, now: () => c.t, sleep, pollMs: 1, readBody: async () => "",
|
|
713
|
+
remote: { max: 5, isRemote: () => true, capacity: capacity(() => POOL - live) },
|
|
714
|
+
runOne: async (n) => {
|
|
715
|
+
if (live >= POOL) { bounced++; return { terminalReason: "deferred", code: "sandbox.capacity", retryInSeconds: 1, detail: "sandbox launch: HTTP 503" }; }
|
|
716
|
+
live++; peak = Math.max(peak, live);
|
|
717
|
+
try { await new Promise((r) => setTimeout(r, 10)); } finally { live--; }
|
|
718
|
+
return { terminalReason: "merged", pr: 100 + n };
|
|
719
|
+
} });
|
|
720
|
+
return { rows, peak, bounced };
|
|
721
|
+
};
|
|
722
|
+
it("queues beyond the free slots inside the batch: no 503 bounce, every issue still runs", async () => {
|
|
723
|
+
let at = 0;
|
|
724
|
+
const { rows, bounced } = await run((free) => () => ({ free: free(), at: ++at })); // a fresh sample on every read
|
|
725
|
+
assert.equal(bounced, 0);
|
|
726
|
+
assert.deepEqual(rows.map((r) => r.outcome), ["merged", "merged", "merged", "merged", "merged"]);
|
|
727
|
+
});
|
|
728
|
+
it("unknown capacity (null) admits as before and the 503 deferral path is the backstop", async () => {
|
|
729
|
+
const { rows, bounced } = await run(() => () => null);
|
|
730
|
+
assert.ok(bounced > 0);
|
|
731
|
+
assert.deepEqual(rows.map((r) => r.outcome).filter((o) => o !== "merged"), []);
|
|
732
|
+
});
|
|
733
|
+
});
|
|
734
|
+
|
|
735
|
+
describe("#4547: opt-in local fallback in mode remote when no pool agent is online", () => {
|
|
736
|
+
const run = async (remoteExtra, online) => {
|
|
737
|
+
const c = { t: 1_000_000 };
|
|
738
|
+
const sleep = async (ms) => { await new Promise((r) => setImmediate(r)); c.t += ms; };
|
|
739
|
+
const where = new Map();
|
|
740
|
+
let remoteTries = 0;
|
|
741
|
+
const rows = await orchestrate({ issues: [1, 2, 3], parallel: 3, now: () => c.t, sleep, pollMs: 1, readBody: async () => "",
|
|
742
|
+
remote: { max: 3, isRemote: () => true, capacity: () => (online === null ? null : { free: null, online, at: 1 }), ...remoteExtra },
|
|
743
|
+
runOne: async (n, _ln, ctx = {}) => {
|
|
744
|
+
where.set(n, ctx.placement?.where ?? "remote");
|
|
745
|
+
if (ctx.placement?.where !== "local") { remoteTries++; if (online === 0) return { terminalReason: "deferred", code: "sandbox.capacity", retryInSeconds: 1, detail: "HTTP 503" }; }
|
|
746
|
+
return { terminalReason: "merged", pr: 100 + n };
|
|
747
|
+
} });
|
|
748
|
+
return { rows, where, remoteTries };
|
|
749
|
+
};
|
|
750
|
+
it("every agent offline: the batch completes on the host", async () => {
|
|
751
|
+
const { rows, where, remoteTries } = await run({ offlineFallback: true }, 0);
|
|
752
|
+
assert.deepEqual(rows.map((r) => r.outcome), ["merged", "merged", "merged"]);
|
|
753
|
+
assert.ok([...where.values()].every((w) => w === "local"));
|
|
754
|
+
assert.equal(remoteTries, 0);
|
|
755
|
+
});
|
|
756
|
+
it("an agent online, or liveness unknown, stays remote", async () => {
|
|
757
|
+
for (const online of [1, null]) {
|
|
758
|
+
const { where } = await run({ offlineFallback: true }, online);
|
|
759
|
+
assert.ok([...where.values()].every((w) => w === "remote"), String(online));
|
|
760
|
+
}
|
|
761
|
+
});
|
|
762
|
+
});
|
package/bin/lean/budget.mjs
CHANGED
|
@@ -5,7 +5,8 @@
|
|
|
5
5
|
* else the frozen contract's distinct deliverable paths (S <= 2, M <= 5, else L). The budget for the size is forge.yaml
|
|
6
6
|
* `engine.turn_budget_s|m|l` (defaults 25/60/100; 0 disables that size). The implement stage (the build call and its
|
|
7
7
|
* context/timeout restarts) spends it; on overrun the builder gets one wrap-up grant to finish or stop with a digest, and
|
|
8
|
-
* an unfinished lane ends
|
|
8
|
+
* an unfinished lane ends the non-human `budget-exceeded` reason (code `budget.exceeded`, #4505): the batch recovers it once
|
|
9
|
+
* from the checkpoint at the next size (`recoverySize`); only a final recovery that overruns too ends needs-human. Pure module: no I/O.
|
|
9
10
|
*/
|
|
10
11
|
|
|
11
12
|
export const TURN_BUDGET_DEFAULTS = Object.freeze({ S: 25, M: 60, L: 100 });
|
|
@@ -36,6 +37,11 @@ export function laneSize({ contract = null, labels = [] } = {}) {
|
|
|
36
37
|
return n <= 2 ? "S" : n <= 5 ? "M" : "L";
|
|
37
38
|
}
|
|
38
39
|
|
|
40
|
+
/** #4505: the next size up (S -> M -> L; L stays L); anything else unchanged. */
|
|
41
|
+
export const nextSize = (size) => (SIZES.includes(size) ? SIZES[Math.min(SIZES.indexOf(size) + 1, SIZES.length - 1)] : size);
|
|
42
|
+
/** #4505: a `budget.exceeded` recovery lane runs at the next size's budget; any other lane keeps its size. */
|
|
43
|
+
export const recoverySize = (size, recover) => (recover?.code === "budget.exceeded" ? nextSize(size) : size);
|
|
44
|
+
|
|
39
45
|
/** Turns the wrap-up call may use after the budget is spent. */
|
|
40
46
|
export const wrapupGrant = (budget) => Math.max(5, Math.ceil(budget / 5));
|
|
41
47
|
|
package/bin/lean/budget.test.mjs
CHANGED
|
@@ -5,7 +5,7 @@ import { describe, it } from "node:test";
|
|
|
5
5
|
import assert from "node:assert/strict";
|
|
6
6
|
import { writeFileSync, chmodSync } from "node:fs";
|
|
7
7
|
import { tmpDir } from "../tests/_tmp.mjs";
|
|
8
|
-
import { TURN_BUDGET_DEFAULTS, parseTurnBudgets, laneSize, wrapupGrant, makeTurnBudget, budgetDigest, formatTurnSummary } from "./budget.mjs";
|
|
8
|
+
import { TURN_BUDGET_DEFAULTS, parseTurnBudgets, laneSize, nextSize, recoverySize, wrapupGrant, makeTurnBudget, budgetDigest, formatTurnSummary } from "./budget.mjs";
|
|
9
9
|
import { orchestrate } from "./orchestrate.mjs";
|
|
10
10
|
import { OUTCOME_CODES, RECOVERABLE_CODES } from "../engine/outcome-codes.mjs";
|
|
11
11
|
import { RECOVER_HOW } from "./lean.mjs";
|
|
@@ -34,6 +34,14 @@ describe("#4392 turn budget", () => {
|
|
|
34
34
|
assert.equal(laneSize({}), null);
|
|
35
35
|
});
|
|
36
36
|
|
|
37
|
+
it("#4505 a budget.exceeded recovery runs at the next size (S->M->L, L stays L); other lanes keep their size", () => {
|
|
38
|
+
assert.deepEqual(["S", "M", "L"].map(nextSize), ["M", "L", "L"]);
|
|
39
|
+
assert.equal(nextSize(null), null); assert.equal(nextSize("XL"), "XL");
|
|
40
|
+
assert.equal(recoverySize("S", { code: "budget.exceeded", attempt: 1, final: true }), "M");
|
|
41
|
+
assert.equal(recoverySize("L", { code: "budget.exceeded" }), "L");
|
|
42
|
+
assert.equal(recoverySize("S", { code: "ci.red" }), "S"); assert.equal(recoverySize("S", null), "S"); assert.equal(recoverySize(null, { code: "budget.exceeded" }), null);
|
|
43
|
+
});
|
|
44
|
+
|
|
37
45
|
it("the tally: build turns spend the budget, other attempts count only in total; off or unsized is unlimited", () => {
|
|
38
46
|
const tb = makeTurnBudget({ M: 60 });
|
|
39
47
|
assert.equal(tb.record(), null); assert.equal(tb.remaining(), Infinity);
|
package/bin/lean/conformance.mjs
CHANGED
|
@@ -261,18 +261,28 @@ export function validateContract(c, issueText, { repro = false } = {}) {
|
|
|
261
261
|
return { ok: problems.length === 0, problems };
|
|
262
262
|
}
|
|
263
263
|
|
|
264
|
+
/** forge#4519: a shell pattern as in scripts/hotspots.sh `case` (`*` crosses `/`, `?` one character), anchored. */
|
|
265
|
+
const shellGlobRe = (g) => new RegExp(`^${String(g).replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*").replace(/\?/g, ".")}$`);
|
|
266
|
+
|
|
264
267
|
/**
|
|
265
268
|
* Apply result.json amendments to the frozen contract. Kinds: add-deliverable {path}, add-requirement {requirement},
|
|
266
269
|
* defer|post-deploy {requirement: id}. Every amendment needs a why. Returns {contract, applied[], toTrack[], problems[]}.
|
|
270
|
+
* `generatedPaths` (forge#4519, forge.yaml maintenance.generated_paths via scripts/hotspots.sh): an add-deliverable on a
|
|
271
|
+
* matching path is refused; the repo's generator or bot owns that file.
|
|
267
272
|
*/
|
|
268
|
-
export function applyAmendments(frozen, amendments, issueText = null) {
|
|
273
|
+
export function applyAmendments(frozen, amendments, issueText = null, { generatedPaths = [] } = {}) {
|
|
269
274
|
const c = structuredClone(frozen);
|
|
270
275
|
c.deliverables = c.deliverables ?? [];
|
|
271
276
|
const applied = [], toTrack = [], problems = [];
|
|
277
|
+
const genRes = (Array.isArray(generatedPaths) ? generatedPaths : []).filter((g) => typeof g === "string" && g).map(shellGlobRe);
|
|
272
278
|
for (const a of Array.isArray(amendments) ? amendments : []) {
|
|
273
279
|
const tag = JSON.stringify(a ?? null).slice(0, 100);
|
|
274
280
|
if (blank(a?.why)) { problems.push(`amendment without why: ${tag}`); continue; }
|
|
275
281
|
if (a.kind === "add-deliverable" && !blank(a.path)) {
|
|
282
|
+
if (genRes.some((re) => re.test(cleanPath(a.path)))) {
|
|
283
|
+
problems.push(`add-deliverable ${a.path}: generated path (forge.yaml maintenance.generated_paths) is owned by the repo's generator/bot; revert the file instead of amending it in`);
|
|
284
|
+
continue;
|
|
285
|
+
}
|
|
276
286
|
const have = c.deliverables.find((d) => d.path === a.path);
|
|
277
287
|
// An existing deliverable keeps its id and gains the why, so an amendment naming a package on an already-listed
|
|
278
288
|
// package.json declares the edge (forge#4159); before, it was reported as applied but changed nothing.
|
|
@@ -343,9 +353,11 @@ export function repoTracks(paths) {
|
|
|
343
353
|
* `frozen` (#4350): the contract before typed amendments. A failed scope-check validate (isScopeCheck) whose only files
|
|
344
354
|
* outside `frozen` are covered by deliverables an amendment added, with no scope creep left, is superseded by the engine's
|
|
345
355
|
* scope gate: listed in `superseded` [{id, files}], not a failure. Without `frozen`, `superseded` is always [].
|
|
356
|
+
* `ciCovered` (#4527): the validate ids accepted only because the head is CI-verified (no local result, or a missing host
|
|
357
|
+
* tool), for the trajectory; always an array.
|
|
346
358
|
*/
|
|
347
359
|
export function precheck({ contract, result, changedFiles, ignore = [], ciVerified = false, hotspots = null, repro = null, validateRuns = null, repoHas = () => false, frozen = null }) {
|
|
348
|
-
const failures = [], superseded = [];
|
|
360
|
+
const failures = [], superseded = [], ciCovered = [];
|
|
349
361
|
const outside = (k) => changedFiles.filter((f) => !ignore.some((p) => f.startsWith(p)) && !authorizationOf(k, f));
|
|
350
362
|
const creep = outside(contract);
|
|
351
363
|
const amendedOnly = frozenAmendedFiles(frozen, contract, outside, creep);
|
|
@@ -355,13 +367,13 @@ export function precheck({ contract, result, changedFiles, ignore = [], ciVerifi
|
|
|
355
367
|
const r = runs.find((x) => x?.id === v.id);
|
|
356
368
|
// #4167: a head the engine verified on CI covers validate ids the builder left to CI (no result, or ci:true without an exit)
|
|
357
369
|
const leftToCi = !r || (r.ci === true && r.exit === undefined);
|
|
358
|
-
if (ciVerified && leftToCi) continue;
|
|
370
|
+
if (ciVerified && leftToCi) { ciCovered.push(v.id); continue; }
|
|
359
371
|
if (leftToCi) failures.push({ code: C.CONFORMANCE_UNIMPLEMENTED, kind: "validate_missing", detail: `validate ${v.id} (\`${v.command}\`) has no recorded result` });
|
|
360
372
|
else if (r.exit !== 0) {
|
|
361
373
|
const rerun = validateRuns?.[v.id];
|
|
362
374
|
if (rerun?.rc === 0) continue;
|
|
363
375
|
const miss = rerun ? missingTool(rerun, { repoHas, command: v.command }) : null;
|
|
364
|
-
if (miss && ciVerified) continue;
|
|
376
|
+
if (miss && ciVerified) { ciCovered.push(v.id); continue; }
|
|
365
377
|
if (miss) failures.push({ code: C.ENV_TOOL_MISSING, kind: "validate_env", tool: miss.tool, detail: `validate ${v.id} (\`${v.command}\`) could not run: the host is missing ${KIND_NAME[miss.kind]} '${miss.tool}' (environment, not the change)` });
|
|
366
378
|
else if (amendedOnly && isScopeCheck(v.command)) superseded.push({ id: v.id, files: amendedOnly });
|
|
367
379
|
else failures.push({ code: C.CONFORMANCE_UNIMPLEMENTED, kind: "validate_failed", detail: `validate ${v.id} (\`${v.command}\`) failed (exit ${r.exit})` });
|
|
@@ -382,7 +394,7 @@ export function precheck({ contract, result, changedFiles, ignore = [], ciVerifi
|
|
|
382
394
|
}
|
|
383
395
|
failures.push(...hotspotGrowth({ contract, result, hotspots, changedFiles }));
|
|
384
396
|
if (repro) failures.push(...reproFailures(contract, repro));
|
|
385
|
-
return { ok: failures.length === 0, failures, superseded };
|
|
397
|
+
return { ok: failures.length === 0, failures, superseded, ciCovered };
|
|
386
398
|
}
|
|
387
399
|
|
|
388
400
|
/**
|
|
@@ -419,7 +431,8 @@ export function reproFailures(contract, { base, head } = {}) {
|
|
|
419
431
|
|
|
420
432
|
const EXEMPT_RE = /hotspot-exempt:\s*\S/i;
|
|
421
433
|
/**
|
|
422
|
-
* forge#4195: a changed file already over `file_lines` (not generated, vendored or glob-excluded
|
|
434
|
+
* forge#4195: a changed file already over `file_lines` (not generated, vendored or glob-excluded; forge#4519: a
|
|
435
|
+
* `generated` row, from linguist-generated, maintenance.generated_paths or a generated header, never counts) that grew by more
|
|
423
436
|
* than `growth_lines` since the base, with no `hotspot-exempt: <reason>` in a covering deliverable's change or an
|
|
424
437
|
* add-deliverable amendment's why. `hotspots` is scripts/hotspots.sh --base output; null or disabled checks nothing.
|
|
425
438
|
*/
|
|
@@ -431,7 +444,7 @@ export function hotspotGrowth({ contract, result, hotspots, changedFiles = [] })
|
|
|
431
444
|
const exempt = (f) => (contract?.deliverables ?? []).some((d) => pathCovers(d.path, f) && EXEMPT_RE.test(String(d.change ?? "")))
|
|
432
445
|
|| amends.some((a) => a?.kind === "add-deliverable" && pathCovers(a.path, f) && EXEMPT_RE.test(String(a.why ?? "")));
|
|
433
446
|
return hotspots.files
|
|
434
|
-
.filter((h) => h?.over && !h.excluded && Number(h.growth) > limit && changed.has(cleanPath(h.path)) && !exempt(h.path))
|
|
447
|
+
.filter((h) => h?.over && !h.excluded && !h.generated && Number(h.growth) > limit && changed.has(cleanPath(h.path)) && !exempt(h.path))
|
|
435
448
|
.map((h) => ({ code: C.MAINTENANCE_HOTSPOT_GROWTH, kind: "hotspot_growth",
|
|
436
449
|
detail: `${h.path} grew by ${h.growth} lines (now ${h.lines}, limit +${limit} over ${max}) without hotspot-exempt` }));
|
|
437
450
|
}
|
|
@@ -62,7 +62,7 @@ describe("amendments", () => {
|
|
|
62
62
|
|
|
63
63
|
describe("precheck (deterministic, no reviewer)", () => {
|
|
64
64
|
const res = { requirements: [{ id: "R1", status: "implemented" }, { id: "R2", status: "implemented" }], validation: [{ id: "V1", exit: 0 }] };
|
|
65
|
-
it("passes a complete, validated, in-scope change", () => assert.deepEqual(precheck({ contract: C, result: res, changedFiles: files }), { ok: true, failures: [], superseded: [] }));
|
|
65
|
+
it("passes a complete, validated, in-scope change", () => assert.deepEqual(precheck({ contract: C, result: res, changedFiles: files }), { ok: true, failures: [], superseded: [], ciCovered: [] }));
|
|
66
66
|
it("unauthorized file -> scope_creep; tests and ignored paths are always allowed", () => {
|
|
67
67
|
assert.deepEqual(codes(precheck({ contract: C, result: res, changedFiles: [...files, "src/billing.js", ".forge-lean/x", "tests/test_x.py", "pkg/foo_test.go"], ignore: [".forge-lean/"] })), ["conformance.scope_creep"]);
|
|
68
68
|
for (const t of ["test/a.js", "src/a.test.ts", "tests/test_a.py", "a_test.go", "spec/a.spec.js"]) assert.ok(isTestPath(t), t);
|
|
@@ -85,6 +85,15 @@ describe("precheck (deterministic, no reviewer)", () => {
|
|
|
85
85
|
const local = precheck({ contract: C, result: failed, changedFiles: files, repoHas: repoTracks(["src/leak/__init__.py"]), validateRuns: { V1: { rc: 1, output: "ModuleNotFoundError: No module named 'leak.core'" } } });
|
|
86
86
|
assert.deepEqual(codes(local), ["conformance.unimplemented"], "a repo module the change did not create is a code defect");
|
|
87
87
|
});
|
|
88
|
+
it("#4527 a CI-verified head lists the validate ids it accepted without a local result; a ran-and-failed validate still fails", () => {
|
|
89
|
+
const C2 = { ...C, validate: [{ id: "V1", command: "node --test a.test.mjs" }, { id: "V2", command: "tsc" }, { id: "V3", command: "lint" }] };
|
|
90
|
+
const pc = precheck({ contract: C2, result: { ...res, validation: [{ id: "V2", ci: true }, { id: "V3", exit: 0 }] }, changedFiles: files, ciVerified: true });
|
|
91
|
+
assert.deepEqual(codes(pc), []); assert.deepEqual(pc.ciCovered, ["V1", "V2"]);
|
|
92
|
+
const local = precheck({ contract: C2, result: { ...res, validation: [{ id: "V2", ci: true }, { id: "V3", exit: 0 }] }, changedFiles: files });
|
|
93
|
+
assert.deepEqual(local.failures.map((f) => f.kind), ["validate_missing", "validate_missing"]); assert.deepEqual(local.ciCovered, []);
|
|
94
|
+
const failed = precheck({ contract: C2, result: { ...res, validation: [{ id: "V1", exit: 1 }, { id: "V2", ci: true }, { id: "V3", exit: 0 }] }, changedFiles: files, ciVerified: true });
|
|
95
|
+
assert.deepEqual(failed.failures.map((f) => f.kind), ["validate_failed"]); assert.deepEqual(failed.ciCovered, ["V2"]);
|
|
96
|
+
});
|
|
88
97
|
it("#4323 missingTool: commands, python and node modules; paths, relative and repo-tracked names are not the environment", () => {
|
|
89
98
|
assert.deepEqual(missingTool({ rc: 127, output: "sh: 1: jq: not found" }), { tool: "jq", kind: "command" });
|
|
90
99
|
assert.deepEqual(missingTool({ rc: 127, output: "" }, { command: "uv run pytest -q" }), { tool: "uv", kind: "command" });
|
|
@@ -362,6 +371,24 @@ describe("hotspot growth precheck (forge#4195)", () => {
|
|
|
362
371
|
assert.match(pc.failures[0].detail, /^src\/scrape\.js grew by 200 lines \(now 2600, limit \+150 over 2000\) without hotspot-exempt$/);
|
|
363
372
|
});
|
|
364
373
|
|
|
374
|
+
it("#4519 generated paths: applyAmendments refuses add-deliverable on maintenance.generated_paths, hotspotGrowth skips generated rows", () => {
|
|
375
|
+
const gen = ["web/public/openapi*.json", "web/public/openapi-versions/*"];
|
|
376
|
+
const amends = [{ kind: "add-deliverable", path: "web/public/openapi-versions/3.29.2.json", why: "sync the spec archive" },
|
|
377
|
+
{ kind: "add-deliverable", path: "./web/public/openapi.json", why: "sync the spec" }, { kind: "add-deliverable", path: "src/shared.js", why: "helper" }];
|
|
378
|
+
const a = applyAmendments(C, amends, null, { generatedPaths: gen });
|
|
379
|
+
assert.deepEqual(a.applied.map((x) => x.target), ["src/shared.js"]);
|
|
380
|
+
assert.equal(a.problems.length, 2);
|
|
381
|
+
assert.match(a.problems[0], /^add-deliverable web\/public\/openapi-versions\/3\.29\.2\.json: generated path \(forge\.yaml maintenance\.generated_paths\)/);
|
|
382
|
+
assert.ok(!a.contract.deliverables.some((d) => d.path.includes("openapi")), "a refused path never becomes a deliverable");
|
|
383
|
+
assert.equal(applyAmendments(C, amends).applied.length, 3, "without generatedPaths every add-deliverable applies as before");
|
|
384
|
+
assert.equal(applyAmendments(C, amends, null, { generatedPaths: ["web/public/openapi.json"] }).applied.length, 2, "a literal glob matches only that path");
|
|
385
|
+
// linguist-generated / generated_paths / header rows (hotspots.sh `generated`) never trigger hotspot_growth
|
|
386
|
+
const big = H({ path: "src/scrape.js", lines: 83974, growth: 83974 });
|
|
387
|
+
assert.equal(run(hs([big])).ok, false);
|
|
388
|
+
assert.equal(run(hs([{ ...big, generated: true }])).ok, true);
|
|
389
|
+
assert.equal(run(hs([{ ...big, generated: true, excluded: true }])).ok, true);
|
|
390
|
+
});
|
|
391
|
+
|
|
365
392
|
it("within the limit, under the threshold, excluded, unchanged, disabled or no facts: no failure", () => {
|
|
366
393
|
for (const h of [hs([H({ growth: 150 })]), hs([H({ over: false, lines: 1900 })]), hs([H({ excluded: true })]), hs([H({ path: "src/other.js" })]),
|
|
367
394
|
hs([H()], { enabled: false }), hs([H({ growth: null })]), null, undefined, {}]) assert.equal(run(h).ok, true, JSON.stringify(h));
|
|
@@ -398,7 +425,7 @@ describe("repro_test on bug lanes (#4271)", () => {
|
|
|
398
425
|
});
|
|
399
426
|
it("red on base and green on head passes; anything else fails the precheck as conformance.unimplemented", () => {
|
|
400
427
|
const k = { ...C, repro_test: RT };
|
|
401
|
-
assert.deepEqual(precheck({ contract: k, result: res, changedFiles: files, repro: { base: "FAILED", head: "RAN" } }), { ok: true, failures: [], superseded: [] });
|
|
428
|
+
assert.deepEqual(precheck({ contract: k, result: res, changedFiles: files, repro: { base: "FAILED", head: "RAN" } }), { ok: true, failures: [], superseded: [], ciCovered: [] });
|
|
402
429
|
for (const repro of [{ base: "RAN", head: "RAN" }, { base: "SKIPPED", head: "FAILED" }, { base: "UNRUNNABLE", head: "RAN" }, { base: "SKIPPED", head: "RAN_NOTHING" }]) {
|
|
403
430
|
const pc = precheck({ contract: k, result: res, changedFiles: files, repro });
|
|
404
431
|
assert.deepEqual(pc.failures.map((f) => [f.code, f.kind]), [["conformance.unimplemented", "repro_red_green"]], JSON.stringify(repro));
|
package/bin/lean/depcache.mjs
CHANGED
|
@@ -11,7 +11,9 @@
|
|
|
11
11
|
* <repoKey>/<eco>-<key>.tmp-<pid> snapshot being published (atomic rename into place)
|
|
12
12
|
* stores/{pnpm-store,npm-cache,uv-cache,poetry-cache,pip-cache} the package managers' own shared stores
|
|
13
13
|
* The repo's own package manager does every install (frozen lockfile). Every effect is injected; nothing here throws:
|
|
14
|
-
* a missing tool, another filesystem (EXDEV) or a failed install leaves the lane as it was before.
|
|
14
|
+
* a missing tool, another filesystem (EXDEV) or a failed install leaves the lane as it was before. A snapshot that fails
|
|
15
|
+
* to link or repair falls back to a fresh install in the lane (status materialize-fallback, #4522); failed only when that
|
|
16
|
+
* install fails too.
|
|
15
17
|
*/
|
|
16
18
|
import * as nodeFs from "node:fs";
|
|
17
19
|
import { createHash } from "node:crypto";
|
|
@@ -28,7 +30,17 @@ const TMP_RE = /^(pnpm|npm|yarn|uv|poetry)-[0-9a-f]{16}\.tmp-(\d+)$/;
|
|
|
28
30
|
const LOCK_RE = /^(pnpm|npm|yarn|uv|poetry)-[0-9a-f]{16}\.lock$/;
|
|
29
31
|
|
|
30
32
|
const pidAlive = (pid) => { try { process.kill(pid, 0); return true; } catch (e) { return e?.code === "EPERM"; } };
|
|
31
|
-
|
|
33
|
+
/**
|
|
34
|
+
* Error text for a reason: pnpm prints its ERR_PNPM_* errors on stdout (#4522), so the first such line from either
|
|
35
|
+
* stream leads, then the last lines of stderr and stdout; the message only when both streams are empty.
|
|
36
|
+
*/
|
|
37
|
+
const msg = (e) => {
|
|
38
|
+
const lines = (s) => String(s ?? "").split("\n").map((l) => l.trim()).filter(Boolean);
|
|
39
|
+
const err = lines(e?.stderr), out = lines(e?.stdout);
|
|
40
|
+
const pm = [...err, ...out].find((l) => /\bERR_PNPM_[A-Z0-9_]+/.test(l));
|
|
41
|
+
const parts = [pm, ...err.slice(-3), ...out.slice(-3)].filter((l, i, a) => l && a.indexOf(l) === i);
|
|
42
|
+
return (parts.length ? parts.join(" ") : String(e?.message || e).trim().split("\n").slice(-3).join(" ")).slice(0, 500);
|
|
43
|
+
};
|
|
32
44
|
|
|
33
45
|
/** Lockfile -> ecosystem, in priority order: at most one JS and one Python ecosystem per repo. */
|
|
34
46
|
function detect(fs, wt) {
|
|
@@ -215,7 +227,8 @@ export function makeDepCache({ root = depcacheRoot(), exec, fs = nodeFs, now = (
|
|
|
215
227
|
return done("hit", { key });
|
|
216
228
|
} catch (e) {
|
|
217
229
|
rmTrees(wt, created);
|
|
218
|
-
|
|
230
|
+
if (e?.code === "EXDEV") return done("skipped", { key, reason: "cache is on another filesystem than the worktree (EXDEV)" });
|
|
231
|
+
return { fallback: `materialize failed: ${msg(e)}` }; // installed by the caller, outside the key lock
|
|
219
232
|
}
|
|
220
233
|
};
|
|
221
234
|
const install = async () => {
|
|
@@ -235,6 +248,13 @@ export function makeDepCache({ root = depcacheRoot(), exec, fs = nodeFs, now = (
|
|
|
235
248
|
const why = publish(wt, d, key, ins.trees, snap);
|
|
236
249
|
return done("miss-installed", { key, published: !why, reason: why });
|
|
237
250
|
});
|
|
251
|
+
if (r.locked && r.value?.fallback) {
|
|
252
|
+
// #4522: a broken snapshot must not leave the lane without dependencies. Install outside the lock so other lanes on
|
|
253
|
+
// this key are not serialised behind it; never publish (the snapshot exists and is never replaced).
|
|
254
|
+
const ins = await install();
|
|
255
|
+
if (ins.failed) return { ...ins.failed, reason: `${r.value.fallback}; ${ins.failed.reason}` };
|
|
256
|
+
return done("materialize-fallback", { key, published: false, reason: r.value.fallback });
|
|
257
|
+
}
|
|
238
258
|
if (r.locked) return r.value ?? done("failed", { key, reason: "snapshot manifest unreadable" });
|
|
239
259
|
// The lock stayed busy past the wait: install in this lane without touching the cache.
|
|
240
260
|
const ins = await install();
|