forgedock 1.36.0 → 1.37.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/bot-token-status.mjs +26 -0
- package/bin/engine/events.mjs +10 -1
- package/bin/engine/events.test.mjs +15 -1
- package/bin/engine/outcome-codes.mjs +39 -1
- package/bin/engine/phases.mjs +65 -5
- package/bin/engine/telemetry-seam.mjs +1 -1
- package/bin/engine/telemetry-seam.test.mjs +4 -0
- package/bin/engine-cli.mjs +268 -37
- package/bin/engine.mjs +3 -1
- package/bin/forgedock.mjs +108 -5
- package/bin/labels.json +50 -0
- package/bin/lean/batch.mjs +10 -4
- package/bin/lean/batch.test.mjs +75 -1
- package/bin/lean/conformance.e2e.test.mjs +1 -1
- package/bin/lean/conformance.mjs +125 -11
- package/bin/lean/conformance.test.mjs +122 -1
- package/bin/lean/decisions.mjs +181 -0
- package/bin/lean/decisions.test.mjs +71 -0
- package/bin/lean/effects.mjs +29 -14
- package/bin/lean/execution.mjs +60 -16
- package/bin/lean/execution.test.mjs +59 -2
- package/bin/lean/gatewatch.mjs +31 -0
- package/bin/lean/gatewatch.test.mjs +59 -0
- package/bin/lean/guardgate.mjs +170 -0
- package/bin/lean/guardgate.test.mjs +153 -0
- package/bin/lean/guards.mjs +243 -0
- package/bin/lean/guards.test.mjs +177 -0
- package/bin/lean/heavy.mjs +54 -8
- package/bin/lean/heavy.test.mjs +74 -1
- package/bin/lean/hostkill.mjs +1 -1
- package/bin/lean/integration.mjs +276 -0
- package/bin/lean/integration.test.mjs +189 -0
- package/bin/lean/lean.mjs +134 -28
- package/bin/lean/lean.test.mjs +239 -22
- package/bin/lean/lifecycle.mjs +129 -0
- package/bin/lean/lifecycle.test.mjs +118 -0
- package/bin/lean/light.mjs +1 -1
- package/bin/lean/milestonesync.mjs +130 -0
- package/bin/lean/milestonesync.test.mjs +133 -0
- package/bin/lean/orchestrate.mjs +83 -15
- package/bin/lean/parked-sweep.mjs +45 -0
- package/bin/lean/parked-sweep.test.mjs +71 -0
- package/bin/lean/postdeploy-check.mjs +38 -6
- package/bin/lean/postdeploy-check.test.mjs +62 -2
- package/bin/lean/postdeploy-monitor.mjs +12 -2
- package/bin/lean/postdeploy-monitor.test.mjs +27 -1
- package/bin/lean/prefilter.mjs +83 -23
- package/bin/lean/prefilter.test.mjs +73 -1
- package/bin/lean/probe.mjs +2 -2
- package/bin/lean/probe.test.mjs +1 -1
- package/bin/lean/prompts.mjs +15 -9
- package/bin/lean/resume.test.mjs +1 -1
- package/bin/lean/retry.mjs +28 -0
- package/bin/lean/sandbox-provider.contract.test.mjs +8 -3
- package/bin/lean/sandbox-remote.mjs +4 -4
- package/bin/lean/schedd.mjs +53 -9
- package/bin/lean/schedd.test.mjs +77 -5
- package/bin/lean/sequence.mjs +24 -0
- package/bin/lean/sequence.test.mjs +31 -1
- package/bin/lean/signoff.mjs +66 -0
- package/bin/lean/signoff.test.mjs +102 -0
- package/bin/lean/train.mjs +177 -0
- package/bin/lean/train.test.mjs +113 -0
- package/bin/pool/cli.mjs +2 -0
- package/bin/pool/health.mjs +30 -0
- package/bin/review-context.mjs +298 -0
- package/bin/runner.mjs +26 -3
- package/bin/stats/compounding.mjs +157 -0
- package/bin/stats/compounding.test.mjs +74 -0
- package/bin/stats/index.mjs +21 -2
- package/commands/bootstrap.md +10 -8
- package/commands/deploy-info.md +3 -0
- package/commands/issue.md +2 -0
- package/commands/milestone.md +43 -54
- package/commands/orchestrate/phase-4-execution.md +2 -0
- package/commands/orchestrate.md +8 -1
- package/commands/pipeline-status.md +20 -0
- package/commands/review-pr.md +1 -0
- package/commands/work-on/build/implement.md +1 -1
- package/commands/work-on/build.md +4 -2
- package/commands/work-on/investigate.md +3 -1
- package/commands/work-on/remediate.md +1 -1
- package/docs/CONFIG.md +71 -2
- package/docs/EVENTS.md +1 -4
- package/docs/OUTCOME-CODES.md +9 -1
- package/docs/STATS.md +20 -0
- package/docs/site/start-a-new-project.md +1 -1
- package/docs/spec/label-state-machine.md +6 -0
- package/package.json +1 -1
- package/pool/install/install-agent.sh +4 -2
- package/pool/install/install-router.sh +4 -2
- package/pool/install/install-schedd.sh +28 -0
- package/pool/install/unit-hardening.sh +34 -0
- package/scripts/acceptance-red-green.sh +19 -2
- package/scripts/acceptance-red-green.test.sh +2 -0
- package/scripts/bootstrap-create.mjs +3 -2
- package/scripts/bootstrap-validate.mjs +31 -4
- package/scripts/file-deferred-followups.sh +285 -77
- package/scripts/file-deferred-followups.test.sh +111 -4
- package/scripts/hotspots.sh +23 -2
- package/scripts/hotspots.test.sh +9 -0
- package/scripts/plan-validate-live.mjs +111 -0
- package/scripts/readiness.mjs +141 -0
- package/scripts/review-prepare.sh +17 -1
- package/scripts/review-prepare.test.sh +17 -0
- package/scripts/settle-scope.mjs +74 -0
- package/scripts/systemd/forgedock-bot-token-refresh.service +7 -0
- package/scripts/systemd/forgedock-bot-token-refresh.timer +11 -0
- package/scripts/wait-ci-green.sh +14 -3
- package/scripts/wait-ci-green.test.sh +3 -1
- package/templates/bootstrap/needs-human-issue.md +4 -1
- package/templates/github-actions/migration-journal-guard.test.mjs +26 -0
package/bin/lean/lean.test.mjs
CHANGED
|
@@ -4,11 +4,13 @@ import { describe, it } from "node:test";
|
|
|
4
4
|
import { laneBackendId } from "./limits.mjs";
|
|
5
5
|
import assert from "node:assert/strict";
|
|
6
6
|
import { tmpDir } from "../tests/_tmp.mjs";
|
|
7
|
+
import { execFileSync } from "node:child_process";
|
|
7
8
|
import { writeFileSync, existsSync, readFileSync, mkdirSync, symlinkSync, readdirSync } from "node:fs";
|
|
8
9
|
import { join } from "node:path";
|
|
9
10
|
import { runCore, contractHash, isProtectedLaneBranch, classifyBlock } from "./lean.mjs";
|
|
10
11
|
import { parseFactsComment, normaliseFacts, renderFactsRecord, FACTS_MARKER } from "./facts.mjs";
|
|
11
12
|
import { makeLedger } from "./ledger.mjs";
|
|
13
|
+
import { writeIndex } from "./guards.mjs";
|
|
12
14
|
import { parseVerify } from "./verify.mjs";
|
|
13
15
|
import { worktreeFingerprint } from "./precheck.mjs";
|
|
14
16
|
import { renderInvestigation, renderPlan, renderImplementation, renderReview, renderPrBody, renderTrajectory, renderStructure, renderContract, contractJson, parseContractComment, sanitizeMd, capComment, section, cell, NR, MAX_COMMENT_CHARS } from "./render.mjs";
|
|
@@ -19,13 +21,14 @@ import { mapRecord } from "../engine/telemetry-seam.mjs";
|
|
|
19
21
|
import { readLog as readRepoLog } from "../engine/runlog.mjs";
|
|
20
22
|
// #4432: the run log is keyed by repo + issue; every lane in this file runs in repo o/r.
|
|
21
23
|
const readLog = (dir, issue, repo = "o/r") => readRepoLog(dir, issue, { repo });
|
|
22
|
-
import { makeRunTmp, wrapLaneLimits, systemdRunAvailable, makeLeanRunner, requiredChecksFor, procsInDir, makeLeanIo, makeGhCache } from "./effects.mjs";
|
|
23
|
-
import { retrying, ghCacheRetry, PUSH_TRANSIENT_RE } from "./retry.mjs";
|
|
24
|
+
import { makeRunTmp, wrapLaneLimits, laneCapNotice, systemdRunAvailable, makeLeanRunner, requiredChecksFor, procsInDir, makeLeanIo, makeGhCache } from "./effects.mjs";
|
|
25
|
+
import { retrying, ghCacheRetry, refreshAuthOnce, GH_AUTH_RE, PUSH_TRANSIENT_RE } from "./retry.mjs";
|
|
24
26
|
import { checkAdmission, hostParallelism } from "./admission.mjs";
|
|
25
27
|
import { OUTCOME_CODES, classifyNeedsHuman } from "../engine/outcome-codes.mjs";
|
|
26
28
|
import * as refactorMod from "./refactor.mjs";
|
|
27
29
|
import { readAdmission, readConformanceMode, readReviewIncomplete, readEnvConfig, readContextPack } from "../engine-cli.mjs";
|
|
28
30
|
import { prepareToolchain } from "./toolchain.mjs";
|
|
31
|
+
import { isExternalKill } from "./hostkill.mjs";
|
|
29
32
|
import { runFromCli, orchestrateFromCli, ENGINE_PATHS, readMaintenance } from "../engine-cli.mjs";
|
|
30
33
|
// orchestrateFromCli opens the host lane registry (#4191): keep it in a temp dir.
|
|
31
34
|
process.env.FORGEDOCK_SCHED_DIR = tmpDir("lean-sched-");
|
|
@@ -354,7 +357,7 @@ describe("core work-on", () => {
|
|
|
354
357
|
});
|
|
355
358
|
|
|
356
359
|
it("a reviewer that judges a deferral unjustified gets the builder resumed to implement it; nothing is filed for it", async () => {
|
|
357
|
-
const k = { ...CONTRACT, preserve: [], requirements: [CONTRACT.requirements[0], { id: "R2", quote: "Existing callers of foo keep working.", disposition: "defer", why: "later" }] };
|
|
360
|
+
const k = { ...CONTRACT, preserve: [], requirements: [CONTRACT.requirements[0], { id: "R2", quote: "Existing callers of foo keep working.", disposition: "defer", why: "later", bucket: "new-issue" }] };
|
|
358
361
|
const rej = conf({ requirements: [{ id: "R1", status: "implemented", evidence: "src/foo.js:3", validated_by: "V1" }, { id: "R2", status: "deferred", deferral_ok: false, note: "one line in bar.js; do it now" }] }, k);
|
|
359
362
|
const w = world({ contract: k, claims: [{ id: "R1", status: "implemented" }], reviews: [rej, rej] });
|
|
360
363
|
const r = await run(w);
|
|
@@ -441,6 +444,20 @@ describe("core work-on", () => {
|
|
|
441
444
|
noNeedsHuman(w);
|
|
442
445
|
});
|
|
443
446
|
|
|
447
|
+
it("#4686 a needs-human dependency whose PR is already on the lane is not dependency-blocked", async () => {
|
|
448
|
+
const w = depWorld({ changed: [], result: { status: "blocked", summary: "upstream", block: { kind: "dependency", issues: [3] } } },
|
|
449
|
+
{ 3: { state: "OPEN", labels: [{ name: "needs-human" }] } });
|
|
450
|
+
const gh = w.io.gh, git = w.io.git, seen = [];
|
|
451
|
+
w.io.gh = async (a) => (a[0] === "pr" && a[1] === "list" && a.includes("merged")
|
|
452
|
+
? JSON.stringify([{ number: 88, mergeCommit: { oid: "deadbeefcafe0000" }, closingIssuesReferences: [{ number: 3 }], title: "", body: "" }])
|
|
453
|
+
: gh(a));
|
|
454
|
+
w.io.git = async (a, o) => { if (a[0] === "merge-base" && a[2] === "deadbeefcafe0000") { seen.push(a.join(" ")); return ""; } return git(a, o); };
|
|
455
|
+
const r = await run(w);
|
|
456
|
+
assert.notEqual(r.terminalReason, "dependency-blocked");
|
|
457
|
+
assert.notEqual(r.terminalReason, "dependency-open");
|
|
458
|
+
assert.ok(seen.some((c) => c.includes("deadbeefcafe0000") && c.includes("origin/")), seen.join("|"));
|
|
459
|
+
});
|
|
460
|
+
|
|
444
461
|
it("#4124 split-required files sub-issues with Parent / Depends-on lines and ends decomposed", async () => {
|
|
445
462
|
const w = depWorld({ changed: [], result: { status: "blocked", summary: "epic", block: { kind: "split-required", subissues: [
|
|
446
463
|
{ title: "part A", body: "do A", paths: ["src/a"] }, { title: "part B", body: "do B", paths: ["src/b"], depends_on: [0] }] } } }, {});
|
|
@@ -658,6 +675,10 @@ describe("core work-on", () => {
|
|
|
658
675
|
const remote = { placement: { where: "remote", reason: "mode remote" }, hostCaps: { hostCapabilities: ["sops"] } };
|
|
659
676
|
assert.equal(await rb("this lane has no sops/age key", remote), "env.capability_remote");
|
|
660
677
|
assert.equal(await rb("owner must create a third-party account", remote), "builder.blocked");
|
|
678
|
+
// #4685: a missing tool is never a credential block on a remote lane; local lanes and real credential needs stay human.
|
|
679
|
+
assert.equal(await rb("validate V1 needs promtool via docker; no docker daemon in this sandbox", remote), "env.tool_missing");
|
|
680
|
+
assert.equal(await rb("ModuleNotFoundError: No module named 'yaml'", remote), "env.tool_missing");
|
|
681
|
+
assert.equal(await rb("promtool: command not found", { ...remote, placement: { where: "local", reason: "x" } }), "builder.blocked");
|
|
661
682
|
assert.equal(await rb("this lane has no sops/age key", { ...remote, hostCaps: undefined }), "builder.blocked");
|
|
662
683
|
assert.equal(await rb("this lane has no sops/age key", { ...remote, placement: { where: "local", reason: "x" } }), "builder.blocked");
|
|
663
684
|
assert.equal(await code(world({ changed: [] })), "builder.no_commit");
|
|
@@ -719,7 +740,7 @@ describe("core work-on", () => {
|
|
|
719
740
|
|
|
720
741
|
describe("conformance gate (frozen contract, precheck, structured review)", () => {
|
|
721
742
|
const Q2 = "Existing callers of foo keep working.";
|
|
722
|
-
const two = (d2 = "deliver", extra = {}) => ({ ...CONTRACT, preserve: [], requirements: [CONTRACT.requirements[0], { id: "R2", quote: Q2, disposition: d2, ...extra }],
|
|
743
|
+
const two = (d2 = "deliver", extra = {}) => ({ ...CONTRACT, preserve: [], requirements: [CONTRACT.requirements[0], { id: "R2", quote: Q2, disposition: d2, ...(d2 === "defer" || d2 === "post-deploy" ? { bucket: "post-deploy" } : {}), ...extra }],
|
|
723
744
|
deliverables: [...CONTRACT.deliverables, ...(d2 === "deliver" ? [{ id: "D2", path: "src/bar.js", change: "keep callers", requirements: ["R2"] }] : [])] });
|
|
724
745
|
const files2 = ["src/foo.js", "src/bar.js", "test/foo.test.js"];
|
|
725
746
|
const events = (dir) => readLog(dir, 7).map((e) => e.event);
|
|
@@ -785,7 +806,7 @@ describe("conformance gate (frozen contract, precheck, structured review)", () =
|
|
|
785
806
|
assert.equal(r.terminalReason, "merged");
|
|
786
807
|
assert.equal(w.followups.length, 1);
|
|
787
808
|
assert.deepEqual(w.followups[0].items.map((i) => i.key), ["R2: Existing callers of foo keep working."]);
|
|
788
|
-
assert.match(w.followups[0].items[0].why, /^
|
|
809
|
+
assert.match(w.followups[0].items[0].why, /^post-deploy: \[bucket:post-deploy\] callers live in another module/);
|
|
789
810
|
const body = w.prs[0][w.prs[0].indexOf("--body") + 1];
|
|
790
811
|
assert.match(body, /## Remaining work tracked\n- #900\n\nCloses #7$/);
|
|
791
812
|
assert.ok(w.comments.some((c) => c.includes("<!-- FORGE:TRACKED -->") && c.includes("#900")));
|
|
@@ -1377,6 +1398,20 @@ describe("conformance shadow mode (the default)", () => {
|
|
|
1377
1398
|
const ra = await runShadow(a);
|
|
1378
1399
|
assert.equal(ra.code, "conformance.unimplemented"); assert.match(nhBody(a), /amendment without why/);
|
|
1379
1400
|
});
|
|
1401
|
+
it("#4737 decision records are persisted after the merge, never at gate time", async () => {
|
|
1402
|
+
const dir = tmpDir("dec-"); const prev = process.env.FORGEDOCK_DECISIONS_DIR; process.env.FORGEDOCK_DECISIONS_DIR = dir;
|
|
1403
|
+
try {
|
|
1404
|
+
const amendments = [{ kind: "decision", why: "w", decision: { choice: "use X", anchors: ["src/db.ts"], why: "w" } }];
|
|
1405
|
+
const bad = world({ validation: [], amendments });
|
|
1406
|
+
assert.equal((await runShadow(bad)).code, "conformance.unimplemented");
|
|
1407
|
+
assert.ok(!bad.comments.some((c) => c.includes("FORGE:DECISION")), "a lane that never merges records nothing");
|
|
1408
|
+
assert.equal(readdirSync(dir).length, 0, "no index written");
|
|
1409
|
+
const ok = world({ amendments });
|
|
1410
|
+
assert.equal((await runShadow(ok)).terminalReason, "merged");
|
|
1411
|
+
assert.ok(ok.comments.some((c) => c.includes("FORGE:DECISION")), "recorded after the merge");
|
|
1412
|
+
assert.equal(readdirSync(dir).length, 1);
|
|
1413
|
+
} finally { if (prev === undefined) delete process.env.FORGEDOCK_DECISIONS_DIR; else process.env.FORGEDOCK_DECISIONS_DIR = prev; }
|
|
1414
|
+
});
|
|
1380
1415
|
it("#4160 scope creep found by the precheck stays recorded-only in shadow", async () => {
|
|
1381
1416
|
const w = world({ changed: ["src/foo.js", "src/billing.js", "test/foo.test.js"], reviews: [conf({}, CONTRACT, ["src/foo.js", "src/billing.js", "test/foo.test.js"])] });
|
|
1382
1417
|
assert.equal((await runShadow(w)).terminalReason, "merged");
|
|
@@ -1389,7 +1424,7 @@ describe("conformance shadow mode (the default)", () => {
|
|
|
1389
1424
|
});
|
|
1390
1425
|
|
|
1391
1426
|
it("#4160 shadow files accepted deferrals with track(), the same as enforce, and the PR links them", async () => {
|
|
1392
|
-
const k = { ...CONTRACT, preserve: [], requirements: [CONTRACT.requirements[0], { id: "R2", quote: "Existing callers of foo keep working.", disposition: "defer", why: "w" }] };
|
|
1427
|
+
const k = { ...CONTRACT, preserve: [], requirements: [CONTRACT.requirements[0], { id: "R2", quote: "Existing callers of foo keep working.", disposition: "defer", why: "w", bucket: "new-issue" }] };
|
|
1393
1428
|
const w = world({ contract: k, claims: [{ id: "R1", status: "implemented" }], reviews: [conf({}, k)] });
|
|
1394
1429
|
assert.equal((await runShadow(w)).terminalReason, "merged");
|
|
1395
1430
|
assert.equal(w.followups.length, 1); assert.match(w.followups[0].items[0].key, /^R2: /);
|
|
@@ -1423,7 +1458,7 @@ describe("conformance shadow mode (the default)", () => {
|
|
|
1423
1458
|
});
|
|
1424
1459
|
|
|
1425
1460
|
it("#4398 an investigation whose question is deferred closes partial (not planned), carried forward in the tracking issue", async () => {
|
|
1426
|
-
const k = { ...CONTRACT, preserve: [], requirements: [CONTRACT.requirements[0], { id: "R2", quote: "Existing callers of foo keep working.", disposition: "defer", why: "w" }] };
|
|
1461
|
+
const k = { ...CONTRACT, preserve: [], requirements: [CONTRACT.requirements[0], { id: "R2", quote: "Existing callers of foo keep working.", disposition: "defer", why: "w", bucket: "new-issue" }] };
|
|
1427
1462
|
const w = world({ title: "investigate(stealth): which signal is rejected", contract: k, claims: [{ id: "R1", status: "implemented" }], reviews: [conf({}, k)] });
|
|
1428
1463
|
const r = await runShadow(w);
|
|
1429
1464
|
assert.equal(r.terminalReason, "merged"); assert.equal(r.close, "partial");
|
|
@@ -1447,7 +1482,7 @@ describe("conformance shadow mode (the default)", () => {
|
|
|
1447
1482
|
const ra = await runShadow(a);
|
|
1448
1483
|
assert.equal(ra.terminalReason, "merged"); assert.equal(ra.close, undefined);
|
|
1449
1484
|
assert.deepEqual(a.closed.map((x) => x[x.indexOf("--reason") + 1]), ["completed"]);
|
|
1450
|
-
const k = { ...CONTRACT, preserve: [], requirements: [CONTRACT.requirements[0], { id: "R2", quote: "Existing callers of foo keep working.", disposition: "defer", why: "w" }] };
|
|
1485
|
+
const k = { ...CONTRACT, preserve: [], requirements: [CONTRACT.requirements[0], { id: "R2", quote: "Existing callers of foo keep working.", disposition: "defer", why: "w", bucket: "new-issue" }] };
|
|
1451
1486
|
const b = world({ contract: k, claims: [{ id: "R1", status: "implemented" }], reviews: [conf({}, k)] });
|
|
1452
1487
|
await runShadow(b);
|
|
1453
1488
|
assert.deepEqual(b.closed.map((x) => x[x.indexOf("--reason") + 1]), ["completed"]);
|
|
@@ -1624,9 +1659,9 @@ describe("hotspot prevention (forge#4195): plan facts, build rule, unexempted gr
|
|
|
1624
1659
|
assert.equal(hotspotBuildRule(facts([], { file_lines: 1000, growth_lines: 50 })).trim(), "Don't grow a file over 1000 lines by more than 50 lines. Put new code next to it.");
|
|
1625
1660
|
assert.equal(hotspotBuildRule(null), ""); assert.equal(hotspotBuildRule(facts([], { enabled: false })), "");
|
|
1626
1661
|
const plan = builderPlanPrompt(7) + hotspotPlanNote(facts([HOT, { ...HOT, path: "a/b.py" }, { ...HOT, path: "c/d.py" }]));
|
|
1627
|
-
// 1850: #4404 (verify out_of_band) and #4396 (split size rule) grew the plan prompt to ~1803 tokens on CI paths; 1900: #4589 (check timeout is not a block, one block-rule line).
|
|
1628
|
-
assert.ok(approxTokens(plan) <=
|
|
1629
|
-
assert.ok(approxTokens(builderImplementPrompt() + hotspotBuildRule(facts([]))) <=
|
|
1662
|
+
// 1850: #4404 (verify out_of_band) and #4396 (split size rule) grew the plan prompt to ~1803 tokens on CI paths; 1900: #4589 (check timeout is not a block, one block-rule line). 2050: #4724 (deferred-row bucket rule).
|
|
1663
|
+
assert.ok(approxTokens(plan) <= 2050, `plan prompt with hotspots is ${approxTokens(plan)} tokens`);
|
|
1664
|
+
assert.ok(approxTokens(builderImplementPrompt() + hotspotBuildRule(facts([]))) <= 1650); // 1600: #4591 (never hand-name migration files) and #4596 (i18n translate rule), one line each; 1650: #4726 (e2e rule)
|
|
1630
1665
|
});
|
|
1631
1666
|
|
|
1632
1667
|
it("#4519 generated_paths: both prompts name the globs (even with the gate off), and an amendment adding one blocks", async () => {
|
|
@@ -1801,6 +1836,24 @@ describe("structure delta and decision provenance at merge (forge#4165)", () =>
|
|
|
1801
1836
|
return w;
|
|
1802
1837
|
};
|
|
1803
1838
|
|
|
1839
|
+
it("#4694 a guarded deliverable freezes H preserve rows listed under Decision sources; an unguarded lane gets none", async () => {
|
|
1840
|
+
const home = tmpDir("hist-home-"), prev = process.env.HOME;
|
|
1841
|
+
process.env.HOME = home;
|
|
1842
|
+
try {
|
|
1843
|
+
const plain = await run(mworld(), { dir: tmpDir("hist-a-") });
|
|
1844
|
+
assert.equal(plain.terminalReason, "merged");
|
|
1845
|
+
const guard = { v: 1, issue: 3, pr: 30, merge_sha: "s", tests: [{ path: "test/foo.test.js", ids: [] }], hunks: [{ file: "src/foo.js", start: 1, end: 3, symbol: null }], why: "keep foo stable" };
|
|
1846
|
+
writeIndex("o/r", [guard]);
|
|
1847
|
+
const withH = { ...contract, preserve: [...(contract.preserve ?? []), { id: "H1", what: "x" }] };
|
|
1848
|
+
const w = mworld({ reviews: [conf({}, withH, ["src/foo.js", "test/foo.test.js", "packages/db/package.json"])] });
|
|
1849
|
+
const r = await run(w, { dir: tmpDir("hist-b-") });
|
|
1850
|
+
assert.equal(r.terminalReason, "merged");
|
|
1851
|
+
const frozen = w.comments.filter((c) => c.includes("FORGE:CONTRACT")).map((c) => parseContractComment(c)).find(Boolean).contract;
|
|
1852
|
+
assert.deepEqual(frozen.preserve.filter((p) => p.source === "guard"), [{ id: "H1", what: "keep foo stable (#3, PR #30)", source: "guard" }]);
|
|
1853
|
+
assert.match(w.comments.find((c) => c.includes("FORGE:TRAJECTORY")), /\| Decision sources \| K1 doc, K2 precedent, H1 guard \|/);
|
|
1854
|
+
} finally { if (prev === undefined) delete process.env.HOME; else process.env.HOME = prev; }
|
|
1855
|
+
});
|
|
1856
|
+
|
|
1804
1857
|
it("a merged run writes one STRUCTURE_DELTA with edges, growth, provenance and the PR; the trajectory shows it; no extra gh call", async () => {
|
|
1805
1858
|
const dir = tmpDir("struct-merge-");
|
|
1806
1859
|
const w = mworld();
|
|
@@ -2043,8 +2096,8 @@ describe("comment templates", () => {
|
|
|
2043
2096
|
|
|
2044
2097
|
it("plan and implement prompts carry the stage templates and the contract schema within budget", () => {
|
|
2045
2098
|
const plan = builderPlanPrompt(7), impl = builderImplementPrompt();
|
|
2046
|
-
assert.ok(approxTokens(plan) <=
|
|
2047
|
-
assert.ok(approxTokens(impl) <=
|
|
2099
|
+
assert.ok(approxTokens(plan) <= 1950, `plan prompt is ${approxTokens(plan)} tokens`); // 1950: #4724 (deferred-row bucket rule)
|
|
2100
|
+
assert.ok(approxTokens(impl) <= 1650, `implement prompt is ${approxTokens(impl)} tokens`); // 1650: #4726 e2e rule; 1550: #4596 i18n translate rule
|
|
2048
2101
|
for (const h of ["### What Was Claimed", "### What We Found", "### Root Cause", "### Affected Files", "### Sibling Sweep", "### History", "### Recommendation", "### Related Issues",
|
|
2049
2102
|
"investigation.md", "contract.json", "PLANNING ONLY", '"requirements"', '"deliverables"', '"preserve"', '"validate"', "verbatim", "defer", "post-deploy"]) assert.ok(plan.includes(h), h);
|
|
2050
2103
|
for (const h of ["### Approach", "### Changes", "### Acceptance Criteria Status", "### Verification", "### Notes", "implementation.md", "result.json",
|
|
@@ -2056,12 +2109,23 @@ describe("merge_wait policy", () => {
|
|
|
2056
2109
|
const states = (o) => Object.entries(o).map(([name, state]) => ({ name, state }));
|
|
2057
2110
|
it("required with no protection (empty) merges immediately without polling", async () => {
|
|
2058
2111
|
const w = world(); let polled = 0;
|
|
2059
|
-
w.io.requiredChecks = async () => []; w.io.checkStates = async () =>
|
|
2112
|
+
w.io.requiredChecks = async () => []; w.io.checkStates = async () => []; // #4651: read once by the DCO gate, never polled
|
|
2060
2113
|
w.io.waitCiGreen = async () => { polled++; return { rc: 2, text: "" }; };
|
|
2061
2114
|
const r = await run(w);
|
|
2062
2115
|
assert.equal(r.terminalReason, "merged"); assert.equal(polled, 0);
|
|
2063
2116
|
assert.match(traj(w)[0], /\| Merge \| required \(no required checks: merged right after review\) \|/);
|
|
2064
2117
|
});
|
|
2118
|
+
it("a failed DCO check blocks the merge with ci.dco even when no check is required (#4651)", async () => {
|
|
2119
|
+
for (const mergeWait of ["required", "none"]) {
|
|
2120
|
+
const w = world(); w.io.requiredChecks = async () => []; w.io.checkStates = async () => [{ name: "DCO Sign-Off Verification", state: "failure", head: w.head }, { name: "build", state: "success" }];
|
|
2121
|
+
const r = await run(w, { mergeWait });
|
|
2122
|
+
assert.equal(r.terminalReason, "needs-human", mergeWait); assert.equal(r.code, "ci.dco", mergeWait); assert.equal(w.merges.length, 0, mergeWait);
|
|
2123
|
+
}
|
|
2124
|
+
});
|
|
2125
|
+
it("a passing or stale-head DCO check does not block the merge (#4651)", async () => {
|
|
2126
|
+
const w = world(); w.io.requiredChecks = async () => []; w.io.checkStates = async () => [{ name: "DCO Sign-Off Verification", state: "failure", head: "ffff0000" }, { name: "dco", state: "success", head: w.head }];
|
|
2127
|
+
assert.equal((await run(w)).terminalReason, "merged");
|
|
2128
|
+
});
|
|
2065
2129
|
it("required with a pending required check waits (backoff) then merges", async () => {
|
|
2066
2130
|
const w = world(); const seqs = [states({ build: "pending" }), states({ build: "pending" }), states({ build: "success", other: "failure" })]; let sleeps = 0;
|
|
2067
2131
|
w.io.requiredChecks = async () => ["build"]; w.io.checkStates = async () => seqs.shift();
|
|
@@ -2116,6 +2180,21 @@ describe("merge_wait policy", () => {
|
|
|
2116
2180
|
assert.equal(events(dir, "CI_FLAKE_RERUN").length, 1);
|
|
2117
2181
|
});
|
|
2118
2182
|
|
|
2183
|
+
it("#4708 merge-time CI fix round reviews a head with every validate recorded (the fix session dropped its rows)", async () => {
|
|
2184
|
+
const w = world({ resumeValidation: [] }); w.reran = [];
|
|
2185
|
+
w.io.requiredChecks = async () => ["build"];
|
|
2186
|
+
w.io.rerunFailedJobs = async (id) => { w.reran.push(id); };
|
|
2187
|
+
w.io.checkStates = async () => [{ name: "build", state: w.fixed ? "success" : "failure", url: RUN(77, w.reran.length + 1) }];
|
|
2188
|
+
w.io.failedLogs = async () => { w.fixed = true; return "boom"; };
|
|
2189
|
+
const dir = tmpDir("core-");
|
|
2190
|
+
const r = await run(w, { dir, verify: { ...parseVerify({}), maxRounds: 1 } });
|
|
2191
|
+
assert.equal(r.terminalReason, "merged", r.reason);
|
|
2192
|
+
assert.deepEqual(events(dir, "CI_FIX_ROUND").map((e) => e.trigger), ["merge"]);
|
|
2193
|
+
const written = JSON.parse(w.files[`${WT}/.forge-lean/result.json`]);
|
|
2194
|
+
assert.deepEqual(written.validation.map((x) => [x.id, x.carried === true]), [["V1", true]], "the earlier result is carried forward, marked");
|
|
2195
|
+
assert.ok(traj(w)[0].includes("carried forward across the CI fix round"));
|
|
2196
|
+
});
|
|
2197
|
+
|
|
2119
2198
|
it("a rerun that reports a new attempt still red goes to the fix round and reaches the builder", async () => {
|
|
2120
2199
|
const w = redBuild("failure", RUN(77, 2)); const p = fixPrompts(w), n = waits(w), dir = tmpDir("core-");
|
|
2121
2200
|
await run(w, { dir, verify: { ...parseVerify({}), maxRounds: 1 } });
|
|
@@ -2374,6 +2453,29 @@ describe("orchestrate", () => {
|
|
|
2374
2453
|
}
|
|
2375
2454
|
});
|
|
2376
2455
|
|
|
2456
|
+
it("#4687 a dependency skip waits (logged with its trigger) until the blocker merges; the re-admission is logged too", async () => {
|
|
2457
|
+
const rowsSeen = [];
|
|
2458
|
+
let merged = false;
|
|
2459
|
+
const rows = await orchestrate({ issues: [9, 1], parallel: 1, readBody: async (n) => (n === 1 ? "**Depends on**: #9" : ""),
|
|
2460
|
+
onUpdate: (r) => rowsSeen.push(r),
|
|
2461
|
+
dispatchCheck: async (n) => (n === 9 && merged ? { outcome: "merged", pr: 90, detail: "PR #90 already merged" } : null),
|
|
2462
|
+
runOne: async (n) => { if (n === 9) { merged = true; return { terminalReason: "needs-human", detail: "ci.red" }; } return { terminalReason: "merged", pr: 101 }; } });
|
|
2463
|
+
const skipRow = rowsSeen.find((r) => r.issue === 1 && r.outcome === "skipped");
|
|
2464
|
+
assert.equal(skipRow.waiting, true);
|
|
2465
|
+
assert.equal(skipRow.trigger, 9);
|
|
2466
|
+
assert.ok(rowsSeen.some((r) => r.issue === 1 && r.outcome === "readmitted" && r.trigger === 9));
|
|
2467
|
+
assert.deepEqual(rows.map((r) => [r.issue, r.outcome, r.waiting]), [[9, "merged", undefined], [1, "merged", undefined]]);
|
|
2468
|
+
});
|
|
2469
|
+
|
|
2470
|
+
it("#4687 a blocker that closed as not planned leaves its dependents skipped, no longer waiting", async () => {
|
|
2471
|
+
let checks = 0, ran = false;
|
|
2472
|
+
const rows = await orchestrate({ issues: [9, 1], parallel: 1, readBody: async (n) => (n === 1 ? "**Depends on**: #9" : ""),
|
|
2473
|
+
dispatchCheck: async (n) => { if (n === 9 && ran) { checks++; return { outcome: "invalid", detail: "issue closed (not planned)", final: true }; } return null; },
|
|
2474
|
+
runOne: async () => { ran = true; return { terminalReason: "needs-human", detail: "ci.red" }; } });
|
|
2475
|
+
assert.deepEqual(rows.map((r) => [r.issue, r.outcome, r.waiting]), [[9, "needs-human", undefined], [1, "skipped", undefined]]);
|
|
2476
|
+
assert.equal(checks, 1, "a final blocker is not re-checked");
|
|
2477
|
+
});
|
|
2478
|
+
|
|
2377
2479
|
it("#4124 a repeated dependency-open stops after the requeue cap", async () => {
|
|
2378
2480
|
const h = harness({}, { 1: "dependency-open" });
|
|
2379
2481
|
const clock = { t: 0 }, starts = [];
|
|
@@ -3217,22 +3319,53 @@ describe("batch robustness (#4112-#4115)", () => {
|
|
|
3217
3319
|
}
|
|
3218
3320
|
});
|
|
3219
3321
|
|
|
3220
|
-
it("#4115b lane limits wrap the spawn in systemd-run; unset or invalid limits
|
|
3221
|
-
const w = wrapLaneLimits("claude", ["--print", "x"], { memory_max: "3G", cpu_quota: "150%" });
|
|
3222
|
-
assert.deepEqual(w, { bin: "systemd-run", args: ["--user", "--scope", "-q", "-p", "MemoryMax=3G", "-p", "CPUQuota=150%", "--", "claude", "--print", "x"] });
|
|
3223
|
-
assert.deepEqual(wrapLaneLimits("claude", ["a"], null),
|
|
3224
|
-
assert.deepEqual(wrapLaneLimits("claude", ["a"], { memory_max: "lots; rm -rf /", cpu_quota: "x" }),
|
|
3322
|
+
it("#4115b lane limits wrap the spawn in systemd-run; unset or invalid limits get only the default TasksMax (#4690)", () => {
|
|
3323
|
+
const w = wrapLaneLimits("claude", ["--print", "x"], { memory_max: "3G", cpu_quota: "150%" }, "u1");
|
|
3324
|
+
assert.deepEqual(w, { bin: "systemd-run", unit: "u1", args: ["--user", "--scope", "-q", "--unit=u1", "-p", "TasksMax=1024", "-p", "MemoryMax=3G", "-p", "CPUQuota=150%", "--", "claude", "--print", "x"] });
|
|
3325
|
+
assert.deepEqual(wrapLaneLimits("claude", ["a"], null, "u2").args.slice(0, 6), ["--user", "--scope", "-q", "--unit=u2", "-p", "TasksMax=1024"]);
|
|
3326
|
+
assert.deepEqual(wrapLaneLimits("claude", ["a"], { memory_max: "lots; rm -rf /", cpu_quota: "x" }, "u3").args, ["--user", "--scope", "-q", "--unit=u3", "-p", "TasksMax=1024", "--", "claude", "a"]);
|
|
3225
3327
|
assert.equal(wrapLaneLimits("claude", ["a"], { memory_max: "1G" }).args.includes("CPUQuota=150%"), false);
|
|
3226
3328
|
});
|
|
3227
3329
|
|
|
3228
|
-
it("#
|
|
3330
|
+
it("#4690 tasks_max is configurable; 0 or off disables it and, with nothing else set, leaves the spawn unchanged", () => {
|
|
3331
|
+
assert.ok(wrapLaneLimits("c", [], { tasks_max: "64" }, "u").args.includes("TasksMax=64"));
|
|
3332
|
+
for (const off of ["0", "off", 0]) assert.deepEqual(wrapLaneLimits("c", ["a"], { tasks_max: off }), { bin: "c", args: ["a"] });
|
|
3333
|
+
assert.equal(wrapLaneLimits("c", [], { tasks_max: "off", memory_max: "1G" }, "u").args.some((x) => x.startsWith("TasksMax")), false);
|
|
3334
|
+
});
|
|
3335
|
+
|
|
3336
|
+
it("#4115b the runner wraps by default when systemd-run probes available, never when it does not (clean fallback, #4690)", async () => {
|
|
3229
3337
|
const seen = [];
|
|
3230
3338
|
const runCli = async (o) => { seen.push(o.wrap ? o.wrap("claude", ["a"]).bin : null); return { text: "", usage: null, costUsd: 0, durationMs: 0, turns: 0, sessionId: "s" }; };
|
|
3231
3339
|
const base = { role: "builder", issue: 1, cwd: process.cwd(), prompt: "p", system: "" };
|
|
3232
3340
|
await makeLeanRunner({ runCli, probe: () => true })({ ...base, laneLimits: { memory_max: "3G" } });
|
|
3233
3341
|
await makeLeanRunner({ runCli, probe: () => false })({ ...base, laneLimits: { memory_max: "3G" } });
|
|
3234
3342
|
await makeLeanRunner({ runCli, probe: () => true })({ ...base });
|
|
3235
|
-
assert.deepEqual(seen, ["systemd-run", null,
|
|
3343
|
+
assert.deepEqual(seen, ["systemd-run", null, "systemd-run"]);
|
|
3344
|
+
assert.match(laneCapNotice(() => false), /lanes are uncapped/);
|
|
3345
|
+
assert.equal(laneCapNotice(() => true), null);
|
|
3346
|
+
});
|
|
3347
|
+
|
|
3348
|
+
it("#4690 a scope whose events show a limit stops the lane with CLI_LANE_LIMIT naming it; a scope-less spawn never reads events", async () => {
|
|
3349
|
+
const { writeFileSync, chmodSync } = await import("node:fs");
|
|
3350
|
+
const { runCliBackendAsync } = await import("../runner.mjs");
|
|
3351
|
+
const d = tmpDir("core-limit-");
|
|
3352
|
+
writeFileSync(`${d}/fake-claude`, "#!/bin/sh\nsleep 30\n"); chmodSync(`${d}/fake-claude`, 0o755);
|
|
3353
|
+
const go = (wrap, ev) => runCliBackendAsync({ spec: { name: "t" }, userMessage: "go", args: ["1"], cwd: d, bin: `${d}/fake-claude`, logger: { log() {} }, idleMs: 200, maxMs: 20000, wrap, limitProbe: () => ev });
|
|
3354
|
+
const fake = (b, a) => ({ bin: b, args: a, unit: "forgedock-lane-x" });
|
|
3355
|
+
for (const [ev, limit] of [[{ tasks: 3, memory: 0 }, "tasks"], [{ tasks: 0, memory: 1 }, "memory"]]) {
|
|
3356
|
+
await assert.rejects(go(fake, ev), (e) => e.code === "CLI_LANE_LIMIT" && e.limit === limit);
|
|
3357
|
+
}
|
|
3358
|
+
await assert.rejects(go((b, a) => ({ bin: b, args: a }), { tasks: 9, memory: 9 }), (e) => e.code === "CLI_IDLE");
|
|
3359
|
+
});
|
|
3360
|
+
|
|
3361
|
+
it("#4690 a CLI_LANE_LIMIT ends needs-human env.lane_limit naming the limit: no retry, never engine-error or host kill", async () => {
|
|
3362
|
+
const lim = () => Object.assign(new Error("claude CLI lane hit its scope tasks limit"), { code: "CLI_LANE_LIMIT", limit: "tasks", unit: "u" });
|
|
3363
|
+
assert.equal(isExternalKill(lim()), false);
|
|
3364
|
+
const w = world({ dirty: true }); let builders = 0;
|
|
3365
|
+
w.io.runner = async (c) => { if (c.role === "builder") { builders++; throw lim(); } return { text: "" }; };
|
|
3366
|
+
const r = await run(w);
|
|
3367
|
+
assert.equal(r.terminalReason, "needs-human"); assert.equal(r.code, "env.lane_limit");
|
|
3368
|
+
assert.match(r.detail, /TasksMax/); assert.equal(builders, 1);
|
|
3236
3369
|
});
|
|
3237
3370
|
|
|
3238
3371
|
it("#4115b integration: a lane under systemd-run --scope really runs in a capped scope (skipped without systemd-run)", { skip: !systemdRunAvailable() }, async () => {
|
|
@@ -3244,6 +3377,37 @@ describe("batch robustness (#4112-#4115)", () => {
|
|
|
3244
3377
|
assert.match(r.text, /\.scope/);
|
|
3245
3378
|
});
|
|
3246
3379
|
|
|
3380
|
+
it("#4690 a fixture lane really forking past its task cap is stopped with CLI_LANE_LIMIT (no systemd needed: the probe counts real descendants)", async () => {
|
|
3381
|
+
const { writeFileSync, chmodSync } = await import("node:fs");
|
|
3382
|
+
const { runCliBackendAsync, listDescendants } = await import("../runner.mjs");
|
|
3383
|
+
const d = tmpDir("core-forkfix-");
|
|
3384
|
+
writeFileSync(`${d}/fake-claude`, "#!/bin/sh\ni=0\nwhile [ $i -lt 40 ]; do sleep 20 & i=$((i+1)); done\nsleep 20\n"); chmodSync(`${d}/fake-claude`, 0o755);
|
|
3385
|
+
const cap = 10;
|
|
3386
|
+
const limitProbe = (pid) => ({ tasks: listDescendants(pid).length > cap ? 1 : 0, memory: 0 });
|
|
3387
|
+
await assert.rejects(runCliBackendAsync({ spec: { name: "t" }, userMessage: "go", args: ["1"], cwd: d, bin: `${d}/fake-claude`, logger: { log() {} }, maxMs: 15000, limitProbe,
|
|
3388
|
+
wrap: (b, a) => ({ bin: b, args: a, unit: "forgedock-lane-fixture" }) }), (e) => e.code === "CLI_LANE_LIMIT" && e.limit === "tasks");
|
|
3389
|
+
});
|
|
3390
|
+
|
|
3391
|
+
// The scope's cgroup counters must be readable (cgroup v2, delegated user scope); plain systemd-run availability is not enough on CI.
|
|
3392
|
+
const scopeEventsReadable = () => {
|
|
3393
|
+
if (!systemdRunAvailable()) return false;
|
|
3394
|
+
try {
|
|
3395
|
+
const w = wrapLaneLimits("sh", ["-c", "cat /proc/self/cgroup"], { tasks_max: 50 });
|
|
3396
|
+
const out = execFileSync(w.bin, w.args, { encoding: "utf8", timeout: 8000 });
|
|
3397
|
+
const path = (out.split("\n").find((l) => l.startsWith("0::")) ?? "").slice(3).trim();
|
|
3398
|
+
return path.endsWith(".scope") && existsSync(`/sys/fs/cgroup${path}/pids.events`);
|
|
3399
|
+
} catch { return false; }
|
|
3400
|
+
};
|
|
3401
|
+
it("#4690 integration: a step forking past TasksMax in a real scope ends CLI_LANE_LIMIT (skipped without systemd-run)", { skip: !scopeEventsReadable() }, async () => {
|
|
3402
|
+
const { writeFileSync, chmodSync } = await import("node:fs");
|
|
3403
|
+
const { runCliBackendAsync } = await import("../runner.mjs");
|
|
3404
|
+
const d = tmpDir("core-fork-");
|
|
3405
|
+
// node (not sh): a refused fork must not end the fixture before the sampler sees the scope's pids.events
|
|
3406
|
+
writeFileSync(`${d}/fake-claude`, `#!${process.execPath}\nconst { spawn } = require("node:child_process");\nfor (let i = 0; i < 200; i++) { try { spawn("sleep", ["20"], { stdio: "ignore" }).on("error", () => {}); } catch {} }\nsetTimeout(() => {}, 20000);\n`); chmodSync(`${d}/fake-claude`, 0o755);
|
|
3407
|
+
await assert.rejects(runCliBackendAsync({ spec: { name: "t" }, userMessage: "go", args: ["1"], cwd: d, bin: `${d}/fake-claude`, logger: { log() {} }, maxMs: 15000, wrap: (b, a) => wrapLaneLimits(b, a, { tasks_max: 20 }) }),
|
|
3408
|
+
(e) => e.code === "CLI_LANE_LIMIT" && e.limit === "tasks");
|
|
3409
|
+
});
|
|
3410
|
+
|
|
3247
3411
|
const hostFor = (state) => ({ memAvailableMb: () => 99999, freeMb: async () => 99999, tmpPaths: () => ["/t"], loadAvg1: () => state.load(), cores: () => 2 });
|
|
3248
3412
|
const slowBuilder = (w, ms, hook) => { const orig = w.io.runner; w.io.runner = async (c) => { if (c.role !== "builder") return orig(c); hook?.(c); await new Promise((r) => setTimeout(r, ms)); return orig(c); }; };
|
|
3249
3413
|
|
|
@@ -3439,6 +3603,35 @@ describe("batch robustness (#4112-#4115)", () => {
|
|
|
3439
3603
|
assert.equal(n, 1);
|
|
3440
3604
|
});
|
|
3441
3605
|
|
|
3606
|
+
it("gh.auth (#4662) a 401 runs one token refresh and the retry succeeds, so the lane merges", async () => {
|
|
3607
|
+
const w = world();
|
|
3608
|
+
const gh = w.io.gh; let n = 0, refreshes = 0;
|
|
3609
|
+
w.io.gh = async (a, opts) => { if (n++ === 0) throw new Error("gh: Bad credentials (HTTP 401)"); return gh(a, opts); };
|
|
3610
|
+
w.io.refreshAuth = async () => { refreshes++; };
|
|
3611
|
+
assert.equal((await run(w)).terminalReason, "merged");
|
|
3612
|
+
assert.equal(refreshes, 1);
|
|
3613
|
+
});
|
|
3614
|
+
|
|
3615
|
+
it("gh.auth (#4662) a 401 that outlives the refresh ends deferred gh.auth, never engine-error, and refreshes once", async () => {
|
|
3616
|
+
const w = world();
|
|
3617
|
+
let refreshes = 0;
|
|
3618
|
+
w.io.gh = async () => { throw new Error("gh: Bad credentials (HTTP 401)"); };
|
|
3619
|
+
w.io.refreshAuth = async () => { refreshes++; };
|
|
3620
|
+
const r = await run(w);
|
|
3621
|
+
assert.equal(r.terminalReason, "deferred");
|
|
3622
|
+
assert.equal(r.code, OUTCOME_CODES.GH_AUTH);
|
|
3623
|
+
assert.ok(r.retryInSeconds > 0);
|
|
3624
|
+
assert.equal(refreshes, 1);
|
|
3625
|
+
});
|
|
3626
|
+
|
|
3627
|
+
it("gh.auth (#4662) refreshAuthOnce leaves non-auth errors alone and GH_AUTH_RE ignores 403 and 5xx", async () => {
|
|
3628
|
+
assert.ok(GH_AUTH_RE.test("HTTP 401: Bad credentials") && !GH_AUTH_RE.test("HTTP 403: Resource not accessible") && !GH_AUTH_RE.test("HTTP 502: Bad Gateway"));
|
|
3629
|
+
let refreshes = 0;
|
|
3630
|
+
const other = new Error("HTTP 404: Not Found");
|
|
3631
|
+
await assert.rejects(refreshAuthOnce(async () => { throw other; }, { refresh: async () => { refreshes++; } }), (e) => e === other);
|
|
3632
|
+
assert.equal(refreshes, 0);
|
|
3633
|
+
});
|
|
3634
|
+
|
|
3442
3635
|
it("#4507 makeGhCache: a per-root cache dir, reset swaps in a fresh dir, the no-root fallback is disposed", () => {
|
|
3443
3636
|
const root = tmpDir("ghcache-");
|
|
3444
3637
|
const c = makeGhCache({ root });
|
|
@@ -4097,6 +4290,18 @@ describe("verify-by-CI (#4167): ci mode runs the heavy checks on CI and feeds fa
|
|
|
4097
4290
|
assert.match(traj(w)[0], /Validate V1 verified by CI at [0-9a-f]{0,12}/);
|
|
4098
4291
|
});
|
|
4099
4292
|
|
|
4293
|
+
it("#4708 remote placement: the merge-time CI fix round's head is CI-verified and reviewed with every validate recorded", async () => {
|
|
4294
|
+
const w = withCi(world({ resumeValidation: [] })); w.reran = [];
|
|
4295
|
+
w.io.requiredChecks = async () => ["build"];
|
|
4296
|
+
w.io.rerunFailedJobs = async (id) => { w.reran.push(id); };
|
|
4297
|
+
w.io.checkStates = async () => [{ name: "build", state: w.fixed ? "success" : "failure", url: `https://github.com/o/r/actions/runs/77/job/${w.reran.length + 1}` }];
|
|
4298
|
+
w.io.failedLogs = async () => { w.fixed = true; return "boom"; };
|
|
4299
|
+
const r = await run(w, { verify: CI });
|
|
4300
|
+
assert.equal(r.terminalReason, "merged", r.reason);
|
|
4301
|
+
assert.ok(w.ci.runs.some((x) => x.headSha === w.head), "the fix head is CI-verified before it is reviewed");
|
|
4302
|
+
assert.equal(JSON.parse(w.files[`${WT}/.forge-lean/result.json`]).validation[0].id, "V1");
|
|
4303
|
+
});
|
|
4304
|
+
|
|
4100
4305
|
it("docs-only lane: no push for CI, no dispatch; the decision is recorded", async () => {
|
|
4101
4306
|
const w = withCi(world({ changed: ["README.md", "docs/guide.md"], validation: ciValidation }));
|
|
4102
4307
|
const r = await run(w, { verify: CI, conformance: "shadow" });
|
|
@@ -5172,6 +5377,18 @@ describe("context pack modes (forge#4275)", () => {
|
|
|
5172
5377
|
assert.deepEqual([m.leaseWrites.at(-1).terminalCode, m.leaseWrites.at(-1).failureClasses], [OUTCOME_CODES.COMPLETED, []]);
|
|
5173
5378
|
});
|
|
5174
5379
|
|
|
5380
|
+
it("#4711 a merged lane run records mergedTo {lane, pr, sha} on the terminal FORGE:STATE", async () => {
|
|
5381
|
+
const w = world(); w.leaseWrites = []; w.io.projector = { readState: async () => null, writeState: async (_i, st) => { w.leaseWrites.push(st); } };
|
|
5382
|
+
const gh = w.io.gh;
|
|
5383
|
+
w.io.gh = async (a, o) => (a[0] === "pr" && a[1] === "view" && a.includes("mergeCommit") ? JSON.stringify({ mergeCommit: { oid: "feedface00112233" } }) : gh(a, o));
|
|
5384
|
+
const r = await run(w);
|
|
5385
|
+
assert.equal(r.terminalReason, "merged");
|
|
5386
|
+
const last = w.leaseWrites.at(-1);
|
|
5387
|
+
assert.ok(last.mergedTo?.lane && Number.isInteger(last.mergedTo.pr), JSON.stringify(last.mergedTo));
|
|
5388
|
+
assert.equal(last.mergedTo.sha, "feedface00112233");
|
|
5389
|
+
assert.ok(!w.leaseWrites.slice(0, -1).some((x) => x.mergedTo), "only the terminal write carries it");
|
|
5390
|
+
});
|
|
5391
|
+
|
|
5175
5392
|
it("#4444 ab: the lane runs its issue's arm, logs it as context_pack and tags context_ab", async () => {
|
|
5176
5393
|
const { contextArm } = await import("./context.mjs");
|
|
5177
5394
|
const arm = contextArm({ mode: "ab", repo: "o/r", issue: 7 }).mode;
|
|
@@ -6293,7 +6510,7 @@ describe("#4395 lane profile", () => {
|
|
|
6293
6510
|
assert.match(p, /Issue independent read-only calls \(reads, greps, git log\/blame, gh\) in the same turn or one shell call\./);
|
|
6294
6511
|
assert.match(p, /Read each file's ranges once .* and keep notes; don't re-read it\./);
|
|
6295
6512
|
}
|
|
6296
|
-
assert.ok(approxTokens(builderImplementPrompt()) <=
|
|
6513
|
+
assert.ok(approxTokens(builderImplementPrompt()) <= 1650 && approxTokens(builderPlanPrompt(7)) <= 1950);
|
|
6297
6514
|
});
|
|
6298
6515
|
|
|
6299
6516
|
it("runCliBackendAsync reports the first stream turn's context as baselineTokens; makeLeanRunner drops it on resume", async () => {
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* forge#4657: milestone lifecycle `building -> shipped:staging -> deployed:default -> closed`, recorded as one
|
|
3
|
+
* `<!-- FORGE:LIFECYCLE {json} -->` block in the milestone description (same register as FORGE:DRIFT, drift.mjs).
|
|
4
|
+
* Pure helpers plus `advanceMilestone`, which takes an injected `gh`/`git`. No LLM.
|
|
5
|
+
*/
|
|
6
|
+
import { readMilestone } from "./drift.mjs";
|
|
7
|
+
|
|
8
|
+
export const LIFECYCLE_MARKER = "FORGE:LIFECYCLE";
|
|
9
|
+
export const LIFECYCLE_STATES = ["building", "shipped:staging", "deployed:default", "closed"];
|
|
10
|
+
export const CARRY_CLASSES = ["engineering", "external", "post-deploy", "wont-do"];
|
|
11
|
+
const BLOCK_RE = new RegExp(`<!--\\s*${LIFECYCLE_MARKER}\\s+(\\{[\\s\\S]*?\\})\\s*-->`);
|
|
12
|
+
const POST_DEPLOY_LABELS = ["awaiting-promotion", "awaiting-due", "awaiting-precondition", "awaiting-measurement"];
|
|
13
|
+
const GATED_LABELS = ["flag-gated", "legal-gated", "legal-hold"];
|
|
14
|
+
const arr = (x) => (Array.isArray(x) ? x : []);
|
|
15
|
+
const names = (labels) => arr(labels).map((l) => String(l?.name ?? l));
|
|
16
|
+
|
|
17
|
+
/** The FORGE:LIFECYCLE record in a milestone description ({v, state, sha?}), or null. */
|
|
18
|
+
export function parseLifecycle(description) {
|
|
19
|
+
const m = BLOCK_RE.exec(String(description ?? ""));
|
|
20
|
+
if (!m) return null;
|
|
21
|
+
try { const s = JSON.parse(m[1]); return s && typeof s === "object" && !Array.isArray(s) && LIFECYCLE_STATES.includes(s.state) ? s : null; } catch { return null; }
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** `description` with its FORGE:LIFECYCLE block replaced (or appended); the text around it is unchanged. */
|
|
25
|
+
export function renderLifecycle(description, record) {
|
|
26
|
+
const d = String(description ?? "");
|
|
27
|
+
const block = `<!-- ${LIFECYCLE_MARKER} ${JSON.stringify({ v: 1, ...record }).replace(/>/g, "\\u003e")} -->`;
|
|
28
|
+
if (BLOCK_RE.test(d)) return d.replace(BLOCK_RE, () => block);
|
|
29
|
+
return d ? `${d}\n\n${block}` : block;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** `state` moves forward only: the later of `from` and `to` (a missing `from` is `building`). */
|
|
33
|
+
export function advanceState(from, to) {
|
|
34
|
+
const a = Math.max(0, LIFECYCLE_STATES.indexOf(from)), b = LIFECYCLE_STATES.indexOf(to);
|
|
35
|
+
return LIFECYCLE_STATES[Math.max(a, b)];
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** forge.yaml `milestones.close_on`: `staging` or `default` (the default). */
|
|
39
|
+
export const parseCloseOn = (v) => (String(v ?? "").trim().toLowerCase() === "staging" ? "staging" : "default");
|
|
40
|
+
|
|
41
|
+
/** True when `state` has reached the point `closeOn` closes the milestone at (and it is not closed yet). */
|
|
42
|
+
export function closeDue(state, closeOn = "default") {
|
|
43
|
+
if (state === "closed") return false;
|
|
44
|
+
return LIFECYCLE_STATES.indexOf(state) >= LIFECYCLE_STATES.indexOf(parseCloseOn(closeOn) === "staging" ? "shipped:staging" : "deployed:default");
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Carry-over class of an open issue from its labels: external, post-deploy, wont-do, else engineering. */
|
|
48
|
+
export function classifyCarryOver(issue) {
|
|
49
|
+
const l = names(issue?.labels);
|
|
50
|
+
const explicit = l.map((x) => /^carry:(.+)$/.exec(x)?.[1]).find((x) => CARRY_CLASSES.includes(x));
|
|
51
|
+
if (explicit) return explicit;
|
|
52
|
+
if (l.includes("needs-human:external")) return "external";
|
|
53
|
+
if (l.some((x) => POST_DEPLOY_LABELS.includes(x))) return "post-deploy";
|
|
54
|
+
if (l.includes("wont-do") || l.includes("wontfix")) return "wont-do";
|
|
55
|
+
return "engineering";
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Ship pre-flight over the open issues of a milestone. Each item: `{number, labels, target, dependsOn?}` where `target` is the
|
|
60
|
+
* milestone it is carried to. `onBase(n)` says whether dependency `n` is on the target base (default: yes).
|
|
61
|
+
* Returns `{ok, missing:[{issue, why}], carry:[{issue, class, target, demote}], mustNotDemote:[issue]}`. An issue that is
|
|
62
|
+
* external, flag/legal-gated or depends on an issue that is not on the base is never demoted to fast lane.
|
|
63
|
+
*/
|
|
64
|
+
export function preflightCarryOver(openIssues, { onBase = () => true } = {}) {
|
|
65
|
+
const missing = [], carry = [], mustNotDemote = [];
|
|
66
|
+
for (const it of arr(openIssues)) {
|
|
67
|
+
const n = it?.number, cls = classifyCarryOver(it), target = String(it?.target ?? "").trim();
|
|
68
|
+
if (!target) { missing.push({ issue: n, why: "no target milestone" }); continue; }
|
|
69
|
+
const gated = names(it.labels).some((x) => GATED_LABELS.includes(x));
|
|
70
|
+
const offBase = arr(it.dependsOn).some((d) => { try { return onBase(d) === false; } catch { return false; } });
|
|
71
|
+
const blocked = cls === "external" || gated || offBase;
|
|
72
|
+
if (blocked) mustNotDemote.push(n);
|
|
73
|
+
carry.push({ issue: n, class: cls, target, demote: !blocked });
|
|
74
|
+
}
|
|
75
|
+
return { ok: missing.length === 0, missing, carry, mustNotDemote };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Open milestone `{title, state, open, closed, description}` with a shipped/deployed record and open issues left. */
|
|
79
|
+
export function shippedWithOpen(ms) {
|
|
80
|
+
const s = parseLifecycle(ms?.description)?.state;
|
|
81
|
+
return ms?.state !== "closed" && ["shipped:staging", "deployed:default"].includes(s) && Number(ms?.open) > 0;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Open milestone with no open issues that is still not closed. */
|
|
85
|
+
export const emptyButOpen = (ms) => ms?.state !== "closed" && Number(ms?.open) === 0;
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Advance one milestone to `to` (never backwards), recording `sha` (the merge commit) when given. Closes it when `closeOn` is
|
|
89
|
+
* reached and no open issue is left. `deployed(sha)` (optional) reports whether the shipped commit is on the default branch;
|
|
90
|
+
* it promotes `shipped:staging` to `deployed:default`. Returns `{state, closed}` or null when the milestone is not found.
|
|
91
|
+
*/
|
|
92
|
+
export async function advanceMilestone({ gh, repo, title, to = null, sha = null, closeOn = "default", deployed = null }) {
|
|
93
|
+
const ms = await readMilestone({ gh, repo, title });
|
|
94
|
+
if (!ms) return null;
|
|
95
|
+
const before = parseLifecycle(ms.description);
|
|
96
|
+
let state = to ? advanceState(before?.state, to) : before?.state ?? "building";
|
|
97
|
+
const rsha = sha ?? before?.sha ?? null;
|
|
98
|
+
if (state === "shipped:staging" && rsha && deployed) { try { if (await deployed(rsha)) state = "deployed:default"; } catch { /* not provably deployed */ } }
|
|
99
|
+
let closed = false;
|
|
100
|
+
if (closeDue(state, closeOn)) {
|
|
101
|
+
const open = JSON.parse(String(await gh(["issue", "list", "-R", repo, "--milestone", title, "--state", "open", "--limit", "1", "--json", "number"])) || "[]");
|
|
102
|
+
if (!arr(open).length) { state = "closed"; closed = true; }
|
|
103
|
+
}
|
|
104
|
+
const next = { state, ...(rsha ? { sha: rsha } : {}) };
|
|
105
|
+
if (!before || before.state !== next.state || before.sha !== next.sha) {
|
|
106
|
+
await gh(["api", "-X", "PATCH", `repos/${repo}/milestones/${ms.number}`, "-f", `description=${renderLifecycle(ms.description, next)}`, ...(closed ? ["-f", "state=closed"] : [])]);
|
|
107
|
+
}
|
|
108
|
+
return { state, closed };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* The orchestrate `lifecycle` seam: `onBatchEnd(titles)` records `shipped:staging` for each milestone the batch merged into once it has no open issue, so an
|
|
113
|
+
* orchestrate-driven milestone closes once `close_on` is reached. Errors are the caller's to report.
|
|
114
|
+
*/
|
|
115
|
+
export function makeLifecycle({ gh, git = null, repo, closeOn = "default", defaultBranch = "main" }) {
|
|
116
|
+
const deployed = git ? async (sha) => { try { await git(["merge-base", "--is-ancestor", sha, `origin/${defaultBranch}`]); return true; } catch { return false; } } : null;
|
|
117
|
+
return {
|
|
118
|
+
async onBatchEnd(titles) {
|
|
119
|
+
const out = [];
|
|
120
|
+
for (const title of titles) {
|
|
121
|
+
// D3: only a milestone with no open issue left is shipped by the batch; otherwise it is re-evaluated as is.
|
|
122
|
+
const open = JSON.parse(String(await gh(["issue", "list", "-R", repo, "--milestone", title, "--state", "open", "--limit", "1", "--json", "number"])) || "[]");
|
|
123
|
+
const r = await advanceMilestone({ gh, repo, title, to: arr(open).length ? null : "shipped:staging", closeOn, deployed });
|
|
124
|
+
if (r) out.push({ title, ...r });
|
|
125
|
+
}
|
|
126
|
+
return out;
|
|
127
|
+
},
|
|
128
|
+
};
|
|
129
|
+
}
|