forgedock 1.29.1 → 1.31.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 (166) hide show
  1. package/bin/engine/outcome-codes.mjs +5 -2
  2. package/bin/engine/phases.mjs +20 -3
  3. package/bin/engine/runlog.mjs +82 -12
  4. package/bin/engine/telemetry-seam.mjs +11 -4
  5. package/bin/engine/telemetry-seam.test.mjs +18 -0
  6. package/bin/engine-cli.mjs +453 -66
  7. package/bin/engine.mjs +50 -38
  8. package/bin/forgedock.mjs +35 -3
  9. package/bin/gc.mjs +50 -17
  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 +356 -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 +278 -22
  21. package/bin/lean/conformance.test.mjs +251 -5
  22. package/bin/lean/context.mjs +133 -55
  23. package/bin/lean/context.test.mjs +175 -9
  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 +55 -19
  31. package/bin/lean/enumerating.mjs +189 -0
  32. package/bin/lean/enumerating.test.mjs +97 -0
  33. package/bin/lean/explore.mjs +47 -0
  34. package/bin/lean/explore.test.mjs +61 -0
  35. package/bin/lean/facts.mjs +84 -0
  36. package/bin/lean/facts.test.mjs +66 -0
  37. package/bin/lean/fixround.mjs +90 -0
  38. package/bin/lean/fixround.test.mjs +117 -0
  39. package/bin/lean/heavy.mjs +35 -2
  40. package/bin/lean/heavy.test.mjs +32 -2
  41. package/bin/lean/lean.mjs +799 -127
  42. package/bin/lean/lean.test.mjs +1749 -37
  43. package/bin/lean/ledger.mjs +5 -3
  44. package/bin/lean/ledger.test.mjs +18 -0
  45. package/bin/lean/light.mjs +135 -0
  46. package/bin/lean/light.test.mjs +173 -0
  47. package/bin/lean/limits.test.mjs +36 -1
  48. package/bin/lean/orchestrate.mjs +341 -99
  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 +180 -0
  54. package/bin/lean/precheck.test.mjs +205 -0
  55. package/bin/lean/prefilter.mjs +180 -9
  56. package/bin/lean/prefilter.test.mjs +175 -3
  57. package/bin/lean/preflight.mjs +238 -0
  58. package/bin/lean/preflight.test.mjs +108 -0
  59. package/bin/lean/probe.mjs +297 -0
  60. package/bin/lean/probe.test.mjs +210 -0
  61. package/bin/lean/prompts.mjs +134 -12
  62. package/bin/lean/recovery.test.mjs +75 -0
  63. package/bin/lean/render.mjs +7 -3
  64. package/bin/lean/resume.mjs +67 -0
  65. package/bin/lean/resume.test.mjs +84 -0
  66. package/bin/lean/review-trigger.mjs +12 -0
  67. package/bin/lean/review-trigger.test.mjs +17 -1
  68. package/bin/lean/routing.mjs +74 -0
  69. package/bin/lean/routing.test.mjs +67 -0
  70. package/bin/lean/sched.test.mjs +127 -1
  71. package/bin/lean/schedd.mjs +55 -9
  72. package/bin/lean/schedd.test.mjs +149 -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 +385 -0
  78. package/bin/lean/sequence.test.mjs +373 -0
  79. package/bin/lean/testmap.mjs +43 -6
  80. package/bin/lean/testmap.test.mjs +38 -1
  81. package/bin/lean/toolchain.mjs +60 -15
  82. package/bin/lean/toolchain.test.mjs +120 -3
  83. package/bin/lean/toolstats.mjs +72 -0
  84. package/bin/lean/toolstats.test.mjs +47 -0
  85. package/bin/lean/verifier.mjs +232 -0
  86. package/bin/lean/verifier.test.mjs +163 -0
  87. package/bin/lean/verify.mjs +77 -10
  88. package/bin/lean/verify.test.mjs +74 -3
  89. package/bin/review-staging.mjs +533 -0
  90. package/bin/runner.mjs +82 -13
  91. package/bin/stats/aggregate.mjs +87 -2
  92. package/bin/stats/efficiency.mjs +319 -0
  93. package/bin/stats/efficiency.test.mjs +233 -0
  94. package/bin/stats/github-source.mjs +8 -3
  95. package/bin/stats/index.mjs +69 -6
  96. package/bin/stats/local-source.mjs +86 -3
  97. package/bin/stats/query.mjs +48 -0
  98. package/bin/stats/render.mjs +22 -0
  99. package/bin/stats/stats.test.mjs +224 -5
  100. package/commands/orchestrate.md +6 -2
  101. package/commands/review-pr-agents/protocols.md +1 -1
  102. package/commands/review-pr-staging.md +84 -831
  103. package/commands/review-pr.md +1 -1
  104. package/commands/work-on/build/implement.md +1 -0
  105. package/commands/work-on/build/validate.md +11 -1
  106. package/docs/CONFIG.md +88 -6
  107. package/docs/OUTCOME-CODES.md +7 -4
  108. package/docs/RELEASE-NOTES.md +10 -0
  109. package/docs/STATS.md +53 -1
  110. package/docs/TELEMETRY.md +1 -1
  111. package/docs/spec/review-protocol.md +1 -1
  112. package/package.json +1 -1
  113. package/scripts/acceptance-red-green.sh +13 -4
  114. package/scripts/acceptance-red-green.test.sh +14 -0
  115. package/scripts/ci-local/lib.mjs +20 -0
  116. package/scripts/ci-local/plan.mjs +2 -12
  117. package/scripts/ci-local/scoping.mjs +10 -1
  118. package/scripts/ci-local.test.sh +2 -0
  119. package/scripts/fixtures/apishape/other/go-change/base/x.go +3 -0
  120. package/scripts/fixtures/apishape/other/go-change/head/x.go +3 -0
  121. package/scripts/fixtures/apishape/python/generator-function/base/seq.py +3 -0
  122. package/scripts/fixtures/apishape/python/generator-function/head/seq.py +2 -0
  123. package/scripts/fixtures/apishape/python/ichunked/base/more.py +17 -0
  124. package/scripts/fixtures/apishape/python/ichunked/head/more.py +20 -0
  125. package/scripts/fixtures/apishape/python/private-edit/base/calc.py +6 -0
  126. package/scripts/fixtures/apishape/python/private-edit/head/calc.py +9 -0
  127. package/scripts/fixtures/apishape/python/removed/base/ops.py +6 -0
  128. package/scripts/fixtures/apishape/python/removed/head/ops.py +2 -0
  129. package/scripts/fixtures/apishape/python/renamed/base/text.py +2 -0
  130. package/scripts/fixtures/apishape/python/renamed/head/text.py +2 -0
  131. package/scripts/fixtures/apishape/python/signature/base/shapes.py +2 -0
  132. package/scripts/fixtures/apishape/python/signature/head/shapes.py +2 -0
  133. package/scripts/fixtures/apishape/python/sync-async/base/client.py +7 -0
  134. package/scripts/fixtures/apishape/python/sync-async/head/client.py +7 -0
  135. package/scripts/fixtures/apishape/ts/generator-function/base/seq.js +3 -0
  136. package/scripts/fixtures/apishape/ts/generator-function/head/seq.js +5 -0
  137. package/scripts/fixtures/apishape/ts/private-edit/base/calc.ts +7 -0
  138. package/scripts/fixtures/apishape/ts/private-edit/head/calc.ts +9 -0
  139. package/scripts/fixtures/apishape/ts/removed/base/ops.ts +7 -0
  140. package/scripts/fixtures/apishape/ts/removed/head/ops.ts +3 -0
  141. package/scripts/fixtures/apishape/ts/renamed/base/text.ts +3 -0
  142. package/scripts/fixtures/apishape/ts/renamed/head/text.ts +3 -0
  143. package/scripts/fixtures/apishape/ts/signature/base/shapes.ts +3 -0
  144. package/scripts/fixtures/apishape/ts/signature/head/shapes.ts +3 -0
  145. package/scripts/fixtures/apishape/ts/sync-async/base/client.mjs +5 -0
  146. package/scripts/fixtures/apishape/ts/sync-async/head/client.mjs +5 -0
  147. package/scripts/forge-root.test.sh +2 -2
  148. package/scripts/publish-staging-sync.test.sh +62 -4
  149. package/scripts/review-finalize.sh +17 -6
  150. package/scripts/review-finalize.test.sh +47 -0
  151. package/scripts/review-pr-arguments.test.sh +13 -1
  152. package/scripts/review-pr-delta-wiring.test.sh +2 -2
  153. package/scripts/review-pr-gate2.test.sh +6 -14
  154. package/scripts/review-pr-staging-finding-filter.test.sh +4 -4
  155. package/scripts/review-pr-staging-scope.test.sh +39 -33
  156. package/scripts/review-prepare.sh +73 -6
  157. package/scripts/review-prepare.test.sh +85 -0
  158. package/scripts/review-staging-post.sh +293 -0
  159. package/scripts/review-staging-post.test.sh +143 -0
  160. package/scripts/review-staging-pre.sh +401 -0
  161. package/scripts/review-staging-pre.test.sh +150 -0
  162. package/scripts/symbol-index.mjs +26 -7
  163. package/scripts/symbol-index.py +55 -9
  164. package/scripts/test-map.sh +63 -17
  165. package/scripts/test-map.test.sh +55 -0
  166. package/scripts/trusted-comments.test.sh +1 -1
