forgedock 1.35.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.
Files changed (129) hide show
  1. package/README.md +78 -324
  2. package/bin/bot-token-status.mjs +26 -0
  3. package/bin/engine/events.mjs +161 -0
  4. package/bin/engine/events.test.mjs +142 -0
  5. package/bin/engine/outcome-codes.mjs +39 -1
  6. package/bin/engine/phases.mjs +65 -5
  7. package/bin/engine/telemetry-seam.mjs +5 -4
  8. package/bin/engine/telemetry-seam.test.mjs +4 -0
  9. package/bin/engine-cli.mjs +372 -46
  10. package/bin/engine.mjs +3 -1
  11. package/bin/experiment.mjs +55 -0
  12. package/bin/forgedock.mjs +108 -5
  13. package/bin/hooks/reread-nudge.mjs +15 -4
  14. package/bin/labels.json +50 -0
  15. package/bin/lean/batch.mjs +52 -6
  16. package/bin/lean/batch.test.mjs +176 -2
  17. package/bin/lean/conformance.e2e.test.mjs +1 -1
  18. package/bin/lean/conformance.mjs +136 -11
  19. package/bin/lean/conformance.test.mjs +148 -1
  20. package/bin/lean/decisions.mjs +181 -0
  21. package/bin/lean/decisions.test.mjs +71 -0
  22. package/bin/lean/effects.mjs +32 -14
  23. package/bin/lean/execution.mjs +62 -16
  24. package/bin/lean/execution.test.mjs +59 -2
  25. package/bin/lean/gatewatch.mjs +31 -0
  26. package/bin/lean/gatewatch.test.mjs +59 -0
  27. package/bin/lean/guardgate.mjs +170 -0
  28. package/bin/lean/guardgate.test.mjs +153 -0
  29. package/bin/lean/guards.mjs +243 -0
  30. package/bin/lean/guards.test.mjs +177 -0
  31. package/bin/lean/heavy.mjs +54 -8
  32. package/bin/lean/heavy.test.mjs +74 -1
  33. package/bin/lean/hostkill.mjs +1 -1
  34. package/bin/lean/i18n.mjs +65 -0
  35. package/bin/lean/i18n.test.mjs +47 -0
  36. package/bin/lean/integration.mjs +276 -0
  37. package/bin/lean/integration.test.mjs +189 -0
  38. package/bin/lean/lane.mjs +5 -2
  39. package/bin/lean/lean.mjs +169 -30
  40. package/bin/lean/lean.test.mjs +360 -24
  41. package/bin/lean/lifecycle.mjs +129 -0
  42. package/bin/lean/lifecycle.test.mjs +118 -0
  43. package/bin/lean/light.mjs +1 -1
  44. package/bin/lean/milestonesync.mjs +130 -0
  45. package/bin/lean/milestonesync.test.mjs +133 -0
  46. package/bin/lean/orchestrate.mjs +132 -20
  47. package/bin/lean/parked-sweep.mjs +45 -0
  48. package/bin/lean/parked-sweep.test.mjs +71 -0
  49. package/bin/lean/postdeploy-check.mjs +181 -0
  50. package/bin/lean/postdeploy-check.test.mjs +206 -0
  51. package/bin/lean/postdeploy-monitor.mjs +97 -0
  52. package/bin/lean/postdeploy-monitor.test.mjs +147 -0
  53. package/bin/lean/precheck.mjs +6 -4
  54. package/bin/lean/precheck.test.mjs +17 -2
  55. package/bin/lean/prefilter.mjs +106 -21
  56. package/bin/lean/prefilter.test.mjs +86 -6
  57. package/bin/lean/probe.mjs +4 -3
  58. package/bin/lean/probe.test.mjs +1 -1
  59. package/bin/lean/prompts.mjs +23 -9
  60. package/bin/lean/resume.test.mjs +1 -1
  61. package/bin/lean/retry.mjs +28 -0
  62. package/bin/lean/sandbox-provider.contract.test.mjs +36 -3
  63. package/bin/lean/sandbox-remote.mjs +11 -4
  64. package/bin/lean/schedd.mjs +77 -13
  65. package/bin/lean/schedd.test.mjs +131 -5
  66. package/bin/lean/sequence.mjs +24 -0
  67. package/bin/lean/sequence.test.mjs +31 -1
  68. package/bin/lean/signoff.mjs +66 -0
  69. package/bin/lean/signoff.test.mjs +102 -0
  70. package/bin/lean/train.mjs +177 -0
  71. package/bin/lean/train.test.mjs +113 -0
  72. package/bin/pool/cli.mjs +2 -0
  73. package/bin/pool/health.mjs +30 -0
  74. package/bin/review-context.mjs +298 -0
  75. package/bin/runner.mjs +26 -3
  76. package/bin/stats/aggregate.mjs +1 -1
  77. package/bin/stats/compounding.mjs +157 -0
  78. package/bin/stats/compounding.test.mjs +74 -0
  79. package/bin/stats/experiment.mjs +44 -0
  80. package/bin/stats/github-source.mjs +2 -1
  81. package/bin/stats/index.mjs +62 -3
  82. package/bin/stats/release.mjs +75 -0
  83. package/bin/stats/release.test.mjs +68 -0
  84. package/commands/bootstrap.md +10 -8
  85. package/commands/deploy-info.md +3 -0
  86. package/commands/issue.md +2 -0
  87. package/commands/milestone.md +43 -54
  88. package/commands/orchestrate/phase-4-execution.md +2 -0
  89. package/commands/orchestrate.md +15 -3
  90. package/commands/pipeline-status.md +20 -0
  91. package/commands/review-pr.md +1 -0
  92. package/commands/work-on/build/implement.md +1 -1
  93. package/commands/work-on/build.md +4 -2
  94. package/commands/work-on/investigate.md +3 -1
  95. package/commands/work-on/remediate.md +1 -1
  96. package/docs/CONFIG.md +120 -2
  97. package/docs/EVENTS.md +65 -0
  98. package/docs/OUTCOME-CODES.md +9 -1
  99. package/docs/STATS.md +51 -0
  100. package/docs/TELEMETRY.md +3 -1
  101. package/docs/site/install.md +89 -0
  102. package/docs/site/start-a-new-project.md +1 -1
  103. package/docs/spec/label-state-machine.md +6 -0
  104. package/package.json +1 -1
  105. package/pool/install/install-agent.sh +4 -2
  106. package/pool/install/install-router.sh +4 -2
  107. package/pool/install/install-schedd.sh +28 -0
  108. package/pool/install/unit-hardening.sh +34 -0
  109. package/scripts/acceptance-red-green.sh +19 -2
  110. package/scripts/acceptance-red-green.test.sh +2 -0
  111. package/scripts/bench-lightlane.mjs +2 -1
  112. package/scripts/bootstrap-create.mjs +3 -2
  113. package/scripts/bootstrap-validate.mjs +31 -4
  114. package/scripts/eval-gate-scorecard.mjs +7 -0
  115. package/scripts/file-deferred-followups.sh +286 -77
  116. package/scripts/file-deferred-followups.test.sh +117 -2
  117. package/scripts/hotspots.sh +23 -2
  118. package/scripts/hotspots.test.sh +9 -0
  119. package/scripts/plan-validate-live.mjs +111 -0
  120. package/scripts/readiness.mjs +141 -0
  121. package/scripts/review-prepare.sh +17 -1
  122. package/scripts/review-prepare.test.sh +17 -0
  123. package/scripts/settle-scope.mjs +74 -0
  124. package/scripts/systemd/forgedock-bot-token-refresh.service +7 -0
  125. package/scripts/systemd/forgedock-bot-token-refresh.timer +11 -0
  126. package/scripts/wait-ci-green.sh +14 -3
  127. package/scripts/wait-ci-green.test.sh +3 -1
  128. package/templates/bootstrap/needs-human-issue.md +4 -1
  129. package/templates/github-actions/migration-journal-guard.test.mjs +26 -0
