forgedock 1.30.0 → 1.32.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (162) hide show
  1. package/bin/engine/outcome-codes.mjs +3 -2
  2. package/bin/engine/runlog.mjs +82 -12
  3. package/bin/engine/telemetry-seam.mjs +11 -4
  4. package/bin/engine/telemetry-seam.test.mjs +18 -0
  5. package/bin/engine-cli.mjs +409 -38
  6. package/bin/engine.mjs +47 -37
  7. package/bin/forgedock.mjs +35 -3
  8. package/bin/gc.mjs +50 -17
  9. package/bin/hooks/reread-nudge.mjs +65 -0
  10. package/bin/labels.json +5 -0
  11. package/bin/lean/apishape.mjs +170 -0
  12. package/bin/lean/apishape.test.mjs +187 -0
  13. package/bin/lean/batch.mjs +32 -0
  14. package/bin/lean/batch.test.mjs +338 -2
  15. package/bin/lean/budget.mjs +89 -0
  16. package/bin/lean/budget.test.mjs +106 -0
  17. package/bin/lean/checks.mjs +32 -9
  18. package/bin/lean/checks.test.mjs +78 -1
  19. package/bin/lean/conformance.e2e.test.mjs +27 -5
  20. package/bin/lean/conformance.mjs +150 -15
  21. package/bin/lean/conformance.test.mjs +175 -3
  22. package/bin/lean/context.mjs +128 -44
  23. package/bin/lean/context.test.mjs +157 -5
  24. package/bin/lean/decompose.mjs +90 -0
  25. package/bin/lean/decompose.test.mjs +105 -0
  26. package/bin/lean/depcache.mjs +11 -7
  27. package/bin/lean/depcache.test.mjs +81 -0
  28. package/bin/lean/e2e.mjs +102 -0
  29. package/bin/lean/e2e.test.mjs +86 -0
  30. package/bin/lean/effects.mjs +48 -15
  31. package/bin/lean/explore.mjs +47 -0
  32. package/bin/lean/explore.test.mjs +61 -0
  33. package/bin/lean/facts.mjs +84 -0
  34. package/bin/lean/facts.test.mjs +66 -0
  35. package/bin/lean/fixround.mjs +90 -0
  36. package/bin/lean/fixround.test.mjs +117 -0
  37. package/bin/lean/heavy.mjs +35 -2
  38. package/bin/lean/heavy.test.mjs +32 -2
  39. package/bin/lean/lean.mjs +712 -134
  40. package/bin/lean/lean.test.mjs +1626 -37
  41. package/bin/lean/ledger.mjs +5 -3
  42. package/bin/lean/ledger.test.mjs +18 -0
  43. package/bin/lean/light.mjs +135 -0
  44. package/bin/lean/light.test.mjs +173 -0
  45. package/bin/lean/lightlane.mjs +219 -0
  46. package/bin/lean/lightlane.test.mjs +237 -0
  47. package/bin/lean/limits.test.mjs +36 -1
  48. package/bin/lean/orchestrate.mjs +303 -41
  49. package/bin/lean/pitfalls.mjs +136 -0
  50. package/bin/lean/pitfalls.test.mjs +117 -0
  51. package/bin/lean/postmerge.mjs +85 -0
  52. package/bin/lean/postmerge.test.mjs +79 -0
  53. package/bin/lean/precheck.mjs +138 -13
  54. package/bin/lean/precheck.test.mjs +118 -1
  55. package/bin/lean/prefilter.mjs +220 -5
  56. package/bin/lean/prefilter.test.mjs +260 -4
  57. package/bin/lean/preflight.mjs +108 -3
  58. package/bin/lean/preflight.test.mjs +48 -1
  59. package/bin/lean/probe.mjs +297 -0
  60. package/bin/lean/probe.test.mjs +210 -0
  61. package/bin/lean/prompts.mjs +104 -13
  62. package/bin/lean/render.mjs +7 -3
  63. package/bin/lean/resume.mjs +67 -0
  64. package/bin/lean/resume.test.mjs +84 -0
  65. package/bin/lean/review-trigger.mjs +12 -0
  66. package/bin/lean/review-trigger.test.mjs +17 -1
  67. package/bin/lean/routing.mjs +74 -0
  68. package/bin/lean/routing.test.mjs +67 -0
  69. package/bin/lean/sched.mjs +16 -1
  70. package/bin/lean/sched.test.mjs +175 -1
  71. package/bin/lean/schedd.mjs +55 -9
  72. package/bin/lean/schedd.test.mjs +170 -3
  73. package/bin/lean/selfreview.mjs +37 -0
  74. package/bin/lean/selfreview.test.mjs +51 -0
  75. package/bin/lean/seqlease.mjs +40 -0
  76. package/bin/lean/seqlease.test.mjs +101 -0
  77. package/bin/lean/sequence.mjs +296 -13
  78. package/bin/lean/sequence.test.mjs +263 -2
  79. package/bin/lean/toolchain.mjs +60 -15
  80. package/bin/lean/toolchain.test.mjs +120 -3
  81. package/bin/lean/toolstats.mjs +72 -0
  82. package/bin/lean/toolstats.test.mjs +47 -0
  83. package/bin/lean/verifier.mjs +232 -0
  84. package/bin/lean/verifier.test.mjs +163 -0
  85. package/bin/lean/verify.mjs +72 -10
  86. package/bin/lean/verify.test.mjs +68 -3
  87. package/bin/review-staging.mjs +533 -0
  88. package/bin/runner.mjs +82 -13
  89. package/bin/stats/aggregate.mjs +87 -2
  90. package/bin/stats/efficiency.mjs +335 -0
  91. package/bin/stats/efficiency.test.mjs +266 -0
  92. package/bin/stats/github-source.mjs +8 -3
  93. package/bin/stats/index.mjs +91 -6
  94. package/bin/stats/local-source.mjs +145 -8
  95. package/bin/stats/query.mjs +181 -3
  96. package/bin/stats/render.mjs +22 -0
  97. package/bin/stats/stats.test.mjs +403 -8
  98. package/commands/orchestrate.md +3 -1
  99. package/commands/review-pr-agents/protocols.md +1 -1
  100. package/commands/review-pr-staging.md +84 -831
  101. package/commands/review-pr.md +1 -1
  102. package/commands/work-on/build/implement.md +1 -0
  103. package/commands/work-on/build/validate.md +11 -1
  104. package/docs/CONFIG.md +80 -5
  105. package/docs/OUTCOME-CODES.md +2 -1
  106. package/docs/STATS.md +66 -1
  107. package/docs/TELEMETRY.md +1 -1
  108. package/docs/eval/corpus/lightlane-v1.json +55 -0
  109. package/docs/eval/lightlane-bench.md +91 -0
  110. package/docs/spec/review-protocol.md +1 -1
  111. package/package.json +1 -1
  112. package/scripts/bench-lightlane.mjs +286 -0
  113. package/scripts/ci-local/lib.mjs +20 -0
  114. package/scripts/ci-local/plan.mjs +2 -12
  115. package/scripts/ci-local/scoping.mjs +10 -1
  116. package/scripts/ci-local.test.sh +2 -0
  117. package/scripts/fixtures/apishape/other/go-change/base/x.go +3 -0
  118. package/scripts/fixtures/apishape/other/go-change/head/x.go +3 -0
  119. package/scripts/fixtures/apishape/python/generator-function/base/seq.py +3 -0
  120. package/scripts/fixtures/apishape/python/generator-function/head/seq.py +2 -0
  121. package/scripts/fixtures/apishape/python/ichunked/base/more.py +17 -0
  122. package/scripts/fixtures/apishape/python/ichunked/head/more.py +20 -0
  123. package/scripts/fixtures/apishape/python/private-edit/base/calc.py +6 -0
  124. package/scripts/fixtures/apishape/python/private-edit/head/calc.py +9 -0
  125. package/scripts/fixtures/apishape/python/removed/base/ops.py +6 -0
  126. package/scripts/fixtures/apishape/python/removed/head/ops.py +2 -0
  127. package/scripts/fixtures/apishape/python/renamed/base/text.py +2 -0
  128. package/scripts/fixtures/apishape/python/renamed/head/text.py +2 -0
  129. package/scripts/fixtures/apishape/python/signature/base/shapes.py +2 -0
  130. package/scripts/fixtures/apishape/python/signature/head/shapes.py +2 -0
  131. package/scripts/fixtures/apishape/python/sync-async/base/client.py +7 -0
  132. package/scripts/fixtures/apishape/python/sync-async/head/client.py +7 -0
  133. package/scripts/fixtures/apishape/ts/generator-function/base/seq.js +3 -0
  134. package/scripts/fixtures/apishape/ts/generator-function/head/seq.js +5 -0
  135. package/scripts/fixtures/apishape/ts/private-edit/base/calc.ts +7 -0
  136. package/scripts/fixtures/apishape/ts/private-edit/head/calc.ts +9 -0
  137. package/scripts/fixtures/apishape/ts/removed/base/ops.ts +7 -0
  138. package/scripts/fixtures/apishape/ts/removed/head/ops.ts +3 -0
  139. package/scripts/fixtures/apishape/ts/renamed/base/text.ts +3 -0
  140. package/scripts/fixtures/apishape/ts/renamed/head/text.ts +3 -0
  141. package/scripts/fixtures/apishape/ts/signature/base/shapes.ts +3 -0
  142. package/scripts/fixtures/apishape/ts/signature/head/shapes.ts +3 -0
  143. package/scripts/fixtures/apishape/ts/sync-async/base/client.mjs +5 -0
  144. package/scripts/fixtures/apishape/ts/sync-async/head/client.mjs +5 -0
  145. package/scripts/forge-root.test.sh +2 -2
  146. package/scripts/publish-staging-sync.test.sh +62 -4
  147. package/scripts/review-finalize.sh +17 -6
  148. package/scripts/review-finalize.test.sh +47 -0
  149. package/scripts/review-pr-arguments.test.sh +13 -1
  150. package/scripts/review-pr-delta-wiring.test.sh +2 -2
  151. package/scripts/review-pr-gate2.test.sh +6 -14
  152. package/scripts/review-pr-staging-finding-filter.test.sh +4 -4
  153. package/scripts/review-pr-staging-scope.test.sh +39 -33
  154. package/scripts/review-prepare.sh +73 -6
  155. package/scripts/review-prepare.test.sh +85 -0
  156. package/scripts/review-staging-post.sh +293 -0
  157. package/scripts/review-staging-post.test.sh +143 -0
  158. package/scripts/review-staging-pre.sh +401 -0
  159. package/scripts/review-staging-pre.test.sh +150 -0
  160. package/scripts/symbol-index.mjs +26 -7
  161. package/scripts/symbol-index.py +55 -9
  162. package/scripts/trusted-comments.test.sh +1 -1