@@ -0,0 +1,74 @@
1
+ // SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
2
+ // SPDX-License-Identifier: AGPL-3.0-or-later
3
+ /**
4
+ * Builder model routing (#4394). The build runs on a mid-tier model when the frozen contract is small or touches only
5
+ * docs, config and tests, and on the top model (the configured builder model, else the CLI default) when it is large or
6
+ * hits a risk path (the built-in risk words or forge.yaml review.risk_paths). The route is computed once from the
7
+ * contract's deliverable paths; a failed validate round on the mid tier escalates the lane to the top model.
8
+ * Pure: no I/O, no model call. Repo-agnostic: paths are classified by extension and directory names only.
9
+ */
10
+ import { riskClassOf, riskPathHits } from "./review-trigger.mjs";
11
+
12
+ export const ROUTING_MODES = Object.freeze(["on", "off"]);
13
+ export const DEFAULT_MID_MODEL = "claude-sonnet-5-5";
14
+ /** Deliverables of at most this many non-test files make a small change. */
15
+ export const SMALL_MAX_FILES = 2;
16
+ /** Fix-round triggers that are validate rounds: a failure on the mid tier escalates the lane to the top model. */
17
+ export const ESCALATE_TRIGGERS = Object.freeze(["precheck", "ci_red", "merge_ci"]);
18
+
19
+ const MODEL_RE = /^[A-Za-z0-9._:[\]-]+$/;
20
+
21
+ /** {mode, midModel}: an unknown mode is on, an invalid model id falls back to the default mid model. */
22
+ export function parseBuilderRouting({ mode, midModel } = {}) {
23
+ const m = String(mode ?? "").trim().toLowerCase();
24
+ const id = String(midModel ?? "").trim();
25
+ return { mode: ROUTING_MODES.includes(m) ? m : "on", midModel: MODEL_RE.test(id) ? id : DEFAULT_MID_MODEL };
26
+ }
27
+
28
+ const LOCKFILE_RE = /(^|\/)(package-lock\.json|npm-shrinkwrap\.json|yarn\.lock|pnpm-lock\.yaml|bun\.lockb?|Cargo\.lock|poetry\.lock|uv\.lock|Pipfile\.lock|Gemfile\.lock|composer\.lock|go\.sum|[^/]*\.lock)$/i;
29
+ const TEST_FILE_RE = /(\.(test|spec)\.[A-Za-z0-9]+$)|(_test\.(go|py|rb|exs?)$)|((^|\/)test_[^/]+\.py$)|(_spec\.rb$)|(Tests?\.(java|kt|cs|swift)$)/;
30
+ const TEST_DIR_RE = /(^|\/)(tests?|__tests__|spec|specs|fixtures|__snapshots__)\//i;
31
+ const DOCS_EXT_RE = /\.(md|mdx|markdown|rst|txt|adoc)$/i;
32
+ const DOCS_DIR_RE = /(^|\/)(docs?|devdocs)\//i;
33
+ const CONFIG_EXT_RE = /\.(json|jsonc|ya?ml|toml|ini|cfg|conf|properties|editorconfig)$/i;
34
+ const DOTFILE_RE = /(^|\/)\.[^/]+$/;
35
+ const ENV_EXAMPLE_RE = /(^|\/)\.env\.(example|sample|template)$/i;
36
+
37
+ /** 'test' | 'docs' | 'config' | 'code' for one repo-relative path. Lockfiles are code (they change what runs). */
38
+ export function classifyPath(p) {
39
+ const s = String(p ?? "").trim().replace(/^\.\//, "");
40
+ if (LOCKFILE_RE.test(s)) return "code";
41
+ if (TEST_FILE_RE.test(s) || TEST_DIR_RE.test(s)) return "test";
42
+ if (DOCS_EXT_RE.test(s) || DOCS_DIR_RE.test(s)) return "docs";
43
+ if (ENV_EXAMPLE_RE.test(s) || CONFIG_EXT_RE.test(s) || DOTFILE_RE.test(s)) return "config";
44
+ return "code";
45
+ }
46
+
47
+ /** Deliverable paths of a frozen contract (trimmed, unique, in order). */
48
+ export function deliverablePaths(contract) {
49
+ const ds = Array.isArray(contract?.deliverables) ? contract.deliverables : [];
50
+ return [...new Set(ds.map((d) => String(d?.path ?? "").trim()).filter(Boolean))];
51
+ }
52
+
53
+ /**
54
+ * The route of one lane: {class, tier, model, risk}. class: risk | docs-config-test | small | large; tier: mid | top.
55
+ * `topModel` null means the CLI default. Mode off keeps every lane on the top model but still reports its class.
56
+ */
57
+ export function routeBuilder({ contract = null, riskPaths = [], cfg = {}, topModel = null } = {}) {
58
+ const c = parseBuilderRouting(cfg);
59
+ const paths = deliverablePaths(contract);
60
+ const declared = new Set(riskPathHits(paths, riskPaths));
61
+ const risk = paths.filter((p) => riskClassOf(p) || declared.has(p));
62
+ const kinds = paths.map(classifyPath);
63
+ const cls = !paths.length ? "large"
64
+ : risk.length ? "risk"
65
+ : kinds.every((k) => k !== "code") ? "docs-config-test"
66
+ : kinds.filter((k) => k !== "test").length <= SMALL_MAX_FILES ? "small" : "large";
67
+ const tier = c.mode === "on" && (cls === "docs-config-test" || cls === "small") ? "mid" : "top";
68
+ return { mode: c.mode, class: cls, tier, model: tier === "mid" ? c.midModel : topModel, risk };
69
+ }
70
+
71
+ /** True when a failed `trigger` round on the mid tier escalates the lane (once). */
72
+ export function shouldEscalate(route, escalated, trigger) {
73
+ return route?.tier === "mid" && !escalated && ESCALATE_TRIGGERS.includes(trigger);
74
+ }
@@ -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
+ });
@@ -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,129 @@ 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
+ });
@@ -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);
@@ -8,7 +8,7 @@ import { join } from "node:path";
8
8
  import { tmpDir } from "../tests/_tmp.mjs";