@@ -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-");
@@ -124,6 +127,8 @@ function world(o = {}) {
124
127
  if (a[0] === "diff" && a[1] === "--check") return "";
125
128
  if (a[0] === "grep") return w.markers ? "src/foo.js:3:<<<<<<< HEAD" : "";
126
129
  if (a[0] === "merge" && a[1] === "--abort") { w.mergeInProgress = false; return ""; }
130
+ if (a[0] === "rev-parse" && a.length === 2 && (o.shipped ?? []).includes(String(a[1]).replace(/^origin\//, ""))) return `tip-${a[1]}`; // #4623
131
+ if (a[0] === "rev-list" && a.includes("--merges")) { const l = String(a.at(-1)).slice(1).replace(/^origin\//, ""); return (o.shipped ?? []).includes(l) ? `m1 s1 tip-origin/${l}\n` : ""; }
127
132
  if (a[0] === "rev-parse" && a.length === 2 && /^origin\//.test(String(a[1]))) return w.baseHeads.length > 1 ? w.baseHeads.shift() : w.baseHeads[0]; // #4386: the base head moves only when a test says so
128
133
  if (a[0] === "rev-parse") return w.head;
129
134
  if (a[0] === "diff" && a[1] === "--name-only" && !a.some((x) => String(x).includes("..."))) return w.changed.filter((f) => !(o.equalToBase ?? []).includes(f)).join("\n");
@@ -131,7 +136,7 @@ function world(o = {}) {
131
136
  if (a[0] === "diff" && a[1] === "--numstat") return w.numstat ?? "";
132
137
  if (a[0] === "ls-files") return [...new Set(["src/foo.js", "src/bar.js", "test/foo.test.js", ...w.changed])].join("\n");
133
138
  if (a[0] === "diff") return "diff --git a/src/foo.js b/src/foo.js\n+fix";
134
- if (a[0] === "merge-base" && a[1] === "--is-ancestor") { if ((o.shipped ?? []).includes(String(a[2]).replace(/^origin\//, ""))) return ""; throw new Error("not an ancestor"); } // #4593
139
+ if (a[0] === "merge-base" && a[1] === "--is-ancestor") { if ([...(o.shipped ?? []), ...(o.unusedAncestors ?? [])].includes(String(a[2]).replace(/^origin\//, ""))) return ""; throw new Error("not an ancestor"); } // #4593
135
140
  if (a[0] === "ls-remote" && String(a.at(-1)).startsWith("refs/heads/")) { const b = a.at(-1).slice("refs/heads/".length); return (o.branchesOnOrigin ?? ["staging", "main"]).includes(b) ? `abc\trefs/heads/${b}\n` : ""; }
136
141
  if (a[0] === "ls-remote") return w.remote;
137
142
  if (a[0] === "show" && w.baseFiles) { if (!(a[1] in w.baseFiles)) throw new Error(`fatal: path not in ${a[1]}`); return w.baseFiles[a[1]]; }
@@ -352,7 +357,7 @@ describe("core work-on", () => {
352
357
  });
353
358
 
354
359
  it("a reviewer that judges a deferral unjustified gets the builder resumed to implement it; nothing is filed for it", async () => {
355
- 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" }] };
356
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);
357
362
  const w = world({ contract: k, claims: [{ id: "R1", status: "implemented" }], reviews: [rej, rej] });
358
363
  const r = await run(w);
@@ -439,6 +444,20 @@ describe("core work-on", () => {
439
444
  noNeedsHuman(w);
440
445
  });
441
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
+
442
461
  it("#4124 split-required files sub-issues with Parent / Depends-on lines and ends decomposed", async () => {
443
462
  const w = depWorld({ changed: [], result: { status: "blocked", summary: "epic", block: { kind: "split-required", subissues: [
444
463
  { title: "part A", body: "do A", paths: ["src/a"] }, { title: "part B", body: "do B", paths: ["src/b"], depends_on: [0] }] } } }, {});
@@ -656,6 +675,10 @@ describe("core work-on", () => {
656
675
  const remote = { placement: { where: "remote", reason: "mode remote" }, hostCaps: { hostCapabilities: ["sops"] } };
657
676
  assert.equal(await rb("this lane has no sops/age key", remote), "env.capability_remote");
658
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");
659
682
  assert.equal(await rb("this lane has no sops/age key", { ...remote, hostCaps: undefined }), "builder.blocked");
660
683
  assert.equal(await rb("this lane has no sops/age key", { ...remote, placement: { where: "local", reason: "x" } }), "builder.blocked");
661
684
  assert.equal(await code(world({ changed: [] })), "builder.no_commit");
@@ -717,7 +740,7 @@ describe("core work-on", () => {
717
740
 
718
741
  describe("conformance gate (frozen contract, precheck, structured review)", () => {
719
742
  const Q2 = "Existing callers of foo keep working.";
720
- 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 }],
721
744
  deliverables: [...CONTRACT.deliverables, ...(d2 === "deliver" ? [{ id: "D2", path: "src/bar.js", change: "keep callers", requirements: ["R2"] }] : [])] });
722
745
  const files2 = ["src/foo.js", "src/bar.js", "test/foo.test.js"];
723
746
  const events = (dir) => readLog(dir, 7).map((e) => e.event);
@@ -783,7 +806,7 @@ describe("conformance gate (frozen contract, precheck, structured review)", () =
783
806
  assert.equal(r.terminalReason, "merged");
784
807
  assert.equal(w.followups.length, 1);
785
808
  assert.deepEqual(w.followups[0].items.map((i) => i.key), ["R2: Existing callers of foo keep working."]);
786
- assert.match(w.followups[0].items[0].why, /^defer: callers live in another module/);
809
+ assert.match(w.followups[0].items[0].why, /^post-deploy: \[bucket:post-deploy\] callers live in another module/);
787
810
  const body = w.prs[0][w.prs[0].indexOf("--body") + 1];
788
811
  assert.match(body, /## Remaining work tracked\n- #900\n\nCloses #7$/);
789
812
  assert.ok(w.comments.some((c) => c.includes("<!-- FORGE:TRACKED -->") && c.includes("#900")));
@@ -794,8 +817,9 @@ describe("conformance gate (frozen contract, precheck, structured review)", () =
794
817
 
795
818
  for (const d of ["defer", "post-deploy"]) {
796
819
  it(`a contracted ${d} is filed only after the reviewer accepts it, then linked in the PR and on close`, async () => {
797
- const k = two(d, { why: d === "defer" ? "separate module" : "only measurable after deploy" });
820
+ const k = two(d, { why: d === "defer" ? "separate module" : "only measurable after deploy", ...(d === "post-deploy" ? { check: "login-smoke" } : {}) });
798
821
  const w = world({ contract: k, claims: [{ id: "R1", status: "implemented" }], reviews: [conf({}, { ...k, requirements: [k.requirements[0], { ...k.requirements[1], tracked: "#900" }] })] });
822
+ if (d === "post-deploy") w.baseFiles = { "origin/staging:.forge/post-deploy-checks.json": JSON.stringify({ "login-smoke": "true" }) };
799
823
  const r = await run(w);
800
824
  assert.equal(r.terminalReason, "merged");
801
825
  assert.equal(w.followups.length, 1); assert.equal(w.followups[0].parent, 7);
@@ -1374,6 +1398,20 @@ describe("conformance shadow mode (the default)", () => {
1374
1398
  const ra = await runShadow(a);
1375
1399
  assert.equal(ra.code, "conformance.unimplemented"); assert.match(nhBody(a), /amendment without why/);
1376
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
+ });
1377
1415
  it("#4160 scope creep found by the precheck stays recorded-only in shadow", async () => {
1378
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"])] });
1379
1417
  assert.equal((await runShadow(w)).terminalReason, "merged");
@@ -1386,7 +1424,7 @@ describe("conformance shadow mode (the default)", () => {
1386
1424
  });
1387
1425
 
1388
1426
  it("#4160 shadow files accepted deferrals with track(), the same as enforce, and the PR links them", async () => {
1389
- 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" }] };
1390
1428
  const w = world({ contract: k, claims: [{ id: "R1", status: "implemented" }], reviews: [conf({}, k)] });
1391
1429
  assert.equal((await runShadow(w)).terminalReason, "merged");
1392
1430
  assert.equal(w.followups.length, 1); assert.match(w.followups[0].items[0].key, /^R2: /);
@@ -1420,7 +1458,7 @@ describe("conformance shadow mode (the default)", () => {
1420
1458
  });
1421
1459
 
1422
1460
  it("#4398 an investigation whose question is deferred closes partial (not planned), carried forward in the tracking issue", async () => {
1423
- 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" }] };
1424
1462
  const w = world({ title: "investigate(stealth): which signal is rejected", contract: k, claims: [{ id: "R1", status: "implemented" }], reviews: [conf({}, k)] });
1425
1463
  const r = await runShadow(w);
1426
1464
  assert.equal(r.terminalReason, "merged"); assert.equal(r.close, "partial");
@@ -1444,7 +1482,7 @@ describe("conformance shadow mode (the default)", () => {
1444
1482
  const ra = await runShadow(a);
1445
1483
  assert.equal(ra.terminalReason, "merged"); assert.equal(ra.close, undefined);
1446
1484
  assert.deepEqual(a.closed.map((x) => x[x.indexOf("--reason") + 1]), ["completed"]);
1447
- 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" }] };
1448
1486
  const b = world({ contract: k, claims: [{ id: "R1", status: "implemented" }], reviews: [conf({}, k)] });
1449
1487
  await runShadow(b);
1450
1488
  assert.deepEqual(b.closed.map((x) => x[x.indexOf("--reason") + 1]), ["completed"]);
@@ -1621,9 +1659,9 @@ describe("hotspot prevention (forge#4195): plan facts, build rule, unexempted gr
1621
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.");
1622
1660
  assert.equal(hotspotBuildRule(null), ""); assert.equal(hotspotBuildRule(facts([], { enabled: false })), "");
1623
1661
  const plan = builderPlanPrompt(7) + hotspotPlanNote(facts([HOT, { ...HOT, path: "a/b.py" }, { ...HOT, path: "c/d.py" }]));
1624
- // 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).
1625
- assert.ok(approxTokens(plan) <= 1900, `plan prompt with hotspots is ${approxTokens(plan)} tokens`);
1626
- assert.ok(approxTokens(builderImplementPrompt() + hotspotBuildRule(facts([]))) <= 1550); // 1550: #4591 (never hand-name migration files, one rule line)
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)
1627
1665
  });
1628
1666
 
1629
1667
  it("#4519 generated_paths: both prompts name the globs (even with the gate off), and an amendment adding one blocks", async () => {
@@ -1798,6 +1836,24 @@ describe("structure delta and decision provenance at merge (forge#4165)", () =>
1798
1836
  return w;
1799
1837
  };
1800
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
+
1801
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 () => {
1802
1858
  const dir = tmpDir("struct-merge-");
1803
1859
  const w = mworld();
@@ -2040,8 +2096,8 @@ describe("comment templates", () => {
2040
2096
 
2041
2097
  it("plan and implement prompts carry the stage templates and the contract schema within budget", () => {
2042
2098
  const plan = builderPlanPrompt(7), impl = builderImplementPrompt();
2043
- assert.ok(approxTokens(plan) <= 1800, `plan prompt is ${approxTokens(plan)} tokens`);
2044
- assert.ok(approxTokens(impl) <= 1500, `implement prompt is ${approxTokens(impl)} tokens`);
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
2045
2101
  for (const h of ["### What Was Claimed", "### What We Found", "### Root Cause", "### Affected Files", "### Sibling Sweep", "### History", "### Recommendation", "### Related Issues",
2046
2102
  "investigation.md", "contract.json", "PLANNING ONLY", '"requirements"', '"deliverables"', '"preserve"', '"validate"', "verbatim", "defer", "post-deploy"]) assert.ok(plan.includes(h), h);
2047
2103
  for (const h of ["### Approach", "### Changes", "### Acceptance Criteria Status", "### Verification", "### Notes", "implementation.md", "result.json",
@@ -2053,12 +2109,23 @@ describe("merge_wait policy", () => {
2053
2109
  const states = (o) => Object.entries(o).map(([name, state]) => ({ name, state }));
2054
2110
  it("required with no protection (empty) merges immediately without polling", async () => {
2055
2111
  const w = world(); let polled = 0;
2056
- w.io.requiredChecks = async () => []; w.io.checkStates = async () => { polled++; return []; };
2112
+ w.io.requiredChecks = async () => []; w.io.checkStates = async () => []; // #4651: read once by the DCO gate, never polled
2057
2113
  w.io.waitCiGreen = async () => { polled++; return { rc: 2, text: "" }; };
2058
2114
  const r = await run(w);
2059
2115
  assert.equal(r.terminalReason, "merged"); assert.equal(polled, 0);
2060
2116
  assert.match(traj(w)[0], /\| Merge \| required \(no required checks: merged right after review\) \|/);
2061
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
+ });
2062
2129
  it("required with a pending required check waits (backoff) then merges", async () => {
2063
2130
  const w = world(); const seqs = [states({ build: "pending" }), states({ build: "pending" }), states({ build: "success", other: "failure" })]; let sleeps = 0;
2064
2131
  w.io.requiredChecks = async () => ["build"]; w.io.checkStates = async () => seqs.shift();
@@ -2074,6 +2141,98 @@ describe("merge_wait policy", () => {
2074
2141
  const w2 = world(); w2.io.requiredChecks = async () => ["build"]; w2.io.checkStates = async () => states({ build: "pending" });
2075
2142
  assert.equal((await run(w2, { ciMaxMinutes: 5 })).terminalReason, "ci-pending");
2076
2143
  });
2144
+ describe("#4624 merge-time rerun / wait / flaky branch of mergeFixRound", () => {
2145
+ const RUN = (n, attempt) => `https://github.com/o/r/actions/runs/${n}/job/${attempt}`;
2146
+ const NO_FIX = { ...parseVerify({}), maxRounds: 0 };
2147
+ const WAIT = 20000;
2148
+ const waits = (w) => { const n = { n: 0 }; const s = w.io.sleep; w.io.sleep = async (ms) => { if (ms === WAIT) n.n++; return s(ms); }; return n; };
2149
+ const fixPrompts = (w) => { const p = []; const inner = w.io.runner; w.io.runner = async (c) => { if (/CI failed on the pushed branch/.test(c.prompt)) p.push(c.prompt); return inner(c); }; return p; };
2150
+ const events = (dir, name) => readLog(dir, 7).filter((e) => e.event === name);
2151
+ /** `build` (run 77) is red; `urlAfter` is its URL once the rerun was triggered, `stateAfter` its state then. */
2152
+ const redBuild = (stateAfter, urlAfter) => {
2153
+ const w = world(); w.reran = [];
2154
+ w.io.requiredChecks = async () => ["build"];
2155
+ w.io.rerunFailedJobs = async (id) => { w.reran.push(id); };
2156
+ w.io.checkStates = async () => (w.reran.length ? [{ name: "build", state: stateAfter, url: urlAfter }] : [{ name: "build", state: "failure", url: RUN(77, 1) }]);
2157
+ w.io.failedLogs = async () => "boom";
2158
+ return w;
2159
+ };
2160
+
2161
+ it("a failed required check is rerun once, waited on while its URL is unchanged, and merges when the new attempt is green without a fix round", async () => {
2162
+ const w = redBuild("success", RUN(77, 2)); const p = fixPrompts(w), dir = tmpDir("core-");
2163
+ // the rerun was triggered, but the check still reports the old attempt for two more polls
2164
+ const states = w.io.checkStates; let stale = 2;
2165
+ w.io.checkStates = async () => (w.reran.length && stale-- > 0 ? [{ name: "build", state: "failure", url: RUN(77, 1) }] : states());
2166
+ const n = waits(w);
2167
+ const r = await run(w, { dir });
2168
+ assert.equal(r.terminalReason, "merged"); assert.deepEqual(w.reran, ["77"]); assert.equal(p.length, 0);
2169
+ assert.equal(n.n, 3, "one wait after the rerun, then two while the URL is unchanged");
2170
+ assert.deepEqual(events(dir, "CI_FLAKE_RERUN").map((e) => [e.trigger, e.result, e.runs]), [["merge", "rerun-started", ["77"]]]);
2171
+ assert.equal(events(dir, "CI_FIX_ROUND").length, 0);
2172
+ });
2173
+
2174
+ it("a rerun whose check URL never changes is waited on 20 times, then falls through to the fix round; the head is rerun only once", async () => {
2175
+ const w = redBuild("failure", RUN(77, 1)); const n = waits(w), dir = tmpDir("core-");
2176
+ const r = await run(w, { dir, verify: NO_FIX });
2177
+ assert.equal(r.terminalReason, "needs-human"); assert.equal(r.code, OUTCOME_CODES.CI_RED); assert.equal(w.merges.length, 0);
2178
+ assert.equal(n.n, 21, "the wait after the rerun plus 20 bounded waits");
2179
+ assert.deepEqual(w.reran, ["77"]);
2180
+ assert.equal(events(dir, "CI_FLAKE_RERUN").length, 1);
2181
+ });
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
+
2198
+ it("a rerun that reports a new attempt still red goes to the fix round and reaches the builder", async () => {
2199
+ const w = redBuild("failure", RUN(77, 2)); const p = fixPrompts(w), n = waits(w), dir = tmpDir("core-");
2200
+ await run(w, { dir, verify: { ...parseVerify({}), maxRounds: 1 } });
2201
+ assert.ok(n.n >= 1); assert.equal(p.length, 1); assert.equal(w.reran[0], "77");
2202
+ assert.deepEqual(events(dir, "CI_FIX_ROUND").map((e) => [e.trigger, e.jobs]), [["merge", ["build"]]]);
2203
+ });
2204
+
2205
+ it("a rerun that cannot start is logged rerun-failed and the lane does not wait", async () => {
2206
+ const w = redBuild("failure", RUN(77, 1)); const n = waits(w), dir = tmpDir("core-");
2207
+ w.io.rerunFailedJobs = async () => { throw new Error("HTTP 403"); };
2208
+ const r = await run(w, { dir, verify: NO_FIX });
2209
+ assert.equal(r.code, OUTCOME_CODES.CI_RED); assert.equal(n.n, 0);
2210
+ assert.deepEqual(events(dir, "CI_FLAKE_RERUN").map((e) => e.result), ["rerun-failed"]);
2211
+ });
2212
+
2213
+ it("verify.flaky: a required check still red after the rerun is reported and ends needs-human; nothing merged, builder not resumed", async () => {
2214
+ const w = redBuild("failure", RUN(77, 2)); const p = fixPrompts(w), dir = tmpDir("core-");
2215
+ const r = await run(w, { dir, verify: { ...parseVerify({}), flaky: ["build"] } });
2216
+ assert.equal(r.terminalReason, "needs-human"); assert.equal(r.code, OUTCOME_CODES.CI_RED);
2217
+ assert.equal(w.merges.length, 0); assert.equal(p.length, 0); assert.deepEqual(w.reran, ["77"]);
2218
+ assert.deepEqual(events(dir, "CI_FLAKY_REPORTED").map((e) => [e.trigger, e.jobs]), [["merge", ["build"]]]);
2219
+ assert.ok(JSON.stringify(w.comments).includes("still failed on PR #"), "needs-human message names the rerun");
2220
+ });
2221
+
2222
+ it("verify.flaky with a mix: only the non-flaky required check reaches the builder", async () => {
2223
+ const w = world(); w.reran = [];
2224
+ w.io.requiredChecks = async () => ["build", "lint"];
2225
+ w.io.rerunFailedJobs = async (id) => { w.reran.push(id); };
2226
+ w.io.checkStates = async () => [{ name: "build", state: "failure", url: RUN(77, w.reran.length + 1) }, { name: "lint", state: "failure", url: RUN(78, w.reran.length + 1) }];
2227
+ w.io.failedLogs = async () => "boom";
2228
+ const dir = tmpDir("core-");
2229
+ await run(w, { dir, verify: { ...parseVerify({}), flaky: ["build"], maxRounds: 1 } });
2230
+ assert.deepEqual(w.reran.slice(0, 2).sort(), ["77", "78"], "both red runs rerun on the first head");
2231
+ assert.deepEqual(events(dir, "CI_FLAKY_REPORTED")[0].jobs, ["build"]);
2232
+ assert.deepEqual(events(dir, "CI_FIX_ROUND").map((e) => e.jobs), [["lint"]]);
2233
+ });
2234
+ });
2235
+
2077
2236
  it("all waits for every check (waitCiGreen); none merges immediately", async () => {
2078
2237
  const w = world({ waits: [{ rc: 2, text: "" }] }); let req = 0; w.io.requiredChecks = async () => { req++; return []; };
2079
2238
  assert.equal((await run(w, { mergeWait: "all" })).terminalReason, "merged");
@@ -2294,6 +2453,29 @@ describe("orchestrate", () => {
2294
2453
  }
2295
2454
  });
2296
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
+
2297
2479
  it("#4124 a repeated dependency-open stops after the requeue cap", async () => {
2298
2480
  const h = harness({}, { 1: "dependency-open" });
2299
2481
  const clock = { t: 0 }, starts = [];
@@ -3137,22 +3319,53 @@ describe("batch robustness (#4112-#4115)", () => {
3137
3319
  }
3138
3320
  });
3139
3321
 
3140
- it("#4115b lane limits wrap the spawn in systemd-run; unset or invalid limits do not", () => {
3141
- const w = wrapLaneLimits("claude", ["--print", "x"], { memory_max: "3G", cpu_quota: "150%" });
3142
- assert.deepEqual(w, { bin: "systemd-run", args: ["--user", "--scope", "-q", "-p", "MemoryMax=3G", "-p", "CPUQuota=150%", "--", "claude", "--print", "x"] });
3143
- assert.deepEqual(wrapLaneLimits("claude", ["a"], null), { bin: "claude", args: ["a"] });
3144
- assert.deepEqual(wrapLaneLimits("claude", ["a"], { memory_max: "lots; rm -rf /", cpu_quota: "x" }), { bin: "claude", args: ["a"] });
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"]);
3145
3327
  assert.equal(wrapLaneLimits("claude", ["a"], { memory_max: "1G" }).args.includes("CPUQuota=150%"), false);
3146
3328
  });
3147
3329
 
3148
- it("#4115b the runner wraps only when limits are set AND systemd-run probes available (clean fallback)", async () => {
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 () => {
3149
3337
  const seen = [];
3150
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" }; };
3151
3339
  const base = { role: "builder", issue: 1, cwd: process.cwd(), prompt: "p", system: "" };
3152
3340
  await makeLeanRunner({ runCli, probe: () => true })({ ...base, laneLimits: { memory_max: "3G" } });
3153
3341
  await makeLeanRunner({ runCli, probe: () => false })({ ...base, laneLimits: { memory_max: "3G" } });
3154
3342
  await makeLeanRunner({ runCli, probe: () => true })({ ...base });
3155
- assert.deepEqual(seen, ["systemd-run", null, 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);
3156
3369
  });
3157
3370
 
3158
3371
  it("#4115b integration: a lane under systemd-run --scope really runs in a capped scope (skipped without systemd-run)", { skip: !systemdRunAvailable() }, async () => {
@@ -3164,6 +3377,37 @@ describe("batch robustness (#4112-#4115)", () => {
3164
3377
  assert.match(r.text, /\.scope/);
3165
3378
  });
3166
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
+
3167
3411
  const hostFor = (state) => ({ memAvailableMb: () => 99999, freeMb: async () => 99999, tmpPaths: () => ["/t"], loadAvg1: () => state.load(), cores: () => 2 });
3168
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); }; };
3169
3413
 
@@ -3359,6 +3603,35 @@ describe("batch robustness (#4112-#4115)", () => {
3359
3603
  assert.equal(n, 1);
3360
3604
  });
3361
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
+
3362
3635
  it("#4507 makeGhCache: a per-root cache dir, reset swaps in a fresh dir, the no-root fallback is disposed", () => {
3363
3636
  const root = tmpDir("ghcache-");
3364
3637
  const c = makeGhCache({ root });
@@ -3785,6 +4058,12 @@ describe("shipped lane follows the open PR base (#4593)", () => {
3785
4058
  assert.equal(r.terminalReason, "merged");
3786
4059
  assert.ok(w.calls.some((c) => c[0] === "git" && c.includes("origin/milestone/new..refs/heads/feat/issue-7-lean")), "work is compared against the PR base");
3787
4060
  });
4061
+ it("unused lane at the staging tip is not shipped (#4623)", async () => {
4062
+ // ancestor of staging but no merge commit took it: the hand-retargeted PR must stop, not be followed
4063
+ const w = world({ milestone: "Old", branchesOnOrigin: ["staging", "milestone/old", "milestone/new"], unusedAncestors: ["milestone/old"], prList: pr("milestone/new"), remote: "abc\trefs/heads/feat/issue-7-lean" });
4064
+ const r = await run(w, { lane: undefined });
4065
+ assert.deepEqual([r.terminalReason, r.code], ["needs-human", "lane.mismatch"]);
4066
+ });
3788
4067
  it("stays needs-human when the lane is not shipped, the base is shipped, missing, or the default branch", async () => {
3789
4068
  for (const [o, code] of [
3790
4069
  [{ prList: pr("milestone/new") }, "lane.mismatch"],
@@ -3949,6 +4228,17 @@ describe("verify-by-CI (#4167): ci mode runs the heavy checks on CI and feeds fa
3949
4228
  const ciValidation = [{ id: "V1", command: "node --test test/foo.test.js", ci: true }];
3950
4229
  const events = (dir) => readLog(dir, 7).filter((e) => e.event === "CI_VERIFY");
3951
4230
 
4231
+ it("untrack commit before CI verify: a tool cache the builder committed does not move HEAD under the CI lookup (#4621)", async () => {
4232
+ const w = withCi(world({ validation: ciValidation })); w.added = ["src/foo.js", "apps/worker/.eslintcache"];
4233
+ const inner = w.io.git;
4234
+ w.io.git = async (a, o) => { const out = await inner(a, o); if (a[0] === "commit" && a.some((x) => /untrack lane tool state/.test(x))) w.head = `dddd${w.calls.length}`.padEnd(16, "0"); return out; };
4235
+ const dir = tmpDir("core-ci-");
4236
+ const r = await run(w, { verify: CI, dir });
4237
+ assert.equal(r.terminalReason, "merged");
4238
+ assert.deepEqual(events(dir).map((e) => e.result), ["pass"], "CI found the run for the pushed tip");
4239
+ assert.ok(!builders(w).some((b) => /CI (is )?unavailable|cannot run/i.test(String(b[4]))), "no ci_unavailable builder round");
4240
+ });
4241
+
3952
4242
  it("green CI: the builder is told not to run test suites, the branch is pushed and verified before review, validate left to CI passes enforce", async () => {
3953
4243
  const w = withCi(world({ validation: ciValidation }));
3954
4244
  const dir = tmpDir("core-ci-");
@@ -4000,6 +4290,18 @@ describe("verify-by-CI (#4167): ci mode runs the heavy checks on CI and feeds fa
4000
4290
  assert.match(traj(w)[0], /Validate V1 verified by CI at [0-9a-f]{0,12}/);
4001
4291
  });
4002
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
+
4003
4305
  it("docs-only lane: no push for CI, no dispatch; the decision is recorded", async () => {
4004
4306
  const w = withCi(world({ changed: ["README.md", "docs/guide.md"], validation: ciValidation }));
4005
4307
  const r = await run(w, { verify: CI, conformance: "shadow" });
@@ -5075,6 +5377,18 @@ describe("context pack modes (forge#4275)", () => {
5075
5377
  assert.deepEqual([m.leaseWrites.at(-1).terminalCode, m.leaseWrites.at(-1).failureClasses], [OUTCOME_CODES.COMPLETED, []]);
5076
5378
  });
5077
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
+
5078
5392
  it("#4444 ab: the lane runs its issue's arm, logs it as context_pack and tags context_ab", async () => {
5079
5393
  const { contextArm } = await import("./context.mjs");
5080
5394
  const arm = contextArm({ mode: "ab", repo: "o/r", issue: 7 }).mode;
@@ -5189,6 +5503,17 @@ describe("enumerating tests + sequence numbers at hand-off (#4322)", () => {
5189
5503
  assert.equal(w.dupArgs.cwd, WT);
5190
5504
  });
5191
5505
 
5506
+ it("#4596 English copies in a locale catalog resume the builder once to translate; a leftover is an anomaly", async () => {
5507
+ const w = hworld(); const c = [[{ path: "messages/ta.json", key: "calendarView.day", value: "Day view", source: "messages/en.json" }], []]; w.cpCalls = 0;
5508
+ w.io.englishCopies = async (x) => { w.cpCalls++; w.cpArgs = x; return c.length > 1 ? c.shift() : c[0]; };
5509
+ assert.equal((await run(w)).terminalReason, "merged");
5510
+ const b = builders(w);
5511
+ assert.equal(b.length, 3);
5512
+ assert.match(b[2][4], /messages\/ta\.json: calendarView\.day = "Day view" \(same as messages\/en\.json\)[\s\S]*Translate each/);
5513
+ assert.ok(w.seq.indexOf("pr") > w.seq.lastIndexOf("builder-resume"), "fixed before the PR");
5514
+ assert.equal(w.cpArgs.cwd, WT);
5515
+ });
5516
+
5192
5517
  it("#4406 a duplicate that remains after the round is an anomaly; verify.duplicate_exports false skips the check", async () => {
5193
5518
  let w = hworld(); w.io.duplicateExports = async () => DUP;
5194
5519
  assert.equal((await run(w)).terminalReason, "merged");
@@ -5838,6 +6163,17 @@ describe("e2e merge gate (#4403)", () => {
5838
6163
  assert.equal(r2.terminalReason, "needs-human"); assert.equal(w2.merges.length, 0);
5839
6164
  });
5840
6165
 
6166
+ it("#4624 verify.flaky: a flaky-listed own e2e failure is reported as an anomaly, never reaches the builder, and the lane merges; a non-listed one still does", async () => {
6167
+ const w = withE2e(world({ changed: SPECS }), ["failure"]);
6168
+ const fixes = []; const inner = w.io.runner; w.io.runner = async (c) => { if (/CI failed on the pushed branch/.test(c.prompt)) fixes.push(c.prompt); return inner(c); };
6169
+ const r = await go(w, { verify: { ...cfg(), flaky: ["e2e"] } });
6170
+ assert.equal(r.terminalReason, "merged"); assert.equal(fixes.length, 0);
6171
+ assert.ok(traj(w).some((t) => /Known-flaky e2e failure\(s\) reported, not blocking \(verify\.flaky\): e2e/.test(t)));
6172
+ const w2 = withE2e(world({ changed: SPECS }), ["failure"]);
6173
+ const r2 = await go(w2, { verify: { ...cfg(), flaky: ["other"], maxRounds: 0 } });
6174
+ assert.equal(r2.code, OUTCOME_CODES.CI_RED); assert.equal(w2.merges.length, 0);
6175
+ });
6176
+
5841
6177
  it("verify.e2e: never (or no label/workflow configured) keeps today's behaviour: no label, no e2e wait, no event", async () => {
5842
6178
  for (const verify of [cfg({ e2e: "never" }), parseVerify({})]) {
5843
6179
  const w = withE2e(world({ changed: SPECS }), ["failure"]);
@@ -6174,7 +6510,7 @@ describe("#4395 lane profile", () => {
6174
6510
  assert.match(p, /Issue independent read-only calls \(reads, greps, git log\/blame, gh\) in the same turn or one shell call\./);
6175
6511
  assert.match(p, /Read each file's ranges once .* and keep notes; don't re-read it\./);
6176
6512
  }
6177
- assert.ok(approxTokens(builderImplementPrompt()) <= 1500 && approxTokens(builderPlanPrompt(7)) <= 1800);
6513
+ assert.ok(approxTokens(builderImplementPrompt()) <= 1650 && approxTokens(builderPlanPrompt(7)) <= 1950);
6178
6514
  });
6179
6515
 
6180
6516
  it("runCliBackendAsync reports the first stream turn's context as baselineTokens; makeLeanRunner drops it on resume", async () => {