forgedock 1.26.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 (137) hide show
  1. package/README.md +4 -1
  2. package/bin/bootstrap.mjs +55 -0
  3. package/bin/engine/outcome-codes.mjs +13 -0
  4. package/bin/engine/phases.mjs +38 -2
  5. package/bin/engine/telemetry-seam.mjs +41 -6
  6. package/bin/engine/telemetry-seam.test.mjs +35 -0
  7. package/bin/engine-cli.mjs +616 -54
  8. package/bin/engine.mjs +13 -5
  9. package/bin/forgedock.mjs +169 -39
  10. package/bin/gc.mjs +580 -0
  11. package/bin/init-detect.mjs +68 -3
  12. package/bin/init-enrich.mjs +3 -0
  13. package/bin/init-repo.mjs +213 -0
  14. package/bin/journey.mjs +278 -43
  15. package/bin/lean/admission.mjs +33 -3
  16. package/bin/lean/batch.mjs +24 -5
  17. package/bin/lean/batch.test.mjs +54 -4
  18. package/bin/lean/checks.mjs +199 -0
  19. package/bin/lean/checks.test.mjs +183 -0
  20. package/bin/lean/conformance.mjs +235 -37
  21. package/bin/lean/conformance.test.mjs +143 -5
  22. package/bin/lean/credentials.mjs +29 -0
  23. package/bin/lean/depcache.mjs +312 -0
  24. package/bin/lean/depcache.test.mjs +298 -0
  25. package/bin/lean/drift.mjs +198 -0
  26. package/bin/lean/drift.test.mjs +223 -0
  27. package/bin/lean/effects.mjs +178 -16
  28. package/bin/lean/execution.e2e.test.mjs +81 -0
  29. package/bin/lean/execution.mjs +431 -0
  30. package/bin/lean/execution.test.mjs +583 -0
  31. package/bin/lean/heavy.mjs +289 -0
  32. package/bin/lean/heavy.test.mjs +207 -0
  33. package/bin/lean/lean.mjs +877 -151
  34. package/bin/lean/lean.test.mjs +1798 -28
  35. package/bin/lean/ledger.mjs +92 -0
  36. package/bin/lean/ledger.test.mjs +100 -0
  37. package/bin/lean/limits.mjs +58 -0
  38. package/bin/lean/limits.test.mjs +150 -0
  39. package/bin/lean/orchestrate.mjs +321 -19
  40. package/bin/lean/prompts.mjs +125 -17
  41. package/bin/lean/refactor.mjs +276 -0
  42. package/bin/lean/refactor.test.mjs +359 -0
  43. package/bin/lean/render.mjs +25 -6
  44. package/bin/lean/review-trigger.mjs +134 -0
  45. package/bin/lean/review-trigger.test.mjs +104 -0
  46. package/bin/lean/sandbox-provider.contract.test.mjs +257 -0
  47. package/bin/lean/sandbox-remote.mjs +142 -0
  48. package/bin/lean/sched.mjs +221 -0
  49. package/bin/lean/sched.test.mjs +310 -0
  50. package/bin/lean/schedd.mjs +247 -0
  51. package/bin/lean/schedd.test.mjs +180 -0
  52. package/bin/lean/toolchain.mjs +228 -0
  53. package/bin/lean/toolchain.test.mjs +154 -0
  54. package/bin/lean/verify.mjs +124 -0
  55. package/bin/lean/verify.test.mjs +118 -0
  56. package/bin/lean/worktree.mjs +69 -0
  57. package/bin/lean/worktree.test.mjs +141 -0
  58. package/bin/runner.mjs +53 -10
  59. package/bin/stats/github-source.mjs +1 -1
  60. package/bin/stats/index.mjs +14 -2
  61. package/bin/stats/query.mjs +72 -0
  62. package/bin/stats/stats.test.mjs +54 -0
  63. package/commands/adopt.md +3 -0
  64. package/commands/audit.md +5 -8
  65. package/commands/autopilot.md +2 -0
  66. package/commands/bootstrap.md +35 -0
  67. package/commands/cleanup.md +7 -22
  68. package/commands/forge-stats.md +2 -1
  69. package/commands/forgedock-init.md +125 -6
  70. package/commands/issue.md +89 -830
  71. package/commands/milestone.md +55 -6
  72. package/commands/orchestrate.md +10 -2
  73. package/commands/review-pr.md +2 -2
  74. package/commands/upgrade-deps.md +3 -3
  75. package/commands/work-on/build/validate.md +4 -0
  76. package/commands/work-on/close.md +2 -0
  77. package/commands/work-on/decompose.md +24 -6
  78. package/docs/CI.md +3 -3
  79. package/docs/CONFIG.md +227 -3
  80. package/docs/OUTCOME-CODES.md +11 -0
  81. package/docs/TELEMETRY.md +1 -1
  82. package/docs/site/command-reference.md +22 -0
  83. package/docs/site/getting-started.md +2 -0
  84. package/docs/site/start-a-new-project.md +327 -0
  85. package/package.json +1 -1
  86. package/scripts/affected-files-check.sh +91 -0
  87. package/scripts/affected-files-check.test.sh +80 -0
  88. package/scripts/bootstrap-create.mjs +393 -0
  89. package/scripts/bootstrap-validate.mjs +321 -0
  90. package/scripts/calibration.mjs +116 -0
  91. package/scripts/check-devdocs-freshness.sh +125 -0
  92. package/scripts/check-devdocs-freshness.test.sh +130 -0
  93. package/scripts/check-spec-markers.sh +3 -0
  94. package/scripts/check-spec-size.sh +29 -11
  95. package/scripts/check-spec-size.test.sh +27 -4
  96. package/scripts/ci-local/lib.mjs +8 -1
  97. package/scripts/ci-local/parallelism.test.mjs +27 -0
  98. package/scripts/ci-local/run.mjs +16 -5
  99. package/scripts/ci-local/scoping.mjs +82 -0
  100. package/scripts/ci-local.test.sh +14 -2
  101. package/scripts/fixtures/symbol-index/other/sample.rb +3 -0
  102. package/scripts/fixtures/symbol-index/py/pkg/__init__.py +1 -0
  103. package/scripts/fixtures/symbol-index/py/pkg/a.py +5 -0
  104. package/scripts/fixtures/symbol-index/py/pkg/b.py +14 -0
  105. package/scripts/fixtures/symbol-index/py/pkg/c.py +1 -0
  106. package/scripts/fixtures/symbol-index/py/sample.py +41 -0
  107. package/scripts/fixtures/symbol-index/ts/sample.ts +13 -0
  108. package/scripts/fixtures/symbol-move/gen_big.py +201 -0
  109. package/scripts/fixtures/symbol-move-ts/gen_big.mjs +56 -0
  110. package/scripts/forge-root.test.sh +1 -1
  111. package/scripts/hotspots.sh +100 -0
  112. package/scripts/hotspots.test.sh +101 -0
  113. package/scripts/issue-create.sh +197 -0
  114. package/scripts/issue-create.test.sh +166 -0
  115. package/scripts/maintenance-scan.sh +185 -0
  116. package/scripts/maintenance-scan.test.sh +89 -0
  117. package/scripts/milestone-drift.sh +206 -0
  118. package/scripts/milestone-drift.test.sh +120 -0
  119. package/scripts/northstar-docs.test.sh +68 -0
  120. package/scripts/refactor-verify.sh +368 -0
  121. package/scripts/refactor-verify.test.sh +202 -0
  122. package/scripts/review-prepare.sh +11 -9
  123. package/scripts/review-prepare.test.sh +14 -0
  124. package/scripts/structure-delta.sh +155 -0
  125. package/scripts/structure-delta.test.sh +136 -0
  126. package/scripts/symbol-index.mjs +253 -0
  127. package/scripts/symbol-index.py +354 -0
  128. package/scripts/symbol-index.test.sh +119 -0
  129. package/scripts/symbol-move-ts.test.sh +161 -0
  130. package/scripts/symbol-move.mjs +379 -0
  131. package/scripts/symbol-move.py +383 -0
  132. package/scripts/symbol-move.test.sh +168 -0
  133. package/templates/bootstrap/interview.md +34 -0
  134. package/templates/bootstrap/issue-body.md +42 -0
  135. package/templates/bootstrap/needs-human-issue.md +45 -0
  136. package/templates/bootstrap/stack-profiles.md +34 -0
  137. package/templates/bootstrap/technical-spike.md +75 -0