9
9
  import { openBatch, isLaneAlive, listBatches } from "./batch.mjs";
10
10
  import { startDaemon, scheddPaths, connectDaemon, request, mergeEnv, batchEnv } from "./schedd.mjs";
11
- import { formatTable } from "./orchestrate.mjs";
11
+ import { formatTable, orchestrate } from "./orchestrate.mjs";
12
12
  import { orchestrateFromCli } from "../engine-cli.mjs";
13
13
 
14
14
  const until = async (cond, ms = 15000) => {
@@ -32,16 +32,18 @@ const a = process.argv.slice(2); const n = Number(a[1]); const rf = a[a.indexOf(
32
32
  appendFileSync(${JSON.stringify(h.launches)}, n + "\\n");
33
33
  const t = setInterval(() => { if (!existsSync(${JSON.stringify(h.release)})) return; clearInterval(t);
34
34
  writeFileSync(rf + ".tmp", JSON.stringify({ terminalReason: "merged", pr: 100 + n, cost_usd: 0 })); renameSync(rf + ".tmp", rf); }, 20);
35
+ // Like run-issue's #4179 teardown: SIGTERM reports deferred run.interrupted.
36
+ process.once("SIGTERM", () => { writeFileSync(rf + ".tmp", JSON.stringify({ terminalReason: "deferred", code: "run.interrupted", detail: "interrupted by SIGTERM" })); renameSync(rf + ".tmp", rf); process.exit(143); });
35
37
  `);
36
38
  h.daemonScript = join(work, "daemon.mjs");
37
- writeFileSync(h.daemonScript, `import { startDaemon } from ${JSON.stringify(new URL("./schedd.mjs", import.meta.url).href)};
39
+ writeFileSync(h.daemonScript, `import { startDaemon, exitOnSignals } from ${JSON.stringify(new URL("./schedd.mjs", import.meta.url).href)};
38
40
  import { runBatch } from ${JSON.stringify(new URL("../engine-cli.mjs", import.meta.url).href)};
39
41
  const deps = { allowTmpfs: true, laneScript: ${JSON.stringify(stub)}, pollMs: 30, orchestration: { breaker: null, maxDeferrals: 3 }, io: { gh: async () => "o/r\\n" },
40
42
  leanIo: { gh: async (a) => a.includes("milestone") ? JSON.stringify({ milestone: null }) : a[0] === "repo" ? "main\\n" : JSON.stringify({ body: a[2] === "3" ? "Depends on #1" : "", labels: [] }),
41
43
  git: async (a) => (a[0] === "ls-remote" ? "abc\\trefs/heads/staging\\n" : "") } };
42
44
  const d = await startDaemon({ runBatch: (a) => runBatch({ ...a, daemon: true, deps }), idleMs: 600000 });
43
45
  if (!d.running) process.exit(3);
44
- process.once("SIGTERM", () => d.close());
46
+ exitOnSignals(d); // the forgedock schedd handler (#4454)
45
47
  await d.closed;
46
48
  `);
47
49
  h.env = { ...process.env, FORGEDOCK_SCHED_DIR: h.sched, FORGEDOCK_BATCH_DIR: h.root, FORGEDOCK_LIMITS_FILE: join(work, "limits.json") };
@@ -151,6 +153,67 @@ await orchestrateFromCli(["1", "2", "--parallel", "2", "--repo", "o/r", "--no-pr
151
153
  } finally { h.cleanup(); }
152
154
  });
153
155
 
156
+ it("#4408: --add and --resume <id> N add issues to the live batch (no second batch); an unhosted finished batch runs its adds on --resume", async () => {
157
+ const h = host();
158
+ try {
159
+ await h.daemon();
160
+ const client = { ...h.client, io: { gh: async (a) => (a[0] === "issue" ? JSON.stringify({ state: a[2] === "9" ? "CLOSED" : "OPEN" }) : "o/r\n") } };
161
+ const { res } = await withRoot(h, () => capture(async () => {
162
+ const first = orchestrateFromCli(["1", "--parallel", "3", "--repo", "o/r", "--no-prefilter"], h.client);
163
+ await until(() => h.launched().length === 1 && h.lanePids().length === 1);
164
+ const [id] = listBatches(h.root);
165
+ const add = await orchestrateFromCli(["--add", id, "2", "1", "x", "9"], client);
166
+ assert.deepEqual(add.added, [2]);
167
+ assert.deepEqual(add.skipped.map((x) => [x.issue, x.reason]).sort(), [[1, "already in batch"], [9, "closed"], ["x", "not an integer"]]);
168
+ await until(() => h.launched().includes(2));
169
+ const resumed = orchestrateFromCli(["--resume", id, "4"], client);
170
+ await until(() => h.launched().includes(4));
171
+ assert.deepEqual(listBatches(h.root), [id], "no second batch");
172
+ writeFileSync(h.release, "");
173
+ const [done] = await Promise.all([first, resumed]);
174
+ assert.deepEqual(done.rows.map((r) => [r.issue, r.outcome]), [[1, "merged"], [2, "merged"], [4, "merged"]]);
175
+ // Finished and no longer hosted: the add is persisted and the next --resume runs it instead of replaying the report.
176
+ const later = await orchestrateFromCli(["--add", id, "6"], client);
177
+ assert.deepEqual(later.added, [6]);
178
+ const again = await orchestrateFromCli(["--resume", id], client);
179
+ assert.deepEqual(again.rows.map((r) => [r.issue, r.outcome]), [[1, "merged"], [2, "merged"], [4, "merged"], [6, "merged"]]);
180
+ return done;
181
+ }));
182
+ assert.equal(res.rows.length, 3);
183
+ assert.deepEqual(h.launched(), [1, 2, 4, 6], "each issue launched once");
184
+ } finally { h.cleanup(); }
185
+ });
186
+
187
+ it("#4376: --cancel stops one batch (its lane torn down, the rest held, report written); the other batch and the daemon run on", async () => {
188
+ const h = host();
189
+ try {
190
+ const d = await h.daemon();
191
+ await withRoot(h, () => capture(async () => {
192
+ const a = orchestrateFromCli(["1", "2", "--parallel", "1", "--repo", "o/r", "--no-prefilter"], h.client);
193
+ await until(() => h.launched().length === 1 && h.lanePids().length === 1);
194
+ const [idA] = listBatches(h.root);
195
+ const b = orchestrateFromCli(["5", "--parallel", "1", "--repo", "o/r", "--no-prefilter"], h.client);
196
+ await until(() => h.launched().includes(5) && h.lanePids().length === 2);
197
+ const idB = listBatches(h.root).find((x) => x !== idA);
198
+ const res = await orchestrateFromCli(["--cancel", idA], h.client);
199
+ assert.deepEqual(res, { ok: true, batch: idA, cancelled: true });
200
+ const doneA = await a;
201
+ assert.deepEqual(doneA.rows.map((r) => [r.issue, r.outcome, r.detail]), [[1, "held", "cancelled by operator"], [2, "held", "cancelled by operator"]]);
202
+ assert.ok(openBatch(h.root, idA).readReport(), "the cancelled batch wrote its report");
203
+ assert.deepEqual(h.launched(), [1, 5], "#2 never launched");
204
+ const st = await request(await connectDaemon({ dir: h.sched, spawnDaemon: null }), { op: "status" });
205
+ assert.equal(st.pid, d.pid, "the daemon kept running");
206
+ assert.deepEqual(st.batches.map((x) => x.id), [idB]);
207
+ writeFileSync(h.release, "");
208
+ const doneB = await b;
209
+ assert.deepEqual(doneB.rows.map((r) => [r.issue, r.outcome]), [[5, "merged"]]);
210
+ const again = await orchestrateFromCli(["--cancel", idA], h.client);
211
+ assert.equal(again.cancelled, false, "a finished batch is left alone");
212
+ await assert.rejects(orchestrateFromCli(["--cancel", "nope"], h.client), /no batch nope/);
213
+ }));
214
+ } finally { h.cleanup(); }
215
+ });
216
+
154
217
  it("single instance: a live daemon keeps its lock; a dead owner's lock and socket are taken over", async () => {
155
218
  const h = host();
156
219
  try {
@@ -171,6 +234,89 @@ await orchestrateFromCli(["1", "2", "--parallel", "2", "--repo", "o/r", "--no-pr
171
234
  } finally { h.cleanup(); }
172
235
  });
173
236
 
237
+ it("#4454: close() stops every hosted batch loop before releasing the lock; no batch admits a lane after close()", async () => {
238
+ const dir = tmpDir("schedd-stop-"), root = join(dir, "batches"), lock = scheddPaths(dir).lock;
239
+ const started = { a: [], b: [] }, stoppedWithLock = {};
240
+ let releaseLane;
241
+ const lane = new Promise((r) => { releaseLane = r; });
242
+ // An unref'd cancellable sleep: a base without the stop cannot hang the test process on the 30-min requeue delay.
243
+ const sleep = (ms, sig) => new Promise((r) => { const t = setTimeout(r, ms); t.unref(); sig?.addEventListener("abort", () => { clearTimeout(t); r(); }, { once: true }); });
244
+ const runBatch = async ({ argv, sink, signal }) => {
245
+ const id = argv[0];
246
+ sink.batch({ id, dir: join(root, id) });
247
+ try {
248
+ return await orchestrate({ issues: id === "a" ? [1, 2] : [9], parallel: 1, readBody: async () => "", signal, sleep,
249
+ runOne: async (n) => {
250
+ started[id].push(n);
251
+ if (n === 1) { await lane; return { terminalReason: "merged" }; } // a detached lane, still running at shutdown
252
+ if (n === 9) return { terminalReason: "deferred", detail: `leased by x until ${new Date(Date.now() + 3600e3).toISOString()}` };
253
+ return { terminalReason: "merged" };
254
+ } });
255
+ } finally { stoppedWithLock[id] = existsSync(lock); }
256
+ };
257
+ const d = await startDaemon({ dir, root, runBatch, log: () => {}, resumeOnStart: false });
258
+ const submit = async (id) => request(await connectDaemon({ dir, spawnDaemon: null }), { op: "submit", argv: [id], cwd: dir, env: {} }).catch((e) => e);
259
+ const clients = [submit("a"), submit("b")]; // each resolves (or rejects) once its connection ends
260
+ await until(() => started.a.length === 1 && started.b.length === 1);
261
+ await new Promise((r) => setTimeout(r, 100)); // b is asleep on its requeue delay
262
+ const t0 = Date.now();
263
+ await d.close();
264
+ assert.ok(Date.now() - t0 < 3000, `close() took ${Date.now() - t0} ms`);
265
+ assert.deepEqual(stoppedWithLock, { a: true, b: true }, "both batch loops stopped while the lock was still held");
266
+ assert.equal(existsSync(lock), false, "lock released after the loops stopped");
267
+ assert.equal(existsSync(scheddPaths(dir).sock), false);
268
+ releaseLane();
269
+ await new Promise((r) => setTimeout(r, 200));
270
+ assert.deepEqual(started, { a: [1], b: [9] }, "no hosted batch admits a lane after close()");
271
+ await Promise.all(clients);
272
+ });
273
+
274
+ it("#4454: close() waits at most stopMs for a batch that ignores the stop, then releases the lock", async () => {
275
+ const dir = tmpDir("schedd-bound-");
276
+ const d = await startDaemon({ dir, root: join(dir, "batches"), log: () => {}, resumeOnStart: false, stopMs: 200,
277
+ runBatch: ({ sink }) => { sink.batch({ id: "x", dir }); return new Promise(() => {}); } });
278
+ request(await connectDaemon({ dir, spawnDaemon: null }), { op: "submit", argv: ["x"], cwd: dir, env: {} }).catch(() => {});
279
+ await new Promise((r) => setTimeout(r, 50));
280
+ const t0 = Date.now();
281
+ await d.close();
282
+ const took = Date.now() - t0;
283
+ assert.ok(took >= 150 && took < 2000, `close() took ${took} ms`);
284
+ assert.equal(existsSync(scheddPaths(dir).lock), false);
285
+ });
286
+
287
+ it("#4454: kill -TERM stops both batches and exits 143 within the bound; lanes keep running; the next daemon resumes and adopts them", async () => {
288
+ const h = host();
289
+ try {
290
+ const d1 = await h.daemon();
291
+ await withRoot(h, () => capture(async () => {
292
+ const a = orchestrateFromCli(["1", "2", "--parallel", "1", "--repo", "o/r", "--no-prefilter"], h.client).then(() => null, (e) => e);
293
+ await until(() => h.launched().length === 1 && h.lanePids().length === 1);
294
+ const [idA] = listBatches(h.root);
295
+ const b = orchestrateFromCli(["5", "--parallel", "1", "--repo", "o/r", "--no-prefilter"], h.client).then(() => null, (e) => e);
296
+ await until(() => h.launched().includes(5) && h.lanePids().length === 2);
297
+ const idB = listBatches(h.root).find((x) => x !== idA);
298
+ const lanes = h.lanePids();
299
+ const exit = new Promise((r) => d1.on("exit", (code) => r(code)));
300
+ const t0 = Date.now();
301
+ d1.kill("SIGTERM");
302
+ assert.equal(await exit, 143);
303
+ assert.ok(Date.now() - t0 < 12000, "exits within the bound");
304
+ for (const pid of lanes) assert.equal(isLaneAlive(pid, null), true, "lanes keep running");
305
+ assert.equal(existsSync(scheddPaths(h.sched).lock), false);
306
+ assert.equal(openBatch(h.root, idA).readReport(), null, "a stopped batch writes no report");
307
+ assert.equal(openBatch(h.root, idB).readReport(), null);
308
+ assert.match(String((await a)?.message), /--resume/);
309
+ await b;
310
+ await h.daemon(); // resumes both batches
311
+ const ra = orchestrateFromCli(["--resume", idA], h.client), rb = orchestrateFromCli(["--resume", idB], h.client);
312
+ setTimeout(() => writeFileSync(h.release, ""), 200);
313
+ assert.deepEqual((await ra).rows.map((r) => [r.issue, r.outcome]), [[1, "merged"], [2, "merged"]]);
314
+ assert.deepEqual((await rb).rows.map((r) => [r.issue, r.outcome]), [[5, "merged"]]);
315
+ assert.deepEqual(h.launched(), [1, 2, 5], "adopted, not relaunched");
316
+ }));
317
+ } finally { h.cleanup(); }
318
+ });
319
+
174
320
  it("lane env: allowlisted keys come from the client only, the rest from the daemon", () => {
175
321
  const kept = batchEnv({ PATH: "/c", CLAUDE_CONFIG_DIR: "/acct-b", SECRET_X: "no", FORGEDOCK_SCHED_DIR: "/s" });
176
322
  assert.deepEqual(kept, { PATH: "/c", CLAUDE_CONFIG_DIR: "/acct-b", FORGEDOCK_SCHED_DIR: "/s" });