forgedock 1.40.0 → 1.42.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 (62) hide show
  1. package/bin/engine/outcome-codes.mjs +6 -3
  2. package/bin/engine-cli.mjs +40 -4
  3. package/bin/forgedock.mjs +22 -1
  4. package/bin/lean/batch.test.mjs +33 -1
  5. package/bin/lean/capwait.mjs +36 -0
  6. package/bin/lean/capwait.test.mjs +48 -0
  7. package/bin/lean/conformance.mjs +150 -2
  8. package/bin/lean/conformance.test.mjs +126 -0
  9. package/bin/lean/depcache.mjs +10 -2
  10. package/bin/lean/depcache.test.mjs +22 -17
  11. package/bin/lean/execution.mjs +21 -5
  12. package/bin/lean/execution.test.mjs +7 -1
  13. package/bin/lean/heavy.mjs +22 -1
  14. package/bin/lean/lanecheck.mjs +129 -0
  15. package/bin/lean/lanecheck.test.mjs +118 -0
  16. package/bin/lean/lanelock.mjs +31 -0
  17. package/bin/lean/lean.mjs +102 -30
  18. package/bin/lean/lean.test.mjs +173 -3
  19. package/bin/lean/orchestrate.mjs +42 -7
  20. package/bin/lean/phaseledger.mjs +116 -0
  21. package/bin/lean/phaseledger.test.mjs +72 -0
  22. package/bin/lean/prodwatch.mjs +12 -1
  23. package/bin/lean/prodwatch.test.mjs +32 -3
  24. package/bin/lean/prompts.mjs +19 -0
  25. package/bin/lean/render.mjs +10 -2
  26. package/bin/lean/sandbox-provider.contract.test.mjs +48 -0
  27. package/bin/lean/sandbox-remote.mjs +8 -1
  28. package/bin/lean/schedd.mjs +5 -1
  29. package/bin/lean/selftest-gate.mjs +46 -0
  30. package/bin/lean/selftest-gate.test.mjs +40 -0
  31. package/bin/lean/selftest-live.mjs +248 -0
  32. package/bin/lean/selftest-live.test.mjs +154 -0
  33. package/bin/lean/selftest-nightly.mjs +81 -0
  34. package/bin/lean/selftest-nightly.test.mjs +84 -0
  35. package/bin/lean/selftest-static.mjs +81 -0
  36. package/bin/lean/selftest-static.test.mjs +32 -0
  37. package/bin/pool/agent.mjs +16 -5
  38. package/bin/pool/router.mjs +17 -1
  39. package/bin/pool/warm.mjs +80 -0
  40. package/bin/review-staging.mjs +28 -6
  41. package/bin/stats/aggregate.mjs +2 -1
  42. package/bin/stats/efficiency.mjs +3 -1
  43. package/bin/stats/efficiency.test.mjs +4 -1
  44. package/bin/stats/github-source.mjs +2 -1
  45. package/bin/stats/local-source.mjs +1 -0
  46. package/bin/stats/render.mjs +2 -0
  47. package/commands/deploy-pr.md +3 -3
  48. package/commands/review-pr-staging.md +2 -2
  49. package/commands/work-on/build.md +2 -2
  50. package/docs/CONFIG.md +33 -0
  51. package/docs/OUTCOME-CODES.md +1 -1
  52. package/docs/PROD-WATCH.md +1 -1
  53. package/docs/RELEASE-NOTES.md +10 -0
  54. package/docs/SELFTEST.md +34 -0
  55. package/docs/eval/conformance-labels-v1.json +13 -0
  56. package/package.json +1 -1
  57. package/pool/image/Dockerfile +2 -1
  58. package/pool/image/entrypoint.sh +44 -11
  59. package/pool/image/warm-refresh.sh +14 -0
  60. package/scripts/conformance-calibrate.mjs +132 -0
  61. package/scripts/file-deferred-followups.sh +21 -8
  62. package/scripts/file-deferred-followups.test.sh +27 -6
@@ -13,9 +13,9 @@ import { makeLedger } from "./ledger.mjs";
13
13
  import { writeIndex } from "./guards.mjs";
14
14
  import { parseVerify } from "./verify.mjs";
15
15
  import { worktreeFingerprint } from "./precheck.mjs";
16
- import { renderInvestigation, renderPlan, renderImplementation, renderReview, renderPrBody, renderTrajectory, renderStructure, renderContract, contractJson, parseContractComment, sanitizeMd, capComment, section, cell, NR, MAX_COMMENT_CHARS } from "./render.mjs";
16
+ import { renderInvestigation, renderPlan, renderImplementation, renderReview, renderPrBody, renderTrajectory, renderStructure, renderContract, renderAmendments, parseAmendmentsComment, contractJson, parseContractComment, sanitizeMd, capComment, section, cell, NR, MAX_COMMENT_CHARS } from "./render.mjs";
17
17
  import { orchestrate, parseDeps, formatTable, pickNext } from "./orchestrate.mjs";
18
- import { REVIEWER_SYSTEM, REVIEW_RUBRIC, approxTokens, builderPlanPrompt, builderImplementPrompt, reviewerPrompt, hotspotPlanNote, hotspotBuildRule } from "./prompts.mjs";
18
+ import { REVIEWER_SYSTEM, REVIEW_RUBRIC, approxTokens, builderPlanPrompt, builderImplementPrompt, reviewerPrompt, decisionNote, hotspotPlanNote, hotspotBuildRule } from "./prompts.mjs";
19
19
  import { parseReview, unnamedEdges, structureNote, applyAmendments, validateContract, scopeText, DECISION_SOURCES } from "./conformance.mjs";
20
20
  import { mapRecord } from "../engine/telemetry-seam.mjs";
21
21
  import { readLog as readRepoLog } from "../engine/runlog.mjs";
@@ -24,7 +24,7 @@ const readLog = (dir, issue, repo = "o/r") => readRepoLog(dir, issue, { repo });
24
24
  import { makeRunTmp, wrapLaneLimits, laneCapNotice, systemdRunAvailable, makeLeanRunner, requiredChecksFor, procsInDir, makeLeanIo, makeGhCache } from "./effects.mjs";
25
25
  import { retrying, ghCacheRetry, refreshAuthOnce, GH_AUTH_RE, PUSH_TRANSIENT_RE } from "./retry.mjs";
26
26
  import { checkAdmission, hostParallelism } from "./admission.mjs";
27
- import { OUTCOME_CODES, classifyNeedsHuman } from "../engine/outcome-codes.mjs";
27
+ import { OUTCOME_CODES, ENV_VALIDATE_TIMEOUT, classifyNeedsHuman } from "../engine/outcome-codes.mjs";
28
28
  import * as refactorMod from "./refactor.mjs";
29
29
  import { readAdmission, readConformanceMode, readReviewIncomplete, readEnvConfig, readContextPack } from "../engine-cli.mjs";
30
30
  import { prepareToolchain } from "./toolchain.mjs";
@@ -1342,6 +1342,18 @@ describe("conformance shadow mode (the default)", () => {
1342
1342
  assert.ok(ran[0].env && typeof ran[0].env === "object", "the rerun gets the lane env");
1343
1343
  }
1344
1344
  });
