forgedock 1.27.0 → 1.28.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 (127) hide show
  1. package/README.md +4 -1
  2. package/bin/bootstrap.mjs +55 -0
  3. package/bin/engine/outcome-codes.mjs +10 -0
  4. package/bin/engine/telemetry-seam.mjs +37 -8
  5. package/bin/engine/telemetry-seam.test.mjs +31 -0
  6. package/bin/engine-cli.mjs +542 -38
  7. package/bin/engine.mjs +8 -4
  8. package/bin/forgedock.mjs +75 -9
  9. package/bin/gc.mjs +580 -0
  10. package/bin/lean/admission.mjs +33 -3
  11. package/bin/lean/batch.mjs +22 -4
  12. package/bin/lean/batch.test.mjs +5 -3
  13. package/bin/lean/checks.mjs +199 -0
  14. package/bin/lean/checks.test.mjs +183 -0
  15. package/bin/lean/conformance.mjs +190 -58
  16. package/bin/lean/conformance.test.mjs +116 -7
  17. package/bin/lean/credentials.mjs +29 -0
  18. package/bin/lean/depcache.mjs +312 -0
  19. package/bin/lean/depcache.test.mjs +298 -0
  20. package/bin/lean/drift.mjs +198 -0
  21. package/bin/lean/drift.test.mjs +223 -0
  22. package/bin/lean/effects.mjs +85 -8
  23. package/bin/lean/execution.e2e.test.mjs +81 -0
  24. package/bin/lean/execution.mjs +431 -0
  25. package/bin/lean/execution.test.mjs +583 -0
  26. package/bin/lean/heavy.mjs +289 -0
  27. package/bin/lean/heavy.test.mjs +207 -0
  28. package/bin/lean/lean.mjs +572 -139
  29. package/bin/lean/lean.test.mjs +1193 -31
  30. package/bin/lean/ledger.mjs +27 -2
  31. package/bin/lean/ledger.test.mjs +36 -1
  32. package/bin/lean/limits.mjs +58 -0
  33. package/bin/lean/limits.test.mjs +150 -0
  34. package/bin/lean/orchestrate.mjs +312 -19
  35. package/bin/lean/prompts.mjs +89 -15
  36. package/bin/lean/refactor.mjs +276 -0
  37. package/bin/lean/refactor.test.mjs +359 -0
  38. package/bin/lean/render.mjs +24 -5
  39. package/bin/lean/review-trigger.mjs +134 -0
  40. package/bin/lean/review-trigger.test.mjs +104 -0
  41. package/bin/lean/sandbox-provider.contract.test.mjs +257 -0
  42. package/bin/lean/sandbox-remote.mjs +142 -0
  43. package/bin/lean/sched.mjs +221 -0
  44. package/bin/lean/sched.test.mjs +310 -0
  45. package/bin/lean/schedd.mjs +247 -0
  46. package/bin/lean/schedd.test.mjs +180 -0
  47. package/bin/lean/toolchain.mjs +228 -0
  48. package/bin/lean/toolchain.test.mjs +154 -0
  49. package/bin/lean/verify.mjs +20 -9
  50. package/bin/lean/verify.test.mjs +26 -3
  51. package/bin/lean/worktree.mjs +69 -0
  52. package/bin/lean/worktree.test.mjs +141 -0
  53. package/bin/runner.mjs +53 -10
  54. package/bin/stats/github-source.mjs +1 -1
  55. package/bin/stats/index.mjs +14 -2
  56. package/bin/stats/query.mjs +72 -0
  57. package/bin/stats/stats.test.mjs +54 -0
  58. package/commands/audit.md +2 -0
  59. package/commands/autopilot.md +2 -0
  60. package/commands/bootstrap.md +35 -0
  61. package/commands/cleanup.md +7 -22
  62. package/commands/forge-stats.md +2 -1
  63. package/commands/forgedock-init.md +7 -0
  64. package/commands/issue.md +89 -830
  65. package/commands/milestone.md +55 -6
  66. package/commands/orchestrate.md +10 -2
  67. package/commands/upgrade-deps.md +3 -3
  68. package/commands/work-on/build/validate.md +4 -0
  69. package/commands/work-on/close.md +2 -0
  70. package/commands/work-on/decompose.md +24 -6
  71. package/docs/CI.md +3 -3
  72. package/docs/CONFIG.md +177 -2
  73. package/docs/OUTCOME-CODES.md +8 -0
  74. package/docs/TELEMETRY.md +1 -1
  75. package/docs/site/command-reference.md +22 -0
  76. package/docs/site/getting-started.md +2 -0
  77. package/docs/site/start-a-new-project.md +327 -0
  78. package/package.json +1 -1
  79. package/scripts/affected-files-check.sh +91 -0
  80. package/scripts/affected-files-check.test.sh +80 -0
  81. package/scripts/bootstrap-create.mjs +393 -0
  82. package/scripts/bootstrap-validate.mjs +36 -2
  83. package/scripts/calibration.mjs +116 -0
  84. package/scripts/check-devdocs-freshness.sh +9 -4
  85. package/scripts/check-devdocs-freshness.test.sh +11 -1
  86. package/scripts/check-spec-markers.sh +3 -0
  87. package/scripts/check-spec-size.sh +29 -11
  88. package/scripts/check-spec-size.test.sh +27 -4
  89. package/scripts/ci-local/lib.mjs +8 -1
  90. package/scripts/ci-local/parallelism.test.mjs +27 -0
  91. package/scripts/ci-local/run.mjs +3 -3
  92. package/scripts/ci-local/scoping.mjs +82 -0
  93. package/scripts/ci-local.test.sh +1 -1
  94. package/scripts/fixtures/symbol-index/other/sample.rb +3 -0
  95. package/scripts/fixtures/symbol-index/py/pkg/__init__.py +1 -0
  96. package/scripts/fixtures/symbol-index/py/pkg/a.py +5 -0
  97. package/scripts/fixtures/symbol-index/py/pkg/b.py +14 -0
  98. package/scripts/fixtures/symbol-index/py/pkg/c.py +1 -0
  99. package/scripts/fixtures/symbol-index/py/sample.py +41 -0
  100. package/scripts/fixtures/symbol-index/ts/sample.ts +13 -0
  101. package/scripts/fixtures/symbol-move/gen_big.py +201 -0
  102. package/scripts/fixtures/symbol-move-ts/gen_big.mjs +56 -0
  103. package/scripts/forge-root.test.sh +1 -1
  104. package/scripts/hotspots.sh +100 -0
  105. package/scripts/hotspots.test.sh +101 -0
  106. package/scripts/issue-create.sh +197 -0
  107. package/scripts/issue-create.test.sh +166 -0
  108. package/scripts/maintenance-scan.sh +185 -0
  109. package/scripts/maintenance-scan.test.sh +89 -0
  110. package/scripts/milestone-drift.sh +206 -0
  111. package/scripts/milestone-drift.test.sh +120 -0
  112. package/scripts/refactor-verify.sh +368 -0
  113. package/scripts/refactor-verify.test.sh +202 -0
  114. package/scripts/structure-delta.sh +10 -5
  115. package/scripts/structure-delta.test.sh +3 -2
  116. package/scripts/symbol-index.mjs +253 -0
  117. package/scripts/symbol-index.py +354 -0
  118. package/scripts/symbol-index.test.sh +119 -0
  119. package/scripts/symbol-move-ts.test.sh +161 -0
  120. package/scripts/symbol-move.mjs +379 -0
  121. package/scripts/symbol-move.py +383 -0
  122. package/scripts/symbol-move.test.sh +168 -0
  123. package/templates/bootstrap/interview.md +34 -0
  124. package/templates/bootstrap/issue-body.md +42 -0
  125. package/templates/bootstrap/needs-human-issue.md +45 -0
  126. package/templates/bootstrap/stack-profiles.md +34 -0
  127. package/templates/bootstrap/technical-spike.md +75 -0