@@ -0,0 +1,90 @@
1
+ // SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
2
+ // SPDX-License-Identifier: AGPL-3.0-or-later
3
+ /**
4
+ * Fresh fix sessions (#4363): which fix rounds run in a new, narrow session (resume: null, lean.fix_model) instead of
5
+ * resuming the builder's whole plan+build transcript, and the caps of that session's prompt. Pure: no I/O.
6
+ * Repo-agnostic: a failure "cites a file:line" when its text names any `path.ext:N`, whatever the language.
7
+ */
8
+
9
+ import { noComments } from "./render.mjs";
10
+
11
+ /** Fix-round kinds whose failures are mechanical: always a fresh session. */
12
+ export const MECHANICAL_KINDS = Object.freeze(["precheck", "ci", "docs", "structure", "enumerating", "sequence", "post_merge", "duplicate"]);
13
+
14
+ /** The fresh prompt stays under this many characters; the per-part caps leave room for the fixed text. */
15
+ export const FIX_PROMPT_MAX = 60000;
16
+ export const FIX_CAPS = Object.freeze({ task: 20000, diff: 24000, contract: 8000, summary: 1500 });
17
+
18
+ const FILE_LINE_RE = /[\w.@/-]+\.[A-Za-z0-9]+:\d+/;
19
+
20
+ /** True only for a non-empty list whose every failure (a string, or `f.detail`) cites a `path.ext:N`. */
21
+ export function citesFileLine(failures) {
22
+ if (!Array.isArray(failures) || !failures.length) return false;
23
+ return failures.every((f) => FILE_LINE_RE.test(typeof f === "string" ? f : String(f?.detail ?? "")));
24
+ }
25
+
26
+ /** A mechanical kind, or a conformance/review FAIL whose failures all cite a file:line. */
27
+ export function freshFix(kind, failures = []) {
28
+ if (MECHANICAL_KINDS.includes(kind)) return true;
29
+ return (kind === "conformance" || kind === "review") && citesFileLine(failures);
30
+ }
31
+
32
+ /** `s` cut to `max` characters with a marker naming what was dropped. */
33
+ export function capText(s, max) {
34
+ const t = String(s ?? "");
35
+ return t.length <= max ? t : `${t.slice(0, max)}\n[... ${t.length - max} more characters cut]`;
36
+ }
37
+
38
+ /** The issue body without HTML comments (FORGE:STATE and other markers), trimmed and capped. */
39
+ export function issueSummary(body, max = FIX_CAPS.summary) {
40
+ const t = noComments(body).replace(/\n{3,}/g, "\n\n").trim();
41
+ return capText(t, max);
42
+ }
43
+
44
+ /**
45
+ * #4362: why a PHASE_ATTEMPT_USAGE attempt ran (its `trigger`). Every builder, reviewer and grouping call passes one of
46
+ * these; events written before the field existed read as "unknown" in `forgedock stats`.
47
+ */
48
+ export const TRIGGERS = Object.freeze(["plan", "build", "block_correction", "contract_correction", "precheck", "structure", "docs", "enumerating",
49
+ "sequence", "ci_unavailable", "ci_red", "conformance", "review", "merge_ci", "timeout_restart", "recovery", "verifier", "post_merge", "duplicate",
50
+ "context_restart", // #4390: a session stopped at the context ceiling, restarted fresh (restart_of: the stopped trigger)
51
+ "budget_wrapup"]); // #4392: the bounded wrap-up after the implement turn budget is spent (restart_of: the stopped trigger)
52
+
53
+ /** Builder attempts after the build that mean its diff needed rework (environment and engine repairs do not). */
54
+ export const REWORK_TRIGGERS = Object.freeze(["precheck", "structure", "docs", "enumerating", "sequence", "ci_red", "conformance", "review", "merge_ci", "post_merge", "duplicate"]);
55
+
56
+ /**
57
+ * First pass of one lane from its implement-phase attempts in order ({trigger, restart_of}): null when it never reached the
58
+ * build or an attempt has no trigger (not measurable), else true when no rework-trigger attempt follows the first `build`
59
+ * (or the `timeout_restart`/`context_restart`/`budget_wrapup` of a build, `restart_of: "build"`, when the build itself was killed).
60
+ */
61
+ export function firstPassOf(attempts) {
62
+ const list = Array.isArray(attempts) ? attempts : [];
63
+ if (list.some((a) => !a?.trigger)) return null;
64
+ const at = list.findIndex((a) => a.trigger === "build" || (["timeout_restart", "context_restart", "budget_wrapup"].includes(a.trigger) && a.restart_of === "build"));
65
+ if (at < 0) return null;
66
+ return !list.slice(at + 1).some((a) => REWORK_TRIGGERS.includes(a.trigger));
67
+ }
68
+
69
+ /** The batch report's first-pass line (rows carry `first_pass`), or null when no lane reached the build. */
70
+ export function formatFirstPassSummary(rows) {
71
+ const reached = (rows ?? []).filter((r) => typeof r?.first_pass === "boolean");
72
+ if (!reached.length) return null;
73
+ const k = reached.filter((r) => r.first_pass).length;
74
+ return `first-pass: ${Math.round((k / reached.length) * 100)}% (${k}/${reached.length} lane${reached.length === 1 ? "" : "s"} that reached the build)`;
75
+ }
76
+
77
+ /**
78
+ * #4407: one attempt's cost from the CLI envelope's `total_cost_usd`, which is cumulative over a resumed session.
79
+ * `last` maps a session id to its last reported total (updated in place). A resumed attempt costs its total minus the
80
+ * session's previous total; a fresh call, an unknown session or a total below the previous one counts in full, so the
81
+ * deltas of a session's attempts sum to its final total. A non-finite cost yields nulls and leaves `last` alone.
82
+ */
83
+ export function sessionCostDelta(last, { resume = null, sessionId = null, cost = null } = {}) {
84
+ if (!Number.isFinite(cost)) return { delta: null, session: null };
85
+ const prev = resume ? last.get(resume) : undefined;
86
+ const delta = Number.isFinite(prev) && cost >= prev ? Math.round((cost - prev) * 1e9) / 1e9 : cost;
87
+ const key = sessionId ?? resume;
88
+ if (key) last.set(key, cost);
89
+ return { delta, session: cost };
90
+ }
@@ -0,0 +1,117 @@
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 { MECHANICAL_KINDS, FIX_PROMPT_MAX, citesFileLine, freshFix, issueSummary, capText, TRIGGERS, REWORK_TRIGGERS, firstPassOf, formatFirstPassSummary, sessionCostDelta } from "./fixround.mjs";
6
+ import { fixSessionPrompt } from "./prompts.mjs";
7
+
8
+ describe("#4363 fresh fix sessions: which rounds, and the prompt caps", () => {
9
+ it("every mechanical kind is fresh; block/contract/conflict kinds are not", () => {
10
+ assert.deepEqual([...MECHANICAL_KINDS], ["precheck", "ci", "docs", "structure", "enumerating", "sequence", "post_merge", "duplicate"]);
11
+ for (const k of MECHANICAL_KINDS) assert.equal(freshFix(k, []), true, k);
12
+ for (const k of ["block", "contract", "base-sync", "ci-unavailable"]) assert.equal(freshFix(k, [{ detail: "src/a.js:1" }]), false, k);
13
+ });
14
+
15
+ it("a conformance or review FAIL is fresh only when every failure cites a file:line", () => {
16
+ const cited = [{ code: "c", detail: "R1 partial: null throws (src/foo.js:9)" }, { code: "c", detail: "P1 violated (lib/bar.ts:12)" }];
17
+ const mixed = [...cited, { code: "c", detail: "validate V1 (`npm test`) failed (exit 1)" }];
18
+ for (const k of ["conformance", "review"]) {
19
+ assert.equal(freshFix(k, cited), true, k);
20
+ assert.equal(freshFix(k, mixed), false, `${k}: one failure without a file:line`);
21
+ assert.equal(freshFix(k, []), false, `${k}: no failures`);
22
+ }
23
+ });
24
+
25
+ it("file:line detection is codebase-agnostic", () => {
26
+ for (const d of ["app/models.py:42", "cmd/main.go:7", "src/lib.rs:310", "web/src/App.tsx:5", "lib/x/y.rb:1", "@scope/pkg/index.mjs:88", "Foo.java:3"]) {
27
+ assert.equal(citesFileLine([`fails at ${d}`]), true, d);
28
+ }
29
+ for (const d of ["the foo module", "R1 partial", "exit 1: 3 tests failed", "http://host:8080"]) assert.equal(citesFileLine([d]), false, d);
30
+ });
31
+
32
+ it("the issue summary drops HTML comments (FORGE:STATE) and is capped", () => {
33
+ const s = issueSummary(`## Problem\nfoo breaks\n\n<!-- FORGE:STATE\n{"v":1}\n-->\n${"y".repeat(5000)}`);
34
+ assert.doesNotMatch(s, /FORGE:STATE|"v":1/);
35
+ assert.match(s, /^## Problem\nfoo breaks/);
36
+ assert.ok(s.length < 1600);
37
+ assert.equal(capText("abc", 5), "abc");
38
+ });
39
+
40
+ it("the issue summary leaves no comment opener for nested or unclosed markers", () => {
41
+ for (const body of ["a <!<!-- x -->-- FORGE:STATE --> b", "a <!-- unclosed FORGE:STATE", "a <!--<!-- x -->--> b"]) {
42
+ assert.doesNotMatch(issueSummary(body), /<!--|<!/);
43
+ }
44
+ });
45
+
46
+ it("an oversized task, diff and contract still give a prompt under 60K with the title, summary, contract and diff", () => {
47
+ const diff = Array.from({ length: 50 }, (_, i) => `diff --git a/src/f${i}.js b/src/f${i}.js\n${"+line\n".repeat(2000)}`).join("");
48
+ const contract = { requirements: [{ id: "R1", quote: "q".repeat(50000), disposition: "deliver" }] };
49
+ const p = fixSessionPrompt({ task: `lint failed\n${"z".repeat(200000)}`, diff, stat: " src/f0.js | 2000 +", base: "origin/staging", contract, title: "foo breaks", summary: "foo crashes on null" });
50
+ assert.ok(p.length < 60000, `prompt ${p.length} chars`);
51
+ assert.ok(p.length < FIX_PROMPT_MAX);
52
+ assert.match(p, /no earlier session/); assert.match(p, /## Issue: foo breaks\nfoo crashes on null/);
53
+ assert.match(p, /"id": "R1"/); assert.match(p, /diff --git a\/src\/f0\.js/); assert.match(p, /git diff origin\/staging\.\.\.HEAD/);
54
+ assert.match(p, /lint failed/); assert.match(p, /Host rules/);
55
+ });
56
+ });
57
+
58
+ describe("#4362 attempt triggers and first pass", () => {
59
+ const t = (...xs) => xs.map((trigger) => ({ trigger }));
60
+ it("the trigger vocabulary is the issue's closed list; rework is a subset", () => {
61
+ assert.deepEqual([...TRIGGERS], ["plan", "build", "block_correction", "contract_correction", "precheck", "structure", "docs", "enumerating",
62
+ "sequence", "ci_unavailable", "ci_red", "conformance", "review", "merge_ci", "timeout_restart", "recovery", "verifier", "post_merge", "duplicate", "context_restart", "budget_wrapup"]);
63
+ for (const r of REWORK_TRIGGERS) assert.ok(TRIGGERS.includes(r), r);
64
+ });
65
+ it("firstPassOf: null without a build or with an untagged attempt; rework after the build breaks it, repairs do not", () => {
66
+ assert.equal(firstPassOf(t("plan")), null);
67
+ assert.equal(firstPassOf([{ trigger: "plan" }, {}]), null);
68
+ assert.equal(firstPassOf(t("plan", "build")), true);
69
+ assert.equal(firstPassOf(t("plan", "precheck", "build")), true, "only attempts after the build count");
70
+ assert.equal(firstPassOf(t("plan", "build", "timeout_restart", "recovery", "ci_unavailable", "block_correction")), true);
71
+ for (const r of REWORK_TRIGGERS) assert.equal(firstPassOf(t("plan", "build", r)), false, r);
72
+ const killed = [{ trigger: "plan" }, { trigger: "timeout_restart", restart_of: "build" }];
73
+ assert.equal(firstPassOf(killed), true, "a killed and restarted build still reached the build");
74
+ assert.equal(firstPassOf([...killed, { trigger: "review" }]), false);
75
+ assert.equal(firstPassOf([{ trigger: "timeout_restart", restart_of: "plan" }]), null, "a restarted plan is not the build");
76
+ assert.equal(firstPassOf([{ trigger: "plan" }, { trigger: "context_restart", restart_of: "build" }]), true, "#4390: a context-ceiling restart of the build reached the build");
77
+ assert.equal(firstPassOf(t("plan", "build", "context_restart")), true, "#4390: a context restart is not rework");
78
+ });
79
+ it("formatFirstPassSummary counts only rows that reached the build", () => {
80
+ assert.equal(formatFirstPassSummary([{ first_pass: true }, { first_pass: false }, { issue: 3 }, { first_pass: true }]), "first-pass: 67% (2/3 lanes that reached the build)");
81
+ assert.equal(formatFirstPassSummary([{ first_pass: true }]), "first-pass: 100% (1/1 lane that reached the build)");
82
+ assert.equal(formatFirstPassSummary([{ issue: 1 }]), null);
83
+ assert.equal(formatFirstPassSummary(undefined), null);
84
+ });
85
+ });
86
+
87
+ describe("#4407 sessionCostDelta: per-attempt cost from a resumed session's running total", () => {
88
+ it("a session of N attempts: the deltas sum to the final session total", () => {
89
+ const last = new Map(), totals = [2.33, 2.41, 15.25, 16.31];
90
+ const ds = totals.map((cost, i) => sessionCostDelta(last, { resume: i ? "s" : null, sessionId: "s", cost }));
91
+ assert.deepEqual(ds.map((d) => d.session), totals);
92
+ assert.equal(Math.round(ds.reduce((a, d) => a + d.delta, 0) * 1e9) / 1e9, 16.31);
93
+ assert.deepEqual(ds.map((d) => d.delta), [2.33, 0.08, 12.84, 1.06]);
94
+ });
95
+
96
+ it("a fresh call, an unknown session and a total below the previous one count in full", () => {
97
+ const last = new Map([["s", 5]]);
98
+ assert.equal(sessionCostDelta(last, { resume: null, sessionId: "s", cost: 1 }).delta, 1, "fresh");
99
+ assert.equal(sessionCostDelta(new Map(), { resume: "x", sessionId: "x", cost: 3 }).delta, 3, "unknown session");
100
+ const l2 = new Map([["s", 5]]);
101
+ assert.equal(sessionCostDelta(l2, { resume: "s", sessionId: "s", cost: 2 }).delta, 2, "below the previous total");
102
+ });
103
+
104
+ it("a non-finite cost yields nulls and leaves the map alone", () => {
105
+ const last = new Map([["s", 1]]);
106
+ assert.deepEqual(sessionCostDelta(last, { resume: "s", sessionId: "s", cost: null }), { delta: null, session: null });
107
+ assert.deepEqual(sessionCostDelta(last, { resume: "s", sessionId: "s", cost: NaN }), { delta: null, session: null });
108
+ assert.deepEqual([...last], [["s", 1]]);
109
+ });
110
+
111
+ it("a resume that returns a new session id: the next resume of that id subtracts its total", () => {
112
+ const last = new Map();
113
+ sessionCostDelta(last, { resume: null, sessionId: "a", cost: 1 });
114
+ assert.equal(sessionCostDelta(last, { resume: "a", sessionId: "b", cost: 1.5 }).delta, 0.5);
115
+ assert.equal(sessionCostDelta(last, { resume: "b", sessionId: "b", cost: 2 }).delta, 0.5);
116
+ });
117
+ });
@@ -64,6 +64,31 @@ export function classifyStep(name, args = []) {
64
64
  return "llm";
65
65
  }
66
66
 
67
+ const INTERP = new Set(["sh", "bash", "dash", "zsh", "node", "nodejs", "python", "python3", "env"]);
68
+ const PY_HEAVY_MODULES = new Set(["pytest", "mypy", "tox", "unittest"]);
69
+ /**
70
+ * #4405: is this `ps` command line a heavy step (a shim-gated step, test runner, linter, type checker, install)? Looks
71
+ * through an interpreter (`/bin/sh …/vitest run`, `node …/eslint .`) and knows `node --test` and `python -m pytest`.
72
+ * Pollers (`sleep`, `until grep …`), MCP servers and plain scripts are not.
73
+ */
74
+ export function isHeavyProcess(cmd) {
75
+ const s = String(cmd ?? "");
76
+ if (/\bheavy\.mjs['"]?\s+run\b/.test(s)) return true;
77
+ const t = s.trim().split(/\s+/).filter(Boolean);
78
+ if (!t.length) return false;
79
+ const base = (x) => x.split("/").pop().replace(/\.(c|m)?js$/, "");
80
+ if (classifyStep(base(t[0]), t.slice(1)) === "heavy") return true;
81
+ const b0 = base(t[0]), py = /^python\d*(\.\d+)?$/.test(b0);
82
+ if (!INTERP.has(b0) && !py) return false;
83
+ const rest = t.slice(1);
84
+ if ((b0 === "node" || b0 === "nodejs") && rest.includes("--test")) return true;
85
+ const mi = rest.indexOf("-m");
86
+ if (py && mi >= 0 && PY_HEAVY_MODULES.has(rest[mi + 1])) return true;
87
+ const si = rest.findIndex((x) => !x.startsWith("-") && !/^\w+=/.test(x));
88
+ if (si < 0 || rest[si - 1] === "-c") return false;
89
+ return classifyStep(base(rest[si]), rest.slice(si + 1)) === "heavy";
90
+ }
91
+
67
92
  /** Merged heavy config: defaults ← cfg (forge.yaml engine.heavy_*) ← env (FORGE_HEAVY_MAX|STEP_MEM_MB|RESERVE_MB). */
68
93
  export function heavyConfig(cfg = {}, env = {}) {
69
94
  const pos = (v) => { const x = Number(v); return v !== undefined && v !== null && String(v).trim() !== "" && Number.isFinite(x) && x > 0 ? x : null; };
@@ -175,6 +200,14 @@ export function openHeavy(dir = schedDir(), { cfg = {}, env = {}, host = default
175
200
  },
176
201
  withdraw: (key) => { try { mutate((s) => { delete s.waiters[key]; }); } catch { /* fail-open */ } },
177
202
  release: (key) => { try { mutate((s) => { delete s.holders[key]; delete s.waiters[key]; }); } catch { /* fail-open */ } },
203
+ /** #4405: {queued, running} of live entries whose pid is in `pids` (one session's processes), read-only; null when unreadable. */
204
+ lane: (pids = []) => {
205
+ try {
206
+ const s = read(), set = new Set([...pids].map(Number));
207
+ const n = (o) => Object.values(o).filter((e) => set.has(Number(e?.pid)) && alive(e.pid)).length;
208
+ return { queued: n(s.waiters), running: n(s.holders) };
209
+ } catch { return null; }
210
+ },
178
211
  /** {holders, queued, maxQueued, max} of live entries, read-only (no lock, no write); null when unreadable. */
179
212
  stats: () => {
180
213
  try {
@@ -205,10 +238,10 @@ export async function acquireHeavy(h, entry = {}, { pollMs = HEAVY_POLL_MS, slee
205
238
  }
206
239
  }
207
240
 
208
- /** Default lane effect: `acquire(entry, cfg)` → {waitMs, queued, release()} (cfg = the lane's forge.yaml heavy keys); `stats()`. */
241
+ /** Default lane effect: `acquire(entry, cfg)` → {waitMs, queued, release()} (cfg = the lane's forge.yaml heavy keys); `stats()`; `lane(pids)` (#4405). */
209
242
  export function makeHeavyIo({ dir = schedDir(), env = process.env, host } = {}) {
210
243
  const open = (cfg) => openHeavy(dir, { cfg, env, ...(host ? { host } : {}) });
211
- return { acquire: (entry, cfg = {}) => acquireHeavy(open(cfg), entry), stats: () => open({}).stats() };
244
+ return { acquire: (entry, cfg = {}) => acquireHeavy(open(cfg), entry), stats: () => open({}).stats(), lane: (pids) => open({}).lane(pids) };
212
245
  }
213
246
 
214
247
  const SHIM_HEADER = "#!/bin/sh\n# forgedock heavy-step shim (#4169): waits for a host heavy-step slot, then runs the real command; do not edit.\n";
@@ -5,9 +5,9 @@ import assert from "node:assert/strict";
5
5
  import { writeFileSync, readFileSync, mkdirSync, chmodSync, existsSync } from "node:fs";
6
6
  import { join } from "node:path";
7
7
  import { tmpDir } from "../tests/_tmp.mjs";
8
- import { classifyStep, heavyConfig, openHeavy, acquireHeavy, writeHeavyShims, heavyShimScript, sumWaits, runWrapped, HEAVY_BINS } from "./heavy.mjs";
8
+ import { classifyStep, heavyConfig, openHeavy, acquireHeavy, writeHeavyShims, heavyShimScript, sumWaits, runWrapped, HEAVY_BINS, isHeavyProcess } from "./heavy.mjs";
9
9
  import { checkAdmission, ADMISSION_DEFAULTS } from "./admission.mjs";
10
- import { formatWaitSummary } from "./orchestrate.mjs";
10
+ import { formatAlertLines, formatWaitSummary } from "./orchestrate.mjs";
11
11
  import { readHeavy } from "../engine-cli.mjs";
12
12
 
13
13
  // Injected pids, clock and host: `live` is the set of pids the registry sees as alive; `mem` the MemAvailable in MB.
@@ -50,6 +50,29 @@ describe("step classes (#4169)", () => {
50
50
  });
51
51
  });
52
52
 
53
+ describe("idle-detector inputs (#4405)", () => {
54
+ it("isHeavyProcess: shim steps, test runners and linters behind an interpreter are heavy; pollers, MCP servers and git are not", () => {
55
+ for (const c of ["/usr/bin/node /x/bin/lean/heavy.mjs run --name npm -- /usr/bin/npm test", "/bin/sh /x/vitest run", "node /x/node_modules/.bin/eslint .",
56
+ "node --test a.test.mjs", "python3 -m pytest -q", "pytest -x", "cargo test", "env CI=1 jest"]) assert.equal(isHeavyProcess(c), true, c);
57
+ for (const c of ["sleep 10", "bash -c until grep -q done out; do sleep 10; done", "node mcp-server.js", "git status", "npm --version", "", null]) assert.equal(isHeavyProcess(c), false, String(c));
58
+ });
59
+
60
+ it("lane(pids) counts only this session's live queued and running entries, read-only", () => {
61
+ const { handle, live, dir } = world({ mem: 64000, cores: 2 });
62
+ const a = handle(201), b = handle(202), c = handle(301);
63
+ assert.equal(a.tryAcquire("a", { issue: 7 }).ok, true);
64
+ assert.equal(b.tryAcquire("b", { issue: 7 }).ok, false);
65
+ assert.equal(c.tryAcquire("c", { issue: 7 }).ok, false);
66
+ const before = readFileSync(join(dir, "heavy.json"), "utf8");
67
+ assert.deepEqual(a.lane([201, 202, 999]), { queued: 1, running: 1 });
68
+ assert.deepEqual(a.lane([301]), { queued: 1, running: 0 }, "same issue number, other session: matched by pid only");
69
+ assert.deepEqual(a.lane([]), { queued: 0, running: 0 });
70
+ live.delete(201);
71
+ assert.deepEqual(a.lane([201, 202]), { queued: 1, running: 0 }, "a dead holder does not count");
72
+ assert.equal(readFileSync(join(dir, "heavy.json"), "utf8"), before, "lane() never writes the registry");
73
+ });
74
+ });
75
+
53
76
  describe("heavy-step semaphore (#4169)", () => {
54
77
  it("caps live holders at max(1, cores-1) and frees a slot on release or holder death", () => {
55
78
  const { handle, live } = world({ mem: 64000, cores: 4 });
@@ -204,4 +227,11 @@ describe("heavy shims and the run wrapper (#4169)", () => {
204
227
  assert.equal(formatWaitSummary([{ issue: 3 }], { queued: 0 }), null, "no waits and an empty queue print no line");
205
228
  assert.equal(formatWaitSummary([{ issue: 3 }], { queued: 2 }), "waits by phase: none; heavy queue: now 2, max 0");
206
229
  });
230
+
231
+ it("formatAlertLines (#4354): the batch-report alert tail, nothing when no alert fired", () => {
232
+ assert.deepEqual(formatAlertLines([]), []);
233
+ assert.deepEqual(formatAlertLines(undefined), []);
234
+ assert.deepEqual(formatAlertLines([{ event: "deferred-to-ci", rate: 0.4, count: 4, sample: 10, threshold: 0.3, example: "npx tsc" }]),
235
+ ["ALERTS", 'deferred-to-ci: 40% (4/10 lanes, threshold 30%), e.g. "npx tsc"']);
236
+ });
207
237
  });