1345
+ it("#4906 a validate rerun that times out (rc 124) ends env.validate_timeout: recoverable, no builder resume, never conformance.unimplemented", async () => {
1346
+ const v = [{ id: "V1", command: "node --test test/foo.test.js", exit: 124 }];
1347
+ for (const conformance of ["shadow", "enforce"]) {
1348
+ const w = world({ validation: v });
1349
+ w.io.runCommand = async () => ({ rc: 124, output: "(timed out after 1s)" });
1350
+ const r = await run(w, { conformance });
1351
+ assert.equal(r.code, ENV_VALIDATE_TIMEOUT, conformance); assert.equal(r.terminalReason, "needs-human");
1352
+ assert.equal(classifyNeedsHuman(r), "recoverable"); assert.equal(w.merges.length, 0);
1353
+ assert.equal(builders(w).length, 2, "plan + implement only"); assert.equal(reviewers(w).length, 0);
1354
+ assert.match(nhBody(w), /timed out \(exit 124\) even after one longer retry/);
1355
+ }
1356
+ });
1345
1357
  it("#4323 a rerun that fails for a repo reason stays conformance.unimplemented; one that passes merges", async () => {
1346
1358
  const v = [{ id: "V1", command: "node --test test/foo.test.js", exit: 1 }];
1347
1359
  const w = world({ validation: v, resumeValidation: v });
@@ -3800,6 +3812,45 @@ describe("batch robustness (#4112-#4115)", () => {
3800
3812
  }
3801
3813
  });
3802
3814
 
3815
+ const NONFF = "To github.com:o/r.git\n ! [rejected] feat/issue-7-lean -> feat/issue-7-lean (non-fast-forward)\nerror: failed to push some refs";
3816
+ const ownerProbe = (w, { open = [], body = "" } = {}) => { // answers #4920's lane-owner lookups only
3817
+ const g0 = w.io.gh;
3818
+ w.io.gh = async (a, o) => {
3819
+ if (a[0] === "pr" && a[1] === "list" && a[a.indexOf("--limit") + 1] === "5") return JSON.stringify(open);
3820
+ if (a[0] === "issue" && a[1] === "view" && a[a.length - 1] === "body") return JSON.stringify({ body });
3821
+ return g0(a, o);
3822
+ };
3823
+ return w;
3824
+ };
3825
+ it("#4920 non-ff push with another lane's open PR ends deferred (lease held), not needs-human", async () => {
3826
+ const w = ownerProbe(world({ pushErrors: [NONFF] }), { open: [{ number: 4919 }] });
3827
+ const r = await run(w);
3828
+ assert.equal(r.terminalReason, "deferred");
3829
+ assert.equal(r.code, "lease.held");
3830
+ assert.match(r.detail, /non-fast-forward.*PR #4919 is open on feat\/issue-7-lean/);
3831
+ });
3832
+ it("#4920 non-ff push while another lane holds a live lease ends deferred", async () => {
3833
+ const lease = { v: 1, issue: 7, lease: { by: "core_sbx_1_ab12", until: Date.now() + 600000, host: "sbx", pid: 1 } };
3834
+ const w = ownerProbe(world({ pushErrors: [NONFF] }), { body: `x\n<!-- FORGE:STATE\n${JSON.stringify(lease)}\n-->` });
3835
+ const r = await run(w);
3836
+ assert.equal([r.terminalReason, r.code].join("/"), "deferred/lease.held");
3837
+ });
3838
+ it("#4920 a non-ff push nobody else owns, and any other permanent rejection, stay needs-human", async () => {
3839
+ assert.equal((await run(ownerProbe(world({ pushErrors: [NONFF] })))).terminalReason, "needs-human");
3840
+ assert.equal((await run(ownerProbe(world({ pushErrors: ["remote: error: GH006: Protected branch update failed"] }), { open: [{ number: 4919 }] }))).terminalReason, "needs-human");
3841
+ });
3842
+ it("#4920 two sandbox lanes (pid 1) get distinct lease holders, and a lease another lane wrote meanwhile defers this lane", async () => {
3843
+ const ids = [];
3844
+ for (let i = 0; i < 2; i++) { const w = world(); w.leaseWrites = []; w.io.projector = { readState: async () => null, writeState: async (_i, st) => { w.leaseWrites.push(st); } }; await run(w, { pid: 1, hostName: "sbx" }); ids.push(w.leaseWrites.find((x) => x.lease)?.lease.by); }
3845
+ assert.ok(ids[0] && ids[1] && ids[0] !== ids[1], `${ids}`);
3846
+ assert.match(ids[0], /^core_sbx_1_[0-9a-f]{6}$/);
3847
+ const w = world(); w.leaseWrites = []; let reads = 0;
3848
+ w.io.projector = { readState: async () => (++reads === 1 ? null : { v: 1, lease: { by: "core_sbx_1_other", until: Date.now() + 600000 } }), writeState: async (_i, st) => { w.leaseWrites.push(st); } };
3849
+ const r = await run(w, { pid: 1, hostName: "sbx" });
3850
+ assert.equal([r.terminalReason, r.code].join("/"), "deferred/lease-held");
3851
+ assert.equal(w.builderCalls?.length ?? 0, 0);
3852
+ });
3853
+
3803
3854
  it("#4118 a rerun reuses a local lane branch with unpushed commits instead of resetting it to the base", async () => {
3804
3855
  const w = world();
3805
3856
  const git0 = w.io.git;
@@ -4073,6 +4124,41 @@ describe("batch robustness (#4112-#4115)", () => {
4073
4124
  assert.ok(!w.comments.some((c) => c.includes("FORGE:CONTRACT")), "not re-posted");
4074
4125
  assert.match(traj(w)[0], /frozen contract [0-9a-f]{12} recovered from the issue/);
4075
4126
  });
4127
+ it("recovery lane replays accepted supersedes amendment (#4899)", async () => {
4128
+ const sup = { kind: "supersedes", guard: "#4785", why: "behaviour changed on purpose" };
4129
+ const state = { contract: contractHash(contractJson(CONTRACT)), amendments: [sup] };
4130
+ const mk = (st) => ({ body: `<!-- FORGE:AMENDMENT -->\n${renderAmendments([{ kind: "supersedes", target: "#4785", why: sup.why }], {}, st)}`, author_association: "OWNER", user: { type: "User", login: "op" } });
4131
+ const w = world({ prList: [], remote: "abc\trefs/heads/feat/issue-7-lean", issueComments: [frozenComment(), mk(state)] });
4132
+ assert.equal((await run(w, { recover: { code: "ci.red", attempt: 1 } })).terminalReason, "merged");
4133
+ const posted = w.comments.find((c) => c.includes("FORGE:AMENDMENT-STATE"));
4134
+ assert.ok(posted, "the recovery lane re-applies the prior supersedes and posts it");
4135
+ assert.deepEqual(parseAmendmentsComment(posted).amendments, [sup]);
4136
+ // a state bound to another contract hash is ignored
4137
+ const w2 = world({ prList: [], remote: "abc\trefs/heads/feat/issue-7-lean", issueComments: [frozenComment(), mk({ ...state, contract: "000000000000" })] });
4138
+ await run(w2, { recover: { code: "ci.red", attempt: 1 } });
4139
+ assert.ok(!w2.comments.some((c) => c.includes("FORGE:AMENDMENT-STATE")));
4140
+ });
4141
+ it("#4899 recovery lane keeps enforcing a guard that was not superseded", async () => {
4142
+ const mkGuard = (issue) => ({ v: 1, issue, pr: issue * 10, merge_sha: "abc1234", tests: [{ path: "src/foo.js", ids: [] }], hunks: [], why: `guard ${issue}` });
4143
+ const sup = { kind: "supersedes", guard: "#4785", why: "behaviour changed on purpose" };
4144
+ const prior = { body: `<!-- FORGE:AMENDMENT -->\n${renderAmendments([{ kind: "supersedes", target: "#4785", why: sup.why }], {}, { contract: contractHash(contractJson(CONTRACT)), amendments: [sup] })}`, author_association: "OWNER", user: { type: "User", login: "op" } };
4145
+ const dir = tmpDir("guard-recover-");
4146
+ const w = world({ prList: [], remote: "abc\trefs/heads/feat/issue-7-lean", issueComments: [frozenComment(), prior], baseFiles: { ":src/foo.js": "old" } });
4147
+ w.io.runCommand = async () => ({ rc: 1, output: "guard test failed" });
4148
+ await run(w, { dir, worktreeRoot: dir, recover: { code: "ci.red", attempt: 1 }, loadGuards: async () => [mkGuard(4785), mkGuard(9)] });
4149
+ const text = JSON.stringify([w.comments, w.calls.filter((c) => c[0] === "runner").map((c) => c[4])]);
4150
+ assert.match(text, /conformance\.guard_broken.{0,400}?guard\\":\\"#9\\"/, "precheck reports #9 as guard_broken");
4151
+ assert.doesNotMatch(text, /conformance\.guard_broken.{0,400}?guard\\":\\"#4785\\"/, "precheck does not report the superseded #4785");
4152
+ });
4153
+ it("#4899 amendment state round-trips; precheck still enforces a guard that was not superseded", () => {
4154
+ const am = [{ kind: "supersedes", guard: "#1", why: "w" }];
4155
+ assert.deepEqual(parseAmendmentsComment(renderAmendments([{ kind: "supersedes", target: "#1", why: "w" }], {}, { contract: "abc", amendments: am })), { contract: "abc", amendments: am });
4156
+ assert.equal(parseAmendmentsComment(renderAmendments([], {})), null);
4157
+ const frozen = { requirements: [], deliverables: [] };
4158
+ const eff = applyAmendments(frozen, am).contract;
4159
+ assert.deepEqual((eff.supersedes ?? []).map((s) => s.guard), ["#1"]);
4160
+ assert.equal(applyAmendments(frozen, []).contract.supersedes, undefined);
4161
+ });
4076
4162
  const scoped = (body = ISSUE_BODY) => contractHash(scopeText({ title: "foo breaks", body: `${body}\n\n<!-- FORGE:STATE {"v":1} -->` }, []));
4077
4163
  it("#4265 a rerun recovers a contract frozen from the current issue scope (engine annotations aside)", async () => {
4078
4164
  const c = frozenComment(CONTRACT, (b) => b.replace(/\(sha256 ([0-9a-f]{12})\)/, `(sha256 $1, issue ${scoped()})`));
@@ -6552,6 +6638,42 @@ describe("resume digest in builder prompts (#4400)", () => {
6552
6638
  assert.ok(p.indexOf("Recovery pass (ci.red)") >= 0 && p.indexOf("Recovery pass (ci.red)") < p.indexOf("Resume digest of the last run"), p);
6553
6639
  });
6554
6640
 
6641
+ describe("#4901 FORGE:DECISION after the contract froze", () => {
6642
+ const dec = (t, who = BOT) => ({ body: `<!-- FORGE:DECISION {"v":1} -->\n## Decision\n${t}`, ...who });
6643
+ const CONTRACT_C = { body: frozen().body, ...BOT };
6644
+ const NOBODY = { author: { login: "rando" }, authorAssociation: "NONE" };
6645
+ const withDecisions = (viewComments) => world({ prList: [], remote: "abc\trefs/heads/feat/issue-7-lean", issueComments: [frozen()], issueBodies: [body], viewComments });
6646
+
6647
+ it("reviewerPrompt keeps trusted FORGE:DECISION comments, drops other FORGE: records and untrusted decisions", () => {
6648
+ const p = reviewerPrompt({ issue: { number: 7, title: "t", body: "b" }, contract: CONTRACT, diff: "", comments: [dec("deliver the nightly timer"), dec("evil override", NOBODY),
6649
+ { body: "<!-- FORGE:STATE\n{}\n-->", ...BOT }, { body: "<!-- FORGE:TRAJECTORY -->\nt", ...BOT }, { body: "<!-- FORGE:FACTS -->\nf", ...BOT }] });
6650
+ assert.match(p, /deliver the nightly timer/);
6651
+ assert.doesNotMatch(p, /evil override|FORGE:STATE|FORGE:TRAJECTORY|FORGE:FACTS/);
6652
+ });
6653
+
6654
+ it("decisionNote: only trusted decisions after the last contract; recovery records skipped", () => {
6655
+ const n = decisionNote([dec("old, before contract"), CONTRACT_C, dec("declare supersedes X"), dec("untrusted", NOBODY), { body: "<!-- FORGE:DECISION -->\n## Decision: recovery pass for `ci.red` (attempt 1)", ...BOT }]);
6656
+ assert.match(n, /declare supersedes X/);
6657
+ assert.doesNotMatch(n, /old, before contract|untrusted|recovery pass/);
6658
+ assert.equal(decisionNote([CONTRACT_C]), "");
6659
+ });
6660
+
6661
+ it("a requeued lane with a frozen contract gives the builder a newer trusted decision, not an untrusted or older one", async () => {
6662
+ const w = withDecisions([dec("older decision"), CONTRACT_C, dec("deliver the nightly timer"), dec("untrusted override", NOBODY), { body: factsRec, ...BOT }]);
6663
+ await run(w);
6664
+ const p = builders(w)[0][4];
6665
+ assert.match(p, /Binding decisions posted after the contract froze/);
6666
+ assert.match(p, /deliver the nightly timer/);
6667
+ assert.doesNotMatch(p, /untrusted override|older decision/);
6668
+ });
6669
+
6670
+ it("context_pack off still passes the decision", async () => {
6671
+ const w = withDecisions([CONTRACT_C, dec("deliver the nightly timer")]);
6672
+ await run(w, { contextPack: { mode: "off" } });
6673
+ assert.match(builders(w)[0][4], /deliver the nightly timer/);
6674
+ });
6675
+ });
6676
+
6555
6677
  it("a first run gets no digest; context_pack off gives none; the contract quotes are unchanged", async () => {
6556
6678
  const first = world({ issueBodies: [body], viewComments: [{ body: factsRec, ...BOT }] });
6557
6679
  await run(first);
@@ -6898,3 +7020,51 @@ describe("block rules: a check timeout is not a block (#4589)", () => {
6898
7020
  assert.match(blockCorrectionPrompt("x"), /times out \(rc 124\) is host load, never a block/);
6899
7021
  });
6900
7022
  });
7023
+
7024
+ describe("per-kind conformance modes (engine.conformance_kinds)", () => {
7025
+ const CREEP = ["src/foo.js", "src/billing.js", "test/foo.test.js"];
7026
+ const shadowNote = (w) => w.comments.find((c) => c.includes("<!-- FORGE:REVIEW -->")) ?? "";
7027
+ const blockedNothingMerged = (w) => { assert.equal(w.prs.length, 0); assert.equal(w.merges.length, 0); assert.ok(w.edits.some((e) => e.includes("needs-human"))); };
7028
+ it("a shadow kind in an enforce lane is recorded, not blocking; the lane merges", async () => {
7029
+ const w = world({ changed: CREEP, reviews: [conf({}, CONTRACT, CREEP)] });
7030
+ const r = await run(w, { conformance: "enforce", conformanceKinds: { "conformance.scope_creep": "shadow" } });
7031
+ assert.equal(r.terminalReason, "merged");
7032
+ assert.match(shadowNote(w), /would BLOCK[\s\S]*conformance\.scope_creep[\s\S]*src\/billing\.js/);
7033
+ });
7034
+
7035
+ it("an enforce kind in a shadow lane blocks (scope creep is recorded-only in shadow by default)", async () => {
7036
+ const w = world({ changed: CREEP });
7037
+ const r = await run(w, { conformance: "shadow", conformanceKinds: { "conformance.scope_creep": "enforce" } });
7038
+ assert.equal(r.code, "conformance.scope_creep"); assert.equal(reviewers(w).length, 0);
7039
+ blockedNothingMerged(w);
7040
+ });
7041
+
7042
+ it("an enforce lane without kinds still blocks scope creep (unchanged default)", async () => {
7043
+ const w = world({ changed: CREEP });
7044
+ assert.equal((await run(w, { conformance: "enforce", conformanceKinds: {} })).code, "conformance.scope_creep");
7045
+ });
7046
+ });
7047
+
7048
+ describe("mutation check (engine.conformance_mutation, #4913)", () => {
7049
+ const MC = { ...CONTRACT, deliverables: [...CONTRACT.deliverables, { id: "D2", path: "test/foo.test.js", change: "t", requirements: ["R1"] }] };
7050
+ const setup = (rc) => {
7051
+ const w = world({ contract: MC, result: { status: "fixed", summary: "s", evidence: [], tests: [], validation: [{ id: "V1", exit: 0 }] } });
7052
+ w.cmds = []; w.io.runCommand = async (a) => { w.cmds.push(a.command); return { rc: a.command.includes("--test") && w.cmds.length > 0 ? rc : 0, output: "" }; };
7053
+ return w;
7054
+ };
7055
+ const applied = (w) => w.calls.filter((c) => c[0] === "git" && c[1] === "apply").length;
7056
+ it("off (default): no revert, no extra command", async () => {
7057
+ const w = setup(0); await run(w, { conformance: "shadow" });
7058
+ assert.equal(applied(w), 0); assert.ok(!w.calls.some((c) => c[0] === "git" && c[1] === "checkout" && c[2] === "HEAD"));
7059
+ });
7060
+ it("report: a vacuous test is recorded (shadow kind), the lane still merges, the tree is restored", async () => {
7061
+ const w = setup(0); const r = await run(w, { conformance: "enforce", conformanceMutation: "report" });
7062
+ assert.equal(r.terminalReason, "merged"); assert.equal(applied(w), 1);
7063
+ const seq = w.calls.filter((c) => c[0] === "git" && (c[1] === "apply" || (c[1] === "reset" && c[3] === "--hard"))).map((c) => c[1]);
7064
+ assert.deepEqual(seq, ["apply", "reset"]);
7065
+ });
7066
+ it("report with the kind set to enforce blocks on a survivor", async () => {
7067
+ const w = setup(0); const r = await run(w, { conformance: "enforce", conformanceMutation: "report", conformanceKinds: { "conformance.mutation_survived": "enforce" } });
7068
+ assert.equal(r.code, "conformance.mutation_survived"); assert.equal(w.merges.length, 0);
7069
+ });
7070
+ });
@@ -111,6 +111,7 @@ import { renderAlerts } from "../stats/render.mjs";
111
111
  import { formatParkedLine, postDeployOnly } from "./prefilter.mjs";
112
112
  import { laneSize, SIZES } from "./budget.mjs";
113
113
  import { requeueKey, REQUEUABLE } from "./batch.mjs";
114
+ import { makeCapWait } from "./capwait.mjs";
114
115
 
115
116
  // A dependency that finished like this no longer blocks its dependents.
116
117
  const SATISFIED = new Set(["merged", "invalid"]);
@@ -245,7 +246,7 @@ const toMap = (o) => new Map(Object.entries(o ?? {}).map(([k, v]) => [Number(k),
245
246
  */
246
247
  export async function orchestrate({ issues, parallel = 3, runOne, readBody, prefilter = null, resolveLane = null, now = () => Date.now(), onUpdate = () => {}, onCrossLane = () => {},
247
248
  store = null, adopt = null, getParallel = null, getAdded = null, getRequeued = null, breaker = null, maxDeferrals = DEFAULT_MAX_DEFERRALS, sleep = cancellableSleep, pollMs = 5000,
248
- classify = null, maintenance = null, limits = null, identity = () => "default", batchId = null, drift = null, integration = null, sched = null, priorityOf = null, remote = null, beforeAdmit = null,
249
+ classify = null, maintenance = null, limits = null, identity = () => "default", batchId = null, drift = null, integration = null, sched = null, priorityOf = null, remote = null, beforeAdmit = null, preflight = null,
249
250
  recovery = null, decide = () => {}, conflicting = null, relabelNeedsHuman = null, onExit = null, issueLabels = null, issueState = null, getCancelled = null, getHeld = null, getBlocked = null, trusted = null, getGates = null, stopLane = null,
250
251
  estimate = null, starveMs = DEFAULT_STARVE_MS, signal = null, dispatchCheck = null, syncParked = null, syncEveryMs = DEFAULT_SYNC_EVERY_MS, lifecycle = null }) {
251
252
  const saved = store?.load?.() ?? null;
@@ -271,6 +272,7 @@ export async function orchestrate({ issues, parallel = 3, runOne, readBody, pref
271
272
  const holds = limits ?? memoryLimits(now);
272
273
  const heldUntil = () => { try { const id = identity(); const u = holds.holdUntil(id); return Number.isFinite(u) && u > now() ? u : null; } catch { return null; } };
273
274
  let tripped = saved?.breaker ?? null;
275
+ let pfAll = false, pfRemote = false, pfNoted = new Set(); // #4866: lane preflight holds
274
276
  let opHeld = new Set(); // #4592: operator-held issues this pass (the batch `hold` file is the authority)
275
277
  const opHeldHit = new Set(); // held issues that were pending this pass
276
278
  // #4376: operator cancel (the batch `cancel` file through `getCancelled`); persisted, but the file stays the authority.
@@ -289,6 +291,7 @@ export async function orchestrate({ issues, parallel = 3, runOne, readBody, pref
289
291
  const priorities = toMap(saved?.priorities); // issue -> priority number; only with the registry
290
292
  const remoteMax = remote && Number.isInteger(remote.max) && remote.max >= 0 ? remote.max : 0;
291
293
  let poolOfflineSince = null, poolStallNoted = false; // #4788: when sampled online first read 0, and whether the stall was reported
294
+ const capWait = makeCapWait({ now }); // #4909: a lane refused for capacity waits for a free slot, recorded as queue time
292
295
  let capAt, capAdmits = 0; // #4540: provider capacity sample and the remote admissions made since it
293
296
  const remoteRun = new Set(); // remote lanes running now (#4253): admitted against remoteMax, never the host cap
294
297
  let budgetLocal = false; // the execution budget refused a remote lane under on_budget local: the rest run on the host
@@ -505,9 +508,10 @@ export async function orchestrate({ issues, parallel = 3, runOne, readBody, pref
505
508
  p = { where: "local", reason: `fallback to local: no pool agent online (${p.reason})` };
506
509
  } else if (cap && Number.isInteger(cap.free)) {
507
510
  if (cap.at !== capAt) { capAt = cap.at; capAdmits = 0; }
511
+ if (capWait.blocked(n, cap)) { notBefore.set(n, now() + Math.max(pollMs, 1)); return false; }
508
512
  if (capAdmits >= cap.free && remoteRun.size < remoteMax) { notBefore.set(n, now() + Math.max(pollMs, 1)); return false; }
509
513
  }
510
- if (p.where === "remote" && remoteRun.size < remoteMax) { remoteRun.add(n); capAdmits++; placed.set(n, p); return true; }
514
+ if (p.where === "remote" && remoteRun.size < remoteMax) { capWait.admitted(n); remoteRun.add(n); capAdmits++; placed.set(n, p); return true; }
511
515
  if (p.where === "remote" && !remote.fallbackLocal) return false;
512
516
  if (p.where === "remote") p = { where: "local", reason: `fallback to local: max_remote ${remoteMax} reached (${p.reason})` }; // #4256: hybrid runs it on the host
513
517
  } else {
@@ -554,8 +558,9 @@ export async function orchestrate({ issues, parallel = 3, runOne, readBody, pref
554
558
  // #4304: the pre-filter runs once per issue; a recovery pass's row adds the earlier attempt's cost, which already holds it.
555
559
  const pfCost = recovering.has(n) ? 0 : (pf?.cost_usd ?? 0);
556
560
  const row = { issue: n, outcome: r?.terminalReason ?? "engine-error", pr: r?.pr ?? null, cost_usd: (r?.cost_usd ?? 0) + pfCost, minutes: Math.round((now() - rec.startedAt) / 6000) / 10, detail: r?.detail, ...(r?.code ? { code: r.code } : {}), ...(pf ? { prefilter: pf } : {}),
557
- ...(r?.waits && typeof r.waits === "object" ? { waits: r.waits } : {}), ...(Number.isInteger(r?.heavyQueueMax) ? { heavyQueueMax: r.heavyQueueMax } : {}),
561
+ ...(() => { const w = capWait.take(n, r?.waits); return w && typeof w === "object" ? { waits: w } : {}; })(), ...(Number.isInteger(r?.heavyQueueMax) ? { heavyQueueMax: r.heavyQueueMax } : {}),
558
562
  ...(Number.isFinite(r?.wall_ms) ? { wall_ms: r.wall_ms } : {}), ...(Number.isFinite(r?.model_ms) ? { model_ms: r.model_ms } : {}), // #4451
563
+ ...(phasesOf(r, rec) ? { phases: phasesOf(r, rec) } : {}), // #4908
559
564
  ...(Number.isFinite(r?.precheckMs) ? { precheck_ms: r.precheckMs } : {}), ...(Number.isInteger(r?.precheckDeferred) && r.precheckDeferred > 0 ? { precheck_deferred: r.precheckDeferred } : {}), // #4331
560
565
  ...(typeof r?.firstPass === "boolean" ? { first_pass: r.firstPass } : {}), // #4362
561
566
  ...(r?.turns && typeof r.turns === "object" ? { turns: r.turns } : {}), // #4392
@@ -621,6 +626,11 @@ export async function orchestrate({ issues, parallel = 3, runOne, readBody, pref
621
626
  }
622
627
  if (row.outcome === "deferred") {
623
628
  const pol = deferPolicy(r, { now: now(), deferrals: deferrals.get(n) ?? 0, maxDeferrals });
629
+ let capGated = false; // #4909: the pool reports free slots, so the slot (not the retry-in timer) gates the relaunch
630
+ if (r?.code === "sandbox.capacity") {
631
+ capWait.refused(n);
632
+ try { capGated = Number.isInteger(remote?.capacity?.()?.free); } catch { capGated = false; }
633
+ }
624
634
  if (!pol.capped) {
625
635
  if (!pol.host) deferrals.set(n, (deferrals.get(n) ?? 0) + 1);
626
636
  // A usage limit is held per account; the hold, not a per-issue delay, gates the retry, so a lane on another
@@ -629,7 +639,7 @@ export async function orchestrate({ issues, parallel = 3, runOne, readBody, pref
629
639
  if (pol.hold) {
630
640
  try { holds.set(r?.backendId ?? identity(), now() + pol.delayMs, `batch ${batchId ?? "?"} #${n}`); delayMs = 0; } catch { /* fail-open */ }
631
641
  }
632
- requeue(n, delayMs); done(); save();
642
+ requeue(n, capGated && !pol.hold ? 0 : delayMs); done(); save();
633
643
  update({ ...row, detail: `requeued in ${Math.round(pol.delayMs / 1000)}s${pol.hold ? " (admissions held)" : ""}: ${r?.code ?? "deferred"}${r?.detail ? ` ${r.detail}` : ""}` });
634
644
  return;
635
645
  }
@@ -740,8 +750,9 @@ export async function orchestrate({ issues, parallel = 3, runOne, readBody, pref
740
750
  if (relabelled.delete(n)) save(); // #4315: the recovery lane launches after all; it removes needs-human itself
741
751
  const pl = placed.get(n);
742
752
  placed.delete(n);
753
+ const queueMs = Number.isFinite(enqueued.get(n)) ? Math.max(0, now() - enqueued.get(n)) : null; // #4908: queue_wait in the phase ledger
743
754
  heads.delete(n); enqueued.delete(n); busyNoted.delete(n);
744
- const rec = { startedAt: now(), ...(lane ? { lane } : {}), ...(remoteRun.has(n) ? { remote: true } : {}), ...(pl ? { placement: pl } : {}) };
755
+ const rec = { startedAt: now(), ...(queueMs != null ? { queueMs } : {}), ...(lane ? { lane } : {}), ...(remoteRun.has(n) ? { remote: true } : {}), ...(pl ? { placement: pl } : {}) };
745
756
  runRecs.set(n, rec); notBefore.delete(n); save();
746
757
  track(n, (async () => {
747
758
  // Pre-filter once per issue (a requeued issue is not re-checked); fail-open: an error means a normal lane.
@@ -751,13 +762,13 @@ export async function orchestrate({ issues, parallel = 3, runOne, readBody, pref
751
762
  let gone = null;
752
763
  if (dispatchCheck && !cancelled && !pf?.closed && !pf?.parked) {
753
764
  try { gone = (await dispatchCheck(n)) ?? null; } catch { gone = null; }
754
- if (!["merged", "invalid"].includes(gone?.outcome)) gone = null;
765
+ if (!["merged", "invalid", "deferred"].includes(gone?.outcome)) gone = null; // #4920: deferred = another lane owns the issue (lease or open PR on GitHub)
755
766
  }
756
767
  if (halted) return; // #4454: stopped before launch; no pid, so a resume queues it again
757
768
  let r;
758
769
  if (cancelled) r = { terminalReason: "deferred", code: "run.interrupted", cost_usd: 0, detail: "cancelled before launch" }; // #4376
759
770
  else if (pf?.closed) r = { terminalReason: "invalid", cost_usd: 0, detail: `pre-filter: ${pf.detail}` };
760
- else if (gone) r = { terminalReason: gone.outcome, pr: Number.isInteger(gone.pr) ? gone.pr : null, cost_usd: 0, detail: `skipped at dispatch: ${gone.detail ?? gone.outcome}` };
771
+ else if (gone) r = { terminalReason: gone.outcome, ...(gone.outcome === "deferred" && gone.code ? { code: gone.code } : {}), pr: Number.isInteger(gone.pr) ? gone.pr : null, cost_usd: 0, detail: `skipped at dispatch: ${gone.detail ?? gone.outcome}` };
761
772
  // #4389: a parked post-deploy follow-up starts no lane; it ends awaiting-promotion|awaiting-precondition, re-checked next batch.
762
773
  else if (pf?.parked) r = { terminalReason: pf.kind, cost_usd: 0, detail: `pre-filter: ${pf.detail}` };
763
774
  else {
@@ -1059,7 +1070,20 @@ export async function orchestrate({ issues, parallel = 3, runOne, readBody, pref
1059
1070
  refreshHeld();
1060
1071
  await refreshBlocked();
1061
1072
  checkGates();
1073
+ // #4866: lane preflight. A failing check holds admission (all lanes, or only remote ones by its scope), says why once per batch,
1074
+ // and is re-checked every pass so the batch resumes on its own; it is not an engine-error, so the breaker never sees it.
1075
+ pfAll = false; pfRemote = false;
1076
+ if (preflight && !tripped && !cancelled && !holdUntil && pending.length) {
1077
+ let fails = [];
1078
+ try { fails = (await preflight()) ?? []; } catch { fails = []; }
1079
+ for (const f of fails) { if (f.scope === "remote") pfRemote = true; else pfAll = true; }
1080
+ const keys = new Set(fails.map((f) => `${f.code}|${f.detail}`));
1081
+ for (const f of fails) if (!pfNoted.has(`${f.code}|${f.detail}`)) update({ issue: null, outcome: "preflight", code: f.code, detail: `${f.code}: ${f.detail}; admissions paused until it clears` });
1082
+ if (pfNoted.size && !keys.size) update({ issue: null, outcome: "preflight", detail: "preflight cleared; admissions resumed" });
1083
+ pfNoted = keys;
1084
+ }
1062
1085
  const isReady = (n) => {
1086
+ if (pfAll || (pfRemote && isRemote(n))) return false;
1063
1087
  if (opHeld.has(n)) { opHeldHit.add(n); return false; }
1064
1088
  if (blocked.has(n)) { blockedHit.add(n); return false; } // #4773
1065
1089
  if (!((notBefore.get(n) ?? 0) <= t && edges(n).every((d) => results.has(d) && SATISFIED.has(results.get(d).outcome)))) return false;
@@ -1140,6 +1164,7 @@ export async function orchestrate({ issues, parallel = 3, runOne, readBody, pref
1140
1164
  if ((getParallel || getHeld) && pending.length) wake = Math.min(wake ?? pollMs, pollMs);
1141
1165
  if (offered.size && pending.length) wake = Math.min(wake ?? pollMs, pollMs); // a slot another batch frees
1142
1166
  if ((getAdded || getRequeued) && running.size) wake = Math.min(wake ?? pollMs, pollMs); // #4408/#4550: an add or requeue while only lanes run
1167
+ if ((pfAll || pfRemote) && pending.length) wake = Math.min(wake ?? pollMs, pollMs); // #4866
1143
1168
  if (getBlocked && pending.length && blockedHit.size) wake = Math.min(wake ?? pollMs, pollMs); // #4773: a removed label releases on the next tick
1144
1169
  if (getHeld && running.size) wake = Math.min(wake ?? pollMs, pollMs); // #4592: a release while only lanes run
1145
1170
  if (syncParked && running.size && parkedPrs().length) wake = Math.min(wake ?? syncEveryMs, Math.max(0, syncEveryMs - (t - (lastParkedSync ?? t)))); // #4525
@@ -1151,6 +1176,7 @@ export async function orchestrate({ issues, parallel = 3, runOne, readBody, pref
1151
1176
  else { const ac = new AbortController(); await race([...running.values(), sleep(wake, ac.signal)]); ac.abort(); }
1152
1177
  } else if (started) continue; // only lane-refused rows settled (synchronously): re-resolve skips first
1153
1178
  else if (pending.length && offered.size) await nap(wake); // waiting for a host slot
1179
+ else if (pending.length && (pfAll || pfRemote)) await nap(Math.min(wake ?? pollMs, pollMs)); // #4866: a failing lane preflight is re-checked on the next poll
1154
1180
  else if (pending.length && blockedHit.size) await nap(wake ?? pollMs); // #4773: only blocked issues wait: poll for the label's removal
1155
1181
  else if (pending.length && waits.length) await nap(Math.min(wake, getCancelled ? pollMs : Infinity)); // wake is poll-capped by a hold or a live K; #4454: nap wakes on a stop
1156
1182
  else if (pending.length && budgetHeld) { if (await drainAdds()) continue; break; } // maintenance the budget does not admit now: held for a later batch
@@ -1240,6 +1266,15 @@ export function formatTable(rows) {
1240
1266
  * #4169 batch-report line: wait time per phase summed over the rows' lane `waits` ({phase: ms}), and the heavy-step
1241
1267
  * semaphore queue: `now` live from `heavyStats.queued`, `max` the longest queue a lane of this batch saw. Null when nothing waited or queued.
1242
1268
  */
1269
+ /**
1270
+ * #4908: the lane's phase ledger for its batch row, plus `queue_wait` (the admission wait the batch saw; it precedes the lane
1271
+ * clock, so the other phases still sum to `wall_ms`). Null without a ledger.
1272
+ */
1273
+ export function phasesOf(r, rec) {
1274
+ if (!r?.phases || typeof r.phases !== "object" || !Object.keys(r.phases).length) return null;
1275
+ return { ...(Number.isFinite(rec?.queueMs) && rec.queueMs > 0 ? { queue_wait: { ms: Math.round(rec.queueMs) } } : {}), ...r.phases };
1276
+ }
1277
+
1243
1278
  export function formatWaitSummary(rows, heavyStats = null) {
1244
1279
  const by = {};
1245
1280
  for (const r of rows ?? []) for (const [p, ms] of Object.entries(r?.waits ?? {})) if (Number.isFinite(ms) && ms >= 0) by[p] = (by[p] ?? 0) + ms;
@@ -0,0 +1,116 @@
1
+ // SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
2
+ // SPDX-License-Identifier: AGPL-3.0-or-later
3
+ /**
4
+ * Per-lane phase ledger (#4908): where a lane's wall time goes. Engine phases only, nothing repo-specific.
5
+ * The lane marks the phase it enters; entering one closes the previous, so the lane phases sum to the lane's wall time.
6
+ * `queue_wait` (and, for a remote lane, the sandbox start the dispatcher saw) happens before the lane clock starts, so the
7
+ * batch runner adds them to the row; they are not part of the lane-wall sum.
8
+ * Row shape: `phases: { <id>: { ms, model_ms?, usd? } }`. FORGE:TELEMETRY values are scalars, so `flattenPhases` gives
9
+ * `phase_<id>_ms|_model_ms|_usd` keys for it and `unflattenPhases` reads them back.
10
+ */
11
+
12
+ export const LANE_PHASES = Object.freeze(["queue_wait", "sandbox_start", "deps_install", "validate", "plan", "implement", "precheck", "review", "ci_wait", "base_sync", "merge"]);
13
+ const isNum = (x) => typeof x === "number" && Number.isFinite(x);
14
+
15
+ function pct(values, p) {
16
+ const v = values.filter(isNum).sort((a, b) => a - b);
17
+ if (!v.length) return null;
18
+ if (p === 50) return v.length % 2 ? v[(v.length - 1) / 2] : (v[v.length / 2 - 1] + v[v.length / 2]) / 2;
19
+ return v[Math.max(0, Math.ceil((v.length * p) / 100) - 1)];
20
+ }
21
+
22
+ /** Sequential ledger starting in `first` at `start`. `now` is the lane clock. */
23
+ export function createLedger(now, first = "sandbox_start", start = now()) {
24
+ let cur = LANE_PHASES.includes(first) ? first : null, since = start;
25
+ const ms = {}, model = {}, usd = {};
26
+ const close = (t) => { if (cur) ms[cur] = (ms[cur] ?? 0) + Math.max(0, t - since); };
27
+ const enter = (phase) => {
28
+ if (!LANE_PHASES.includes(phase) || phase === cur) return;
29
+ const t = now(); close(t); cur = phase; since = t;
30
+ };
31
+ return {
32
+ enter,
33
+ /** Run `fn` inside `phase`, then return to the phase that was current. */
34
+ async during(phase, fn) {
35
+ const prev = cur; enter(phase);
36
+ try { return await fn(); } finally { if (prev) enter(prev); }
37
+ },
38
+ current: () => cur,
39
+ addModel(phase, modelMs, cost) {
40
+ const id = LANE_PHASES.includes(phase) ? phase : cur;
41
+ if (!id) return;
42
+ if (isNum(modelMs) && modelMs > 0) model[id] = (model[id] ?? 0) + modelMs;
43
+ if (isNum(cost) && cost > 0) usd[id] = (usd[id] ?? 0) + cost;
44
+ },
45
+ /** The ledger as of now (the open phase counted up to now); does not end the current phase. */
46
+ snapshot() {
47
+ const t = now(), total = { ...ms };
48
+ if (cur) total[cur] = (total[cur] ?? 0) + Math.max(0, t - since);
49
+ const out = {};
50
+ for (const id of LANE_PHASES) {
51
+ if (!(id in total) && !(id in model) && !(id in usd)) continue;
52
+ out[id] = { ms: Math.round(total[id] ?? 0), ...(model[id] ? { model_ms: Math.round(model[id]) } : {}), ...(usd[id] ? { usd: Math.round(usd[id] * 1e6) / 1e6 } : {}) };
53
+ }
54
+ return out;
55
+ },
56
+ };
57
+ }
58
+
59
+ /** Sum of the lane phases' durations (queue_wait excluded: it precedes the lane clock). */
60
+ export const laneSum = (phases) => Object.entries(phases ?? {}).reduce((s, [id, v]) => (id === "queue_wait" || !isNum(v?.ms) ? s : s + v.ms), 0);
61
+
62
+ /** True when the lane phases sum to within `tol` of `wallMs`. */
63
+ export const withinWall = (phases, wallMs, tol = 0.05) => isNum(wallMs) && wallMs >= 0 && Math.abs(laneSum(phases) - wallMs) <= Math.max(1, wallMs * tol);
64
+
65
+ export function flattenPhases(phases) {
66
+ const out = {};
67
+ for (const [id, v] of Object.entries(phases ?? {})) {
68
+ if (!LANE_PHASES.includes(id) || !v) continue;
69
+ if (isNum(v.ms)) out[`phase_${id}_ms`] = v.ms;
70
+ if (isNum(v.model_ms)) out[`phase_${id}_model_ms`] = v.model_ms;
71
+ if (isNum(v.usd)) out[`phase_${id}_usd`] = v.usd;
72
+ }
73
+ return out;
74
+ }
75
+
76
+ export function unflattenPhases(rec) {
77
+ const out = {};
78
+ for (const id of LANE_PHASES) {
79
+ const ms = rec?.[`phase_${id}_ms`], mm = rec?.[`phase_${id}_model_ms`], usd = rec?.[`phase_${id}_usd`];
80
+ if (!isNum(ms)) continue;
81
+ out[id] = { ms, ...(isNum(mm) ? { model_ms: mm } : {}), ...(isNum(usd) ? { usd } : {}) };
82
+ }
83
+ return out;
84
+ }
85
+
86
+ /**
87
+ * p50/p90 per phase over the given `phases` objects, and the phase with the largest share of the summed time.
88
+ * `{ n: 0, phases: {}, largest: null }` when none carries a ledger.
89
+ */
90
+ export function summarizePhases(list) {
91
+ const by = {}, lanes = (list ?? []).filter((p) => p && typeof p === "object" && Object.keys(p).length);
92
+ for (const p of lanes) for (const [id, v] of Object.entries(p)) if (LANE_PHASES.includes(id) && isNum(v?.ms) && v.ms >= 0) (by[id] ??= []).push(v.ms);
93
+ const phases = {};
94
+ let total = 0;
95
+ for (const id of LANE_PHASES) {
96
+ if (!by[id]) continue;
97
+ const sum = by[id].reduce((a, b) => a + b, 0);
98
+ total += sum;
99
+ phases[id] = { n: by[id].length, p50: pct(by[id], 50), p90: pct(by[id], 90), sum };
100
+ }
101
+ let largest = null;
102
+ for (const [id, v] of Object.entries(phases)) if (!largest || v.sum > phases[largest].sum) largest = id;
103
+ return { n: lanes.length, phases, largest: largest ? { phase: largest, share: total > 0 ? phases[largest].sum / total : 0 } : null };
104
+ }
105
+
106
+ const min = (ms) => `${(ms / 60000).toFixed(1)}m`;
107
+
108
+ /** Report line(s) for p50/p90 per phase and the largest phase; `n/a` when no lane carried a ledger. Never throws. */
109
+ export function formatLedgerSummary(rows) {
110
+ try {
111
+ const s = summarizePhases((rows ?? []).map((r) => r?.phases));
112
+ if (!s.n) return "phases (wall time): n/a";
113
+ const parts = Object.entries(s.phases).map(([id, v]) => `${id} ${min(v.p50)}/${min(v.p90)}`);
114
+ return `phases (p50/p90 per lane, ${s.n} lane${s.n === 1 ? "" : "s"}): ${parts.join(", ")}; largest: ${s.largest.phase} (${Math.round(s.largest.share * 100)}% of ledger time)`;
115
+ } catch { return "phases (wall time): n/a"; }
116
+ }
@@ -0,0 +1,72 @@
1
+ // SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
2
+ // SPDX-License-Identifier: AGPL-3.0-or-later
3
+ import { describe, it } from "node:test";
4
+ import assert from "node:assert/strict";
5
+ import { phasesOf } from "./orchestrate.mjs";
6
+ import { aggregate } from "../stats/aggregate.mjs";
7
+ import { LANE_PHASES, createLedger, laneSum, withinWall, flattenPhases, unflattenPhases, summarizePhases, formatLedgerSummary } from "./phaseledger.mjs";
8
+
9
+ const clock = () => { let t = 1000; const f = () => t; f.tick = (ms) => { t += ms; }; return f; };
10
+
11
+ describe("phase ledger (#4908)", () => {
12
+ it("lane phases sum to within 5% of wall and only reached phases appear", async () => {
13
+ const now = clock(), t0 = now(), L = createLedger(now);
14
+ now.tick(2000); L.enter("deps_install");
15
+ now.tick(70000); L.enter("plan");
16
+ now.tick(60000); L.enter("implement");
17
+ now.tick(120000);
18
+ await L.during("validate", async () => { now.tick(30000); });
19
+ now.tick(10000); L.enter("ci_wait"); now.tick(5000); L.enter("merge"); now.tick(1000);
20
+ L.addModel("implement", 100000, 1.5);
21
+ const p = L.snapshot(), wall = now() - t0;
22
+ assert.deepEqual(Object.keys(p), ["sandbox_start", "deps_install", "validate", "plan", "implement", "ci_wait", "merge"]);
23
+ assert.equal(p.implement.ms, 130000);
24
+ assert.deepEqual([p.implement.model_ms, p.implement.usd], [100000, 1.5]);
25
+ assert.ok(withinWall(p, wall));
26
+ assert.equal(laneSum(p), wall);
27
+ assert.ok(!withinWall({ plan: { ms: 1000 } }, 100000));
28
+ });
29
+
30
+ it("queue_wait is outside the lane sum; unknown phases are ignored", () => {
31
+ const now = clock(), L = createLedger(now);
32
+ L.enter("nope"); now.tick(500);
33
+ const p = { ...L.snapshot(), queue_wait: { ms: 99999 } };
34
+ assert.equal(laneSum(p), 500);
35
+ });
36
+
37
+ it("flatten/unflatten round trip for FORGE:TELEMETRY scalars", () => {
38
+ const p = { plan: { ms: 5, model_ms: 3, usd: 0.25 }, merge: { ms: 7 } };
39
+ const flat = flattenPhases(p);
40
+ assert.deepEqual(flat, { phase_plan_ms: 5, phase_plan_model_ms: 3, phase_plan_usd: 0.25, phase_merge_ms: 7 });
41
+ assert.deepEqual(unflattenPhases(flat), p);
42
+ });
43
+
44
+ it("summary gives p50/p90 per phase and the largest phase; n/a without ledger data", () => {
45
+ const rows = [{ phases: { plan: { ms: 60000 }, implement: { ms: 300000 } } }, { phases: { plan: { ms: 120000 }, implement: { ms: 900000 } } }, { issue: 3 }, { phases: {} }];
46
+ const s = summarizePhases(rows.map((r) => r.phases));
47
+ assert.equal(s.n, 2);
48
+ assert.equal(s.phases.plan.p50, 90000);
49
+ assert.equal(s.phases.implement.p90, 900000);
50
+ assert.equal(s.largest.phase, "implement");
51
+ const line = formatLedgerSummary(rows);
52
+ assert.match(line, /implement 10\.0m\/15\.0m/);
53
+ assert.match(line, /largest: implement/);
54
+ assert.equal(formatLedgerSummary([{ issue: 1 }]), "phases (wall time): n/a");
55
+ assert.equal(formatLedgerSummary(null), "phases (wall time): n/a");
56
+ assert.equal(LANE_PHASES.length, 11);
57
+ });
58
+
59
+ it("the batch row adds queue_wait to the lane ledger and stays null without one (#4908)", () => {
60
+ const lane = { plan: { ms: 10 }, merge: { ms: 5 } };
61
+ assert.deepEqual(phasesOf({ phases: lane }, { queueMs: 300 }), { queue_wait: { ms: 300 }, ...lane });
62
+ assert.deepEqual(phasesOf({ phases: lane }, {}), lane);
63
+ assert.equal(phasesOf({ wall_ms: 5 }, { queueMs: 1 }), null);
64
+ });
65
+
66
+ it("forgedock stats aggregates the ledger and tolerates runs without one", () => {
67
+ const run = (ledger) => ({ source: "local", repo: "o/r", version: "1.0.0", issue: 1, run: "r", finished: true, reason: "merged", code: null, remediated: false, usageEvents: 1, startTs: null, endTs: null, wallMs: 100, phases: {}, builderAttempts: [], ledger });
68
+ const t = aggregate({ runs: [run({ plan: { ms: 40 }, implement: { ms: 60 } }), run({}), run(undefined)] }).groups?.[0]?.time;
69
+ assert.equal(t?.ledger.n, 1);
70
+ assert.equal(t.ledger.largest.phase, "implement");
71
+ });
72
+ });
@@ -120,6 +120,15 @@ const isSummary = (i) => trailerOf(i?.body).includes(SUMMARY_TAG);
120
120
 
121
121
  const issueNumber = (out) => Number(/(\d+)\s*$/.exec(String(out ?? "").trim())?.[1]) || null;
122
122
 
123
+ const LIST_FIELDS = "number,state,stateReason,body,createdAt,closedAt";
124
+
125
+ /** Every open issue carrying `label`: `gh api --paginate --slurp` walks all pages (`gh issue list` has no cursor); PRs are dropped and items mapped to the `issue list` shape. */
126
+ async function listOpen(io, label) {
127
+ const pages = JSON.parse(String(await io.gh(["api", "--paginate", "--slurp", `repos/{owner}/{repo}/issues?labels=${encodeURIComponent(label)}&state=open&per_page=100`])));
128
+ return pages.flat().filter((i) => !i.pull_request)
129
+ .map((i) => ({ number: i.number, state: "OPEN", stateReason: i.state_reason ?? null, body: i.body ?? "", createdAt: i.created_at, closedAt: i.closed_at ?? null }));
130
+ }
131
+
123
132
  // ── one round ──────────────────────────────────────────────────────────────────────────────
124
133
  /**
125
134
  * One poll round. `state` is the cache `{filed:[{at,fp,issue}], verified:{fp:at}, paged:{fp:at}, digest:{date,issue,items}}`; it is mutated
@@ -132,8 +141,10 @@ export async function roundOnce({ cfg, io, state = {}, dryRun = false, label = D
132
141
  const write = async (what, fn) => { if (dryRun) return null; try { return await fn(); } catch (e) { rep.errors.push(`${what}: ${e.message}`); return null; } };
133
142
 
134
143
  let issues;
135
- try { issues = JSON.parse(String(await io.gh(["issue", "list", "--label", label, "--state", "all", "--limit", "200", "--json", "number,state,stateReason,body,createdAt,closedAt"]))); }
144
+ try { issues = await listOpen(io, label); }
136
145
  catch (e) { return { ...rep, errors: [`issue list failed, nothing filed: ${e.message}`] }; }
146
+ try { issues = issues.concat(JSON.parse(String(await io.gh(["issue", "list", "--label", label, "--state", "closed", "--limit", "200", "--json", LIST_FIELDS]))).filter((i) => i.state === "CLOSED")); }
147
+ catch (e) { rep.errors.push(`closed issue list failed, metrics and verify skip closed issues: ${e.message}`); }
137
148
  const openAll = issues.filter((i) => i.state === "OPEN"), openSummary = openAll.find(isSummary) ?? null;
138
149
  const open = openAll.filter((i) => !isSummary(i)), rows = metricRows(issues.filter((i) => !isSummary(i)));
139
150
  rep.metrics = computeMetrics(rows);