@@ -0,0 +1,221 @@
1
+ // SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
2
+ // SPDX-License-Identifier: AGPL-3.0-or-later
3
+ /**
4
+ * Host lane registry (#4191): one host-wide cap on running `forgedock orchestrate` lanes across every batch and repo,
5
+ * and cross-batch priority admission. State is one file, `~/.forge/sched/registry.json` (or `FORGEDOCK_SCHED_DIR`):
6
+ * `{v, cap, weights, seq, slots:{key:{pid, lanePid?, batch, repo, issue, priority, at}}, queue:{key:{pid, batch, repo,
7
+ * issue, priority, seq, enqueuedAt}}}` with key `<batch>#<issue>`. Every mutation is a read-modify-write under an
8
+ * exclusive lock file (`registry.lock`, holding the owner pid; a dead owner's lock is taken over) and `writeAtomic`.
9
+ *
10
+ * A dispatcher offers the issues it could start now; `tryAcquire` grants a slot only while live slots < cap and the
11
+ * key ranks within the free slots of the queue in grant order (`grantOrder`): priority first, so a P0 offered last is
12
+ * granted next. Within a priority, slots are shared across repos by the registry's `weights` (#4193; `{"owner/repo": N}`,
13
+ * `forgedock sched weight`, unset = 1): the next entry comes from the repo with the smallest (live slots + entries
14
+ * ordered ahead) / weight, FIFO inside a repo; an entry without a repo groups by batch. Running lanes are never revoked.
15
+ * A queued entry dies with its dispatcher pid; a slot dies with its dispatcher unless its detached lane (`lanePid`)
16
+ * still runs, so an orphaned lane keeps counting until it exits or a `--resume` adopts it. Cap: the registry's own
17
+ * (`forgedock sched cap N`), else the caller's forge.yaml `orchestration.global_parallel`, else none. Fail-open: an
18
+ * unreadable registry or a lock not taken within `lockWaitMs` grants (`fallback: true`), since a stuck registry would
19
+ * stall every batch on the host.
20
+ */
21
+ import { closeSync, mkdirSync, openSync, readFileSync, renameSync, rmSync, statSync, writeSync } from "node:fs";
22
+ import { homedir } from "node:os";
23
+ import { join } from "node:path";
24
+ import { writeAtomic } from "./batch.mjs";
25
+
26
+ export const DEFAULT_PRIORITY = 2;
27
+ export const LOCK_WAIT_MS = 2000;
28
+ const EMPTY_LOCK_STALE_MS = 5000;
29
+
30
+ export function schedDir(env = process.env) {
31
+ return env.FORGEDOCK_SCHED_DIR || join(homedir(), ".forge", "sched");
32
+ }
33
+
34
+ /** Priority from label names (or `{name}` objects): `priority:P<n>` or bare `P<n>`, lowest n wins; unlabelled = 2. */
35
+ export function priorityFromLabels(labels) {
36
+ let best = null;
37
+ for (const l of labels ?? []) {
38
+ const m = /^(?:priority:)?P(\d+)$/i.exec(String(l?.name ?? l ?? "").trim());
39
+ if (m && (best === null || Number(m[1]) < best)) best = Number(m[1]);
40
+ }
41
+ return best ?? DEFAULT_PRIORITY;
42
+ }
43
+
44
+ export function pidAlive(pid) {
45
+ if (!Number.isInteger(pid) || pid <= 0) return false;
46
+ try { process.kill(pid, 0); return true; } catch (e) { return e?.code === "EPERM"; }
47
+ }
48
+
49
+ const posInt = (k) => (Number.isInteger(k) && k >= 1 ? k : null);
50
+ const pause = (ms) => Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
51
+ const byRank = ([ka, a], [kb, b]) => (a.priority - b.priority) || (a.seq - b.seq) || (ka < kb ? -1 : ka > kb ? 1 : 0);
52
+ const groupOf = (k, e) => e?.repo ?? e?.batch ?? k;
53
+
54
+ /**
55
+ * Queue entries `[key, entry]` in grant order: priority ascending, then a weighted fair share across repos within each
56
+ * priority (smallest load/weight first, load = live slots + entries already ordered; ties: higher weight, then FIFO).
57
+ */
58
+ export function grantOrder(s) {
59
+ const weight = (g) => posInt(s.weights?.[g]) ?? 1;
60
+ const load = new Map();
61
+ for (const [k, e] of Object.entries(s.slots ?? {})) { const g = groupOf(k, e); load.set(g, (load.get(g) ?? 0) + 1); }
62
+ const levels = new Map(); // priority -> Map(group -> FIFO entries)
63
+ for (const ent of Object.entries(s.queue ?? {}).sort(byRank)) {
64
+ const [k, e] = ent;
65
+ if (!levels.has(e.priority)) levels.set(e.priority, new Map());
66
+ const groups = levels.get(e.priority), g = groupOf(k, e);
67
+ if (!groups.has(g)) groups.set(g, []);
68
+ groups.get(g).push(ent);
69
+ }
70
+ const out = [];
71
+ for (const p of [...levels.keys()].sort((a, b) => a - b)) {
72
+ const groups = levels.get(p);
73
+ while (groups.size) {
74
+ let best = null;
75
+ for (const [g, list] of groups) {
76
+ if (!best) { best = g; continue; }
77
+ const lg = (load.get(g) ?? 0) * weight(best), lb = (load.get(best) ?? 0) * weight(g);
78
+ if (lg < lb || (lg === lb && (weight(g) > weight(best) || (weight(g) === weight(best) && byRank(list[0], groups.get(best)[0]) < 0)))) best = g;
79
+ }
80
+ const list = groups.get(best);
81
+ out.push(list.shift());
82
+ load.set(best, (load.get(best) ?? 0) + 1);
83
+ if (!list.length) groups.delete(best);
84
+ }
85
+ }
86
+ return out;
87
+ }
88
+
89
+ /**
90
+ * 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}}
92
+ */
93
+ export function openSched(dir = schedDir(), { now = () => Date.now(), pid = process.pid, alive = pidAlive, lockWaitMs = LOCK_WAIT_MS } = {}) {
94
+ const file = join(dir, "registry.json"), lockFile = join(dir, "registry.lock");
95
+
96
+ const lock = () => {
97
+ mkdirSync(dir, { recursive: true });
98
+ const end = Date.now() + lockWaitMs;
99
+ for (;;) {
100
+ try {
101
+ const fd = openSync(lockFile, "wx");
102
+ try { writeSync(fd, String(pid)); } finally { closeSync(fd); }
103
+ return;
104
+ } catch (e) { if (e?.code !== "EEXIST") throw e; }
105
+ let owner = NaN;
106
+ try { owner = parseInt(readFileSync(lockFile, "utf8"), 10); } catch { /* released meanwhile */ }
107
+ // An empty lock is one being created (open, then the pid is written): stale only once it is old.
108
+ let empty = false;
109
+ if (Number.isNaN(owner)) { try { empty = Date.now() - statSync(lockFile).mtimeMs < EMPTY_LOCK_STALE_MS; } catch { continue; } }
110
+ if (!empty && (Number.isNaN(owner) || !alive(owner))) {
111
+ // Take over a dead owner's lock: move it aside, and put it back if it turned out to be a fresh one.
112
+ const aside = `${lockFile}.stale-${pid}`;
113
+ try {
114
+ renameSync(lockFile, aside);
115
+ let got = NaN;
116
+ try { got = parseInt(readFileSync(aside, "utf8"), 10); } catch { /* gone */ }
117
+ if (!Number.isNaN(got) && got !== owner && alive(got)) { try { renameSync(aside, lockFile); } catch { /* best effort */ } }
118
+ else rmSync(aside, { force: true });
119
+ } catch { /* someone else took it over */ }
120
+ continue;
121
+ }
122
+ if (Date.now() >= end) throw new Error(`sched registry lock held by pid ${Number.isNaN(owner) ? "?" : owner}`);
123
+ pause(10);
124
+ }
125
+ };
126
+ const unlock = () => { try { rmSync(lockFile, { force: true }); } catch { /* best effort */ } };
127
+
128
+ const read = () => {
129
+ let o;
130
+ try { o = JSON.parse(readFileSync(file, "utf8")); } catch (e) {
131
+ if (e?.code !== "ENOENT") throw new Error(`sched registry ${file} unreadable: ${e?.message ?? e}`);
132
+ o = {};
133
+ }
134
+ const obj = (x) => (x && typeof x === "object" && !Array.isArray(x) ? x : {});
135
+ const weights = Object.fromEntries(Object.entries(obj(o?.weights)).filter(([, n]) => posInt(n)));
136
+ return { v: 1, cap: posInt(o?.cap), weights, seq: Number.isInteger(o?.seq) ? o.seq : 0, slots: obj(o?.slots), queue: obj(o?.queue) };
137
+ };
138
+ // Drop queued entries of dead dispatchers, and slots whose dispatcher and lane are both gone.
139
+ const reclaim = (s) => {
140
+ for (const [k, e] of Object.entries(s.queue)) if (!alive(e.pid)) delete s.queue[k];
141
+ for (const [k, e] of Object.entries(s.slots)) if (!alive(e.pid) && !(e.lanePid && alive(e.lanePid))) delete s.slots[k];
142
+ };
143
+ const mutate = (fn) => {
144
+ lock();
145
+ try {
146
+ const s = read();
147
+ reclaim(s);
148
+ const r = fn(s);
149
+ writeAtomic(file, JSON.stringify(s, null, 2) + "\n");
150
+ return r;
151
+ } finally { unlock(); }
152
+ };
153
+ const enqueue = (s, key, e) => {
154
+ if (s.slots[key]) return;
155
+ const prev = s.queue[key];
156
+ const priority = Number.isFinite(Number(e.priority)) ? Number(e.priority) : DEFAULT_PRIORITY;
157
+ s.queue[key] = prev
158
+ ? { ...prev, pid, priority }
159
+ : { pid, batch: e.batch ?? null, repo: e.repo ?? null, issue: e.issue ?? null, priority, seq: ++s.seq, enqueuedAt: now() };
160
+ };
161
+ const effCap = (s, callerCap) => s.cap ?? posInt(callerCap);
162
+
163
+ return {
164
+ dir,
165
+ /** Grant `key` a slot, queueing it when none is free for it now. @returns {{ok:boolean, fallback?:true, reason?:string}} */
166
+ tryAcquire: (key, entry = {}, callerCap = null) => {
167
+ try {
168
+ return mutate((s) => {
169
+ if (s.slots[key]) return { ok: true };
170
+ enqueue(s, key, entry);
171
+ const cap = effCap(s, callerCap);
172
+ if (cap !== null) {
173
+ const free = cap - Object.keys(s.slots).length;
174
+ const rank = grantOrder(s).findIndex(([k]) => k === key);
175
+ if (free <= 0) return { ok: false, reason: `host cap ${cap} reached` };
176
+ if (rank >= free) return { ok: false, reason: `queued behind ${rank} higher-ranked issue(s)` };
177
+ }
178
+ const q = s.queue[key];
179
+ delete s.queue[key];
180
+ s.slots[key] = { pid, batch: q.batch, repo: q.repo, issue: q.issue, priority: q.priority, at: now() };
181
+ return { ok: true };
182
+ });
183
+ } catch (e) { return { ok: true, fallback: true, reason: e?.message ?? String(e) }; }
184
+ },
185
+ /** Queue entries `[{key, batch, repo, issue, priority}]` (their FIFO position is kept on a re-offer). */
186
+ offer: (entries) => { try { mutate((s) => { for (const e of entries) enqueue(s, e.key, e); }); } catch { /* fail-open */ } },
187
+ withdraw: (keys) => { if (!keys.length) return; try { mutate((s) => { for (const k of keys) delete s.queue[k]; }); } catch { /* fail-open */ } },
188
+ release: (key) => { try { mutate((s) => { delete s.slots[key]; delete s.queue[key]; }); } catch { /* fail-open */ } },
189
+ setLanePid: (key, lanePid) => { try { mutate((s) => { if (s.slots[key] && posInt(lanePid)) s.slots[key].lanePid = lanePid; }); } catch { /* fail-open */ } },
190
+ /** Record a lane this dispatcher re-attached to: it runs already, so the cap does not gate it. */
191
+ adoptSlot: (key, entry = {}) => {
192
+ try {
193
+ mutate((s) => {
194
+ delete s.queue[key];
195
+ s.slots[key] = { pid, ...(posInt(entry.lanePid) ? { lanePid: entry.lanePid } : {}), batch: entry.batch ?? null, repo: entry.repo ?? null,
196
+ issue: entry.issue ?? null, priority: Number.isFinite(Number(entry.priority)) ? Number(entry.priority) : DEFAULT_PRIORITY, at: now() };
197
+ });
198
+ } catch { /* fail-open */ }
199
+ },
200
+ cap: () => read().cap,
201
+ /** Set the live host cap (a positive integer), or null to fall back to each dispatcher's config. Throws. */
202
+ setCap: (k) => {
203
+ if (k !== null && !posInt(k)) throw new Error("cap must be a positive integer");
204
+ return mutate((s) => { s.cap = k; return k; });
205
+ },
206
+ weights: () => read().weights,
207
+ /** Set the fair-share weight of `repo` (a positive integer), or null to reset it to 1. Throws. */
208
+ setWeight: (repo, n) => {
209
+ if (typeof repo !== "string" || !repo.trim()) throw new Error("repo must be a non-empty string");
210
+ if (n !== null && !posInt(n)) throw new Error("weight must be a positive integer");
211
+ return mutate((s) => { if (n === null) delete s.weights[repo]; else s.weights[repo] = n; return n; });
212
+ },
213
+ /** Live state after reclaim: `{cap, weights, slots:[{key,...}], queue:[{key,...}] in grant order}`. Throws when unreadable. */
214
+ list: () => mutate((s) => ({
215
+ cap: s.cap,
216
+ weights: { ...s.weights },
217
+ slots: Object.entries(s.slots).map(([key, e]) => ({ key, ...e })),
218
+ queue: grantOrder(s).map(([key, e]) => ({ key, ...e })),
219
+ })),
220
+ };
221
+ }
@@ -0,0 +1,310 @@
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 { writeFileSync } from "node:fs";
6
+ import { join } from "node:path";
7
+ import { tmpDir } from "../tests/_tmp.mjs";
8
+ import { grantOrder, openSched, priorityFromLabels } from "./sched.mjs";
9
+ import { orchestrate } from "./orchestrate.mjs";
10
+ import { schedFromCli } from "../engine-cli.mjs";
11
+
12
+ const quiet = async (fn) => { const l = console.log; console.log = () => {}; try { return await fn(); } finally { console.log = l; } };
13
+ const until = async (cond, what, ms = 10000) => {
14
+ const end = Date.now() + ms;
15
+ while (!cond()) { if (Date.now() > end) throw new Error(`timed out waiting for ${what}`); await new Promise((r) => setTimeout(r, 2)); }
16
+ };
17
+ // Injected pids and clock: `live` is the set of pids the registry sees as alive.
18
+ const world = () => {
19
+ const live = new Set(), clock = { t: 1_000_000 };
20
+ const dir = tmpDir("sched-");
21
+ const handle = (pid, extra = {}) => { live.add(pid); return openSched(dir, { pid, alive: (p) => live.has(p), now: () => ++clock.t, ...extra }); };
22
+ return { dir, live, handle };
23
+ };
24
+
25
+ describe("host lane registry (#4191)", () => {
26
+ it("priorityFromLabels: priority:P<n> or bare P<n>, lowest wins, unlabelled = P2", () => {
27
+ assert.equal(priorityFromLabels(["priority:P0"]), 0);
28
+ assert.equal(priorityFromLabels([{ name: "P1" }, { name: "bug" }]), 1);
29
+ assert.equal(priorityFromLabels(["priority:P3", "p1", "Priority:P2"]), 1);
30
+ assert.equal(priorityFromLabels(["bug", "P", "priority:high", "XP0"]), 2);
31
+ assert.equal(priorityFromLabels([]), 2);
32
+ assert.equal(priorityFromLabels(undefined), 2);
33
+ });
34
+
35
+ it("grants within the cap in (priority, FIFO) order; a P0 queued last is granted next", () => {
36
+ const { handle } = world();
37
+ const a = handle(101), b = handle(102), c = handle(103);
38
+ const e = (batch, issue, priority) => ({ batch, repo: "o/r", issue, priority });
39
+ assert.equal(a.tryAcquire("A#1", e("A", 1, 2), 2).ok, true);
40
+ assert.equal(b.tryAcquire("B#1", e("B", 1, 2), 2).ok, true);
41
+ const r = b.tryAcquire("B#2", e("B", 2, 2), 2);
42
+ assert.equal(r.ok, false);
43
+ assert.match(r.reason, /host cap 2/);
44
+ a.offer([{ key: "A#2", ...e("A", 2, 3) }]);
45
+ c.offer([{ key: "C#9", ...e("C", 9, 0) }]); // P0, offered last
46
+ assert.deepEqual(a.list().queue.map((q) => q.key), ["C#9", "B#2", "A#2"]);
47
+ a.release("A#1");
48
+ assert.equal(b.tryAcquire("B#2", e("B", 2, 2), 2).ok, false, "the freed slot belongs to the P0");
49
+ assert.equal(c.tryAcquire("C#9", e("C", 9, 0), 2).ok, true);
50
+ b.release("B#1");
51
+ assert.equal(a.tryAcquire("A#2", e("A", 2, 3), 2).ok, false, "FIFO within priority: B#2 (P2) before A#2 (P3)");
52
+ assert.equal(b.tryAcquire("B#2", e("B", 2, 2), 2).ok, true);
53
+ assert.equal(a.list().slots.length, 2);
54
+ // A re-offer keeps the FIFO position.
55
+ a.offer([{ key: "A#2", ...e("A", 2, 3) }]);
56
+ assert.deepEqual(a.list().queue.map((q) => q.key), ["A#2"]);
57
+ });
58
+
59
+ it("dead dispatchers' queued entries and slots are reclaimed; a slot whose detached lane still runs is kept", () => {
60
+ const { handle, live } = world();
61
+ const a = handle(201), b = handle(202);
62
+ assert.equal(a.tryAcquire("A#1", { issue: 1 }, 2).ok, true);
63
+ assert.equal(a.tryAcquire("A#2", { issue: 2 }, 2).ok, true);
64
+ a.setLanePid("A#2", 9002);
65
+ live.add(9002);
66
+ a.offer([{ key: "A#3", issue: 3, priority: 0 }]);
67
+ assert.equal(b.tryAcquire("B#1", { issue: 1 }, 2).ok, false);
68
+ live.delete(201); // dispatcher A dies
69
+ assert.deepEqual(b.list().slots.map((s) => s.key), ["A#2"], "A#1 reclaimed; A#2's lane is still running");
70
+ assert.deepEqual(b.list().queue.map((q) => q.key), ["B#1"], "A's queued P0 is gone");
71
+ assert.equal(b.tryAcquire("B#1", { issue: 1 }, 2).ok, true);
72
+ live.delete(9002); // the orphaned lane exits
73
+ assert.deepEqual(b.list().slots.map((s) => s.key), ["B#1"]);
74
+ // A resume adopts a live lane without being gated by the cap.
75
+ const a2 = handle(203);
76
+ a2.adoptSlot("A#4", { issue: 4, lanePid: 9004 });
77
+ a2.adoptSlot("A#5", { issue: 5 });
78
+ assert.equal(a2.list().slots.length, 3);
79
+ });
80
+
81
+ it("forgedock sched cap changes the cap live; --clear falls back to the caller's config", async () => {
82
+ const { handle } = world();
83
+ const a = handle(301), b = handle(302);
84
+ assert.equal(a.tryAcquire("A#1", {}, 5).ok, true);
85
+ await quiet(() => schedFromCli(["cap", "1"], { sched: a }));
86
+ assert.equal(b.cap(), 1);
87
+ assert.equal(b.tryAcquire("B#1", {}, 5).ok, false, "the registry cap overrides the config cap");
88
+ const shown = await quiet(() => schedFromCli(["show"], { sched: b }));
89
+ assert.deepEqual([shown.cap, shown.slots.length, shown.queue.length], [1, 1, 1]);
90
+ await quiet(() => schedFromCli(["cap", "--clear"], { sched: a }));
91
+ assert.equal(b.tryAcquire("B#1", {}, 5).ok, true);
92
+ assert.equal(b.tryAcquire("B#2", {}, null).ok, true, "no cap anywhere: always granted");
93
+ for (const bad of [["cap"], ["cap", "0"], ["cap", "x"], ["cap", "1.5"], ["nope"]]) {
94
+ await assert.rejects(() => quiet(() => schedFromCli(bad, { sched: a })), /usage: forgedock sched/);
95
+ }
96
+ });
97
+
98
+ it("fails open: an unreadable registry or a held lock grants with fallback", () => {
99
+ const { dir, handle } = world();
100
+ writeFileSync(join(dir, "registry.json"), "{not json");
101
+ const a = handle(401);
102
+ const r = a.tryAcquire("A#1", {}, 1);
103
+ assert.deepEqual([r.ok, r.fallback], [true, true]);
104
+ assert.match(r.reason, /unreadable/);
105
+ const w2 = world();
106
+ const owner = w2.handle(402);
107
+ writeFileSync(join(w2.dir, "registry.lock"), "402"); // a live owner never releases it
108
+ const b = w2.handle(403, { lockWaitMs: 30 });
109
+ const r2 = b.tryAcquire("B#1", {}, 1);
110
+ assert.deepEqual([r2.ok, r2.fallback], [true, true]);
111
+ assert.match(r2.reason, /lock held by pid 402/);
112
+ // A dead owner's lock is taken over.
113
+ w2.live.delete(402);
114
+ const r3 = b.tryAcquire("B#1", {}, 1);
115
+ assert.deepEqual([r3.ok, r3.fallback], [true, undefined]);
116
+ assert.ok(owner);
117
+ });
118
+
119
+ it("14 dispatchers against one registry: running lanes ≤ the cap, each batch ≤ its K, a P0 submitted last starts next", async () => {
120
+ const { handle, live } = world();
121
+ const CAP = 4, K = 2, BATCHES = 14;
122
+ const inFlight = new Map(); // key -> resolve
123
+ const perBatch = new Map();
124
+ const starts = [];
125
+ let maxHost = 0, maxBatch = 0;
126
+ let autoRelease = false;
127
+ const runOneFor = (batch) => async (n) => {
128
+ const key = `${batch}#${n}`;
129
+ starts.push(key);
130
+ perBatch.set(batch, (perBatch.get(batch) ?? 0) + 1);
131
+ maxHost = Math.max(maxHost, inFlight.size + 1);
132
+ maxBatch = Math.max(maxBatch, perBatch.get(batch));
133
+ const r = await new Promise((res) => { inFlight.set(key, res); if (autoRelease) setTimeout(() => res(), 1); });
134
+ inFlight.delete(key);
135
+ perBatch.set(batch, perBatch.get(batch) - 1);
136
+ return r ?? { terminalReason: "merged", pr: n, cost_usd: 0 };
137
+ };
138
+ const dispatch = (i, issues, prio) => {
139
+ const batch = `b${i}`;
140
+ const registry = handle(5000 + i);
141
+ return orchestrate({ issues, parallel: K, readBody: async () => "", runOne: runOneFor(batch), pollMs: 2, batchId: batch,
142
+ sched: { registry, batch, repo: `o/r${i % 3}`, cap: CAP }, priorityOf: async (n) => prio(n) });
143
+ };
144
+ const reg = handle(4999);
145
+ const runs = [];
146
+ for (let i = 0; i < BATCHES; i++) runs.push(dispatch(i, [1, 2, 3], () => 2));
147
+ await until(() => inFlight.size === CAP && reg.list().queue.length === BATCHES * K - CAP, "the cap to fill and every batch to offer");
148
+ assert.equal(reg.list().slots.length, CAP);
149
+ // A P0 submitted last, in its own batch behind 24 queued P2s.
150
+ runs.push(dispatch(BATCHES, [7, 8], (n) => (n === 7 ? 0 : 3)));
151
+ await until(() => reg.list().queue.some((q) => q.key === `b${BATCHES}#7`), "the P0 to be queued");
152
+ assert.equal(reg.list().queue[0].key, `b${BATCHES}#7`);
153
+ const before = starts.length;
154
+ inFlight.get(starts[0])(); // one lane ends
155
+ await until(() => starts.length > before, "the next start");
156
+ assert.equal(starts[before], `b${BATCHES}#7`, "the freed slot went to the P0");
157
+ assert.equal(starts.length, before + 1);
158
+ autoRelease = true;
159
+ for (const res of [...inFlight.values()]) res();
160
+ const results = await Promise.all(runs);
161
+ assert.ok(results.flat().every((r) => r.outcome === "merged"), JSON.stringify(results));
162
+ assert.equal(starts.length, BATCHES * 3 + 2);
163
+ assert.ok(maxHost <= CAP, `host lanes peaked at ${maxHost}`);
164
+ assert.ok(maxBatch <= K, `a batch peaked at ${maxBatch}`);
165
+ const end = reg.list();
166
+ assert.deepEqual([end.slots.length, end.queue.length], [0, 0], "every slot released and offer withdrawn");
167
+ assert.ok(live.size > BATCHES);
168
+ });
169
+
170
+ it("a dead dispatcher's slots are reclaimed so a waiting batch proceeds; adopted lanes hold a slot until they settle", async () => {
171
+ const { handle, live } = world();
172
+ const ghost = handle(6001);
173
+ assert.equal(ghost.tryAcquire("g#1", { issue: 1 }, 1).ok, true);
174
+ const registry = handle(6002);
175
+ let ran = 0;
176
+ const p = orchestrate({ issues: [1], readBody: async () => "", runOne: async () => { ran++; return { terminalReason: "merged", cost_usd: 0 }; },
177
+ pollMs: 2, batchId: "b", sched: { registry, batch: "b", repo: "o/r", cap: 1 } });
178
+ await until(() => registry.list().queue.length === 1, "the offer");
179
+ assert.equal(ran, 0, "the host is full");
180
+ live.delete(6001); // the other dispatcher dies
181
+ const rows = await p;
182
+ assert.deepEqual([rows[0].outcome, ran], ["merged", 1]);
183
+ // --resume: a live lane recorded by the earlier dispatcher is adopted into the registry, and released once it settles.
184
+ const saved = { v: 1, order: [5], deps: { 5: [] }, results: {}, running: { 5: { startedAt: 0, pid: 777 } }, pending: [] };
185
+ let store = saved;
186
+ const calls = [];
187
+ const spy = { ...registry, adoptSlot: (k, e) => { calls.push(["adopt", k, e.lanePid]); registry.adoptSlot(k, e); calls.push(["slots", registry.list().slots.map((x) => x.key)]); },
188
+ release: (k) => { calls.push(["release", k]); registry.release(k); } };
189
+ live.add(777);
190
+ const q = await orchestrate({ issues: [], readBody: async () => "", runOne: async () => ({ terminalReason: "merged" }), adopt: async () => ({ terminalReason: "merged", cost_usd: 0 }),
191
+ store: { load: () => store, save: (s) => { store = s; } }, pollMs: 2, batchId: "r", sched: { registry: spy, batch: "r", repo: "o/r", cap: 1 } });
192
+ assert.equal(q[0].outcome, "merged");
193
+ assert.deepEqual(calls, [["adopt", "r#5", 777], ["slots", ["r#5"]], ["release", "r#5"]]);
194
+ assert.equal(registry.list().slots.length, 0);
195
+ assert.deepEqual(store.priorities, { 5: 2 });
196
+ });
197
+
198
+ it("an unreadable registry admits without the global cap and says so once", async () => {
199
+ const { dir, handle } = world();
200
+ writeFileSync(join(dir, "registry.json"), "garbage");
201
+ const rows = [];
202
+ const res = await orchestrate({ issues: [1, 2], parallel: 2, readBody: async () => "", runOne: async () => ({ terminalReason: "merged", cost_usd: 0 }),
203
+ pollMs: 2, batchId: "f", sched: { registry: handle(7001), batch: "f", repo: "o/r", cap: 1 }, onUpdate: (r) => rows.push(r) });
204
+ assert.deepEqual(res.map((r) => r.outcome), ["merged", "merged"]);
205
+ assert.equal(rows.filter((r) => r.outcome === "sched").length, 1);
206
+ });
207
+
208
+ it("without a registry, admission and state are unchanged (no priorities key, FIFO)", async () => {
209
+ let store = null;
210
+ const order = [];
211
+ await orchestrate({ issues: [3, 1, 2], parallel: 1, readBody: async () => "", runOne: async (n) => { order.push(n); return { terminalReason: "merged" }; },
212
+ priorityOf: async (n) => (n === 2 ? 0 : 2), store: { load: () => null, save: (s) => { store = s; } } });
213
+ assert.deepEqual(order, [3, 1, 2]);
214
+ assert.equal("priorities" in store, false);
215
+ });
216
+ });
217
+
218
+ describe("cross-repo fairness weights (#4193)", () => {
219
+ // Two repos, one dispatcher each, keep twice the cap queued at P2 (o/b offers its first batch before o/a). Each
220
+ // round the oldest running lane ends and every queued key retries, lowest-ranked first, so only the registry's
221
+ // grant order decides who starts.
222
+ const saturate = ({ weights = {}, cap = 6 } = {}) => {
223
+ const { handle } = world();
224
+ const disp = { "o/a": handle(501), "o/b": handle(502) };
225
+ for (const [r, n] of Object.entries(weights)) disp["o/a"].setWeight(r, n);
226
+ const next = { "o/a": 0, "o/b": 0 };
227
+ const offer = (repo, priority = 2) => {
228
+ const n = ++next[repo];
229
+ disp[repo].offer([{ key: `${repo}#${n}`, batch: repo, repo, issue: n, priority }]);
230
+ return `${repo}#${n}`;
231
+ };
232
+ for (const r of ["o/b", "o/a"]) for (let i = 0; i < cap * 2; i++) offer(r);
233
+ const pass = () => {
234
+ for (let granted = true; granted;) {
235
+ granted = false;
236
+ for (const q of disp["o/a"].list().queue.reverse()) {
237
+ if (disp[q.repo].tryAcquire(q.key, q, cap).ok) { granted = true; if (q.priority === 2) offer(q.repo); }
238
+ }
239
+ }
240
+ };
241
+ const slots = () => disp["o/a"].list().slots;
242
+ const running = () => { const c = { "o/a": 0, "o/b": 0 }; for (const s of slots()) c[s.repo]++; return c; };
243
+ const endOldest = () => { const s = slots().sort((x, y) => x.at - y.at)[0]; disp[s.repo].release(s.key); };
244
+ return { disp, offer, pass, slots, running, endOldest };
245
+ };
246
+
247
+ it("two repos weighted 2:1, both saturated at one priority: running lanes converge to 2:1", () => {
248
+ const w = saturate({ weights: { "o/a": 2 } });
249
+ w.pass();
250
+ assert.deepEqual(w.running(), { "o/a": 4, "o/b": 2 }, "the first fill already follows the weights, not who offered first");
251
+ for (let round = 0; round < 40; round++) {
252
+ w.endOldest();
253
+ w.pass();
254
+ assert.deepEqual(w.running(), { "o/a": 4, "o/b": 2 }, `round ${round}`);
255
+ }
256
+ });
257
+
258
+ it("default weights are equal: a repo that offered first does not take every slot", () => {
259
+ const w = saturate();
260
+ w.pass();
261
+ assert.deepEqual(w.running(), { "o/a": 3, "o/b": 3 });
262
+ for (let round = 0; round < 20; round++) { w.endOldest(); w.pass(); assert.deepEqual(w.running(), { "o/a": 3, "o/b": 3 }); }
263
+ });
264
+
265
+ it("a P0 from any repo still starts next, even from the repo already at its share", () => {
266
+ const w = saturate({ weights: { "o/a": 2 } });
267
+ w.pass();
268
+ const p0 = w.offer("o/b", 0);
269
+ assert.equal(w.disp["o/a"].list().queue[0].key, p0, "the P0 heads the queue ahead of the heavier repo's P2s");
270
+ assert.equal(w.disp["o/a"].tryAcquire("o/a#13", { repo: "o/a", priority: 2 }, 6).ok, false, "no slot free");
271
+ w.endOldest();
272
+ assert.equal(w.disp["o/a"].tryAcquire(w.disp["o/a"].list().queue[1].key, { repo: "o/a", priority: 2 }, 6).ok, false, "the freed slot belongs to the P0");
273
+ assert.equal(w.disp["o/b"].tryAcquire(p0, { repo: "o/b", priority: 0 }, 6).ok, true);
274
+ assert.ok(w.slots().some((s) => s.key === p0));
275
+ });
276
+
277
+ it("grantOrder on an injected queue: priority first, then load/weight across repos, FIFO inside a repo", () => {
278
+ const q = (repo, seq, priority = 2, batch = null) => ({ repo, batch, issue: seq, priority, seq });
279
+ const s = {
280
+ weights: {},
281
+ slots: { "x#1": { repo: "o/a", priority: 2 }, "x#2": { repo: "o/a", priority: 1 } },
282
+ queue: { a1: q("o/a", 1), a2: q("o/a", 2), a3: q("o/a", 3), b1: q("o/b", 4), b2: q("o/b", 5), c0: q("o/c", 9, 0) },
283
+ };
284
+ assert.deepEqual(grantOrder(s).map(([k]) => k), ["c0", "b1", "b2", "a1", "a2", "a3"]);
285
+ s.weights = { "o/a": 3 };
286
+ assert.deepEqual(grantOrder(s).map(([k]) => k), ["c0", "b1", "a1", "a2", "b2", "a3"], "ties in load/weight go to the heavier repo");
287
+ // One repo (or no repo, grouped by batch, else by key): plain FIFO, as before #4193.
288
+ const one = { weights: {}, slots: {}, queue: { k3: q(null, 3, 2, "B"), k1: q(null, 1, 2, "B"), k2: q(null, 2, 2, "B") } };
289
+ assert.deepEqual(grantOrder(one).map(([k]) => k), ["k1", "k2", "k3"]);
290
+ const none = { weights: {}, slots: {}, queue: { k3: q(null, 3), k1: q(null, 1), k2: q(null, 2) } };
291
+ assert.deepEqual(grantOrder(none).map(([k]) => k), ["k1", "k2", "k3"]);
292
+ });
293
+
294
+ it("forgedock sched weight sets and clears a repo's weight; show lists it; invalid input is rejected", async () => {
295
+ const { handle } = world();
296
+ const a = handle(601);
297
+ assert.deepEqual(a.weights(), {});
298
+ await quiet(() => schedFromCli(["weight", "o/a", "2"], { sched: a }));
299
+ assert.deepEqual(a.weights(), { "o/a": 2 });
300
+ const shown = await quiet(() => schedFromCli(["show"], { sched: a }));
301
+ assert.deepEqual(shown.weights, { "o/a": 2 });
302
+ await quiet(() => schedFromCli(["weight", "o/a", "--clear"], { sched: a }));
303
+ assert.deepEqual(a.weights(), {});
304
+ for (const bad of [["weight"], ["weight", "o/a"], ["weight", "o/a", "0"], ["weight", "o/a", "1.5"], ["weight", "--clear"]]) {
305
+ await assert.rejects(() => quiet(() => schedFromCli(bad, { sched: a })), /usage: forgedock sched/);
306
+ }
307
+ assert.throws(() => a.setWeight("o/a", 0), /positive integer/);
308
+ assert.throws(() => a.setWeight("", 2), /non-empty/);
309
+ });
310
+ });