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,67 @@
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 { parseBuilderRouting, classifyPath, routeBuilder, shouldEscalate, ESCALATE_TRIGGERS, DEFAULT_MID_MODEL } from "./routing.mjs";
6
+
7
+ const contract = (...paths) => ({ deliverables: paths.map((path, i) => ({ id: `D${i + 1}`, path, change: "x", requirements: ["R1"] })) });
8
+
9
+ describe("parseBuilderRouting (#4394)", () => {
10
+ it("defaults to on with the default mid model; unknown modes are on; invalid ids fall back", () => {
11
+ assert.deepEqual(parseBuilderRouting(), { mode: "on", midModel: DEFAULT_MID_MODEL });
12
+ assert.equal(DEFAULT_MID_MODEL, "claude-sonnet-5-5");
13
+ assert.equal(parseBuilderRouting({ mode: "OFF" }).mode, "off");
14
+ assert.equal(parseBuilderRouting({ mode: "shadow" }).mode, "on");
15
+ assert.equal(parseBuilderRouting({ midModel: "claude-haiku-5-5" }).midModel, "claude-haiku-5-5");
16
+ assert.equal(parseBuilderRouting({ midModel: "openai/gpt[1m]" }).midModel, DEFAULT_MID_MODEL);
17
+ assert.equal(parseBuilderRouting({ midModel: "sonnet; rm -rf /" }).midModel, DEFAULT_MID_MODEL);
18
+ assert.equal(parseBuilderRouting({ midModel: "opus[1m]" }).midModel, "opus[1m]");
19
+ });
20
+ });
21
+
22
+ describe("classifyPath (#4394)", () => {
23
+ it("classifies tests across ecosystems", () => {
24
+ for (const p of ["src/a.test.ts", "web/b.spec.tsx", "pkg/c_test.go", "tests/test_d.py", "app/test_e.py", "crates/x/tests/it.rs", "src/__tests__/f.js", "spec/models/user_spec.rb", "src/test/java/FooTest.java"]) {
25
+ assert.equal(classifyPath(p), "test", p);
26
+ }
27
+ });
28
+ it("classifies docs, config and code; lockfiles are code", () => {
29
+ for (const p of ["README.md", "docs/guide.mdx", "devdocs/modules/lean.md", "CHANGES.rst", "notes.txt", "docs/img/arch.svg"]) assert.equal(classifyPath(p), "docs", p);
30
+ for (const p of ["forge.yaml", ".github/workflows/ci.yml", "pyproject.toml", "setup.cfg", "tsconfig.json", ".env.example", ".eslintrc", "Cargo.toml"]) assert.equal(classifyPath(p), "config", p);
31
+ for (const p of ["package-lock.json", "pnpm-lock.yaml", "yarn.lock", "Cargo.lock", "poetry.lock", "go.sum", "src/main.rs", "lib/x.py", "cmd/main.go", "src/foo.ts", "Makefile"]) assert.equal(classifyPath(p), "code", p);
32
+ });
33
+ });
34
+
35
+ describe("routeBuilder (#4394)", () => {
36
+ it("risk: a built-in risk word or a declared review.risk_paths glob routes to the top model", () => {
37
+ const r = routeBuilder({ contract: contract("src/auth/session.ts", "README.md"), topModel: "opus" });
38
+ assert.deepEqual(r, { mode: "on", class: "risk", tier: "top", model: "opus", risk: ["src/auth/session.ts"] });
39
+ const g = routeBuilder({ contract: contract("detect/rules.py"), riskPaths: ["detect/**"] });
40
+ assert.deepEqual([g.class, g.tier, g.model, g.risk], ["risk", "top", null, ["detect/rules.py"]]);
41
+ assert.equal(routeBuilder({ contract: contract("detect/rules.py") }).class, "small", "detection is not a built-in risk word");
42
+ });
43
+ it("docs-config-test, small and large classes", () => {
44
+ assert.deepEqual(routeBuilder({ contract: contract("docs/a.md", "forge.yaml", "test/a.test.js"), topModel: "opus" }),
45
+ { mode: "on", class: "docs-config-test", tier: "mid", model: DEFAULT_MID_MODEL, risk: [] });
46
+ const s = routeBuilder({ contract: contract("src/a.js", "src/b.js", "test/a.test.js", "test/b.test.js"), cfg: { midModel: "haiku" } });
47
+ assert.deepEqual([s.class, s.tier, s.model], ["small", "mid", "haiku"]);
48
+ const l = routeBuilder({ contract: contract("src/a.js", "src/b.js", "src/c.js"), topModel: "opus" });
49
+ assert.deepEqual([l.class, l.tier, l.model], ["large", "top", "opus"]);
50
+ const lock = routeBuilder({ contract: contract("package.json", "package-lock.json") });
51
+ assert.equal(lock.class, "small", "a lockfile change is code, not config");
52
+ });
53
+ it("no deliverables is large; mode off keeps the top tier but still reports the class", () => {
54
+ assert.deepEqual([routeBuilder({ contract: { deliverables: [] } }).class, routeBuilder({}).tier], ["large", "top"]);
55
+ const off = routeBuilder({ contract: contract("docs/a.md"), cfg: { mode: "off" }, topModel: "opus" });
56
+ assert.deepEqual([off.mode, off.class, off.tier, off.model], ["off", "docs-config-test", "top", "opus"]);
57
+ });
58
+ it("escalates once, on validate rounds only, from the mid tier", () => {
59
+ assert.deepEqual(ESCALATE_TRIGGERS, ["precheck", "ci_red", "merge_ci"]);
60
+ const mid = { tier: "mid" };
61
+ for (const t of ESCALATE_TRIGGERS) assert.equal(shouldEscalate(mid, false, t), true, t);
62
+ for (const t of ["conformance", "review", "build", "recovery"]) assert.equal(shouldEscalate(mid, false, t), false, t);
63
+ assert.equal(shouldEscalate(mid, true, "precheck"), false);
64
+ assert.equal(shouldEscalate({ tier: "top" }, false, "precheck"), false);
65
+ assert.equal(shouldEscalate(null, false, "precheck"), false);
66
+ });
67
+ });
@@ -17,6 +17,9 @@
17
17
  * (`forgedock sched cap N`), else the caller's forge.yaml `orchestration.global_parallel`, else none. Fail-open: an
