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.
- package/bin/engine/outcome-codes.mjs +3 -2
- package/bin/engine/runlog.mjs +82 -12
- package/bin/engine/telemetry-seam.mjs +11 -4
- package/bin/engine/telemetry-seam.test.mjs +18 -0
- package/bin/engine-cli.mjs +409 -38
- package/bin/engine.mjs +47 -37
- package/bin/forgedock.mjs +35 -3
- package/bin/gc.mjs +50 -17
- package/bin/hooks/reread-nudge.mjs +65 -0
- package/bin/labels.json +5 -0
- package/bin/lean/apishape.mjs +170 -0
- package/bin/lean/apishape.test.mjs +187 -0
- package/bin/lean/batch.mjs +32 -0
- package/bin/lean/batch.test.mjs +338 -2
- package/bin/lean/budget.mjs +89 -0
- package/bin/lean/budget.test.mjs +106 -0
- package/bin/lean/checks.mjs +32 -9
- package/bin/lean/checks.test.mjs +78 -1
- package/bin/lean/conformance.e2e.test.mjs +27 -5
- package/bin/lean/conformance.mjs +150 -15
- package/bin/lean/conformance.test.mjs +175 -3
- package/bin/lean/context.mjs +128 -44
- package/bin/lean/context.test.mjs +157 -5
- package/bin/lean/decompose.mjs +90 -0
- package/bin/lean/decompose.test.mjs +105 -0
- package/bin/lean/depcache.mjs +11 -7
- package/bin/lean/depcache.test.mjs +81 -0
- package/bin/lean/e2e.mjs +102 -0
- package/bin/lean/e2e.test.mjs +86 -0
- package/bin/lean/effects.mjs +48 -15
- package/bin/lean/explore.mjs +47 -0
- package/bin/lean/explore.test.mjs +61 -0
- package/bin/lean/facts.mjs +84 -0
- package/bin/lean/facts.test.mjs +66 -0
- package/bin/lean/fixround.mjs +90 -0
- package/bin/lean/fixround.test.mjs +117 -0
- package/bin/lean/heavy.mjs +35 -2
- package/bin/lean/heavy.test.mjs +32 -2
- package/bin/lean/lean.mjs +712 -134
- package/bin/lean/lean.test.mjs +1626 -37
- package/bin/lean/ledger.mjs +5 -3
- package/bin/lean/ledger.test.mjs +18 -0
- package/bin/lean/light.mjs +135 -0
- package/bin/lean/light.test.mjs +173 -0
- package/bin/lean/lightlane.mjs +219 -0
- package/bin/lean/lightlane.test.mjs +237 -0
- package/bin/lean/limits.test.mjs +36 -1
- package/bin/lean/orchestrate.mjs +303 -41
- package/bin/lean/pitfalls.mjs +136 -0
- package/bin/lean/pitfalls.test.mjs +117 -0
- package/bin/lean/postmerge.mjs +85 -0
- package/bin/lean/postmerge.test.mjs +79 -0
- package/bin/lean/precheck.mjs +138 -13
- package/bin/lean/precheck.test.mjs +118 -1
- package/bin/lean/prefilter.mjs +220 -5
- package/bin/lean/prefilter.test.mjs +260 -4
- package/bin/lean/preflight.mjs +108 -3
- package/bin/lean/preflight.test.mjs +48 -1
- package/bin/lean/probe.mjs +297 -0
- package/bin/lean/probe.test.mjs +210 -0
- package/bin/lean/prompts.mjs +104 -13
- package/bin/lean/render.mjs +7 -3
- package/bin/lean/resume.mjs +67 -0
- package/bin/lean/resume.test.mjs +84 -0
- package/bin/lean/review-trigger.mjs +12 -0
- package/bin/lean/review-trigger.test.mjs +17 -1
- package/bin/lean/routing.mjs +74 -0
- package/bin/lean/routing.test.mjs +67 -0
- package/bin/lean/sched.mjs +16 -1
- package/bin/lean/sched.test.mjs +175 -1
- package/bin/lean/schedd.mjs +55 -9
- package/bin/lean/schedd.test.mjs +170 -3
- package/bin/lean/selfreview.mjs +37 -0
- package/bin/lean/selfreview.test.mjs +51 -0
- package/bin/lean/seqlease.mjs +40 -0
- package/bin/lean/seqlease.test.mjs +101 -0
- package/bin/lean/sequence.mjs +296 -13
- package/bin/lean/sequence.test.mjs +263 -2
- package/bin/lean/toolchain.mjs +60 -15
- package/bin/lean/toolchain.test.mjs +120 -3
- package/bin/lean/toolstats.mjs +72 -0
- package/bin/lean/toolstats.test.mjs +47 -0
- package/bin/lean/verifier.mjs +232 -0
- package/bin/lean/verifier.test.mjs +163 -0
- package/bin/lean/verify.mjs +72 -10
- package/bin/lean/verify.test.mjs +68 -3
- package/bin/review-staging.mjs +533 -0
- package/bin/runner.mjs +82 -13
- package/bin/stats/aggregate.mjs +87 -2
- package/bin/stats/efficiency.mjs +335 -0
- package/bin/stats/efficiency.test.mjs +266 -0
- package/bin/stats/github-source.mjs +8 -3
- package/bin/stats/index.mjs +91 -6
- package/bin/stats/local-source.mjs +145 -8
- package/bin/stats/query.mjs +181 -3
- package/bin/stats/render.mjs +22 -0
- package/bin/stats/stats.test.mjs +403 -8
- package/commands/orchestrate.md +3 -1
- package/commands/review-pr-agents/protocols.md +1 -1
- package/commands/review-pr-staging.md +84 -831
- package/commands/review-pr.md +1 -1
- package/commands/work-on/build/implement.md +1 -0
- package/commands/work-on/build/validate.md +11 -1
- package/docs/CONFIG.md +80 -5
- package/docs/OUTCOME-CODES.md +2 -1
- package/docs/STATS.md +66 -1
- package/docs/TELEMETRY.md +1 -1
- package/docs/eval/corpus/lightlane-v1.json +55 -0
- package/docs/eval/lightlane-bench.md +91 -0
- package/docs/spec/review-protocol.md +1 -1
- package/package.json +1 -1
- package/scripts/bench-lightlane.mjs +286 -0
- package/scripts/ci-local/lib.mjs +20 -0
- package/scripts/ci-local/plan.mjs +2 -12
- package/scripts/ci-local/scoping.mjs +10 -1
- package/scripts/ci-local.test.sh +2 -0
- package/scripts/fixtures/apishape/other/go-change/base/x.go +3 -0
- package/scripts/fixtures/apishape/other/go-change/head/x.go +3 -0
- package/scripts/fixtures/apishape/python/generator-function/base/seq.py +3 -0
- package/scripts/fixtures/apishape/python/generator-function/head/seq.py +2 -0
- package/scripts/fixtures/apishape/python/ichunked/base/more.py +17 -0
- package/scripts/fixtures/apishape/python/ichunked/head/more.py +20 -0
- package/scripts/fixtures/apishape/python/private-edit/base/calc.py +6 -0
- package/scripts/fixtures/apishape/python/private-edit/head/calc.py +9 -0
- package/scripts/fixtures/apishape/python/removed/base/ops.py +6 -0
- package/scripts/fixtures/apishape/python/removed/head/ops.py +2 -0
- package/scripts/fixtures/apishape/python/renamed/base/text.py +2 -0
- package/scripts/fixtures/apishape/python/renamed/head/text.py +2 -0
- package/scripts/fixtures/apishape/python/signature/base/shapes.py +2 -0
- package/scripts/fixtures/apishape/python/signature/head/shapes.py +2 -0
- package/scripts/fixtures/apishape/python/sync-async/base/client.py +7 -0
- package/scripts/fixtures/apishape/python/sync-async/head/client.py +7 -0
- package/scripts/fixtures/apishape/ts/generator-function/base/seq.js +3 -0
- package/scripts/fixtures/apishape/ts/generator-function/head/seq.js +5 -0
- package/scripts/fixtures/apishape/ts/private-edit/base/calc.ts +7 -0
- package/scripts/fixtures/apishape/ts/private-edit/head/calc.ts +9 -0
- package/scripts/fixtures/apishape/ts/removed/base/ops.ts +7 -0
- package/scripts/fixtures/apishape/ts/removed/head/ops.ts +3 -0
- package/scripts/fixtures/apishape/ts/renamed/base/text.ts +3 -0
- package/scripts/fixtures/apishape/ts/renamed/head/text.ts +3 -0
- package/scripts/fixtures/apishape/ts/signature/base/shapes.ts +3 -0
- package/scripts/fixtures/apishape/ts/signature/head/shapes.ts +3 -0
- package/scripts/fixtures/apishape/ts/sync-async/base/client.mjs +5 -0
- package/scripts/fixtures/apishape/ts/sync-async/head/client.mjs +5 -0
- package/scripts/forge-root.test.sh +2 -2
- package/scripts/publish-staging-sync.test.sh +62 -4
- package/scripts/review-finalize.sh +17 -6
- package/scripts/review-finalize.test.sh +47 -0
- package/scripts/review-pr-arguments.test.sh +13 -1
- package/scripts/review-pr-delta-wiring.test.sh +2 -2
- package/scripts/review-pr-gate2.test.sh +6 -14
- package/scripts/review-pr-staging-finding-filter.test.sh +4 -4
- package/scripts/review-pr-staging-scope.test.sh +39 -33
- package/scripts/review-prepare.sh +73 -6
- package/scripts/review-prepare.test.sh +85 -0
- package/scripts/review-staging-post.sh +293 -0
- package/scripts/review-staging-post.test.sh +143 -0
- package/scripts/review-staging-pre.sh +401 -0
- package/scripts/review-staging-pre.test.sh +150 -0
- package/scripts/symbol-index.mjs +26 -7
- package/scripts/symbol-index.py +55 -9
- 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
|
+
});
|
package/bin/lean/sched.mjs
CHANGED
|
@@ -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 */ } },
|
package/bin/lean/sched.test.mjs
CHANGED
|
@@ -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
|
+
});
|
package/bin/lean/schedd.mjs
CHANGED
|
@@ -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
|
-
* `
|
|
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
|
-
|
|
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);
|