@@ -13,9 +13,43 @@
13
13
  * Durable batch runner (#4121): with a `store` every transition is persisted, so a restarted dispatcher continues the
14
14
  * batch; lanes it launched that are still alive are re-attached through `adopt(issue, rec)` instead of relaunched.
15
15
  * `deferred` is a scheduling fact, not an end: each typed code is requeued with its retry-at (`deferPolicy`), a usage
16
- * limit holds all admissions until its reset, and non-host deferrals stop after `maxDeferrals`. An `engine-error` is
16
+ * limit holds admissions for that account until its reset, and non-host deferrals stop after `maxDeferrals`.
17
+ * Usage-limit holds (#4190) are keyed by backend identity in `limits` (the host store, shared by every batch on the
18
+ * host; in memory when absent): admission checks the hold of `identity()`, the account the next lane would use. An `engine-error` is
17
19
  * relaunched once; `breaker` ({errors, window_min}) stops admissions after a burst and lets running lanes finish, the
18
20
  * rest stay pending (outcome `held`) for a resume. `getParallel()` is read on every pass, so K can change live.
21
+ *
22
+ * Maintenance budget (#4197), off unless `maintenance: {share, whenIdle}` is passed: `classify(issue)` gives each issue
23
+ * `{maintenance, priority}` (persisted as `classes`) and admission follows `pickNext`: P0/P1 features, then a maintenance
24
+ * issue while maintenance starts stay within `share` of all lane starts (`dispatch: {started, maint}`, persisted), then
25
+ * P2+ features, then (`whenIdle`) maintenance once no feature is pending. Maintenance the budget never admits ends `held`.
26
+ *
27
+ * Milestone drift (#4161), off unless `drift` is passed (`bin/lean/drift.mjs` makeDrift): a `merged` milestone-lane run
28
+ * whose result carries `structure` awaits `drift.onMerged(issue, row, result)` before its slot frees. A drift error is
29
+ * reported as a `drift` row and never changes the outcome. With `drift.holds(issue, {body, lane})` (block_dispatch), a
30
+ * pending issue it returns a package for is not started; it ends `held` (`drift: <package> flagged`) if nothing else runs.
31
+ *
32
+ * Host lane registry (#4191), off unless `sched: {registry, batch, repo, cap}` is passed (`sched.mjs` `openSched`): a lane
33
+ * starts only when the batch is under its K and the registry grants it a host slot; `priorityOf(issue)` (persisted as
34
+ * `priorities`) ranks it host-wide, and the batch's ready issues are tried in priority order. Ready issues it cannot
35
+ * start yet stay offered in the registry (polled every `pollMs`) and are withdrawn once no longer ready; a settled lane
36
+ * releases its slot, an adopted one re-records it.
37
+ *
38
+ * Remote lanes (#4253), off unless `remote: {max, isRemote(issue)}` is passed (execution.mjs): a lane `isRemote` places
39
+ * in a sandbox takes no host registry slot; it starts only while fewer than `max` remote lanes of this batch run (the
40
+ * batch K still applies). Its run record keeps `remote: true` and the provider handle, so a resume re-counts and adopts it.
41
+ * `runOne` gets `ctx.remote` (true for a lane admitted remote).
42
+ *
43
+ * Execution budget (#4255): a lane result's `sandbox: {minutes, cost_usd}` is summed per issue (persisted as `sandbox`,
44
+ * over attempts) onto the row and handed to `remote.onUsage(issue, sandbox, rec)`. Before a remote lane is admitted,
45
+ * `remote.admit(usage)` gets the batch's settled usage plus the running remote lanes' elapsed minutes
46
+ * (× `remote.costPerMinute`); when it refuses, running lanes finish and the queue either runs the rest locally
47
+ * (`remote.onBudget: "local"`, host path) or stops: the rest end `held` ("execution budget: ...") for a resume.
48
+ *
49
+ * Hybrid placement (#4256): `remote.place(issue) -> {where, reason}` (execution.mjs `placeLane`) replaces `isRemote`.
50
+ * With `remote.fallbackLocal`, a lane placed remote runs on the host path instead of waiting when `max` remote lanes run,
51
+ * and instead of stopping when the budget refuses. The final `{where, reason}` is kept on the run record and the row as
52
+ * `placement` and passed to `runOne` as `ctx.placement` (the lane records it in its FORGE:STATE).
19
53
  */
20
54
  import { parseDependsOn } from "../engine/phases.mjs";
21
55
 
@@ -67,6 +101,41 @@ const cancellableSleep = (ms, signal) => new Promise((res) => {
67
101
  signal?.addEventListener("abort", () => { clearTimeout(t); res(); }, { once: true });
68
102
  });
69
103
 
104
+ // In-memory hold store with the `openLimits` interface, for callers without the host store.
105
+ const memoryLimits = (now) => {
106
+ const holds = new Map();
107
+ return {
108
+ holdUntil: (id) => { const u = holds.get(id)?.until; return u > now() ? u : null; },
109
+ set: (id, untilMs, source = null) => {
110
+ const prev = holds.get(id);
111
+ if (!(prev?.until > untilMs)) holds.set(id, { until: untilMs, source, setAt: now() });
112
+ return holds.get(id).until;
113
+ },
114
+ clear: (id) => { const live = !!(holds.get(id)?.until > now()); holds.delete(id); return live; },
115
+ clearAll: () => { const n = [...holds.values()].filter((h) => h.until > now()).length; holds.clear(); return n; },
116
+ list: () => [...holds].filter(([, h]) => h.until > now()).map(([id, h]) => ({ id, ...h })),
117
+ };
118
+ };
119
+
120
+ const clsOf = (classes, n) => (classes instanceof Map ? classes.get(n) : classes?.[n]) ?? { maintenance: false, priority: 2 };
121
+
122
+ /**
123
+ * The next issue to start from `ready` (pending order) under the maintenance budget, or null when none may start now.
124
+ * Order: P0/P1 features; maintenance while `maint + 1 <= floor(share * (started + 1))`; P2+ features; maintenance
125
+ * regardless of share when `whenIdle` and no feature is pending at all.
126
+ */
127
+ export function pickNext(ready, { classes, share, whenIdle = false, started = 0, maint = 0, featuresPending = true }) {
128
+ const isM = (n) => !!clsOf(classes, n).maintenance;
129
+ const urgent = ready.find((n) => !isM(n) && Number(clsOf(classes, n).priority) <= 1);
130
+ if (urgent !== undefined) return urgent;
131
+ const m = ready.find(isM);
132
+ if (m !== undefined && maint + 1 <= Math.floor(share * (started + 1) + 1e-9)) return m;
133
+ const f = ready.find((n) => !isM(n));
134
+ if (f !== undefined) return f;
135
+ if (m !== undefined && whenIdle && !featuresPending) return m;
136
+ return null;
137
+ }
138
+
70
139
  const toObj = (m) => Object.fromEntries([...m].map(([k, v]) => [String(k), v]));
71
140
  const toMap = (o) => new Map(Object.entries(o ?? {}).map(([k, v]) => [Number(k), v]));
72
141
 
@@ -74,7 +143,8 @@ const toMap = (o) => new Map(Object.entries(o ?? {}).map(([k, v]) => [Number(k),
74
143
  * @returns {Promise<Array<{issue:number, outcome:string, pr:?number, cost_usd:number, minutes:number, detail?:string}>>}
75
144
  */
76
145
  export async function orchestrate({ issues, parallel = 3, runOne, readBody, prefilter = null, resolveLane = null, now = () => Date.now(), onUpdate = () => {}, onCrossLane = () => {},
77
- store = null, adopt = null, getParallel = null, breaker = null, maxDeferrals = DEFAULT_MAX_DEFERRALS, sleep = cancellableSleep, pollMs = 5000 }) {
146
+ store = null, adopt = null, getParallel = null, breaker = null, maxDeferrals = DEFAULT_MAX_DEFERRALS, sleep = cancellableSleep, pollMs = 5000,
147
+ classify = null, maintenance = null, limits = null, identity = () => "default", batchId = null, drift = null, sched = null, priorityOf = null, remote = null, beforeAdmit = null }) {
78
148
  const saved = store?.load?.() ?? null;
79
149
  const order = saved ? [...saved.order] : [];
80
150
  const set = new Set(order);
@@ -91,20 +161,66 @@ export async function orchestrate({ issues, parallel = 3, runOne, readBody, pref
91
161
  const engineErrors = toMap(saved?.engineErrors);
92
162
  const checked = toMap(saved?.prefilter); // issue -> pre-filter result
93
163
  let errorTimes = [...(saved?.errorTimes ?? [])];
94
- let holdUntil = saved?.holdUntil ?? null;
164
+ // A legacy batch-local `holdUntil` (pre-#4190) names no account and is ignored: a same-account lane re-sets the hold.
165
+ const holds = limits ?? memoryLimits(now);
166
+ const heldUntil = () => { try { const id = identity(); const u = holds.holdUntil(id); return Number.isFinite(u) && u > now() ? u : null; } catch { return null; } };
95
167
  let tripped = saved?.breaker ?? null;
168
+ const budget = maintenance && Number(maintenance.share) > 0 ? { share: Number(maintenance.share), whenIdle: !!maintenance.whenIdle } : null;
169
+ const classes = toMap(saved?.classes); // issue -> {maintenance, priority}; only with a maintenance budget
170
+ const dispatch = { started: saved?.dispatch?.started ?? 0, maint: saved?.dispatch?.maint ?? 0 };
171
+ let budgetHeld = false;
172
+ const bodies = new Map(); // issue -> body, kept in memory for drift.holds only
173
+ const driftHeld = new Map(); // issue -> flagged package dir holding it this pass
174
+ const reg = sched?.registry ?? null;
175
+ const priorities = toMap(saved?.priorities); // issue -> priority number; only with the registry
176
+ const remoteMax = remote && Number.isInteger(remote.max) && remote.max >= 0 ? remote.max : 0;
177
+ const remoteRun = new Set(); // remote lanes running now (#4253): admitted against remoteMax, never the host cap
178
+ let budgetLocal = false; // the execution budget refused a remote lane under on_budget local: the rest run on the host
179
+ // #4256: where a lane goes before capacity: `remote.place` (hybrid) or `remote.isRemote` (remote mode).
180
+ const placeOf = (n) => {
181
+ if (!remote) return null;
182
+ if (budgetLocal) return { where: "local", reason: `fallback to local: execution budget: ${budgetHit ?? "cap reached"}` };
183
+ try {
184
+ if (typeof remote.place === "function") {
185
+ const p = remote.place(n);
186
+ return { where: p?.where === "remote" ? "remote" : "local", reason: String(p?.reason ?? "") };
187
+ }
188
+ return remote.isRemote?.(n) ? { where: "remote", reason: "mode remote" } : { where: "local", reason: "local" };
189
+ } catch { return { where: "local", reason: "placement failed" }; }
190
+ };
191
+ const isRemote = (n) => placeOf(n)?.where === "remote";
192
+ const placed = new Map(); // issue -> {where, reason} granted this pass, picked up by start()
193
+ const sandboxUse = toMap(saved?.sandbox); // issue -> {minutes, cost_usd, attempts} (#4255)
194
+ const remoteCpm = Number(remote?.costPerMinute) > 0 ? Number(remote.costPerMinute) : 0;
195
+ let budgetStop = null, budgetHit = null; // this pass's refusal under on_budget stop; the last refusal reason
96
196
 
97
197
  const save = () => {
98
198
  if (!store) return;
99
199
  store.save({ v: 1, order, deps: toObj(deps), lanes: toObj(lanes), crossSeen: [...crossSeen], results: toObj(results), running: toObj(runRecs),
100
200
  pending, notBefore: toObj(notBefore), requeues: toObj(requeues), deferrals: toObj(deferrals), engineErrors: toObj(engineErrors),
101
- prefilter: toObj(checked), errorTimes, holdUntil, breaker: tripped });
201
+ prefilter: toObj(checked), errorTimes, breaker: tripped, ...(budget ? { classes: toObj(classes), dispatch: { ...dispatch } } : {}),
202
+ ...(reg ? { priorities: toObj(priorities) } : {}), ...(sandboxUse.size ? { sandbox: toObj(sandboxUse) } : {}) });
102
203
  };
103
204
  const update = (row) => { store?.log?.(row); onUpdate(row); };
205
+ const classifyOne = async (n) => {
206
+ if (!budget) return;
207
+ let c = null;
208
+ try { c = classify ? await classify(n) : null; } catch { c = null; } // unclassifiable: an ordinary P2 feature
209
+ classes.set(n, { maintenance: !!c?.maintenance, priority: Number.isFinite(Number(c?.priority)) ? Number(c.priority) : 2 });
210
+ };
211
+ const readPriority = async (n) => {
212
+ if (!reg) return;
213
+ let p = null;
214
+ try { p = priorityOf ? await priorityOf(n) : null; } catch { p = null; }
215
+ priorities.set(n, p !== null && p !== "" && Number.isFinite(Number(p)) ? Number(p) : 2);
216
+ };
104
217
  const readDeps = async (n) => {
105
218
  let body = "";
106
219
  try { body = await readBody(n); } catch { /* unreadable: no ordering info, work-on's own check still applies */ }
220
+ if (drift?.holds) bodies.set(n, body);
107
221
  deps.set(n, parseDeps(body, n));
222
+ await classifyOne(n);
223
+ await readPriority(n);
108
224
  if (resolveLane) { let l; try { l = await resolveLane(n); } catch (e) { l = { ok: false, code: "lane.unresolved", detail: e?.message ?? String(e) }; } lanes.set(n, l); }
109
225
  };
110
226
  const edges = (n) => (deps.get(n) ?? []).filter((d) => set.has(d));
@@ -126,9 +242,70 @@ export async function orchestrate({ issues, parallel = 3, runOne, readBody, pref
126
242
  // New issues (a fresh batch, or extra issues passed to a resume) join the queue.
127
243
  for (const n of issues) if (!set.has(n)) { set.add(n); order.push(n); pending.push(n); }
128
244
  for (const n of order) if (!deps.has(n)) await readDeps(n);
245
+ if (budget) for (const n of order) if (!classes.has(n)) await classifyOne(n); // a resume that enabled the budget
246
+ if (drift?.holds) for (const n of pending) if (!bodies.has(n)) { try { bodies.set(n, await readBody(n)); } catch { bodies.set(n, ""); } } // a resume
247
+ if (reg) for (const n of order) if (!priorities.has(n)) await readPriority(n); // a resume of a pre-#4191 batch
129
248
  noteCrossLane();
130
249
  save();
131
250
 
251
+ // Host registry seams (#4191): `offered` issues are queued for a slot, `slotted` ones hold one.
252
+ const offered = new Set(), slotted = new Set();
253
+ let fellBack = false;
254
+ const keyOf = (n) => `${sched?.batch ?? batchId ?? "?"}#${n}`;
255
+ const prioOf = (n) => priorities.get(n) ?? 2;
256
+ const entryOf = (n) => ({ key: keyOf(n), batch: sched?.batch ?? batchId ?? null, repo: sched?.repo ?? null, issue: n, priority: prioOf(n) });
257
+ const releaseSlot = (n) => { if (reg && slotted.delete(n)) reg.release(keyOf(n)); };
258
+ const offerFresh = (ns) => { const fresh = ns.filter((n) => !offered.has(n)); if (!fresh.length) return; reg.offer(fresh.map(entryOf)); for (const n of fresh) offered.add(n); };
259
+ const withdrawExcept = (keep) => {
260
+ const gone = [...offered].filter((n) => !keep.has(n));
261
+ if (!gone.length) return;
262
+ reg.withdraw(gone.map(keyOf));
263
+ for (const n of gone) offered.delete(n);
264
+ };
265
+ // The batch's sandbox usage: settled (persisted) plus the running remote lanes' elapsed minutes so far.
266
+ const remoteUsage = () => {
267
+ let minutes = 0, cost_usd = 0, rm = 0;
268
+ for (const u of sandboxUse.values()) { minutes += Number(u?.minutes) || 0; cost_usd += Number(u?.cost_usd) || 0; }
269
+ const t = now();
270
+ for (const n of remoteRun) { const st = runRecs.get(n)?.startedAt; if (Number.isFinite(st)) rm += Math.max(0, t - st) / 60000; }
271
+ return { minutes: minutes + rm, cost_usd: cost_usd + rm * remoteCpm, running: { minutes: rm, cost_usd: rm * remoteCpm } };
272
+ };
273
+ // A thrown budget check refuses: the cap is hard.
274
+ const admitRemote = () => {
275
+ if (typeof remote?.admit !== "function") return { ok: true };
276
+ try { const a = remote.admit(remoteUsage()); return a?.ok === false ? { ok: false, reason: a.reason ?? "cap reached" } : { ok: true }; }
277
+ catch (e) { return { ok: false, reason: `budget check failed: ${e?.message ?? e}` }; }
278
+ };
279
+ const noteBudget = (reason) => {
280
+ if (budgetHit === null) update({ issue: null, outcome: "budget", detail: `execution budget reached: ${reason}; ${remote?.onBudget === "local" || remote?.fallbackLocal ? "remaining lanes run locally" : "no further remote lanes admitted"}` });
281
+ budgetHit = reason;
282
+ };
283
+ // A host slot for `n` (a lane-refused issue runs nothing and needs none); refused: `n` stays queued in the registry.
284
+ const grant = (n, wanted) => {
285
+ if (lanes.get(n)?.ok === false) return true;
286
+ let p = placeOf(n);
287
+ if (p?.where === "remote") {
288
+ const adm = admitRemote();
289
+ if (adm.ok) {
290
+ if (remoteRun.size < remoteMax) { remoteRun.add(n); placed.set(n, p); return true; }
291
+ if (!remote.fallbackLocal) return false;
292
+ p = { where: "local", reason: `fallback to local: max_remote ${remoteMax} reached (${p.reason})` }; // #4256: hybrid runs it on the host
293
+ } else {
294
+ noteBudget(adm.reason);
295
+ if (remote.onBudget !== "local" && !remote.fallbackLocal) { budgetStop = adm.reason; return false; }
296
+ budgetLocal = true; // falls through to the host path
297
+ p = { where: "local", reason: `fallback to local: execution budget: ${adm.reason}` };
298
+ }
299
+ }
300
+ if (!reg) { if (p) placed.set(n, p); return true; }
301
+ const r = reg.tryAcquire(keyOf(n), entryOf(n), sched.cap);
302
+ if (!r?.ok) { offered.add(n); wanted.add(n); return false; }
303
+ offered.delete(n); slotted.add(n);
304
+ if (p) placed.set(n, p);
305
+ if (r.fallback && !fellBack) { fellBack = true; update({ issue: null, outcome: "sched", detail: `host lane registry unavailable, admitting without the global cap: ${r.reason}` }); }
306
+ return true;
307
+ };
308
+
132
309
  const skip = (n, detail) => { results.set(n, { issue: n, outcome: "skipped", pr: null, cost_usd: 0, minutes: 0, detail }); update(results.get(n)); };
133
310
  const requeue = (n, delayMs) => { pending.push(n); if (delayMs > 0) notBefore.set(n, now() + delayMs); else notBefore.delete(n); };
134
311
  const noteEngineError = () => {
@@ -143,10 +320,19 @@ export async function orchestrate({ issues, parallel = 3, runOne, readBody, pref
143
320
 
144
321
  // Apply one lane's result: requeue it (deferred, dependency-open, engine-error) or record its final row.
145
322
  const settle = async (n, r, rec, pf) => {
146
- const row = { issue: n, outcome: r?.terminalReason ?? "engine-error", pr: r?.pr ?? null, cost_usd: (r?.cost_usd ?? 0) + (pf?.cost_usd ?? 0), minutes: Math.round((now() - rec.startedAt) / 6000) / 10, detail: r?.detail, ...(r?.code ? { code: r.code } : {}), ...(pf ? { prefilter: pf } : {}) };
323
+ const row = { issue: n, outcome: r?.terminalReason ?? "engine-error", pr: r?.pr ?? null, cost_usd: (r?.cost_usd ?? 0) + (pf?.cost_usd ?? 0), minutes: Math.round((now() - rec.startedAt) / 6000) / 10, detail: r?.detail, ...(r?.code ? { code: r.code } : {}), ...(pf ? { prefilter: pf } : {}),
324
+ ...(r?.waits && typeof r.waits === "object" ? { waits: r.waits } : {}), ...(Number.isInteger(r?.heavyQueueMax) ? { heavyQueueMax: r.heavyQueueMax } : {}),
325
+ ...(rec?.placement ? { placement: rec.placement } : {}) };
147
326
  const depList = (Array.isArray(r?.dependencies) ? r.dependencies : []).filter((d) => Number.isSafeInteger(d) && d > 0 && d !== n);
148
327
  const subs = (Array.isArray(r?.subissues) ? r.subissues : []).filter((d) => Number.isSafeInteger(d) && d > 0 && d !== n);
149
- const done = () => { running.delete(n); runRecs.delete(n); };
328
+ if (r?.sandbox && typeof r.sandbox === "object") {
329
+ const prev = sandboxUse.get(n) ?? { minutes: 0, cost_usd: 0, attempts: 0 };
330
+ const m = Number(r.sandbox.minutes) >= 0 ? Number(r.sandbox.minutes) : 0, c = Number(r.sandbox.cost_usd) >= 0 ? Number(r.sandbox.cost_usd) : 0;
331
+ sandboxUse.set(n, { minutes: Math.round((prev.minutes + m) * 10) / 10, cost_usd: Math.round((prev.cost_usd + c) * 1e6) / 1e6, attempts: prev.attempts + 1 });
332
+ try { remote?.onUsage?.(n, r.sandbox, rec); } catch { /* the batch total still counts it */ }
333
+ }
334
+ if (sandboxUse.has(n)) row.sandbox = { ...sandboxUse.get(n) };
335
+ const done = () => { running.delete(n); runRecs.delete(n); releaseSlot(n); remoteRun.delete(n); };
150
336
  if (row.outcome === "dependency-open" && depList.length && (requeues.get(n) ?? 0) < MAX_REQUEUES) {
151
337
  // A scheduling fact, not a failure: add the edge, pull the dependency in, and run this issue again after it.
152
338
  requeues.set(n, (requeues.get(n) ?? 0) + 1);
@@ -162,8 +348,13 @@ export async function orchestrate({ issues, parallel = 3, runOne, readBody, pref
162
348
  const pol = deferPolicy(r, { now: now(), deferrals: deferrals.get(n) ?? 0, maxDeferrals });
163
349
  if (!pol.capped) {
164
350
  if (!pol.host) deferrals.set(n, (deferrals.get(n) ?? 0) + 1);
165
- if (pol.hold) holdUntil = Math.max(holdUntil ?? 0, now() + pol.delayMs);
166
- requeue(n, pol.delayMs); done(); save();
351
+ // A usage limit is held per account; the hold, not a per-issue delay, gates the retry, so a lane on another
352
+ // account runs it at once. If the hold cannot be recorded, the reset delay applies to this issue instead.
353
+ let delayMs = pol.delayMs;
354
+ if (pol.hold) {
355
+ try { holds.set(r?.backendId ?? identity(), now() + pol.delayMs, `batch ${batchId ?? "?"} #${n}`); delayMs = 0; } catch { /* fail-open */ }
356
+ }
357
+ requeue(n, delayMs); done(); save();
167
358
  update({ ...row, detail: `requeued in ${Math.round(pol.delayMs / 1000)}s${pol.hold ? " (admissions held)" : ""}: ${r?.code ?? "deferred"}${r?.detail ? ` ${r.detail}` : ""}` });
168
359
  return;
169
360
  }
@@ -183,7 +374,15 @@ export async function orchestrate({ issues, parallel = 3, runOne, readBody, pref
183
374
  for (const [m, ds] of deps) if (ds.includes(n)) deps.set(m, [...new Set(ds.flatMap((d) => (d === n ? subs : [d])))]);
184
375
  noteCrossLane();
185
376
  }
186
- results.set(n, row); done(); save(); update(row);
377
+ results.set(n, row);
378
+ if (row.outcome === "merged" && drift?.onMerged && r?.structure && String(rec?.lane ?? r?.lane ?? "").startsWith("milestone/")) {
379
+ save(); update(row);
380
+ // Before the slot frees: the batch never ends with a fold pending, and block_dispatch sees the new flags.
381
+ try { await drift.onMerged(n, { ...row, lane: rec?.lane ?? r?.lane }, r); } catch (e) { update({ issue: n, outcome: "drift", detail: `drift check failed: ${String(e?.message ?? e).slice(0, 300)}` }); }
382
+ done(); save();
383
+ return;
384
+ }
385
+ done(); save(); update(row);
187
386
  };
188
387
 
189
388
  const track = (n, p) => { running.set(n, p.catch((e) => settle(n, { terminalReason: "engine-error", detail: e?.message ?? String(e) }, runRecs.get(n), null))); };
@@ -195,7 +394,9 @@ export async function orchestrate({ issues, parallel = 3, runOne, readBody, pref
195
394
  return;
196
395
  }
197
396
  const lane = ln?.lane;
198
- const rec = { startedAt: now(), ...(lane ? { lane } : {}) };
397
+ const pl = placed.get(n);
398
+ placed.delete(n);
399
+ const rec = { startedAt: now(), ...(lane ? { lane } : {}), ...(remoteRun.has(n) ? { remote: true } : {}), ...(pl ? { placement: pl } : {}) };
199
400
  runRecs.set(n, rec); notBefore.delete(n); save();
200
401
  track(n, (async () => {
201
402
  // Pre-filter once per issue (a requeued issue is not re-checked); fail-open: an error means a normal lane.
@@ -204,7 +405,12 @@ export async function orchestrate({ issues, parallel = 3, runOne, readBody, pref
204
405
  let r;
205
406
  if (pf?.closed) r = { terminalReason: "invalid", cost_usd: 0, detail: `pre-filter: ${pf.detail}` };
206
407
  else {
207
- try { r = await runOne(n, lane, { onStart: (info) => { Object.assign(rec, info ?? {}); save(); } }); }
408
+ try {
409
+ r = await runOne(n, lane, { remote: !!rec.remote, ...(rec.placement ? { placement: rec.placement } : {}), onStart: (info) => {
410
+ Object.assign(rec, info ?? {}); save();
411
+ if (reg && slotted.has(n) && info?.pid) reg.setLanePid(keyOf(n), info.pid);
412
+ } });
413
+ }
208
414
  catch (e) { r = { terminalReason: "engine-error", detail: e?.message ?? String(e) }; }
209
415
  }
210
416
  await settle(n, r, rec, pf);
@@ -214,10 +420,23 @@ export async function orchestrate({ issues, parallel = 3, runOne, readBody, pref
214
420
  // Lanes launched by an earlier dispatcher: re-attach to the live ones, queue the rest again (no result was recorded).
215
421
  for (const [n, rec] of [...runRecs]) {
216
422
  let p = null;
217
- if (adopt && rec?.pid) { try { p = await adopt(n, rec); } catch { p = null; } }
218
- if (p) track(n, Promise.resolve(p).then((r) => settle(n, r, rec, checked.get(n) ?? null)));
423
+ if (adopt && (rec?.pid || rec?.handle)) { try { p = await adopt(n, rec); } catch { p = null; } }
424
+ if (p && rec.remote) { remoteRun.add(n); track(n, Promise.resolve(p).then((r) => settle(n, r, rec, checked.get(n) ?? null))); }
425
+ else if (p) {
426
+ if (reg) { reg.adoptSlot(keyOf(n), { ...entryOf(n), lanePid: rec.pid }); slotted.add(n); }
427
+ track(n, Promise.resolve(p).then((r) => settle(n, r, rec, checked.get(n) ?? null)));
428
+ }
219
429
  else { runRecs.delete(n); if (!pending.includes(n) && !results.has(n)) pending.unshift(n); }
220
430
  }
431
+ // A resume re-derives each unfinished issue's lane from its current milestone (#4153): a re-milestone takes effect.
432
+ if (saved && resolveLane) {
433
+ for (const n of order) {
434
+ if (results.has(n) || running.has(n)) continue;
435
+ let l; try { l = await resolveLane(n); } catch (e) { l = { ok: false, code: "lane.unresolved", detail: e?.message ?? String(e) }; }
436
+ lanes.set(n, l);
437
+ }
438
+ noteCrossLane();
439
+ }
221
440
  save();
222
441
 
223
442
  const K = () => { let k = null; try { k = getParallel?.(); } catch { k = null; } return Number.isInteger(k) && k >= 1 ? k : parallel; };
@@ -230,40 +449,123 @@ export async function orchestrate({ issues, parallel = 3, runOne, readBody, pref
230
449
  if (bad !== undefined) { pending.splice(i, 1); skip(n, `dependency #${bad} ended ${results.get(bad).outcome}`); }
231
450
  }
232
451
  const t = now();
233
- if (holdUntil && t >= holdUntil) { holdUntil = null; save(); }
452
+ const holdUntil = heldUntil();
234
453
  let started = 0;
235
- if (!tripped && !holdUntil) {
454
+ driftHeld.clear();
455
+ const isReady = (n) => {
456
+ if (!((notBefore.get(n) ?? 0) <= t && edges(n).every((d) => results.has(d) && SATISFIED.has(results.get(d).outcome)))) return false;
457
+ if (!drift?.holds) return true;
458
+ let pkg = null;
459
+ try { pkg = drift.holds(n, { body: bodies.get(n) ?? "", lane: lanes.get(n)?.lane ?? null }); } catch { pkg = null; }
460
+ if (pkg) driftHeld.set(n, pkg);
461
+ return !pkg;
462
+ };
463
+ budgetHeld = false;
464
+ // #4183: a hook (owned-resource GC near the host ceiling) before lanes are admitted; its errors never stop the batch.
465
+ if (beforeAdmit && !tripped && !holdUntil && running.size < K() && pending.some(isReady)) { try { await beforeAdmit(); } catch { /* ignored */ } }
466
+ budgetStop = null;
467
+ const wanted = new Set(); // issues that stay offered in the host registry after this pass
468
+ if (!tripped && !holdUntil && budget) {
469
+ while (running.size < K()) {
470
+ const ready = pending.filter(isReady);
471
+ const n = pickNext(ready, { classes, share: budget.share, whenIdle: budget.whenIdle, started: dispatch.started, maint: dispatch.maint,
472
+ featuresPending: pending.some((p) => !clsOf(classes, p).maintenance) });
473
+ if (n === null) { budgetHeld = ready.length > 0; break; }
474
+ if (!grant(n, wanted)) break;
475
+ pending.splice(pending.indexOf(n), 1);
476
+ if (lanes.get(n)?.ok !== false) { dispatch.started++; if (clsOf(classes, n).maintenance) dispatch.maint++; }
477
+ start(n); started++;
478
+ }
479
+ } else if (!tripped && !holdUntil && reg) {
480
+ // Priority first, pending order within a priority; the free per-batch K is offered so the host queue keeps FIFO.
481
+ const ready = pending.filter(isReady).map((n, i) => [n, i]).sort((a, b) => prioOf(a[0]) - prioOf(b[0]) || a[1] - b[1]).map(([n]) => n);
482
+ const want = ready.filter((n) => lanes.get(n)?.ok !== false && !isRemote(n)).slice(0, Math.max(0, K() - running.size));
483
+ offerFresh(want);
484
+ for (const n of want) wanted.add(n);
485
+ for (const n of ready) {
486
+ if (running.size >= K() || !grant(n, wanted)) break;
487
+ pending.splice(pending.indexOf(n), 1); start(n); started++;
488
+ }
489
+ } else if (!tripped && !holdUntil) {
236
490
  for (let i = 0; i < pending.length && running.size < K();) {
237
491
  const n = pending[i];
238
- if ((notBefore.get(n) ?? 0) <= t && edges(n).every((d) => results.has(d) && SATISFIED.has(results.get(d).outcome))) { pending.splice(i, 1); start(n); started++; }
492
+ if (isReady(n) && grant(n, new Set())) { pending.splice(i, 1); start(n); started++; }
239
493
  else i++;
240
494
  }
241
495
  }
496
+ if (reg) withdrawExcept(wanted);
242
497
  if (tripped && !running.size) break; // breaker: let running lanes finish, keep the rest pending for a resume
498
+ if (budgetStop && !running.size) break; // execution budget (stop): the same, the rest end held
243
499
  // Wake for a delayed issue, the end of a usage-limit hold, or (with a live K) a poll.
244
500
  const waits = pending.map((n) => notBefore.get(n) ?? 0).filter((x) => x > t);
245
501
  if (holdUntil) waits.push(holdUntil);
246
502
  let wake = waits.length ? Math.max(0, Math.min(...waits) - t) : null;
247
503
  if (getParallel && pending.length) wake = Math.min(wake ?? pollMs, pollMs);
504
+ if (offered.size && pending.length) wake = Math.min(wake ?? pollMs, pollMs); // a slot another batch frees
248
505
  if (tripped) wake = null;
249
506
  if (running.size) {
250
507
  if (wake === null) await Promise.race(running.values());
251
508
  else { const ac = new AbortController(); await Promise.race([...running.values(), sleep(wake, ac.signal)]); ac.abort(); }
252
509
  } else if (started) continue; // only lane-refused rows settled (synchronously): re-resolve skips first
510
+ else if (pending.length && offered.size) await sleep(wake); // waiting for a host slot
253
511
  else if (pending.length && waits.length) await sleep(Math.max(0, Math.min(...waits) - t));
512
+ else if (pending.length && budgetHeld) break; // maintenance the budget does not admit now: held for a later batch
513
+ else if (pending.length && driftHeld.size) break; // held by a drift flag (block_dispatch): pending for a later batch
254
514
  else if (pending.length) { // nothing runnable, nothing running, nothing waiting on time: a dependency cycle
255
515
  for (const n of pending.splice(0)) skip(n, "dependency cycle");
256
516
  save();
257
517
  }
258
518
  }
519
+ if (reg) withdrawExcept(new Set());
259
520
  save();
260
- return order.map((n) => results.get(n) ?? { issue: n, outcome: "held", pr: null, cost_usd: 0, minutes: 0, detail: tripped?.detail ?? "not run" });
521
+ return order.map((n) => results.get(n) ?? { issue: n, outcome: "held", pr: null, cost_usd: 0, minutes: 0, detail: tripped?.detail ?? (driftHeld.has(n) ? `drift: ${driftHeld.get(n)} flagged` : budgetStop ? `execution budget: ${budgetStop}` : budgetHeld ? "maintenance share" : "not run") });
261
522
  }
262
523
 
263
524
  export function formatTable(rows) {
264
- const head = ["issue", "outcome", "PR", "cost", "min"];
265
- const body = rows.map((r) => [`#${r.issue}`, r.outcome + (["skipped", "held"].includes(r.outcome) && r.detail ? ` (${r.detail})` : r.prefilter?.closed ? " (pre-filter)" : ""), r.pr ? `#${r.pr}` : "-", `$${(r.cost_usd ?? 0).toFixed(2)}`, String(r.minutes ?? 0)]);
525
+ // #4255: sandbox minutes and cost next to the token cost, only when some lane ran in a sandbox.
526
+ const sbx = rows.some((r) => r?.sandbox);
527
+ const head = ["issue", "outcome", "PR", "cost", ...(sbx ? ["sbx-min", "sbx-$"] : []), "min"];
528
+ const body = rows.map((r) => [`#${r.issue}`, r.outcome + (["skipped", "held"].includes(r.outcome) && r.detail ? ` (${r.detail})` : r.prefilter?.closed ? " (pre-filter)" : ""), r.pr ? `#${r.pr}` : "-", `$${(r.cost_usd ?? 0).toFixed(2)}`,
529
+ ...(sbx ? (r.sandbox ? [(Number(r.sandbox.minutes) || 0).toFixed(1), `$${(Number(r.sandbox.cost_usd) || 0).toFixed(2)}`] : ["-", "-"]) : []), String(r.minutes ?? 0)]);
266
530
  const w = head.map((h, i) => Math.max(h.length, ...body.map((b) => b[i].length)));
267
531
  const line = (c) => c.map((x, i) => x.padEnd(w[i])).join(" ").trimEnd();
268
532
  return [line(head), line(w.map((n) => "-".repeat(n))), ...body.map(line)].join("\n");
269
533
  }
534
+
535
+ /**
536
+ * #4169 batch-report line: wait time per phase summed over the rows' lane `waits` ({phase: ms}), and the heavy-step
537
+ * semaphore queue: `now` live from `heavyStats.queued`, `max` the longest queue a lane of this batch saw. Null when nothing waited or queued.
538
+ */
539
+ export function formatWaitSummary(rows, heavyStats = null) {
540
+ const by = {};
541
+ for (const r of rows ?? []) for (const [p, ms] of Object.entries(r?.waits ?? {})) if (Number.isFinite(ms) && ms >= 0) by[p] = (by[p] ?? 0) + ms;
542
+ const seen = Math.max(0, ...(rows ?? []).map((r) => (Number.isInteger(r?.heavyQueueMax) ? r.heavyQueueMax : 0)));
543
+ const hasRows = Object.keys(by).length > 0, hasStats = heavyStats && typeof heavyStats === "object";
544
+ if (!hasRows && !seen && !(hasStats && heavyStats.queued > 0)) return null; // nothing waited, nothing queued: no line
545
+ const waits = hasRows ? Object.entries(by).sort(([a], [b]) => (a < b ? -1 : 1)).map(([p, ms]) => `${p} ${(ms / 60000).toFixed(1)}m`).join(", ") : "none";
546
+ return `waits by phase: ${waits}; heavy queue: now ${hasStats && Number.isInteger(heavyStats.queued) ? heavyStats.queued : "?"}, max ${seen}`;
547
+ }
548
+
549
+ /**
550
+ * #4255 batch-report line: sandbox lanes, minutes and cost next to those lanes' token cost, plus the execution budget
551
+ * (`budget` from parseExecution, `day` from openExecutionBudget `dayTotals`) when a cap is set. Null without sandbox usage.
552
+ */
553
+ export function formatSandboxSummary(rows, { budget = null, day = null } = {}) {
554
+ const sb = (rows ?? []).filter((r) => r?.sandbox);
555
+ if (!sb.length) return null;
556
+ const sum = (f) => sb.reduce((a, r) => a + (Number(f(r)) || 0), 0);
557
+ const parts = [`sandbox: ${sb.length} lane${sb.length === 1 ? "" : "s"}, ${sum((r) => r.sandbox.minutes).toFixed(1)} min, $${sum((r) => r.sandbox.cost_usd).toFixed(2)} (token cost $${sum((r) => r.cost_usd).toFixed(2)})`];
558
+ const caps = [];
559
+ if (budget?.batchMinutes != null) caps.push(`batch ${budget.batchMinutes} min`);
560
+ if (budget?.batchUsd != null) caps.push(`batch $${budget.batchUsd}`);
561
+ if (budget?.dayMinutes != null) caps.push(`day ${budget.dayMinutes} min`);
562
+ if (budget?.dayUsd != null) caps.push(`day $${budget.dayUsd}`);
563
+ if (caps.length) {
564
+ let b = `budget: ${caps.join(", ")}`;
565
+ if (day?.ok) b += `; host today ${Number(day.minutes).toFixed(1)} min, $${Number(day.cost_usd).toFixed(2)}`;
566
+ const held = (rows ?? []).filter((r) => r?.outcome === "held" && String(r.detail ?? "").startsWith("execution budget")).length;
567
+ if (held) b += `; cap reached, ${held} held`;
568
+ parts.push(b);
569
+ }
570
+ return parts.join("; ");
571
+ }