18
18
  * unreadable registry or a lock not taken within `lockWaitMs` grants (`fallback: true`), since a stuck registry would
19
19
  * stall every batch on the host.
20
+ * Per-issue lock (#4481), asked for per request with `exclusive: true` in the entry (not stored; the batch runner sets it):
21
+ * such a key is never granted while a live slot of another batch holds the same (non-null) repo and issue, so two batches that both include issue N (a resumed batch and a new one) never run two lanes for N at once;
22
+ * `tryAcquire` answers `{ok:false, conflict:<batch>}` and drops the key from the queue (it would hold a grant rank).
20
23
  */
21
24
  import { closeSync, mkdirSync, openSync, readFileSync, renameSync, rmSync, statSync, writeSync } from "node:fs";
22
25
  import { homedir } from "node:os";
@@ -50,6 +53,14 @@ const posInt = (k) => (Number.isInteger(k) && k >= 1 ? k : null);
50
53
  const pause = (ms) => Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
51
54
  const byRank = ([ka, a], [kb, b]) => (a.priority - b.priority) || (a.seq - b.seq) || (ka < kb ? -1 : ka > kb ? 1 : 0);
52
55
  const groupOf = (k, e) => e?.repo ?? e?.batch ?? k;
56
+ /** #4481: the batch of another live slot running the same repo and issue as an `exclusive` `entry`, or null (no repo: never). */
57
+ const holderOf = (s, key, entry) => {
58
+ if (entry?.exclusive !== true || !entry.repo || entry.issue == null) return null;
59
+ for (const [k, e] of Object.entries(s.slots)) {
60
+ if (k !== key && e?.repo === entry.repo && e?.issue === entry.issue && (e.batch ?? null) !== (entry.batch ?? null)) return e.batch ?? k;
61
+ }
62
+ return null;
63
+ };
53
64
 
54
65
  /**
55
66
  * Queue entries `[key, entry]` in grant order: priority ascending, then a weighted fair share across repos within each
@@ -88,7 +99,7 @@ export function grantOrder(s) {
88
99
 
89
100
  /**
90
101
  * Handle on the registry. `pid` is this dispatcher; `alive(pid)` is the liveness probe (injected in tests).
91
- * @returns {{dir, tryAcquire, offer, withdraw, release, setLanePid, adoptSlot, cap, setCap, weights, setWeight, list}}
102
+ * @returns {{dir, tryAcquire, holder, offer, withdraw, release, setLanePid, adoptSlot, cap, setCap, weights, setWeight, list}}
92
103
  */
93
104
  export function openSched(dir = schedDir(), { now = () => Date.now(), pid = process.pid, alive = pidAlive, lockWaitMs = LOCK_WAIT_MS } = {}) {
94
105
  const file = join(dir, "registry.json"), lockFile = join(dir, "registry.lock");
@@ -167,6 +178,8 @@ export function openSched(dir = schedDir(), { now = () => Date.now(), pid = proc
167
178
  try {
168
179
  return mutate((s) => {
169
180
  if (s.slots[key]) return { ok: true };
181
+ const holder = holderOf(s, key, entry);
182
+ if (holder !== null) { delete s.queue[key]; return { ok: false, conflict: holder, reason: `#${entry.issue} has a live lane in batch ${holder}` }; }
170
183
  enqueue(s, key, entry);
171
184
  const cap = effCap(s, callerCap);
172
185
  if (cap !== null) {
@@ -182,6 +195,8 @@ export function openSched(dir = schedDir(), { now = () => Date.now(), pid = proc
182
195
  });
183
196
  } catch (e) { return { ok: true, fallback: true, reason: e?.message ?? String(e) }; }
184
197
  },
198
+ /** #4481: the other batch holding a live slot for `{repo, issue}` (not `batch`), or null; null on any error. */
199
+ holder: (entry = {}) => { try { return mutate((s) => holderOf(s, null, entry)); } catch { return null; } },
185
200
  /** Queue entries `[{key, batch, repo, issue, priority}]` (their FIFO position is kept on a re-offer). */
186
201
  offer: (entries) => { try { mutate((s) => { for (const e of entries) enqueue(s, e.key, e); }); } catch { /* fail-open */ } },
187
202
  withdraw: (keys) => { if (!keys.length) return; try { mutate((s) => { for (const k of keys) delete s.queue[k]; }); } catch { /* fail-open */ } },
@@ -6,7 +6,7 @@ import { writeFileSync } from "node:fs";
6
6
  import { join } from "node:path";
7
7
  import { tmpDir } from "../tests/_tmp.mjs";
8
8
  import { grantOrder, openSched, priorityFromLabels } from "./sched.mjs";
9
- import { orchestrate } from "./orchestrate.mjs";
9
+ import { orchestrate, estimateLaneMinutes, laneHistory, formatEstimateSummary, EST_DEFAULT_MIN, POST_DEPLOY_EST_MIN } from "./orchestrate.mjs";
10
10
  import { schedFromCli } from "../engine-cli.mjs";
11
11
 
12
12
  const quiet = async (fn) => { const l = console.log; console.log = () => {}; try { return await fn(); } finally { console.log = l; } };
@@ -308,3 +308,177 @@ describe("cross-repo fairness weights (#4193)", () => {
308
308
  assert.throws(() => a.setWeight("", 2), /non-empty/);
309
309
  });
310
310
  });
311
+
312
+ // #4409: a virtual clock. Lane durations and sleeps are timers in virtual ms; the clock jumps to the next timer once the
313
+ // event loop has drained every microtask, so the orchestrator's async chains settle before time moves.
314
+ const vclock = () => {
315
+ let t = 0, done = false;
316
+ const timers = [];
317
+ const at = (ms, signal) => new Promise((res) => {
318
+ const x = { when: t + ms, res };
319
+ timers.push(x);
320
+ signal?.addEventListener("abort", () => { const i = timers.indexOf(x); if (i >= 0) timers.splice(i, 1); res(); }, { once: true });
321
+ });
322
+ const tick = () => setImmediate(() => {
323
+ if (done) return;
324
+ if (timers.length) { timers.sort((a, b) => a.when - b.when); const x = timers.shift(); t = x.when; x.res(); }
325
+ tick();
326
+ });
327
+ tick();
328
+ return { now: () => t, at, sleep: (ms, signal) => at(ms, signal), stop: () => { done = true; } };
329
+ };
330
+ const MIN = 60000;
331
+
332
+ describe("longest-first admission (#4409)", () => {
333
+ it("estimateLaneMinutes: size label, unsized = M, post-deploy floor, history only with enough merged samples of the repo", () => {
334
+ assert.equal(estimateLaneMinutes({ labels: ["size:S"] }), EST_DEFAULT_MIN.S);
335
+ assert.equal(estimateLaneMinutes({ labels: [{ name: "size:L" }] }), EST_DEFAULT_MIN.L);
336
+ assert.equal(estimateLaneMinutes({}), EST_DEFAULT_MIN.M);
337
+ assert.equal(estimateLaneMinutes({ labels: ["size:L"], body: "- [ ] `R1: smoke` — post-deploy: needs prod" }), POST_DEPLOY_EST_MIN);
338
+ const run = (repo, size, min, reason = "merged") => ({ repo, size, reason, wallMs: min * MIN });
339
+ const h = laneHistory([run("o/r", "S", 4), run("o/r", "S", 6), run("o/r", "S", 8), run("o/r", "S", 100, "needs-human"), run("x/y", "S", 1),
340
+ run("o/r", "L", 90), run("o/r", "L", 70)], "o/r");
341
+ assert.deepEqual(h, { S: 6 }, "L has only 2 samples; other repos and unmerged runs never count");
342
+ assert.equal(estimateLaneMinutes({ labels: ["size:S"], history: h }), 6);
343
+ assert.equal(estimateLaneMinutes({ labels: ["size:L"], history: h }), EST_DEFAULT_MIN.L);
344
+ });
345
+
346
+ it("admits by estimate, descending, within a priority level; priority across levels is unchanged", async () => {
347
+ const est = { 1: 5, 2: 50, 3: 20 };
348
+ const order = [];
349
+ const rows = await orchestrate({ issues: [1, 2, 3], parallel: 1, readBody: async () => "", estimate: async (n) => est[n],
350
+ runOne: async (n) => { order.push(n); return { terminalReason: "merged" }; } });
351
+ assert.deepEqual(order, [2, 3, 1]);
352
+ assert.deepEqual(rows.map((r) => r.est_minutes), [5, 50, 20]);
353
+ const { handle } = world();
354
+ const order2 = [];
355
+ await orchestrate({ issues: [1, 2, 3], parallel: 1, readBody: async () => "", estimate: async (n) => est[n], pollMs: 2, batchId: "lpt",
356
+ sched: { registry: handle(7101), batch: "lpt", repo: "o/r", cap: 4 }, priorityOf: async (n) => (n === 1 ? 1 : 2),
357
+ runOne: async (n) => { order2.push(n); return { terminalReason: "merged" }; } });
358
+ assert.deepEqual(order2, [1, 2, 3], "the short P1 still precedes the long P2s");
359
+ });
360
+
361
+ it("decomposed sub-issues are admitted before same-priority FIFO items, with or without an estimator", async () => {
362
+ for (const estimate of [null, async () => 10]) {
363
+ const order = [];
364
+ let store = null;
365
+ await orchestrate({ issues: [1, 2, 3], parallel: 1, readBody: async () => "", estimate, store: { load: () => null, save: (x) => { store = x; } },
366
+ runOne: async (n) => { order.push(n); return n === 1 ? { terminalReason: "decomposed", subissues: [8, 9] } : { terminalReason: "merged" }; } });
367
+ assert.deepEqual(order, [1, 8, 9, 2, 3]);
368
+ assert.equal("estimates" in store, !!estimate, "no estimates key without an estimator");
369
+ if (estimate) assert.equal(store.estimates[8], EST_DEFAULT_MIN.M, "parent estimate ÷ N, floored at M");
370
+ }
371
+ });
372
+
373
+ it("starvation guard: an issue queued past starveMs is bumped ahead of a longer estimate", async () => {
374
+ // #2 runs 0-10 min; #3 (longer) is added at 1 min; at 10 min #1 has waited 10 min.
375
+ const run = async (starveMs) => {
376
+ const c = vclock();
377
+ const order = [];
378
+ try {
379
+ await orchestrate({ issues: [1, 2], parallel: 1, readBody: async () => "", now: c.now, sleep: c.sleep, pollMs: MIN, starveMs,
380
+ getAdded: () => (c.now() >= MIN ? [3] : []), estimate: async (n) => ({ 1: 5, 2: 30, 3: 40 })[n],
381
+ runOne: async (n) => { order.push(n); await c.at(10 * MIN); return { terminalReason: "merged" }; } });
382
+ } finally { c.stop(); }
383
+ return order;
384
+ };
385
+ assert.deepEqual(await run(8 * MIN), [2, 1, 3], "#1 waited past the limit");
386
+ assert.deepEqual(await run(30 * MIN), [2, 3, 1], "under the limit: longest first");
387
+ });
388
+
389
+ it("critical path: an issue with an in-batch dependent outranks an equal estimate without one", async () => {
390
+ const order = [];
391
+ await orchestrate({ issues: [1, 2, 3], parallel: 1, readBody: async (n) => (n === 3 ? "Depends on #2" : ""), estimate: async () => 10,
392
+ runOne: async (n) => { order.push(n); return { terminalReason: "merged" }; } });
393
+ assert.deepEqual(order, [2, 1, 3]);
394
+ });
395
+
396
+ it("replay of the 2026-10-22 batch: makespan is about the longest chain, not the FIFO tail", async () => {
397
+ // K=6; 41 short lanes; #41 (size:L, 40 min) is listed last; #3 decomposes 4 min in; its sub-issue #104 runs 48 min.
398
+ const c = vclock();
399
+ const issues = Array.from({ length: 41 }, (_, i) => i + 1);
400
+ const dur = (n) => (n === 41 ? 40 : n === 3 ? 4 : n === 104 ? 48 : 2) * MIN;
401
+ const start = new Map();
402
+ let rows;
403
+ try {
404
+ rows = await orchestrate({ issues, parallel: 6, readBody: async () => "", now: c.now, sleep: c.sleep,
405
+ estimate: async (n) => estimateLaneMinutes({ labels: [n === 41 ? "size:L" : "size:S"] }),
406
+ runOne: async (n) => {
407
+ start.set(n, c.now());
408
+ await c.at(dur(n));
409
+ return n === 3 ? { terminalReason: "decomposed", subissues: [101, 102, 103, 104] } : { terminalReason: "merged" };
410
+ } });
411
+ } finally { c.stop(); }
412
+ const makespan = c.now() / MIN;
413
+ assert.equal(rows.filter((r) => r.outcome === "merged").length, 44);
414
+ assert.equal(start.get(41), 0, "the long lane is admitted first");
415
+ assert.ok(start.get(104) <= 5 * MIN, `sub-issue #104 started at ${start.get(104) / MIN} min`);
416
+ assert.ok(makespan <= 56, `makespan ${makespan} min (longest chain 4 + 48)`);
417
+ });
418
+
419
+ it("formatEstimateSummary: estimated vs. actual over lanes that ran", () => {
420
+ assert.equal(formatEstimateSummary([{ issue: 1, outcome: "merged", minutes: 3 }]), null);
421
+ assert.equal(formatEstimateSummary([{ issue: 1, outcome: "merged", minutes: 12, est_minutes: 10 }, { issue: 2, outcome: "merged", minutes: 40, est_minutes: 25 },
422
+ { issue: 3, outcome: "held", minutes: 0, est_minutes: 10 }]),
423
+ "estimates: 2 lanes, est 35.0 min vs actual 52.0 min; median |error| 8.5 min; worst #2 est 25 actual 40");
424
+ });
425
+
426
+ it("a resume keeps estimates, queue times and the head band from the store", async () => {
427
+ const saved = { v: 1, order: [1, 2, 3], deps: { 1: [], 2: [], 3: [] }, lanes: {}, crossSeen: [], results: {}, running: {}, pending: [2, 3, 1],
428
+ estimates: { 1: 50, 2: 5, 3: 20 }, enqueued: { 1: 0, 2: 0, 3: 0 }, heads: [3] };
429
+ const order = [];
430
+ let estCalls = 0;
431
+ await orchestrate({ issues: [], parallel: 1, readBody: async () => "", estimate: async () => { estCalls++; return 1; }, now: () => 0,
432
+ store: { load: () => saved, save: () => {} }, runOne: async (n) => { order.push(n); return { terminalReason: "merged" }; } });
433
+ assert.deepEqual(order, [3, 1, 2]);
434
+ assert.equal(estCalls, 0);
435
+ });
436
+ });
437
+
438
+ describe("#4481: one lane per repo and issue across batches", () => {
439
+ it("the registry refuses a second batch's key for a live repo+issue slot, names the batch and drops it from the queue", () => {
440
+ const { handle, live } = world();
441
+ const a = handle(201), b = handle(202);
442
+ assert.equal(a.tryAcquire("A#5", { batch: "A", repo: "o/r", issue: 5, exclusive: true }, 4).ok, true);
443
+ const r = b.tryAcquire("B#5", { batch: "B", repo: "o/r", issue: 5, exclusive: true }, 4);
444
+ assert.deepEqual([r.ok, r.conflict], [false, "A"]);
445
+ assert.match(r.reason, /#5 has a live lane in batch A/);
446
+ assert.deepEqual(b.list().queue, [], "a refused key holds no grant rank");
447
+ assert.equal(b.holder({ batch: "B", repo: "o/r", issue: 5, exclusive: true }), "A");
448
+ assert.equal(a.holder({ batch: "A", repo: "o/r", issue: 5, exclusive: true }), null, "its own batch is no conflict");
449
+ assert.equal(b.tryAcquire("B#6", { batch: "B", repo: "o/r", issue: 6, exclusive: true }, 4).ok, true, "another issue");
450
+ assert.equal(b.tryAcquire("C#5", { batch: "C", repo: "o/other", issue: 5, exclusive: true }, 4).ok, true, "another repo");
451
+ assert.equal(b.tryAcquire("D#5", { batch: "D", repo: null, issue: 5, exclusive: true }, 4).ok, true, "no repo: unchanged");
452
+ assert.equal(a.tryAcquire("A#5", { batch: "A", repo: "o/r", issue: 5, exclusive: true }, 4).ok, true, "a granted key stays granted");
453
+ assert.equal(b.tryAcquire("E#5", { batch: "E", repo: "o/r", issue: 5 }, null).ok, true, "not exclusive: unchanged");
454
+ b.release("E#5");
455
+ live.delete(201); // A's dispatcher dies with no lane: its slot is reclaimed
456
+ assert.equal(b.tryAcquire("B#5", { batch: "B", repo: "o/r", issue: 5, exclusive: true }, 4).ok, true);
457
+ });
458
+
459
+ it("two batches that both include issue N never run two lanes for N; the second defers naming the first and runs its other issues", async () => {
460
+ const { handle } = world();
461
+ const regA = handle(301), regB = handle(302);
462
+ const active = new Map(), maxActive = new Map(), ranB = [];
463
+ let releaseA;
464
+ const gateA = new Promise((r) => { releaseA = r; });
465
+ const lane = (n) => { active.set(n, (active.get(n) ?? 0) + 1); maxActive.set(n, Math.max(maxActive.get(n) ?? 0, active.get(n))); };
466
+ const pA = orchestrate({ issues: [5], readBody: async () => "", pollMs: 2, batchId: "bA", sched: { registry: regA, batch: "bA", repo: "o/r", cap: null, exclusive: true },
467
+ runOne: async (n) => { lane(n); await gateA; active.set(n, active.get(n) - 1); return { terminalReason: "merged", cost_usd: 0 }; } });
468
+ await until(() => active.get(5) === 1, "batch A's lane");
469
+ const rowsB = [];
470
+ const pB = orchestrate({ issues: [5, 6], readBody: async () => "", pollMs: 2, batchId: "bB", sched: { registry: regB, batch: "bB", repo: "o/r", cap: null, exclusive: true },
471
+ onUpdate: (r) => rowsB.push(r),
472
+ runOne: async (n) => { lane(n); ranB.push(n); active.set(n, active.get(n) - 1); return { terminalReason: "merged", cost_usd: 0 }; } });
473
+ await until(() => ranB.includes(6), "batch B's other issue");
474
+ const deferred = rowsB.filter((r) => r.issue === 5 && r.outcome === "deferred");
475
+ assert.equal(deferred.length, 1, "one row per other batch");
476
+ assert.match(deferred[0].detail, /live lane in batch bA/);
477
+ assert.ok(!ranB.includes(5), "no second lane while batch A runs #5");
478
+ releaseA();
479
+ await pA;
480
+ const done = await pB;
481
+ assert.deepEqual(done.map((r) => [r.issue, r.outcome]).sort(), [[5, "merged"], [6, "merged"]]);
482
+ assert.equal(maxActive.get(5), 1);
483
+ });
484
+ });
@@ -9,13 +9,22 @@
9
9
  * Protocol: newline-delimited JSON, one request per connection.
10
10
  * {op:"submit", argv, cwd, env} → {type:"batch", id, dir}, {type:"row", row}…, {type:"line", text}…, {type:"done", result, report}
11
11
  * {op:"attach", id} → log.jsonl rows replayed, then live rows/lines and done; a finished batch replays report.json
12
- * ({type:"done", replay:true}); a batch nobody hosts is resumed here
12
+ * ({type:"done", replay:true}) unless it has unrun added issues; a batch nobody hosts is resumed here
13
13
  * {op:"set-parallel", id, k} → {type:"ok", parallel}
14
+ * {op:"add", id, issues} → {type:"ok", added, skipped, hosted} (#4408): merged into the batch's `add` file, which a
15
+ * hosted runner reads on its next pass and an unhosted batch on its next resume
16
+ * {op:"cancel", id} → {type:"ok", cancelled:true, hosted} (#4376): writes the batch's `cancel` file; that batch
17
+ * admits nothing more, its running local lanes get SIGTERM (their teardown saves the work)
18
+ * and the rest end held; no other batch and not the daemon. A finished, unhosted batch:
19
+ * {type:"ok", cancelled:false, finished:true}, nothing written
14
20
  * {op:"status"} → {type:"status", pid, batches:[{id, running}]}
15
- * Failures answer {type:"error", message}. A client closing its connection never cancels a batch, and stopping the daemon
21
+ * Failures answer {type:"error", message}. A client closing its connection never cancels a batch (only `cancel` stops one), and stopping the daemon
16
22
  * never stops a lane (lanes are detached sessions). On start the daemon resumes every daemon-hosted batch without a
17
23
  * report, so live lanes are re-adopted through the batch runner's `adopt`. It exits after `idleMs` with no batch and no client.
18
- * `runBatch({argv, cwd, env, sink, keepBreaker})` is injected (engine-cli `runBatch`); `sink` gets `batch(b)`, `row(r)`, `line(text)`.
24
+ * Shutdown (#4454): `close()` aborts every hosted batch's `signal` (its loop admits nothing more and leaves, lanes keep
25
+ * running, no report is written), waits for them up to `stopMs`, and only then releases `schedd.lock` and the socket, so a
26
+ * daemon started next never resumes a batch this one still dispatches. `exitOnSignals` exits 128+signo after that.
27
+ * `runBatch({argv, cwd, env, sink, keepBreaker, signal})` is injected (engine-cli `runBatch`); `sink` gets `batch(b)`, `row(r)`, `line(text)`.
19
28
  */
20
29
  import { closeSync, existsSync, mkdirSync, openSync, readFileSync, rmSync, writeSync } from "node:fs";
21
30
  import { createConnection, createServer } from "node:net";
@@ -25,6 +34,7 @@ import { schedDir, pidAlive } from "./sched.mjs";
25
34
  import { batchRoot, listBatches, openBatch } from "./batch.mjs";
26
35
 
27
36
  export const IDLE_MS = 10 * 60 * 1000;
37
+ export const STOP_MS = 10 * 1000;
28
38
 
29
39
  export function scheddPaths(dir = schedDir()) {
30
40
  return { dir, sock: join(dir, "schedd.sock"), lock: join(dir, "schedd.lock"), log: join(dir, "schedd.log") };
@@ -64,7 +74,7 @@ function takeLock(lock, pid, alive) {
64
74
  * Start the daemon. @returns {Promise<{running:true, pid, close, closed:Promise} | {running:false, owner:number}>}
65
75
  */
66
76
  export async function startDaemon({ dir = schedDir(), runBatch, idleMs = Number(process.env.FORGEDOCK_SCHEDD_IDLE_MS) || IDLE_MS,
67
- root = batchRoot(), log = (m) => console.log(m), pid = process.pid, alive = pidAlive, resumeOnStart = true } = {}) {
77
+ root = batchRoot(), log = (m) => console.log(m), pid = process.pid, alive = pidAlive, resumeOnStart = true, stopMs = STOP_MS } = {}) {
68
78
  const p = scheddPaths(dir);
69
79
  mkdirSync(dir, { recursive: true });
70
80
  if (!takeLock(p.lock, pid, alive)) {
@@ -77,11 +87,15 @@ export async function startDaemon({ dir = schedDir(), runBatch, idleMs = Number(
77
87
 
78
88
  const hosted = new Map(); // batch id -> {id, dir, subs:Set<fn>}
79
89
  const conns = new Set();
90
+ const runs = new Map(); // every runBatch in flight (hosted or not yet started) -> its AbortController (#4454)
91
+ let closing = null, resolveClosed;
80
92
  let lastActive = Date.now();
81
93
  const touch = () => { lastActive = Date.now(); };
82
94
 
83
95
  /** Run one batch here. Resolves with {id, dir} once the batch exists, rejects when it fails before that. */
84
96
  const host = ({ argv, cwd, env, keepBreaker = false }, first = null) => new Promise((resolve, reject) => {
97
+ if (closing) return reject(new Error("schedd is shutting down"));
98
+ const ac = new AbortController();
85
99
  const ent = { id: null, dir: null, subs: new Set(first ? [first] : []) };
86
100
  const emit = (m) => { for (const s of ent.subs) s(m); };
87
101
  let started = false;
@@ -91,13 +105,15 @@ export async function startDaemon({ dir = schedDir(), runBatch, idleMs = Number(
91
105
  line: (text) => emit({ type: "line", text }),
92
106
  };
93
107
  touch();
94
- Promise.resolve().then(() => runBatch({ argv, cwd, env, sink, keepBreaker })).then((result) => {
108
+ const run = Promise.resolve().then(() => runBatch({ argv, cwd, env, sink, keepBreaker, signal: ac.signal })).then((result) => {
95
109
  const report = ent.dir ? openBatch(root, ent.id).readReport()?.report ?? null : null;
96
110
  emit({ type: "done", result, report });
97
111
  }, (e) => {
98
112
  const message = e?.message ?? String(e);
113
+ if (e?.code === "batch.stopped") { log(`schedd: batch ${ent.id ?? argv.join(" ")} stopped (lanes keep running; resumed by the next daemon)`); if (!started) reject(new Error(message)); return; }
99
114
  if (!started) reject(new Error(message)); else { log(`schedd: batch ${ent.id} failed: ${message}`); emit({ type: "error", message }); }
100
- }).finally(() => { if (ent.id) hosted.delete(ent.id); ent.subs.clear(); touch(); });
115
+ }).finally(() => { if (ent.id) hosted.delete(ent.id); ent.subs.clear(); runs.delete(run); touch(); });
116
+ runs.set(run, ac);
101
117
  });
102
118
 
103
119
  const handle = async (req, send) => {
@@ -108,6 +124,19 @@ export async function startDaemon({ dir = schedDir(), runBatch, idleMs = Number(
108
124
  b.setParallel(Number(req.k));
109
125
  return send({ type: "ok", parallel: Number(req.k) }, true);
110
126
  }
127
+ if (req.op === "add") {
128
+ const b = openBatch(root, req.id);
129
+ if (!b.exists()) throw new Error(`no batch ${req.id} in ${root}`);
130
+ const { added, skipped } = b.addIssues(req.issues ?? []);
131
+ return send({ type: "ok", added, skipped, hosted: hosted.has(req.id) }, true);
132
+ }
133
+ if (req.op === "cancel") {
134
+ const b = openBatch(root, req.id);
135
+ if (!b.exists()) throw new Error(`no batch ${req.id} in ${root}`);
136
+ if (!hosted.has(req.id) && b.readReport()) return send({ type: "ok", cancelled: false, finished: true }, true);
137
+ b.requestCancel({ reason: "operator" });
138
+ return send({ type: "ok", cancelled: true, hosted: hosted.has(req.id) }, true);
139
+ }
111
140
  const sub = (m) => send(m, m.type === "done" || m.type === "error");
112
141
  if (req.op === "submit") {
113
142
  const ent = await host({ argv: req.argv ?? [], cwd: req.cwd ?? process.cwd(), env: req.env ?? {} }, sub);
@@ -124,7 +153,7 @@ export async function startDaemon({ dir = schedDir(), runBatch, idleMs = Number(
124
153
  const b = openBatch(root, req.id);
125
154
  if (!b.exists()) throw new Error(`no batch ${req.id} in ${root}`);
126
155
  const rep = b.readReport();
127
- if (rep && !(rep.rows ?? []).some((r) => r.outcome === "held")) {
156
+ if (rep && !(rep.rows ?? []).some((r) => r.outcome === "held") && !b.unrunAdds().length) {
128
157
  return send({ type: "done", replay: true, result: { rows: rep.rows ?? [], crossLane: rep.crossLane ?? [], batch: b.id, ok: !!rep.ok }, report: rep.report ?? null }, true);
129
158
  }
130
159
  const ent = await host({ argv: ["--resume", req.id], cwd: process.cwd(), env: {} }, sub);
@@ -158,13 +187,18 @@ export async function startDaemon({ dir = schedDir(), runBatch, idleMs = Number(
158
187
  await new Promise((res, rej) => { server.once("error", rej); server.listen(p.sock, () => { server.off("error", rej); res(); }); });
159
188
  log(`schedd: pid ${pid} listening on ${p.sock}`);
160
189
 
161
- let closing = null, resolveClosed;
162
190
  const closed = new Promise((r) => { resolveClosed = r; });
163
191
  const close = () => {
164
192
  if (closing) return closing;
165
193
  clearInterval(idleTimer);
166
194
  for (const c of conns) c.destroy();
167
- closing = new Promise((r) => server.close(() => r())).then(() => {
195
+ const stopping = [...runs.keys()];
196
+ for (const ac of runs.values()) ac.abort();
197
+ let timer;
198
+ // #4454: the lock goes only once every hosted batch loop has stopped (or `stopMs` passed: the caller exits regardless)
199
+ const stopped = stopping.length ? Promise.race([Promise.allSettled(stopping).then(() => true), new Promise((r) => { timer = setTimeout(() => r(false), stopMs); })])
200
+ .then((ok) => { clearTimeout(timer); if (!ok) log(`schedd: ${runs.size} batch(es) did not stop within ${stopMs} ms`); }) : Promise.resolve();
201
+ closing = Promise.all([new Promise((r) => server.close(() => r())), stopped]).then(() => {
168
202
  try { if (parseInt(readFileSync(p.lock, "utf8"), 10) === pid) rmSync(p.lock, { force: true }); } catch { /* gone */ }
169
203
  rmSync(p.sock, { force: true });
170
204
  resolveClosed();
@@ -188,6 +222,18 @@ export async function startDaemon({ dir = schedDir(), runBatch, idleMs = Number(
188
222
  return { running: true, pid, sock: p.sock, close, closed };
189
223
  }
190
224
 
225
+ /** #4454: on SIGTERM/SIGINT stop the daemon (`close()`: batch loops first, then lock and socket), then exit 128+signo, once. */
226
+ export function exitOnSignals(d, { on = (s, f) => process.once(s, f), exit = (c) => process.exit(c), signals = ["SIGTERM", "SIGINT"] } = {}) {
227
+ const SIGNO = { SIGINT: 2, SIGTERM: 15 };
228
+ let fired = false;
229
+ for (const sig of signals) on(sig, async () => {
230
+ if (fired) return;
231
+ fired = true;
232
+ try { await d.close(); } catch { /* exit anyway */ }
233
+ exit(128 + (SIGNO[sig] ?? 0));
234
+ });
235
+ }
236
+
191
237
  /** Connect to the daemon, spawning it (`spawnDaemon()`) when nobody listens. @returns {Promise<import("node:net").Socket>} */
192
238
  export async function connectDaemon({ dir = schedDir(), spawnDaemon = defaultSpawn, waitMs = 10_000, sleep = (ms) => new Promise((r) => setTimeout(r, ms)) } = {}) {
193
239
  const { sock } = scheddPaths(dir);