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.
- package/bin/engine/outcome-codes.mjs +6 -3
- package/bin/engine-cli.mjs +40 -4
- package/bin/forgedock.mjs +22 -1
- package/bin/lean/batch.test.mjs +33 -1
- package/bin/lean/capwait.mjs +36 -0
- package/bin/lean/capwait.test.mjs +48 -0
- package/bin/lean/conformance.mjs +150 -2
- package/bin/lean/conformance.test.mjs +126 -0
- package/bin/lean/depcache.mjs +10 -2
- package/bin/lean/depcache.test.mjs +22 -17
- package/bin/lean/execution.mjs +21 -5
- package/bin/lean/execution.test.mjs +7 -1
- package/bin/lean/heavy.mjs +22 -1
- package/bin/lean/lanecheck.mjs +129 -0
- package/bin/lean/lanecheck.test.mjs +118 -0
- package/bin/lean/lanelock.mjs +31 -0
- package/bin/lean/lean.mjs +102 -30
- package/bin/lean/lean.test.mjs +173 -3
- package/bin/lean/orchestrate.mjs +42 -7
- package/bin/lean/phaseledger.mjs +116 -0
- package/bin/lean/phaseledger.test.mjs +72 -0
- package/bin/lean/prodwatch.mjs +12 -1
- package/bin/lean/prodwatch.test.mjs +32 -3
- package/bin/lean/prompts.mjs +19 -0
- package/bin/lean/render.mjs +10 -2
- package/bin/lean/sandbox-provider.contract.test.mjs +48 -0
- package/bin/lean/sandbox-remote.mjs +8 -1
- package/bin/lean/schedd.mjs +5 -1
- package/bin/lean/selftest-gate.mjs +46 -0
- package/bin/lean/selftest-gate.test.mjs +40 -0
- package/bin/lean/selftest-live.mjs +248 -0
- package/bin/lean/selftest-live.test.mjs +154 -0
- package/bin/lean/selftest-nightly.mjs +81 -0
- package/bin/lean/selftest-nightly.test.mjs +84 -0
- package/bin/lean/selftest-static.mjs +81 -0
- package/bin/lean/selftest-static.test.mjs +32 -0
- package/bin/pool/agent.mjs +16 -5
- package/bin/pool/router.mjs +17 -1
- package/bin/pool/warm.mjs +80 -0
- package/bin/review-staging.mjs +28 -6
- package/bin/stats/aggregate.mjs +2 -1
- package/bin/stats/efficiency.mjs +3 -1
- package/bin/stats/efficiency.test.mjs +4 -1
- package/bin/stats/github-source.mjs +2 -1
- package/bin/stats/local-source.mjs +1 -0
- package/bin/stats/render.mjs +2 -0
- package/commands/deploy-pr.md +3 -3
- package/commands/review-pr-staging.md +2 -2
- package/commands/work-on/build.md +2 -2
- package/docs/CONFIG.md +33 -0
- package/docs/OUTCOME-CODES.md +1 -1
- package/docs/PROD-WATCH.md +1 -1
- package/docs/RELEASE-NOTES.md +10 -0
- package/docs/SELFTEST.md +34 -0
- package/docs/eval/conformance-labels-v1.json +13 -0
- package/package.json +1 -1
- package/pool/image/Dockerfile +2 -1
- package/pool/image/entrypoint.sh +44 -11
- package/pool/image/warm-refresh.sh +14 -0
- package/scripts/conformance-calibrate.mjs +132 -0
- package/scripts/file-deferred-followups.sh +21 -8
- package/scripts/file-deferred-followups.test.sh +27 -6
package/bin/lean/lean.test.mjs
CHANGED
|
@@ -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
|
+
});
|
package/bin/lean/orchestrate.mjs
CHANGED
|
@@ -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
|
|
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
|
+
});
|
package/bin/lean/prodwatch.mjs
CHANGED
|
@@ -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 =
|
|
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);
|