forgedock 1.30.0 → 1.32.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/engine/outcome-codes.mjs +3 -2
- package/bin/engine/runlog.mjs +82 -12
- package/bin/engine/telemetry-seam.mjs +11 -4
- package/bin/engine/telemetry-seam.test.mjs +18 -0
- package/bin/engine-cli.mjs +409 -38
- package/bin/engine.mjs +47 -37
- package/bin/forgedock.mjs +35 -3
- package/bin/gc.mjs +50 -17
- package/bin/hooks/reread-nudge.mjs +65 -0
- package/bin/labels.json +5 -0
- package/bin/lean/apishape.mjs +170 -0
- package/bin/lean/apishape.test.mjs +187 -0
- package/bin/lean/batch.mjs +32 -0
- package/bin/lean/batch.test.mjs +338 -2
- package/bin/lean/budget.mjs +89 -0
- package/bin/lean/budget.test.mjs +106 -0
- package/bin/lean/checks.mjs +32 -9
- package/bin/lean/checks.test.mjs +78 -1
- package/bin/lean/conformance.e2e.test.mjs +27 -5
- package/bin/lean/conformance.mjs +150 -15
- package/bin/lean/conformance.test.mjs +175 -3
- package/bin/lean/context.mjs +128 -44
- package/bin/lean/context.test.mjs +157 -5
- package/bin/lean/decompose.mjs +90 -0
- package/bin/lean/decompose.test.mjs +105 -0
- package/bin/lean/depcache.mjs +11 -7
- package/bin/lean/depcache.test.mjs +81 -0
- package/bin/lean/e2e.mjs +102 -0
- package/bin/lean/e2e.test.mjs +86 -0
- package/bin/lean/effects.mjs +48 -15
- package/bin/lean/explore.mjs +47 -0
- package/bin/lean/explore.test.mjs +61 -0
- package/bin/lean/facts.mjs +84 -0
- package/bin/lean/facts.test.mjs +66 -0
- package/bin/lean/fixround.mjs +90 -0
- package/bin/lean/fixround.test.mjs +117 -0
- package/bin/lean/heavy.mjs +35 -2
- package/bin/lean/heavy.test.mjs +32 -2
- package/bin/lean/lean.mjs +712 -134
- package/bin/lean/lean.test.mjs +1626 -37
- package/bin/lean/ledger.mjs +5 -3
- package/bin/lean/ledger.test.mjs +18 -0
- package/bin/lean/light.mjs +135 -0
- package/bin/lean/light.test.mjs +173 -0
- package/bin/lean/lightlane.mjs +219 -0
- package/bin/lean/lightlane.test.mjs +237 -0
- package/bin/lean/limits.test.mjs +36 -1
- package/bin/lean/orchestrate.mjs +303 -41
- package/bin/lean/pitfalls.mjs +136 -0
- package/bin/lean/pitfalls.test.mjs +117 -0
- package/bin/lean/postmerge.mjs +85 -0
- package/bin/lean/postmerge.test.mjs +79 -0
- package/bin/lean/precheck.mjs +138 -13
- package/bin/lean/precheck.test.mjs +118 -1
- package/bin/lean/prefilter.mjs +220 -5
- package/bin/lean/prefilter.test.mjs +260 -4
- package/bin/lean/preflight.mjs +108 -3
- package/bin/lean/preflight.test.mjs +48 -1
- package/bin/lean/probe.mjs +297 -0
- package/bin/lean/probe.test.mjs +210 -0
- package/bin/lean/prompts.mjs +104 -13
- package/bin/lean/render.mjs +7 -3
- package/bin/lean/resume.mjs +67 -0
- package/bin/lean/resume.test.mjs +84 -0
- package/bin/lean/review-trigger.mjs +12 -0
- package/bin/lean/review-trigger.test.mjs +17 -1
- package/bin/lean/routing.mjs +74 -0
- package/bin/lean/routing.test.mjs +67 -0
- package/bin/lean/sched.mjs +16 -1
- package/bin/lean/sched.test.mjs +175 -1
- package/bin/lean/schedd.mjs +55 -9
- package/bin/lean/schedd.test.mjs +170 -3
- package/bin/lean/selfreview.mjs +37 -0
- package/bin/lean/selfreview.test.mjs +51 -0
- package/bin/lean/seqlease.mjs +40 -0
- package/bin/lean/seqlease.test.mjs +101 -0
- package/bin/lean/sequence.mjs +296 -13
- package/bin/lean/sequence.test.mjs +263 -2
- package/bin/lean/toolchain.mjs +60 -15
- package/bin/lean/toolchain.test.mjs +120 -3
- package/bin/lean/toolstats.mjs +72 -0
- package/bin/lean/toolstats.test.mjs +47 -0
- package/bin/lean/verifier.mjs +232 -0
- package/bin/lean/verifier.test.mjs +163 -0
- package/bin/lean/verify.mjs +72 -10
- package/bin/lean/verify.test.mjs +68 -3
- package/bin/review-staging.mjs +533 -0
- package/bin/runner.mjs +82 -13
- package/bin/stats/aggregate.mjs +87 -2
- package/bin/stats/efficiency.mjs +335 -0
- package/bin/stats/efficiency.test.mjs +266 -0
- package/bin/stats/github-source.mjs +8 -3
- package/bin/stats/index.mjs +91 -6
- package/bin/stats/local-source.mjs +145 -8
- package/bin/stats/query.mjs +181 -3
- package/bin/stats/render.mjs +22 -0
- package/bin/stats/stats.test.mjs +403 -8
- package/commands/orchestrate.md +3 -1
- package/commands/review-pr-agents/protocols.md +1 -1
- package/commands/review-pr-staging.md +84 -831
- package/commands/review-pr.md +1 -1
- package/commands/work-on/build/implement.md +1 -0
- package/commands/work-on/build/validate.md +11 -1
- package/docs/CONFIG.md +80 -5
- package/docs/OUTCOME-CODES.md +2 -1
- package/docs/STATS.md +66 -1
- package/docs/TELEMETRY.md +1 -1
- package/docs/eval/corpus/lightlane-v1.json +55 -0
- package/docs/eval/lightlane-bench.md +91 -0
- package/docs/spec/review-protocol.md +1 -1
- package/package.json +1 -1
- package/scripts/bench-lightlane.mjs +286 -0
- package/scripts/ci-local/lib.mjs +20 -0
- package/scripts/ci-local/plan.mjs +2 -12
- package/scripts/ci-local/scoping.mjs +10 -1
- package/scripts/ci-local.test.sh +2 -0
- package/scripts/fixtures/apishape/other/go-change/base/x.go +3 -0
- package/scripts/fixtures/apishape/other/go-change/head/x.go +3 -0
- package/scripts/fixtures/apishape/python/generator-function/base/seq.py +3 -0
- package/scripts/fixtures/apishape/python/generator-function/head/seq.py +2 -0
- package/scripts/fixtures/apishape/python/ichunked/base/more.py +17 -0
- package/scripts/fixtures/apishape/python/ichunked/head/more.py +20 -0
- package/scripts/fixtures/apishape/python/private-edit/base/calc.py +6 -0
- package/scripts/fixtures/apishape/python/private-edit/head/calc.py +9 -0
- package/scripts/fixtures/apishape/python/removed/base/ops.py +6 -0
- package/scripts/fixtures/apishape/python/removed/head/ops.py +2 -0
- package/scripts/fixtures/apishape/python/renamed/base/text.py +2 -0
- package/scripts/fixtures/apishape/python/renamed/head/text.py +2 -0
- package/scripts/fixtures/apishape/python/signature/base/shapes.py +2 -0
- package/scripts/fixtures/apishape/python/signature/head/shapes.py +2 -0
- package/scripts/fixtures/apishape/python/sync-async/base/client.py +7 -0
- package/scripts/fixtures/apishape/python/sync-async/head/client.py +7 -0
- package/scripts/fixtures/apishape/ts/generator-function/base/seq.js +3 -0
- package/scripts/fixtures/apishape/ts/generator-function/head/seq.js +5 -0
- package/scripts/fixtures/apishape/ts/private-edit/base/calc.ts +7 -0
- package/scripts/fixtures/apishape/ts/private-edit/head/calc.ts +9 -0
- package/scripts/fixtures/apishape/ts/removed/base/ops.ts +7 -0
- package/scripts/fixtures/apishape/ts/removed/head/ops.ts +3 -0
- package/scripts/fixtures/apishape/ts/renamed/base/text.ts +3 -0
- package/scripts/fixtures/apishape/ts/renamed/head/text.ts +3 -0
- package/scripts/fixtures/apishape/ts/signature/base/shapes.ts +3 -0
- package/scripts/fixtures/apishape/ts/signature/head/shapes.ts +3 -0
- package/scripts/fixtures/apishape/ts/sync-async/base/client.mjs +5 -0
- package/scripts/fixtures/apishape/ts/sync-async/head/client.mjs +5 -0
- package/scripts/forge-root.test.sh +2 -2
- package/scripts/publish-staging-sync.test.sh +62 -4
- package/scripts/review-finalize.sh +17 -6
- package/scripts/review-finalize.test.sh +47 -0
- package/scripts/review-pr-arguments.test.sh +13 -1
- package/scripts/review-pr-delta-wiring.test.sh +2 -2
- package/scripts/review-pr-gate2.test.sh +6 -14
- package/scripts/review-pr-staging-finding-filter.test.sh +4 -4
- package/scripts/review-pr-staging-scope.test.sh +39 -33
- package/scripts/review-prepare.sh +73 -6
- package/scripts/review-prepare.test.sh +85 -0
- package/scripts/review-staging-post.sh +293 -0
- package/scripts/review-staging-post.test.sh +143 -0
- package/scripts/review-staging-pre.sh +401 -0
- package/scripts/review-staging-pre.test.sh +150 -0
- package/scripts/symbol-index.mjs +26 -7
- package/scripts/symbol-index.py +55 -9
- package/scripts/trusted-comments.test.sh +1 -1
package/bin/lean/batch.test.mjs
CHANGED
|
@@ -3,12 +3,12 @@
|
|
|
3
3
|
import { describe, it } from "node:test";
|
|
4
4
|
import assert from "node:assert/strict";
|
|
5
5
|
import { spawn } from "node:child_process";
|
|
6
|
-
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
6
|
+
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
10
|
import { openBatch, fsTypeOf, assertDiskPath, isLaneAlive, writeAtomic, launchLane } from "./batch.mjs";
|
|
11
|
-
import { orchestrateFromCli, runFromCli, readOrchestration, makeRelabelNeedsHuman } from "../engine-cli.mjs";
|
|
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.
|
|
14
14
|
process.env.FORGEDOCK_SCHED_DIR = tmpDir("batch-sched-");
|
|
@@ -293,3 +293,339 @@ describe("#4318: the batch dispatcher's signal relabel and the needs-human relab
|
|
|
293
293
|
});
|
|
294
294
|
});
|
|
295
295
|
});
|
|
296
|
+
|
|
297
|
+
describe("#4354: the batch report ends with the fail-soft alerts of the batch's repo", () => {
|
|
298
|
+
const seed = (dir, repo, status) => {
|
|
299
|
+
mkdirSync(dir, { recursive: true });
|
|
300
|
+
for (let i = 0; i < 10; i++) {
|
|
301
|
+
const evs = [{ event: "RUN_START", ts: new Date(Date.UTC(2026, 9, 1, 0, i)).toISOString(), repo, issue: 500 + i },
|
|
302
|
+
{ event: "DEPS_PREPARED", eco: "pnpm", status, reason: "tool not found: spawn pnpm ENOENT" }, { event: "RUN_TERMINAL", reason: "merged", code: "completed" }];
|
|
303
|
+
writeFileSync(join(dir, `${500 + i}.jsonl`), evs.map((e) => JSON.stringify(e)).join("\n") + "\n");
|
|
304
|
+
}
|
|
305
|
+
};
|
|
306
|
+
const run = async (dir) => {
|
|
307
|
+
const lines = [], l = console.log;
|
|
308
|
+
console.log = (...a) => lines.push(a.join(" "));
|
|
309
|
+
try {
|
|
310
|
+
await orchestrateFromCli(["1", "--repo", "o/r", "--no-prefilter"], { dir, batchRoot: tmpDir("alert-batch-"), allowTmpfs: true,
|
|
311
|
+
emitTelemetry: async () => {}, io: { gh: async () => "o/r\n" }, sched: null, decide: () => {}, alertConfig: {}, heavyStats: null,
|
|
312
|
+
leanIo: { gh: async (a) => (a.includes("milestone") ? JSON.stringify({ milestone: null }) : JSON.stringify({ body: "" })), git: async (a) => (a[0] === "ls-remote" ? "abc\trefs/heads/staging\n" : "") },
|
|
313
|
+
orchestration: { breaker: null, maxDeferrals: 3, globalParallel: null, recovery: { maxAttempts: 2 } },
|
|
314
|
+
leanModule: { runCore: async () => ({ terminalReason: "merged", pr: 51 }) } });
|
|
315
|
+
} finally { console.log = l; }
|
|
316
|
+
return lines.join("\n").split("\n").filter((x) => x.trim());
|
|
317
|
+
};
|
|
318
|
+
|
|
319
|
+
it("prints ALERTS last for the repo's failing dependency cache, nothing for a healthy one or another repo's lanes", async () => {
|
|
320
|
+
const bad = tmpDir("alert-runs-");
|
|
321
|
+
seed(bad, "o/r", "failed");
|
|
322
|
+
const out = await run(bad);
|
|
323
|
+
const at = out.indexOf("ALERTS");
|
|
324
|
+
assert.ok(at > 0, out.join("\n"));
|
|
325
|
+
assert.match(out[at + 1], /^DEPS_PREPARED failed \(pnpm\): 100% \(10\/10 lanes, threshold 20%\), e\.g\. "tool not found: spawn pnpm ENOENT"$/);
|
|
326
|
+
assert.equal(out.length, at + 2, "the alerts end the report");
|
|
327
|
+
const good = tmpDir("alert-runs-");
|
|
328
|
+
seed(good, "o/r", "hit");
|
|
329
|
+
assert.ok(!(await run(good)).some((x) => /ALERTS|DEPS_PREPARED/.test(x)));
|
|
330
|
+
const other = tmpDir("alert-runs-");
|
|
331
|
+
seed(other, "x/y", "failed");
|
|
332
|
+
assert.ok(!(await run(other)).some((x) => /ALERTS/.test(x)));
|
|
333
|
+
});
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
describe("#4402: the batch report prints the efficiency lines, fail-soft", () => {
|
|
337
|
+
const run = async (deps) => {
|
|
338
|
+
const lines = [], l = console.log;
|
|
339
|
+
console.log = (...a) => lines.push(a.join(" "));
|
|
340
|
+
try {
|
|
341
|
+
await orchestrateFromCli(["1", "--repo", "o/r", "--no-prefilter"], { dir: tmpDir("eff-runs-"), batchRoot: tmpDir("eff-batch-"), allowTmpfs: true,
|
|
342
|
+
emitTelemetry: async () => {}, io: { gh: async () => "o/r\n" }, sched: null, decide: () => {}, alertConfig: {}, heavyStats: null,
|
|
343
|
+
leanIo: { gh: async (a) => (a.includes("milestone") ? JSON.stringify({ milestone: null }) : a[0] === "api" ? (String(a[1]).endsWith("/comments") ? "" : JSON.stringify({ body: "## Root cause\na.js:1\n## Fix", labels: [] })) : JSON.stringify({ body: "" })),
|
|
344
|
+
git: async (a) => (a[0] === "ls-remote" ? "abc\trefs/heads/staging\n" : "") },
|
|
345
|
+
orchestration: { breaker: null, maxDeferrals: 3, globalParallel: null, recovery: { maxAttempts: 2 } },
|
|
346
|
+
leanModule: { runCore: async () => ({ terminalReason: "merged", pr: 51 }) }, ...deps });
|
|
347
|
+
} finally { console.log = l; }
|
|
348
|
+
return lines.join("\n").split("\n").filter((x) => x.trim());
|
|
349
|
+
};
|
|
350
|
+
it("prints the batch's efficiency section", async () => {
|
|
351
|
+
const out = await run({});
|
|
352
|
+
const at = out.findIndex((x) => x.startsWith("efficiency: merged 1,"));
|
|
353
|
+
assert.ok(at > 0, out.join("\n"));
|
|
354
|
+
assert.match(out[at + 3], /^retries\/merge 0\.00 \(env 0, engine 0, code 0, review 0, unknown 0; lane reruns 0\)$/);
|
|
355
|
+
assert.match(out[at + 5], /^issue quality: full n=1 /);
|
|
356
|
+
assert.equal(out[at + 6], "acceptance (7d after promotion): pass 0/0, pending 0, unprobed 1");
|
|
357
|
+
});
|
|
358
|
+
it("a throwing efficiency stub still ends the batch with its other report lines", async () => {
|
|
359
|
+
const out = await run({ efficiency: async () => { throw new Error("boom"); } });
|
|
360
|
+
assert.ok(out.includes("efficiency: n/a (boom)"), out.join("\n"));
|
|
361
|
+
assert.ok(out.some((x) => x.startsWith("reclaimed:")), out.join("\n"));
|
|
362
|
+
});
|
|
363
|
+
});
|
|
364
|
+
|
|
365
|
+
describe("#4408: issues added to a running batch (forgedock orchestrate --add)", () => {
|
|
366
|
+
const gate = () => { let open; const p = new Promise((r) => { open = r; }); return { p, open }; };
|
|
367
|
+
it("one lane running: an added independent issue starts on the next pass, an added one that Depends on the running issue waits for its merge", async () => {
|
|
368
|
+
const bodies = { 1: "", 2: "", 3: "Depends on #1" };
|
|
369
|
+
let added = [];
|
|
370
|
+
const started = [], g1 = gate(), log = [], decisions = [];
|
|
371
|
+
const done = orchestrate({ issues: [1], parallel: 3, pollMs: 5, readBody: async (n) => bodies[n] ?? "", getAdded: () => added,
|
|
372
|
+
decide: (d) => { decisions.push(d); }, onUpdate: (r) => log.push(r),
|
|
373
|
+
runOne: async (n) => { started.push(n); if (n === 1) await g1.p; return { terminalReason: "merged" }; } });
|
|
374
|
+
await until(() => started.includes(1));
|
|
375
|
+
added = [2, 3];
|
|
376
|
+
await until(() => started.includes(2), 3000);
|
|
377
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
378
|
+
assert.deepEqual(started, [1, 2], "#3 waits for #1 to merge");
|
|
379
|
+
g1.open();
|
|
380
|
+
const rows = await done;
|
|
381
|
+
assert.deepEqual(started, [1, 2, 3]);
|
|
382
|
+
assert.deepEqual(rows.map((r) => [r.issue, r.outcome]), [[1, "merged"], [2, "merged"], [3, "merged"]]);
|
|
383
|
+
assert.deepEqual(log.filter((r) => r.outcome === "added").map((r) => r.detail), ["#2, #3"], "one added row; re-reads add nothing");
|
|
384
|
+
assert.equal(decisions.filter((d) => d.issue === null).length, 1);
|
|
385
|
+
});
|
|
386
|
+
|
|
387
|
+
it("edges hold both ways: a pending issue that Depends on an added one (declared, or written after the batch started) waits for it", async () => {
|
|
388
|
+
const bodies = { 7: "", 4: "Depends on #5", 8: "", 5: "", 6: "" };
|
|
389
|
+
let added = [];
|
|
390
|
+
const started = [], g7 = gate();
|
|
391
|
+
const done = orchestrate({ issues: [7, 4, 8], parallel: 1, pollMs: 5, readBody: async (n) => bodies[n] ?? "", getAdded: () => added,
|
|
392
|
+
runOne: async (n) => { started.push(n); if (n === 7) await g7.p; return { terminalReason: "merged" }; } });
|
|
393
|
+
await until(() => started.includes(7));
|
|
394
|
+
bodies[8] = "Depends on #6"; // edited while the batch runs
|
|
395
|
+
added = [5, 6];
|
|
396
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
397
|
+
g7.open();
|
|
398
|
+
const rows = await done;
|
|
399
|
+
assert.deepEqual(started, [7, 5, 4, 6, 8]);
|
|
400
|
+
assert.ok(rows.every((r) => r.outcome === "merged"));
|
|
401
|
+
});
|
|
402
|
+
|
|
403
|
+
it("an add after the queue drained still runs before the batch returns; without getAdded nothing changes", async () => {
|
|
404
|
+
let added = [];
|
|
405
|
+
const started = [];
|
|
406
|
+
const rows = await orchestrate({ issues: [1], parallel: 2, pollMs: 5, readBody: async () => "", getAdded: () => added,
|
|
407
|
+
runOne: async (n) => { started.push(n); if (n === 1) added = [2]; return { terminalReason: "merged" }; } });
|
|
408
|
+
assert.deepEqual(rows.map((r) => r.issue), [1, 2]);
|
|
409
|
+
const plain = await orchestrate({ issues: [1], parallel: 2, readBody: async () => "", runOne: async () => ({ terminalReason: "merged" }) });
|
|
410
|
+
assert.deepEqual(plain.map((r) => r.issue), [1]);
|
|
411
|
+
});
|
|
412
|
+
|
|
413
|
+
it("the add file: integers only, no issue twice, cumulative; --add through the CLI skips closed and unreadable issues", async () => {
|
|
414
|
+
const root = tmpDir("batch-add-");
|
|
415
|
+
const b = openBatch(root, "b1");
|
|
416
|
+
b.writePlan({ argv: ["1"], repo: "o/r" });
|
|
417
|
+
b.store.save({ order: [1] });
|
|
418
|
+
assert.deepEqual(b.readAdded(), []);
|
|
419
|
+
const r = b.addIssues([2, "#3", "abc", 1, 2, 0, -4]);
|
|
420
|
+
assert.deepEqual(r.added, [2, 3]);
|
|
421
|
+
assert.deepEqual(r.skipped.map((x) => [x.issue, x.reason]), [["abc", "not an integer"], [1, "already in batch"], [2, "already in batch"], [0, "not an integer"], [-4, "not an integer"]]);
|
|
422
|
+
assert.deepEqual(b.addIssues([5]).added, [5]);
|
|
423
|
+
assert.deepEqual(b.readAdded(), [2, 3, 5]);
|
|
424
|
+
assert.deepEqual(b.unrunAdds(), [2, 3, 5]);
|
|
425
|
+
const views = [];
|
|
426
|
+
const io = { gh: async (a) => { views.push(a); if (a[2] === "8") throw new Error("gh down"); return JSON.stringify({ state: a[2] === "7" ? "CLOSED" : "OPEN" }); } };
|
|
427
|
+
const res = await quiet(() => orchestrateFromCli(["--add", "b1", "6", "7", "8", "3", "x"], { batchRoot: root, io }));
|
|
428
|
+
assert.deepEqual(res.added, [6]);
|
|
429
|
+
assert.deepEqual(res.skipped.map((x) => [x.issue, x.reason]).sort(), [[3, "already in batch"], [7, "closed"], [8, "unreadable"], ["x", "not an integer"]]);
|
|
430
|
+
assert.ok(views.every((a) => a.includes("-R") && a.includes("o/r")), "state read against the batch's repo");
|
|
431
|
+
assert.deepEqual(b.readAdded(), [2, 3, 5, 6]);
|
|
432
|
+
await assert.rejects(orchestrateFromCli(["--add", "nope", "2"], { batchRoot: root, io }), /no batch nope/);
|
|
433
|
+
});
|
|
434
|
+
|
|
435
|
+
it("#4469: --resume <id> and --add ignore the values of valued flags (--parallel 5 is not issue #5)", async () => {
|
|
436
|
+
const root = tmpDir("batch-add-flags-");
|
|
437
|
+
const b = openBatch(root, "b1");
|
|
438
|
+
b.writePlan({ argv: [], repo: "o/r" }); // no issues: the resume stops with the usage error after its adds
|
|
439
|
+
b.store.save({ order: [1] });
|
|
440
|
+
const views = [];
|
|
441
|
+
const io = { gh: async (a) => { views.push(a[2]); return JSON.stringify({ state: "OPEN" }); } };
|
|
442
|
+
await assert.rejects(quiet(() => orchestrateFromCli(["--resume", "b1", "--parallel", "5"], { batchRoot: root, io })), /usage/);
|
|
443
|
+
assert.deepEqual(views, [], "no issue read for a flag value");
|
|
444
|
+
assert.deepEqual(b.readAdded(), []);
|
|
445
|
+
await assert.rejects(quiet(() => orchestrateFromCli(["--resume", "b1", "--parallel", "5", "6", "--lane", "staging"], { batchRoot: root, io })), /usage/);
|
|
446
|
+
assert.deepEqual(views, ["6"]);
|
|
447
|
+
assert.deepEqual(b.readAdded(), [6]);
|
|
448
|
+
const res = await quiet(() => orchestrateFromCli(["--add", "b1", "7", "--parallel", "5"], { batchRoot: root, io }));
|
|
449
|
+
assert.deepEqual(res.added, [7]);
|
|
450
|
+
assert.deepEqual(res.skipped, []);
|
|
451
|
+
assert.deepEqual(b.readAdded(), [6, 7]);
|
|
452
|
+
});
|
|
453
|
+
});
|
|
454
|
+
|
|
455
|
+
describe("#4376: forgedock orchestrate --cancel", () => {
|
|
456
|
+
it("the cancel file: requestCancel / readCancel / clearCancel; invalid content reads as null", () => {
|
|
457
|
+
const b = openBatch(tmpDir("batch-cancel-"), "b1");
|
|
458
|
+
b.writePlan({ argv: ["1"], repo: "o/r" });
|
|
459
|
+
assert.equal(b.readCancel(), null);
|
|
460
|
+
b.requestCancel({ reason: "operator" });
|
|
461
|
+
assert.equal(b.readCancel().reason, "operator");
|
|
462
|
+
assert.match(b.readCancel().at, /^\d{4}-\d\d-\d\dT/);
|
|
463
|
+
b.clearCancel();
|
|
464
|
+
assert.equal(b.readCancel(), null);
|
|
465
|
+
writeFileSync(join(b.dir, "cancel"), "[1]\n");
|
|
466
|
+
assert.equal(b.readCancel(), null);
|
|
467
|
+
writeFileSync(join(b.dir, "cancel"), "not json");
|
|
468
|
+
assert.equal(b.readCancel(), null);
|
|
469
|
+
});
|
|
470
|
+
|
|
471
|
+
it("a cancel while a lane runs: no more admissions, the lane is stopped once, its interrupt is no deferral, the rest end held; a resume runs them", async () => {
|
|
472
|
+
let cancel = null, saved = null;
|
|
473
|
+
const store = { load: () => saved, save: (s) => { saved = s; }, log: () => {} };
|
|
474
|
+
const started = [], stops = [], log = [];
|
|
475
|
+
let release;
|
|
476
|
+
const stopped = new Promise((r) => { release = r; });
|
|
477
|
+
const done = orchestrate({ issues: [1, 2, 3], parallel: 1, pollMs: 5, readBody: async () => "", store, getCancelled: () => cancel,
|
|
478
|
+
stopLane: (n, rec) => { stops.push([n, rec.pid]); release(); }, onUpdate: (r) => log.push(r),
|
|
479
|
+
runOne: async (n, _lane, ctx) => {
|
|
480
|
+
started.push(n);
|
|
481
|
+
ctx.onStart?.({ pid: 4242 });
|
|
482
|
+
if (n !== 1) return { terminalReason: "merged" };
|
|
483
|
+
await stopped; // the lane's SIGTERM teardown reports deferred run.interrupted
|
|
484
|
+
return { terminalReason: "deferred", code: "run.interrupted", detail: "interrupted by SIGTERM" };
|
|
485
|
+
} });
|
|
486
|
+
await until(() => started.includes(1));
|
|
487
|
+
cancel = { at: new Date().toISOString(), reason: "operator" };
|
|
488
|
+
const rows = await done;
|
|
489
|
+
assert.deepEqual(started, [1], "nothing admitted after the cancel");
|
|
490
|
+
assert.deepEqual(stops, [[1, 4242]], "the running lane is stopped exactly once");
|
|
491
|
+
assert.deepEqual(rows.map((r) => [r.issue, r.outcome, r.detail]), [[1, "held", "cancelled by operator"], [2, "held", "cancelled by operator"], [3, "held", "cancelled by operator"]]);
|
|
492
|
+
assert.equal(log.filter((r) => r.outcome === "cancelled").length, 1);
|
|
493
|
+
assert.deepEqual(saved.deferrals, {}, "an operator stop is not counted as a deferral");
|
|
494
|
+
assert.ok(saved.cancelled, "the cancel is persisted with the batch state");
|
|
495
|
+
// The operator's resume clears the cancel file: the held issues run.
|
|
496
|
+
cancel = null;
|
|
497
|
+
const resumed = await orchestrate({ issues: [], parallel: 1, pollMs: 5, readBody: async () => "", store, getCancelled: () => cancel,
|
|
498
|
+
runOne: async (n) => { started.push(n); return { terminalReason: "merged" }; } });
|
|
499
|
+
assert.deepEqual(resumed.map((r) => [r.issue, r.outcome]), [[1, "merged"], [2, "merged"], [3, "merged"]]);
|
|
500
|
+
assert.equal(saved.cancelled, undefined);
|
|
501
|
+
});
|
|
502
|
+
|
|
503
|
+
it("without getCancelled nothing changes: a deferred lane still counts a deferral", async () => {
|
|
504
|
+
let saved = null, k = 0;
|
|
505
|
+
const store = { load: () => saved, save: (s) => { saved = s; }, log: () => {} };
|
|
506
|
+
const { now, sleep } = fakeClock();
|
|
507
|
+
const rows = await orchestrate({ issues: [1], parallel: 1, readBody: async () => "", store, now, sleep, stopLane: () => { throw new Error("never"); },
|
|
508
|
+
runOne: async () => (k++ ? { terminalReason: "merged" } : { terminalReason: "deferred", code: "run.interrupted" }) });
|
|
509
|
+
assert.equal(rows[0].outcome, "merged");
|
|
510
|
+
assert.deepEqual(saved.deferrals, { 1: 1 });
|
|
511
|
+
});
|
|
512
|
+
|
|
513
|
+
it("through the CLI: --cancel during a lane holds the rest, a finished batch is left alone, unknown ids reject, --resume clears it", async () => {
|
|
514
|
+
const root = tmpDir("batch-cancel-cli-");
|
|
515
|
+
const leanIo = {
|
|
516
|
+
gh: async (a) => (a.includes("milestone") ? JSON.stringify({ milestone: null }) : JSON.stringify({ body: "" })),
|
|
517
|
+
git: async (a) => (a[0] === "ls-remote" ? "abc\trefs/heads/staging\n" : ""),
|
|
518
|
+
};
|
|
519
|
+
const ran = [];
|
|
520
|
+
const deps = { dir: tmpDir("cancel-run-"), batchRoot: root, batchId: "bc1", allowTmpfs: true, emitTelemetry: async () => {}, io: { gh: async () => "o/r\n" }, leanIo, sched: null,
|
|
521
|
+
decide: () => {}, orchestration: { breaker: null, maxDeferrals: 3, globalParallel: null },
|
|
522
|
+
leanModule: { runCore: async (o) => {
|
|
523
|
+
ran.push(o.issue);
|
|
524
|
+
if (o.issue === 1) {
|
|
525
|
+
const res = await quiet(() => orchestrateFromCli(["--cancel", "bc1"], { batchRoot: root }));
|
|
526
|
+
assert.equal(res.cancelled, true);
|
|
527
|
+
}
|
|
528
|
+
return { terminalReason: "merged", pr: 50 + o.issue };
|
|
529
|
+
} } };
|
|
530
|
+
const first = await quiet(() => orchestrateFromCli(["1", "2", "--parallel", "1", "--repo", "o/r", "--no-prefilter"], deps));
|
|
531
|
+
assert.deepEqual(first.rows.map((r) => [r.issue, r.outcome]), [[1, "merged"], [2, "held"]]);
|
|
532
|
+
assert.equal(first.rows[1].detail, "cancelled by operator");
|
|
533
|
+
assert.deepEqual(ran, [1]);
|
|
534
|
+
const b = openBatch(root, "bc1");
|
|
535
|
+
assert.ok(b.readCancel());
|
|
536
|
+
const lines = [];
|
|
537
|
+
const l = console.log;
|
|
538
|
+
console.log = (...a) => lines.push(a.join(" "));
|
|
539
|
+
try { assert.equal((await orchestrateFromCli(["--cancel", "bc1"], { batchRoot: root })).cancelled, false); } finally { console.log = l; }
|
|
540
|
+
assert.match(lines.join("\n"), /batch bc1: already finished/);
|
|
541
|
+
await assert.rejects(orchestrateFromCli(["--cancel", "nope"], { batchRoot: root }), /no batch nope/);
|
|
542
|
+
await assert.rejects(orchestrateFromCli(["--cancel"], { batchRoot: root }), /usage: forgedock orchestrate/);
|
|
543
|
+
const resumed = await quiet(() => orchestrateFromCli(["--resume", "bc1"], deps));
|
|
544
|
+
assert.equal(b.readCancel(), null, "the operator's resume clears the cancel");
|
|
545
|
+
assert.deepEqual(resumed.rows.map((r) => [r.issue, r.outcome]), [[1, "merged"], [2, "merged"]]);
|
|
546
|
+
assert.deepEqual(ran, [1, 2]);
|
|
547
|
+
});
|
|
548
|
+
});
|
|
549
|
+
|
|
550
|
+
describe("#4454: orchestrate's shutdown signal (schedd stop)", () => {
|
|
551
|
+
it("#4454: an aborted signal admits nothing more, leaves without awaiting or stopping the running lane, relabels nothing, keeps it adoptable", async () => {
|
|
552
|
+
let saved = null;
|
|
553
|
+
const store = { load: () => saved, save: (s) => { saved = s; }, log: () => {} };
|
|
554
|
+
const started = [], stops = [], relabels = [];
|
|
555
|
+
let releaseLane;
|
|
556
|
+
const lane = new Promise((r) => { releaseLane = r; });
|
|
557
|
+
const ac = new AbortController();
|
|
558
|
+
const done = orchestrate({ issues: [1, 2], parallel: 1, pollMs: 5, readBody: async () => "", store, signal: ac.signal,
|
|
559
|
+
stopLane: (n) => stops.push(n), relabelNeedsHuman: async (x) => { relabels.push(x); return true; },
|
|
560
|
+
runOne: async (n, _lane, ctx) => { started.push(n); ctx.onStart?.({ pid: 4242 }); if (n === 1) await lane; return { terminalReason: "merged" }; } });
|
|
561
|
+
await until(() => started.includes(1) && saved?.running?.["1"]?.pid === 4242);
|
|
562
|
+
const t0 = Date.now();
|
|
563
|
+
ac.abort();
|
|
564
|
+
await assert.rejects(done, (e) => e.code === "batch.stopped");
|
|
565
|
+
assert.ok(Date.now() - t0 < 1000, "the running lane is not awaited");
|
|
566
|
+
releaseLane();
|
|
567
|
+
await new Promise((r) => setTimeout(r, 100));
|
|
568
|
+
assert.deepEqual(started, [1], "nothing admitted after the stop, also once the lane ends");
|
|
569
|
+
assert.deepEqual(stops, [], "lanes are detached: no stop on shutdown");
|
|
570
|
+
assert.deepEqual(relabels, []);
|
|
571
|
+
assert.equal(saved.running["1"].pid, 4242, "the running lane stays in the state for a resume to adopt");
|
|
572
|
+
assert.deepEqual(saved.pending, [2]);
|
|
573
|
+
assert.deepEqual(saved.results, {}, "the lane that ended after the stop is not recorded by this process");
|
|
574
|
+
});
|
|
575
|
+
|
|
576
|
+
it("#4454: an abort wakes a loop sleeping on a long requeue delay", async () => {
|
|
577
|
+
const ac = new AbortController();
|
|
578
|
+
let deferred = 0;
|
|
579
|
+
const done = orchestrate({ issues: [9], parallel: 1, readBody: async () => "", signal: ac.signal,
|
|
580
|
+
runOne: async () => { deferred++; return { terminalReason: "deferred", detail: `leased by x until ${new Date(Date.now() + 3600e3).toISOString()}` }; } });
|
|
581
|
+
await until(() => deferred === 1);
|
|
582
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
583
|
+
const t0 = Date.now();
|
|
584
|
+
ac.abort();
|
|
585
|
+
await assert.rejects(done, (e) => e.code === "batch.stopped");
|
|
586
|
+
assert.ok(Date.now() - t0 < 1000);
|
|
587
|
+
});
|
|
588
|
+
});
|
|
589
|
+
|
|
590
|
+
describe("#4481: a closed issue or a merged lane PR is skipped at dispatch", () => {
|
|
591
|
+
it("a requeued issue whose PR merged meanwhile ends merged without a second lane", async () => {
|
|
592
|
+
const { now, sleep } = fakeClock();
|
|
593
|
+
let runs = 0, checks = 0;
|
|
594
|
+
const rows = await orchestrate({ issues: [1], readBody: async () => "", now, sleep,
|
|
595
|
+
dispatchCheck: async () => (++checks === 1 ? null : { outcome: "merged", pr: 77, detail: "PR #77 already merged" }),
|
|
596
|
+
runOne: async () => { runs++; return { terminalReason: "deferred", code: "host.busy", retryInSeconds: 60 }; } });
|
|
597
|
+
assert.deepEqual([runs, checks], [1, 2], "checked on every start, the requeue included");
|
|
598
|
+
assert.deepEqual([rows[0].outcome, rows[0].pr], ["merged", 77]);
|
|
599
|
+
assert.match(rows[0].detail, /skipped at dispatch: PR #77 already merged/);
|
|
600
|
+
});
|
|
601
|
+
|
|
602
|
+
it("a closed issue ends invalid without a lane and its dependents still run; a throwing check runs the lane", async () => {
|
|
603
|
+
const ran = [];
|
|
604
|
+
const rows = await orchestrate({ issues: [1, 2], readBody: async (n) => (n === 2 ? "Depends on #1" : ""),
|
|
605
|
+
dispatchCheck: async (n) => (n === 1 ? { outcome: "invalid", detail: "issue closed" } : (() => { throw new Error("gh down"); })()),
|
|
606
|
+
runOne: async (n) => { ran.push(n); return { terminalReason: "merged", cost_usd: 0 }; } });
|
|
607
|
+
assert.deepEqual(ran, [2]);
|
|
608
|
+
assert.deepEqual(rows.map((r) => [r.issue, r.outcome]), [[1, "invalid"], [2, "merged"]]);
|
|
609
|
+
});
|
|
610
|
+
|
|
611
|
+
it("makeDispatchCheck: merged head-branch PR, closed issue, open issue, unreadable answers fail open", async () => {
|
|
612
|
+
const mk = (issue, prs, events = "") => makeDispatchCheck({ repo: "o/r", gh: async (a) => {
|
|
613
|
+
if (a[0] === "issue") { assert.deepEqual(a, ["issue", "view", "9", "-R", "o/r", "--json", "state"]); if (issue instanceof Error) throw issue; return issue; }
|
|
614
|
+
if (a[0] === "api") { assert.equal(a[1], "repos/o/r/issues/9/events"); if (events instanceof Error) throw events; return events; }
|
|
615
|
+
assert.deepEqual(a.slice(0, 6), ["pr", "list", "-R", "o/r", "--head", "feat/issue-9-lean"]);
|
|
616
|
+
if (prs instanceof Error) throw prs;
|
|
617
|
+
return prs;
|
|
618
|
+
} });
|
|
619
|
+
const merged = '[{"number":12,"mergedAt":"2026-10-23T08:00:00Z"}]';
|
|
620
|
+
assert.deepEqual(await mk('{"state":"OPEN"}', merged)(9), { outcome: "merged", pr: 12, detail: "PR #12 already merged" }, "open, never reopened: the merge is still closing it");
|
|
621
|
+
assert.deepEqual(await mk('{"state":"OPEN"}', merged, "2026-10-22T08:00:00Z\n")(9), { outcome: "merged", pr: 12, detail: "PR #12 already merged" }, "reopened before the merge");
|
|
622
|
+
assert.equal(await mk('{"state":"OPEN"}', merged, "2026-10-22T08:00:00Z\n2026-10-23T09:00:00Z\n")(9), null, "reopened after the merge (failed probe, rework): the lane runs");
|
|
623
|
+
assert.equal(await mk('{"state":"OPEN"}', merged, new Error("api down"))(9), null, "unreadable reopen history: the lane runs");
|
|
624
|
+
assert.equal(await mk('{"state":"OPEN"}', '[{"number":12}]')(9), null, "no mergedAt: the lane runs");
|
|
625
|
+
assert.deepEqual(await mk('{"state":"CLOSED"}', merged, new Error("not read"))(9), { outcome: "merged", pr: 12, detail: "PR #12 already merged" }, "closed: no reopen lookup");
|
|
626
|
+
assert.deepEqual(await mk('{"state":"CLOSED"}', "[]")(9), { outcome: "invalid", detail: "issue closed" });
|
|
627
|
+
assert.equal(await mk('{"state":"OPEN"}', "[]")(9), null);
|
|
628
|
+
assert.equal(await mk('{"body":"","labels":[]}', '{"body":""}')(9), null, "a stub without state or a list: run");
|
|
629
|
+
assert.equal(await mk(new Error("x"), new Error("y"))(9), null);
|
|
630
|
+
});
|
|
631
|
+
});
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
|
|
2
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
3
|
+
/**
|
|
4
|
+
* Per-size implement turn budgets (#4392). A lane's size comes from facts, never a model: a `size:S|M|L` issue label,
|
|
5
|
+
* else the frozen contract's distinct deliverable paths (S <= 2, M <= 5, else L). The budget for the size is forge.yaml
|
|
6
|
+
* `engine.turn_budget_s|m|l` (defaults 25/60/100; 0 disables that size). The implement stage (the build call and its
|
|
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 needs-human `budget.exceeded` (recoverable once by the batch). Pure module: no I/O.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
export const TURN_BUDGET_DEFAULTS = Object.freeze({ S: 25, M: 60, L: 100 });
|
|
12
|
+
export const SIZES = Object.freeze(["S", "M", "L"]);
|
|
13
|
+
/** Max chars of the builder's digest carried into the needs-human detail. */
|
|
14
|
+
export const DIGEST_MAX = 1500;
|
|
15
|
+
|
|
16
|
+
/** forge.yaml engine section -> {S, M, L} budgets; a missing or junk key keeps its default. null when no key is set (callers use the defaults). */
|
|
17
|
+
export function parseTurnBudgets(engine) {
|
|
18
|
+
if (!engine || typeof engine !== "object") return null;
|
|
19
|
+
const out = { ...TURN_BUDGET_DEFAULTS };
|
|
20
|
+
let set = false;
|
|
21
|
+
for (const s of SIZES) {
|
|
22
|
+
const v = String(engine[`turn_budget_${s.toLowerCase()}`] ?? "").trim();
|
|
23
|
+
if (/^\d+$/.test(v)) { out[s] = Number(v); set = true; }
|
|
24
|
+
}
|
|
25
|
+
return set ? out : null;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** "S" | "M" | "L" for a lane: a `size:X` label wins, else the contract's distinct deliverable paths. null without either. */
|
|
29
|
+
export function laneSize({ contract = null, labels = [] } = {}) {
|
|
30
|
+
for (const l of Array.isArray(labels) ? labels : []) {
|
|
31
|
+
const m = /^size[:/]\s*([SML])$/i.exec(String(l?.name ?? l ?? "").trim());
|
|
32
|
+
if (m) return m[1].toUpperCase();
|
|
33
|
+
}
|
|
34
|
+
if (!contract || !Array.isArray(contract.deliverables)) return null;
|
|
35
|
+
const n = new Set(contract.deliverables.map((d) => d?.path).filter((p) => typeof p === "string" && p.trim())).size;
|
|
36
|
+
return n <= 2 ? "S" : n <= 5 ? "M" : "L";
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Turns the wrap-up call may use after the budget is spent. */
|
|
40
|
+
export const wrapupGrant = (budget) => Math.max(5, Math.ceil(budget / 5));
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Lane turn accounting. `note(turns, build)` adds one builder attempt's turns (build: it ran in the implement stage);
|
|
44
|
+
* `remaining()` is the implement budget left (Infinity when off or unsized); `record()` rides on the lane result and
|
|
45
|
+
* RUN_TERMINAL ({size, budget, build, total, exceeded}), null while the lane is unsized.
|
|
46
|
+
*/
|
|
47
|
+
export function makeTurnBudget(budgets = TURN_BUDGET_DEFAULTS) {
|
|
48
|
+
const b = { ...TURN_BUDGET_DEFAULTS, ...(budgets ?? {}) };
|
|
49
|
+
let size = null, build = 0, total = 0, exceeded = false;
|
|
50
|
+
const budget = () => (size && Number.isFinite(b[size]) && b[size] > 0 ? b[size] : 0);
|
|
51
|
+
return {
|
|
52
|
+
setSize(s) { if (SIZES.includes(s)) size = s; },
|
|
53
|
+
get size() { return size; },
|
|
54
|
+
budget,
|
|
55
|
+
on: () => budget() > 0,
|
|
56
|
+
note(turns, isBuild) { const t = Number.isFinite(turns) && turns > 0 ? turns : 0; total += t; if (isBuild) build += t; },
|
|
57
|
+
remaining: () => (budget() > 0 ? budget() - build : Infinity),
|
|
58
|
+
markExceeded() { exceeded = true; },
|
|
59
|
+
record: () => (size ? { size, budget: budget(), build, total, exceeded } : null),
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** The needs-human detail: the builder's digest (capped), else the diff stat and the last self-check state. */
|
|
64
|
+
export function budgetDigest({ size, budget, used, digest = null, stat = "", check = null }) {
|
|
65
|
+
const head = `Implement turn budget spent (size ${size}, ${budget} turns; ${used} used) and the wrap-up did not finish; work is checkpointed on the lane branch.`;
|
|
66
|
+
const d = typeof digest === "string" && digest.trim() ? digest.trim().slice(0, DIGEST_MAX) : null;
|
|
67
|
+
if (d) return `${head}\n\nBuilder digest:\n${d}`;
|
|
68
|
+
return `${head}\n\nNo builder digest; changes so far:\n${String(stat || "(no diff)").trim().slice(0, DIGEST_MAX)}${check ? `\nLast self-check: ${check}` : ""}`;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const pct = (xs, p) => xs[Math.min(xs.length - 1, Math.floor((xs.length - 1) * p))];
|
|
72
|
+
/**
|
|
73
|
+
* Batch-report line (#4392): implement turns per size over the merged rows (`turns.build`), then the lanes that went
|
|
74
|
+
* over budget. null when no row carries turns.
|
|
75
|
+
*/
|
|
76
|
+
export function formatTurnSummary(rows) {
|
|
77
|
+
const withTurns = (rows ?? []).filter((r) => r?.turns && SIZES.includes(r.turns.size));
|
|
78
|
+
if (!withTurns.length) return null;
|
|
79
|
+
const parts = [];
|
|
80
|
+
for (const s of SIZES) {
|
|
81
|
+
const merged = withTurns.filter((r) => r.outcome === "merged" && r.turns.size === s);
|
|
82
|
+
if (!merged.length) continue;
|
|
83
|
+
const xs = merged.map((r) => Number(r.turns.build) || 0).sort((a, c) => a - c);
|
|
84
|
+
const budget = Number(merged[0].turns.budget) || 0;
|
|
85
|
+
parts.push(`${s} n=${xs.length} p50 ${pct(xs, 0.5)} max ${xs[xs.length - 1]}/${budget > 0 ? budget : "off"}`);
|
|
86
|
+
}
|
|
87
|
+
const over = withTurns.filter((r) => r.turns.exceeded).map((r) => `#${r.issue} (${r.turns.size} ${r.turns.budget})`);
|
|
88
|
+
return `turns by size: ${parts.join("; ") || "no merged lanes"}${over.length ? `; over budget: ${over.join(" ")}` : ""}`;
|
|
89
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
|
|
2
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
3
|
+
// #4392: per-size implement turn budgets: sizing, config, digest, the stream turn cap and the batch-report line.
|
|
4
|
+
import { describe, it } from "node:test";
|
|
5
|
+
import assert from "node:assert/strict";
|
|
6
|
+
import { writeFileSync, chmodSync } from "node:fs";
|
|
7
|
+
import { tmpDir } from "../tests/_tmp.mjs";
|
|
8
|
+
import { TURN_BUDGET_DEFAULTS, parseTurnBudgets, laneSize, wrapupGrant, makeTurnBudget, budgetDigest, formatTurnSummary } from "./budget.mjs";
|
|
9
|
+
import { orchestrate } from "./orchestrate.mjs";
|
|
10
|
+
import { OUTCOME_CODES, RECOVERABLE_CODES } from "../engine/outcome-codes.mjs";
|
|
11
|
+
import { RECOVER_HOW } from "./lean.mjs";
|
|
12
|
+
import { TRIGGERS, REWORK_TRIGGERS, firstPassOf } from "./fixround.mjs";
|
|
13
|
+
|
|
14
|
+
process.env.FORGEDOCK_SCHED_DIR = tmpDir("budget-sched-");
|
|
15
|
+
const contract = (n) => ({ deliverables: Array.from({ length: n }, (_, i) => ({ id: `D${i + 1}`, path: `src/f${i % Math.max(1, n)}.js` })) });
|
|
16
|
+
|
|
17
|
+
describe("#4392 turn budget", () => {
|
|
18
|
+
it("defaults are S=25, M=60, L=100; forge.yaml keys override, 0 allowed, junk ignored", () => {
|
|
19
|
+
assert.deepEqual(TURN_BUDGET_DEFAULTS, { S: 25, M: 60, L: 100 });
|
|
20
|
+
assert.equal(parseTurnBudgets({}), null);
|
|
21
|
+
assert.equal(parseTurnBudgets(null), null);
|
|
22
|
+
assert.deepEqual(parseTurnBudgets({ turn_budget_s: "10", turn_budget_m: 0, turn_budget_l: "x" }), { S: 10, M: 0, L: 100 });
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it("size: a size label wins, else distinct deliverable paths (S <= 2, M <= 5, else L); unsized without either", () => {
|
|
26
|
+
assert.equal(laneSize({ contract: contract(1) }), "S");
|
|
27
|
+
assert.equal(laneSize({ contract: contract(2) }), "S");
|
|
28
|
+
assert.equal(laneSize({ contract: contract(3) }), "M");
|
|
29
|
+
assert.equal(laneSize({ contract: contract(5) }), "M");
|
|
30
|
+
assert.equal(laneSize({ contract: contract(6) }), "L");
|
|
31
|
+
assert.equal(laneSize({ contract: { deliverables: [{ path: "a" }, { path: "a" }, { path: "b" }] } }), "S", "paths are deduped");
|
|
32
|
+
assert.equal(laneSize({ contract: contract(9), labels: [{ name: "size:S" }] }), "S");
|
|
33
|
+
assert.equal(laneSize({ contract: contract(1), labels: ["bug", "Size/l"] }), "L");
|
|
34
|
+
assert.equal(laneSize({}), null);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("the tally: build turns spend the budget, other attempts count only in total; off or unsized is unlimited", () => {
|
|
38
|
+
const tb = makeTurnBudget({ M: 60 });
|
|
39
|
+
assert.equal(tb.record(), null); assert.equal(tb.remaining(), Infinity);
|
|
40
|
+
tb.setSize("M"); tb.note(4, false); tb.note(50, true); tb.note(undefined, true);
|
|
41
|
+
assert.equal(tb.remaining(), 10);
|
|
42
|
+
tb.markExceeded();
|
|
43
|
+
assert.deepEqual(tb.record(), { size: "M", budget: 60, build: 50, total: 54, exceeded: true });
|
|
44
|
+
const off = makeTurnBudget({ S: 0 }); off.setSize("S");
|
|
45
|
+
assert.equal(off.on(), false); assert.equal(off.remaining(), Infinity);
|
|
46
|
+
assert.equal(wrapupGrant(25), 5); assert.equal(wrapupGrant(100), 20); assert.equal(wrapupGrant(3), 5);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("the digest is the builder's (capped), else the diff stat and the last self-check", () => {
|
|
50
|
+
const d = budgetDigest({ size: "M", budget: 60, used: 66, digest: "x".repeat(5000) });
|
|
51
|
+
assert.match(d, /size M, 60 turns; 66 used/); assert.ok(d.length < 1800);
|
|
52
|
+
const s = budgetDigest({ size: "S", budget: 25, used: 30, stat: " src/a.js | 4 ++--", check: "red" });
|
|
53
|
+
assert.match(s, /No builder digest/); assert.match(s, /src\/a\.js/); assert.match(s, /Last self-check: red/);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("budget.exceeded is a typed, recoverable outcome with a recovery instruction; budget_wrapup is a build restart, not rework", () => {
|
|
57
|
+
assert.equal(OUTCOME_CODES.BUDGET_EXCEEDED, "budget.exceeded");
|
|
58
|
+
assert.ok(RECOVERABLE_CODES.includes("budget.exceeded"));
|
|
59
|
+
assert.match(RECOVER_HOW["budget.exceeded"], /split-required/);
|
|
60
|
+
assert.ok(TRIGGERS.includes("budget_wrapup")); assert.ok(!REWORK_TRIGGERS.includes("budget_wrapup"));
|
|
61
|
+
assert.equal(firstPassOf([{ trigger: "plan" }, { trigger: "budget_wrapup", restart_of: "build" }]), true);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("runCliBackendAsync turnMax stops a stream session at that many assistant turns (deduped by message id)", async () => {
|
|
65
|
+
const { runCliBackendAsync } = await import("../runner.mjs");
|
|
66
|
+
const d = tmpDir("core-turns-");
|
|
67
|
+
const line = (id) => JSON.stringify({ type: "assistant", message: { id, content: [{ type: "tool_use", name: "Edit", input: { file: "a" } }], usage: { input_tokens: 10, output_tokens: 3, cache_read_input_tokens: 5, cache_creation_input_tokens: 0 } } });
|
|
68
|
+
writeFileSync(`${d}/fake`, `#!/bin/sh\necho '${line("m1")}'\necho '${line("m1")}'\necho '${line("m2")}'\necho '${line("m3")}'\nexec sleep 30\n`); chmodSync(`${d}/fake`, 0o755);
|
|
69
|
+
const t0 = Date.now();
|
|
70
|
+
await assert.rejects(runCliBackendAsync({ spec: { name: "t" }, userMessage: "go", args: ["1"], cwd: d, bin: `${d}/fake`, logger: { log() {} }, stream: true, turnMax: 3 }), (e) => {
|
|
71
|
+
assert.equal(e.code, "CLI_TURNS"); assert.equal(e.turns, 3); assert.equal(e.turnMax, 3);
|
|
72
|
+
assert.deepEqual(e.usage, { input_tokens: 30, output_tokens: 9, cache_creation_input_tokens: 0, cache_read_input_tokens: 15 });
|
|
73
|
+
assert.equal(e.lastTool.name, "Edit");
|
|
74
|
+
return true;
|
|
75
|
+
});
|
|
76
|
+
assert.ok(Date.now() - t0 < 15000, "killed, not waited out");
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("#4410 runCliBackendAsync returns the stream's tool uses, deduped by tool_use id, with path and command", async () => {
|
|
80
|
+
const { runCliBackendAsync } = await import("../runner.mjs");
|
|
81
|
+
const d = tmpDir("core-tools-");
|
|
82
|
+
const use = (id, name, input) => ({ type: "tool_use", id, name, input });
|
|
83
|
+
const line = (id, content) => JSON.stringify({ type: "assistant", message: { id, content, usage: { input_tokens: 1, output_tokens: 1 } } });
|
|
84
|
+
const a = line("m1", [use("t1", "Read", { file_path: "a.sh" })]), b = line("m1", [use("t2", "Bash", { command: "sed -n 1,9p a.sh" })]);
|
|
85
|
+
const c = line("m2", [use("t3", "Grep", { pattern: "x", path: "src" })]);
|
|
86
|
+
const result = JSON.stringify({ type: "result", subtype: "success", is_error: false, result: "ok", session_id: "s-1", usage: { input_tokens: 2, output_tokens: 2 } });
|
|
87
|
+
writeFileSync(`${d}/fake`, `#!/bin/sh\necho '${a}'\necho '${a}'\necho '${b}'\necho '${c}'\necho '${result}'\n`); chmodSync(`${d}/fake`, 0o755);
|
|
88
|
+
const r = await runCliBackendAsync({ spec: { name: "t" }, userMessage: "go", args: ["1"], cwd: d, bin: `${d}/fake`, logger: { log() {} }, stream: true });
|
|
89
|
+
assert.deepEqual(r.toolUses, [{ msg: "m1", name: "Read", path: "a.sh", cmd: null }, { msg: "m1", name: "Bash", path: null, cmd: "sed -n 1,9p a.sh" },
|
|
90
|
+
{ msg: "m2", name: "Grep", path: "src", cmd: null }]);
|
|
91
|
+
writeFileSync(`${d}/fake`, `#!/bin/sh\necho '${a}'\necho '${b}'\necho '${c}'\nexec sleep 30\n`);
|
|
92
|
+
await assert.rejects(runCliBackendAsync({ spec: { name: "t" }, userMessage: "go", args: ["1"], cwd: d, bin: `${d}/fake`, logger: { log() {} }, stream: true, turnMax: 2 }),
|
|
93
|
+
(e) => e.code === "CLI_TURNS" && e.toolUses.length === 3);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it("the batch row carries the lane's turns and the report prints a size x turns line with overruns", async () => {
|
|
97
|
+
const T = (size, budget, build, exceeded = false) => ({ size, budget, build, total: build + 3, exceeded });
|
|
98
|
+
const results = { 1: { terminalReason: "merged", turns: T("S", 25, 10) }, 2: { terminalReason: "merged", turns: T("S", 25, 22) }, 3: { terminalReason: "merged", turns: T("S", 25, 30, true) },
|
|
99
|
+
4: { terminalReason: "merged", turns: T("M", 60, 41) }, 5: { terminalReason: "needs-human", code: "budget.exceeded", turns: T("L", 100, 105, true) } };
|
|
100
|
+
const rows = await orchestrate({ issues: [1, 2, 3, 4, 5], parallel: 2, readBody: async () => "", runOne: async (n) => results[n], recovery: { maxAttempts: 0 } });
|
|
101
|
+
assert.deepEqual(rows.find((r) => r.issue === 4).turns, results[4].turns);
|
|
102
|
+
assert.equal(formatTurnSummary(rows), "turns by size: S n=3 p50 22 max 30/25; M n=1 p50 41 max 41/60; over budget: #3 (S 25) #5 (L 100)");
|
|
103
|
+
assert.equal(formatTurnSummary([{ issue: 1, outcome: "merged" }]), null);
|
|
104
|
+
assert.equal(formatTurnSummary([{ issue: 1, outcome: "merged", turns: T("S", 0, 7) }]), "turns by size: S n=1 p50 7 max 7/off");
|
|
105
|
+
});
|
|
106
|
+
});
|
package/bin/lean/checks.mjs
CHANGED
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
import fs from "node:fs";
|
|
14
14
|
import path from "node:path";
|
|
15
15
|
import { fileURLToPath } from "node:url";
|
|
16
|
-
import { git, changedFiles, sha256, homeForgeDir, shellSplit, shellQuote, globToRegex } from "../../scripts/ci-local/lib.mjs";
|
|
16
|
+
import { git, changedFiles, sha256, homeForgeDir, shellSplit, shellQuote, globToRegex, parseYaml, trustedForgeYaml } from "../../scripts/ci-local/lib.mjs";
|
|
17
17
|
import { makeScopeCtx, scopeCommand } from "../../scripts/ci-local/scoping.mjs";
|
|
18
18
|
|
|
19
19
|
const px = path.posix;
|
|
@@ -67,16 +67,16 @@ export function discoverChecks(repo) {
|
|
|
67
67
|
const turbo = turboTasks(repo);
|
|
68
68
|
const scripts = new Map(pkgDirs.map((d) => [d, (readJson(path.join(repo, d, "package.json")) || {}).scripts || {}]));
|
|
69
69
|
for (const [script, kind] of Object.entries(SCRIPT_KINDS)) {
|
|
70
|
-
if (turbo.has(script)) { out.push({ kind, command: `${exists(repo, "node_modules/.bin/turbo") ? "" : "npx "}turbo run ${script}`, cwd: "." }); continue; }
|
|
70
|
+
if (turbo.has(script)) { out.push({ kind, command: `${exists(repo, "node_modules/.bin/turbo") ? "" : "npx "}turbo run ${script}`, cwd: ".", eco: "js" }); continue; }
|
|
71
71
|
const ws = pkgDirs.filter((d) => d !== "." && typeof scripts.get(d)[script] === "string").sort();
|
|
72
72
|
const dirs = ws.length ? ws : typeof (scripts.get(".") || {})[script] === "string" ? ["."] : [];
|
|
73
|
-
for (const d of dirs) out.push({ kind, command: `${pm} run ${script}`, cwd: d, exec: EXEC[pm] });
|
|
73
|
+
for (const d of dirs) out.push({ kind, command: `${pm} run ${script}`, cwd: d, exec: EXEC[pm], eco: "js" });
|
|
74
74
|
}
|
|
75
75
|
}
|
|
76
76
|
const pyproject = readText(path.join(repo, "pyproject.toml"));
|
|
77
77
|
const run = exists(repo, "uv.lock") ? "uv run " : exists(repo, "poetry.lock") ? "poetry run " : "";
|
|
78
|
-
if (/^\[tool\.ruff/m.test(pyproject) || exists(repo, "ruff.toml") || exists(repo, ".ruff.toml")) out.push({ kind: "lint", command: `${run}ruff check .`, cwd: "." });
|
|
79
|
-
if (/^\[tool\.pytest/m.test(pyproject) || exists(repo, "pytest.ini") || tracked.some((f) => px.basename(f) === "conftest.py")) out.push({ kind: "test", command: `${run}pytest`, cwd: "." });
|
|
78
|
+
if (/^\[tool\.ruff/m.test(pyproject) || exists(repo, "ruff.toml") || exists(repo, ".ruff.toml")) out.push({ kind: "lint", command: `${run}ruff check .`, cwd: ".", eco: "py" });
|
|
79
|
+
if (/^\[tool\.pytest/m.test(pyproject) || exists(repo, "pytest.ini") || tracked.some((f) => px.basename(f) === "conftest.py")) out.push({ kind: "test", command: `${run}pytest`, cwd: ".", eco: "py" });
|
|
80
80
|
return out;
|
|
81
81
|
}
|
|
82
82
|
|
|
@@ -150,16 +150,39 @@ export function laneChanges(repo, base) {
|
|
|
150
150
|
return { files: files.filter((f) => !f.startsWith(".forge-lean/")), from };
|
|
151
151
|
}
|
|
152
152
|
|
|
153
|
-
/**
|
|
154
|
-
|
|
153
|
+
/**
|
|
154
|
+
* The repo's configured `verification.commands.typescript.lint` (#4381), read only from the trusted forge.yaml copy
|
|
155
|
+
* (forge#4040: never the PR's own tree). null when absent, empty or unreadable (no yq, parse error).
|
|
156
|
+
*/
|
|
157
|
+
export function configuredLint(repo, base) {
|
|
158
|
+
try {
|
|
159
|
+
const mb = git(repo, ["merge-base", base, "HEAD"]).out.trim();
|
|
160
|
+
const { text } = trustedForgeYaml(repo, base, mb);
|
|
161
|
+
if (!text) return null;
|
|
162
|
+
const v = parseYaml(text)?.verification?.commands?.typescript?.lint;
|
|
163
|
+
return typeof v === "string" && v.trim() ? v.trim() : null;
|
|
164
|
+
} catch { return null; }
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* The scoped checks for this lane: [{kind, command, cwd, mode, widen_reason, selector, reason, configured}]. A configured
|
|
169
|
+
* typescript.lint replaces the discovered JS lint checks (#4381); Python checks are never replaced.
|
|
170
|
+
*/
|
|
171
|
+
export function scopedChecks({ repo, base, checks = discoverChecks(repo), lint = configuredLint(repo, base) }) {
|
|
155
172
|
const { files, from } = laneChanges(repo, base);
|
|
156
173
|
const sctx = makeScopeCtx({ repo, baseRef: base, from, changed: files });
|
|
174
|
+
if (lint) {
|
|
175
|
+
const i = checks.findIndex((c) => c.kind === "lint" && c.eco === "js");
|
|
176
|
+
const own = { kind: "lint", command: lint, cwd: ".", configured: true, ...(exists(repo, "package.json") ? { exec: EXEC[packageManager(repo)] } : {}) };
|
|
177
|
+
checks = i < 0 ? [...checks, own] : [...checks.slice(0, i), own, ...checks.slice(i).filter((c) => !(c.kind === "lint" && c.eco === "js"))];
|
|
178
|
+
}
|
|
157
179
|
return checks.map((c) => {
|
|
158
180
|
const r = scopeCommand(c.command, c.cwd === "." ? "" : c.cwd, sctx);
|
|
159
181
|
// A resolved script body (`pnpm run typecheck` -> `tsc -p apps/web`) runs its binary through the package manager.
|
|
160
182
|
const resolved = r.action !== "skip" && c.exec && r.command !== c.command && !SYSTEM_BINS.has(px.basename((shellSplit(r.command) || [""])[0]));
|
|
161
183
|
const out = { kind: c.kind, cwd: c.cwd, command: r.action === "skip" ? "" : resolved ? `${c.exec} ${r.command}` : r.command, mode: r.mode, widen_reason: r.widen_reason || "", selector: r.selector || "" };
|
|
162
184
|
if (r.reason) out.reason = r.reason;
|
|
185
|
+
if (c.configured) out.configured = true;
|
|
163
186
|
return r.action === "skip" ? out : withCacheFlags(out, repo);
|
|
164
187
|
});
|
|
165
188
|
}
|
|
@@ -190,8 +213,8 @@ function main(argv) {
|
|
|
190
213
|
if (!checks.length) console.log("# no typecheck/lint/test command found");
|
|
191
214
|
for (const c of checks) {
|
|
192
215
|
const where = c.cwd && c.cwd !== "." ? `(cd ${shellQuote(c.cwd)} && ${c.command})` : c.command;
|
|
193
|
-
if (c.mode === "skip") console.log(`${c.kind} skip # skipped: ${c.reason || "not affected"}`);
|
|
194
|
-
else console.log(`${c.kind} ${c.mode} ${where}${c.widen_reason ? ` # widened: ${c.widen_reason}` : ""}`);
|
|
216
|
+
if (c.mode === "skip") console.log(`${c.kind} skip # skipped: ${c.reason || "not affected"}${c.configured ? " (configured: verification.commands.typescript.lint)" : ""}`);
|
|
217
|
+
else console.log(`${c.kind} ${c.mode} ${where}${c.widen_reason ? ` # widened: ${c.widen_reason}` : ""}${c.configured ? " # configured: verification.commands.typescript.lint" : ""}`);
|
|
195
218
|
}
|
|
196
219
|
return 0;
|
|
197
220
|
}
|