forgedock 1.27.0 → 1.29.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 (159) hide show
  1. package/README.md +4 -1
  2. package/bin/bootstrap.mjs +55 -0
  3. package/bin/engine/outcome-codes.mjs +39 -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 +601 -42
  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 +234 -57
  16. package/bin/lean/conformance.test.mjs +168 -7
  17. package/bin/lean/context.mjs +333 -0
  18. package/bin/lean/context.test.mjs +279 -0
  19. package/bin/lean/credentials.mjs +29 -0
  20. package/bin/lean/depcache.mjs +312 -0
  21. package/bin/lean/depcache.test.mjs +298 -0
  22. package/bin/lean/drift.mjs +198 -0
  23. package/bin/lean/drift.test.mjs +223 -0
  24. package/bin/lean/effects.mjs +104 -9
  25. package/bin/lean/execution.e2e.test.mjs +81 -0
  26. package/bin/lean/execution.mjs +431 -0
  27. package/bin/lean/execution.test.mjs +583 -0
  28. package/bin/lean/heavy.mjs +289 -0
  29. package/bin/lean/heavy.test.mjs +207 -0
  30. package/bin/lean/lean.mjs +723 -154
  31. package/bin/lean/lean.test.mjs +1603 -35
  32. package/bin/lean/ledger.mjs +27 -2
  33. package/bin/lean/ledger.test.mjs +36 -1
  34. package/bin/lean/limits.mjs +58 -0
  35. package/bin/lean/limits.test.mjs +150 -0
  36. package/bin/lean/orchestrate.mjs +389 -19
  37. package/bin/lean/prefilter.mjs +2 -2
  38. package/bin/lean/prefilter.test.mjs +3 -0
  39. package/bin/lean/prompts.mjs +106 -17
  40. package/bin/lean/recovery.test.mjs +230 -0
  41. package/bin/lean/refactor.mjs +276 -0
  42. package/bin/lean/refactor.test.mjs +359 -0
  43. package/bin/lean/render.mjs +49 -10
  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/testmap.mjs +84 -0
  53. package/bin/lean/testmap.test.mjs +58 -0
  54. package/bin/lean/toolchain.mjs +228 -0
  55. package/bin/lean/toolchain.test.mjs +154 -0
  56. package/bin/lean/verify.mjs +66 -10
  57. package/bin/lean/verify.test.mjs +104 -3
  58. package/bin/lean/worktree.mjs +69 -0
  59. package/bin/lean/worktree.test.mjs +141 -0
  60. package/bin/runner.mjs +53 -10
  61. package/bin/stats/github-source.mjs +1 -1
  62. package/bin/stats/index.mjs +24 -2
  63. package/bin/stats/local-source.mjs +13 -0
  64. package/bin/stats/query.mjs +99 -0
  65. package/bin/stats/stats.test.mjs +94 -1
  66. package/commands/audit.md +2 -0
  67. package/commands/autopilot.md +2 -0
  68. package/commands/bootstrap.md +35 -0
  69. package/commands/cleanup.md +7 -22
  70. package/commands/forge-stats.md +2 -1
  71. package/commands/forgedock-init.md +7 -0
  72. package/commands/issue.md +89 -830
  73. package/commands/milestone.md +55 -6
  74. package/commands/orchestrate.md +62 -8
  75. package/commands/upgrade-deps.md +3 -3
  76. package/commands/work-on/build/validate.md +4 -0
  77. package/commands/work-on/close.md +2 -0
  78. package/commands/work-on/decompose.md +24 -6
  79. package/docs/CI.md +3 -3
  80. package/docs/CONFIG.md +192 -2
  81. package/docs/OUTCOME-CODES.md +22 -0
  82. package/docs/RELEASE-NOTES.md +9 -0
  83. package/docs/TELEMETRY.md +1 -1
  84. package/docs/site/command-reference.md +22 -0
  85. package/docs/site/getting-started.md +2 -0
  86. package/docs/site/start-a-new-project.md +327 -0
  87. package/package.json +1 -1
  88. package/scripts/affected-files-check.sh +91 -0
  89. package/scripts/affected-files-check.test.sh +80 -0
  90. package/scripts/bootstrap-create.mjs +393 -0
  91. package/scripts/bootstrap-validate.mjs +36 -2
  92. package/scripts/calibration.mjs +116 -0
  93. package/scripts/check-devdocs-freshness.sh +9 -4
  94. package/scripts/check-devdocs-freshness.test.sh +11 -1
  95. package/scripts/check-spec-markers.sh +4 -0
  96. package/scripts/check-spec-size.sh +29 -11
  97. package/scripts/check-spec-size.test.sh +27 -4
  98. package/scripts/ci-local/lib.mjs +8 -1
  99. package/scripts/ci-local/parallelism.test.mjs +27 -0
  100. package/scripts/ci-local/run.mjs +3 -3
  101. package/scripts/ci-local/scoping.mjs +82 -0
  102. package/scripts/ci-local.test.sh +1 -1
  103. package/scripts/fixtures/symbol-index/other/sample.rb +3 -0
  104. package/scripts/fixtures/symbol-index/py/pkg/__init__.py +1 -0
  105. package/scripts/fixtures/symbol-index/py/pkg/a.py +5 -0
  106. package/scripts/fixtures/symbol-index/py/pkg/b.py +14 -0
  107. package/scripts/fixtures/symbol-index/py/pkg/c.py +1 -0
  108. package/scripts/fixtures/symbol-index/py/sample.py +41 -0
  109. package/scripts/fixtures/symbol-index/ts/sample.ts +13 -0
  110. package/scripts/fixtures/symbol-move/gen_big.py +201 -0
  111. package/scripts/fixtures/symbol-move-ts/gen_big.mjs +56 -0
  112. package/scripts/fixtures/test-map/go/.github/workflows/ci.yml +23 -0
  113. package/scripts/fixtures/test-map/go/Makefile +5 -0
  114. package/scripts/fixtures/test-map/go/README.md +1 -0
  115. package/scripts/fixtures/test-map/go/go.mod +3 -0
  116. package/scripts/fixtures/test-map/go/internal/util/util.go +3 -0
  117. package/scripts/fixtures/test-map/go/main.go +3 -0
  118. package/scripts/fixtures/test-map/node/package.json +10 -0
  119. package/scripts/fixtures/test-map/node/packages/ui/package.json +4 -0
  120. package/scripts/fixtures/test-map/node/packages/ui/src/button.tsx +1 -0
  121. package/scripts/fixtures/test-map/node/pnpm-lock.yaml +1 -0
  122. package/scripts/fixtures/test-map/node/src/a.ts +1 -0
  123. package/scripts/fixtures/test-map/node/vitest.config.ts +1 -0
  124. package/scripts/fixtures/test-map/python/pkg/__init__.py +0 -0
  125. package/scripts/fixtures/test-map/python/pkg/mod.py +2 -0
  126. package/scripts/fixtures/test-map/python/pyproject.toml +9 -0
  127. package/scripts/fixtures/test-map/python/tests/test_mod.py +5 -0
  128. package/scripts/fixtures/test-map/python/uv.lock +1 -0
  129. package/scripts/forge-root.test.sh +1 -1
  130. package/scripts/hotspots.sh +100 -0
  131. package/scripts/hotspots.test.sh +101 -0
  132. package/scripts/issue-create.sh +197 -0
  133. package/scripts/issue-create.test.sh +166 -0
  134. package/scripts/maintenance-scan.sh +185 -0
  135. package/scripts/maintenance-scan.test.sh +89 -0
  136. package/scripts/milestone-drift.sh +206 -0
  137. package/scripts/milestone-drift.test.sh +120 -0
  138. package/scripts/refactor-verify.sh +368 -0
  139. package/scripts/refactor-verify.test.sh +202 -0
  140. package/scripts/repo-facts.sh +146 -0
  141. package/scripts/repo-facts.test.sh +106 -0
  142. package/scripts/structure-delta.sh +10 -5
  143. package/scripts/structure-delta.test.sh +3 -2
  144. package/scripts/symbol-index.mjs +253 -0
  145. package/scripts/symbol-index.py +354 -0
  146. package/scripts/symbol-index.test.sh +119 -0
  147. package/scripts/symbol-move-ts.test.sh +161 -0
  148. package/scripts/symbol-move.mjs +379 -0
  149. package/scripts/symbol-move.py +383 -0
  150. package/scripts/symbol-move.test.sh +168 -0
  151. package/scripts/test-map.sh +211 -0
  152. package/scripts/test-map.test.sh +165 -0
  153. package/scripts/wait-ci-green.sh +10 -4
  154. package/scripts/wait-ci-green.test.sh +9 -0
  155. package/templates/bootstrap/interview.md +34 -0
  156. package/templates/bootstrap/issue-body.md +42 -0
  157. package/templates/bootstrap/needs-human-issue.md +45 -0
  158. package/templates/bootstrap/stack-profiles.md +34 -0
  159. package/templates/bootstrap/technical-spike.md +75 -0
@@ -13,11 +13,55 @@
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).
53
+ *
54
+ * In-batch recovery (#4267), off unless `recovery: {maxAttempts}` is passed: a row `classifyNeedsHuman` calls
55
+ * recoverable (needs-human `ci.red` / `base.conflict` / `builder.block_untyped`, `contract.invalid` after a mid-run issue
56
+ * edit, `ci-pending` whose PR `conflicting(issue, pr)` says conflicts) is not final while its persisted `recoveries` stay
57
+ * under `maxAttempts`: it is requeued as a recovery lane (`ctx.recover = {code, attempt}`) under the same admission as any
58
+ * lane, so its dependents stay queued. A merged recovery row carries `recovered: <code>`; any other end is final with
59
+ * `recovery` evidence. Human codes (`builder.blocked`) are never recovered. Every operational decision the runner takes
60
+ * (recovery, engine-error relaunch, dependency requeue, sub-issues added) goes to `decide({issue, what, why, alternative})`,
61
+ * fire-and-forget: the runner never waits on it.
19
62
  */
20
63
  import { parseDependsOn } from "../engine/phases.mjs";
64
+ import { classifyNeedsHuman, recoveryCodeOf } from "../engine/outcome-codes.mjs";
21
65
 
22
66
  // A dependency that finished like this no longer blocks its dependents.
23
67
  const SATISFIED = new Set(["merged", "invalid"]);
@@ -67,6 +111,41 @@ const cancellableSleep = (ms, signal) => new Promise((res) => {
67
111
  signal?.addEventListener("abort", () => { clearTimeout(t); res(); }, { once: true });
68
112
  });
69
113
 
114
+ // In-memory hold store with the `openLimits` interface, for callers without the host store.
115
+ const memoryLimits = (now) => {
116
+ const holds = new Map();
117
+ return {
118
+ holdUntil: (id) => { const u = holds.get(id)?.until; return u > now() ? u : null; },
119
+ set: (id, untilMs, source = null) => {
120
+ const prev = holds.get(id);
121
+ if (!(prev?.until > untilMs)) holds.set(id, { until: untilMs, source, setAt: now() });
122
+ return holds.get(id).until;
123
+ },
124
+ clear: (id) => { const live = !!(holds.get(id)?.until > now()); holds.delete(id); return live; },
125
+ clearAll: () => { const n = [...holds.values()].filter((h) => h.until > now()).length; holds.clear(); return n; },
126
+ list: () => [...holds].filter(([, h]) => h.until > now()).map(([id, h]) => ({ id, ...h })),
127
+ };
128
+ };
129
+
130
+ const clsOf = (classes, n) => (classes instanceof Map ? classes.get(n) : classes?.[n]) ?? { maintenance: false, priority: 2 };
131
+
132
+ /**
133
+ * The next issue to start from `ready` (pending order) under the maintenance budget, or null when none may start now.
134
+ * Order: P0/P1 features; maintenance while `maint + 1 <= floor(share * (started + 1))`; P2+ features; maintenance
135
+ * regardless of share when `whenIdle` and no feature is pending at all.
136
+ */
137
+ export function pickNext(ready, { classes, share, whenIdle = false, started = 0, maint = 0, featuresPending = true }) {
138
+ const isM = (n) => !!clsOf(classes, n).maintenance;
139
+ const urgent = ready.find((n) => !isM(n) && Number(clsOf(classes, n).priority) <= 1);
140
+ if (urgent !== undefined) return urgent;
141
+ const m = ready.find(isM);
142
+ if (m !== undefined && maint + 1 <= Math.floor(share * (started + 1) + 1e-9)) return m;
143
+ const f = ready.find((n) => !isM(n));
144
+ if (f !== undefined) return f;
145
+ if (m !== undefined && whenIdle && !featuresPending) return m;
146
+ return null;
147
+ }
148
+
70
149
  const toObj = (m) => Object.fromEntries([...m].map(([k, v]) => [String(k), v]));
71
150
  const toMap = (o) => new Map(Object.entries(o ?? {}).map(([k, v]) => [Number(k), v]));
72
151
 
@@ -74,7 +153,9 @@ const toMap = (o) => new Map(Object.entries(o ?? {}).map(([k, v]) => [Number(k),
74
153
  * @returns {Promise<Array<{issue:number, outcome:string, pr:?number, cost_usd:number, minutes:number, detail?:string}>>}
75
154
  */
76
155
  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 }) {
156
+ store = null, adopt = null, getParallel = null, breaker = null, maxDeferrals = DEFAULT_MAX_DEFERRALS, sleep = cancellableSleep, pollMs = 5000,
157
+ classify = null, maintenance = null, limits = null, identity = () => "default", batchId = null, drift = null, sched = null, priorityOf = null, remote = null, beforeAdmit = null,
158
+ recovery = null, decide = () => {}, conflicting = null, relabelNeedsHuman = null }) {
78
159
  const saved = store?.load?.() ?? null;
79
160
  const order = saved ? [...saved.order] : [];
80
161
  const set = new Set(order);
@@ -91,20 +172,73 @@ export async function orchestrate({ issues, parallel = 3, runOne, readBody, pref
91
172
  const engineErrors = toMap(saved?.engineErrors);
92
173
  const checked = toMap(saved?.prefilter); // issue -> pre-filter result
93
174
  let errorTimes = [...(saved?.errorTimes ?? [])];
94
- let holdUntil = saved?.holdUntil ?? null;
175
+ // A legacy batch-local `holdUntil` (pre-#4190) names no account and is ignored: a same-account lane re-sets the hold.
176
+ const holds = limits ?? memoryLimits(now);
177
+ const heldUntil = () => { try { const id = identity(); const u = holds.holdUntil(id); return Number.isFinite(u) && u > now() ? u : null; } catch { return null; } };
95
178
  let tripped = saved?.breaker ?? null;
179
+ const budget = maintenance && Number(maintenance.share) > 0 ? { share: Number(maintenance.share), whenIdle: !!maintenance.whenIdle } : null;
180
+ const classes = toMap(saved?.classes); // issue -> {maintenance, priority}; only with a maintenance budget
181
+ const dispatch = { started: saved?.dispatch?.started ?? 0, maint: saved?.dispatch?.maint ?? 0 };
182
+ let budgetHeld = false;
183
+ const bodies = new Map(); // issue -> body, kept in memory for drift.holds only
184
+ const driftHeld = new Map(); // issue -> flagged package dir holding it this pass
185
+ const reg = sched?.registry ?? null;
186
+ const priorities = toMap(saved?.priorities); // issue -> priority number; only with the registry
187
+ const remoteMax = remote && Number.isInteger(remote.max) && remote.max >= 0 ? remote.max : 0;
188
+ const remoteRun = new Set(); // remote lanes running now (#4253): admitted against remoteMax, never the host cap
189
+ let budgetLocal = false; // the execution budget refused a remote lane under on_budget local: the rest run on the host
190
+ // #4256: where a lane goes before capacity: `remote.place` (hybrid) or `remote.isRemote` (remote mode).
191
+ const placeOf = (n) => {
192
+ if (!remote) return null;
193
+ if (budgetLocal) return { where: "local", reason: `fallback to local: execution budget: ${budgetHit ?? "cap reached"}` };
194
+ try {
195
+ if (typeof remote.place === "function") {
196
+ const p = remote.place(n);
197
+ return { where: p?.where === "remote" ? "remote" : "local", reason: String(p?.reason ?? "") };
198
+ }
199
+ return remote.isRemote?.(n) ? { where: "remote", reason: "mode remote" } : { where: "local", reason: "local" };
200
+ } catch { return { where: "local", reason: "placement failed" }; }
201
+ };
202
+ const isRemote = (n) => placeOf(n)?.where === "remote";
203
+ const placed = new Map(); // issue -> {where, reason} granted this pass, picked up by start()
204
+ const sandboxUse = toMap(saved?.sandbox); // issue -> {minutes, cost_usd, attempts} (#4255)
205
+ const remoteCpm = Number(remote?.costPerMinute) > 0 ? Number(remote.costPerMinute) : 0;
206
+ let budgetStop = null, budgetHit = null; // this pass's refusal under on_budget stop; the last refusal reason
207
+ const recoveryMax = Number.isInteger(recovery?.maxAttempts) && recovery.maxAttempts > 0 ? recovery.maxAttempts : 0;
208
+ const recoveries = toMap(saved?.recoveries); // issue -> recovery lanes launched (#4267)
209
+ const recovering = toMap(saved?.recovering); // issue -> {code, attempt} of the recovery lane queued or running
210
+ // #4267: decisions are recorded, never awaited: a failing or slow recorder cannot block the batch.
211
+ const decided = (d) => { try { Promise.resolve(decide?.(d)).catch(() => {}); } catch { /* best effort */ } };
212
+ const firstLine = (x) => String(x ?? "").split("\n")[0].slice(0, 300);
96
213
 
97
214
  const save = () => {
98
215
  if (!store) return;
99
216
  store.save({ v: 1, order, deps: toObj(deps), lanes: toObj(lanes), crossSeen: [...crossSeen], results: toObj(results), running: toObj(runRecs),
100
217
  pending, notBefore: toObj(notBefore), requeues: toObj(requeues), deferrals: toObj(deferrals), engineErrors: toObj(engineErrors),
101
- prefilter: toObj(checked), errorTimes, holdUntil, breaker: tripped });
218
+ prefilter: toObj(checked), errorTimes, breaker: tripped, ...(budget ? { classes: toObj(classes), dispatch: { ...dispatch } } : {}),
219
+ ...(reg ? { priorities: toObj(priorities) } : {}), ...(sandboxUse.size ? { sandbox: toObj(sandboxUse) } : {}),
220
+ ...(recoveries.size || recovering.size ? { recoveries: toObj(recoveries), recovering: toObj(recovering) } : {}) });
102
221
  };
103
222
  const update = (row) => { store?.log?.(row); onUpdate(row); };
223
+ const classifyOne = async (n) => {
224
+ if (!budget) return;
225
+ let c = null;
226
+ try { c = classify ? await classify(n) : null; } catch { c = null; } // unclassifiable: an ordinary P2 feature
227
+ classes.set(n, { maintenance: !!c?.maintenance, priority: Number.isFinite(Number(c?.priority)) ? Number(c.priority) : 2 });
228
+ };
229
+ const readPriority = async (n) => {
230
+ if (!reg) return;
231
+ let p = null;
232
+ try { p = priorityOf ? await priorityOf(n) : null; } catch { p = null; }
233
+ priorities.set(n, p !== null && p !== "" && Number.isFinite(Number(p)) ? Number(p) : 2);
234
+ };
104
235
  const readDeps = async (n) => {
105
236
  let body = "";
106
237
  try { body = await readBody(n); } catch { /* unreadable: no ordering info, work-on's own check still applies */ }
238
+ if (drift?.holds) bodies.set(n, body);
107
239
  deps.set(n, parseDeps(body, n));
240
+ await classifyOne(n);
241
+ await readPriority(n);
108
242
  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
243
  };
110
244
  const edges = (n) => (deps.get(n) ?? []).filter((d) => set.has(d));
@@ -126,9 +260,70 @@ export async function orchestrate({ issues, parallel = 3, runOne, readBody, pref
126
260
  // New issues (a fresh batch, or extra issues passed to a resume) join the queue.
127
261
  for (const n of issues) if (!set.has(n)) { set.add(n); order.push(n); pending.push(n); }
128
262
  for (const n of order) if (!deps.has(n)) await readDeps(n);
263
+ if (budget) for (const n of order) if (!classes.has(n)) await classifyOne(n); // a resume that enabled the budget
264
+ 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
265
+ if (reg) for (const n of order) if (!priorities.has(n)) await readPriority(n); // a resume of a pre-#4191 batch
129
266
  noteCrossLane();
130
267
  save();
131
268
 
269
+ // Host registry seams (#4191): `offered` issues are queued for a slot, `slotted` ones hold one.
270
+ const offered = new Set(), slotted = new Set();
271
+ let fellBack = false;
272
+ const keyOf = (n) => `${sched?.batch ?? batchId ?? "?"}#${n}`;
273
+ const prioOf = (n) => priorities.get(n) ?? 2;
274
+ const entryOf = (n) => ({ key: keyOf(n), batch: sched?.batch ?? batchId ?? null, repo: sched?.repo ?? null, issue: n, priority: prioOf(n) });
275
+ const releaseSlot = (n) => { if (reg && slotted.delete(n)) reg.release(keyOf(n)); };
276
+ 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); };
277
+ const withdrawExcept = (keep) => {
278
+ const gone = [...offered].filter((n) => !keep.has(n));
279
+ if (!gone.length) return;
280
+ reg.withdraw(gone.map(keyOf));
281
+ for (const n of gone) offered.delete(n);
282
+ };
283
+ // The batch's sandbox usage: settled (persisted) plus the running remote lanes' elapsed minutes so far.
284
+ const remoteUsage = () => {
285
+ let minutes = 0, cost_usd = 0, rm = 0;
286
+ for (const u of sandboxUse.values()) { minutes += Number(u?.minutes) || 0; cost_usd += Number(u?.cost_usd) || 0; }
287
+ const t = now();
288
+ for (const n of remoteRun) { const st = runRecs.get(n)?.startedAt; if (Number.isFinite(st)) rm += Math.max(0, t - st) / 60000; }
289
+ return { minutes: minutes + rm, cost_usd: cost_usd + rm * remoteCpm, running: { minutes: rm, cost_usd: rm * remoteCpm } };
290
+ };
291
+ // A thrown budget check refuses: the cap is hard.
292
+ const admitRemote = () => {
293
+ if (typeof remote?.admit !== "function") return { ok: true };
294
+ try { const a = remote.admit(remoteUsage()); return a?.ok === false ? { ok: false, reason: a.reason ?? "cap reached" } : { ok: true }; }
295
+ catch (e) { return { ok: false, reason: `budget check failed: ${e?.message ?? e}` }; }
296
+ };
297
+ const noteBudget = (reason) => {
298
+ 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"}` });
299
+ budgetHit = reason;
300
+ };
301
+ // A host slot for `n` (a lane-refused issue runs nothing and needs none); refused: `n` stays queued in the registry.
302
+ const grant = (n, wanted) => {
303
+ if (lanes.get(n)?.ok === false) return true;
304
+ let p = placeOf(n);
305
+ if (p?.where === "remote") {
306
+ const adm = admitRemote();
307
+ if (adm.ok) {
308
+ if (remoteRun.size < remoteMax) { remoteRun.add(n); placed.set(n, p); return true; }
309
+ if (!remote.fallbackLocal) return false;
310
+ p = { where: "local", reason: `fallback to local: max_remote ${remoteMax} reached (${p.reason})` }; // #4256: hybrid runs it on the host
311
+ } else {
312
+ noteBudget(adm.reason);
313
+ if (remote.onBudget !== "local" && !remote.fallbackLocal) { budgetStop = adm.reason; return false; }
314
+ budgetLocal = true; // falls through to the host path
315
+ p = { where: "local", reason: `fallback to local: execution budget: ${adm.reason}` };
316
+ }
317
+ }
318
+ if (!reg) { if (p) placed.set(n, p); return true; }
319
+ const r = reg.tryAcquire(keyOf(n), entryOf(n), sched.cap);
320
+ if (!r?.ok) { offered.add(n); wanted.add(n); return false; }
321
+ offered.delete(n); slotted.add(n);
322
+ if (p) placed.set(n, p);
323
+ if (r.fallback && !fellBack) { fellBack = true; update({ issue: null, outcome: "sched", detail: `host lane registry unavailable, admitting without the global cap: ${r.reason}` }); }
324
+ return true;
325
+ };
326
+
132
327
  const skip = (n, detail) => { results.set(n, { issue: n, outcome: "skipped", pr: null, cost_usd: 0, minutes: 0, detail }); update(results.get(n)); };
133
328
  const requeue = (n, delayMs) => { pending.push(n); if (delayMs > 0) notBefore.set(n, now() + delayMs); else notBefore.delete(n); };
134
329
  const noteEngineError = () => {
@@ -143,10 +338,19 @@ export async function orchestrate({ issues, parallel = 3, runOne, readBody, pref
143
338
 
144
339
  // Apply one lane's result: requeue it (deferred, dependency-open, engine-error) or record its final row.
145
340
  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 } : {}) };
341
+ 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 } : {}),
342
+ ...(r?.waits && typeof r.waits === "object" ? { waits: r.waits } : {}), ...(Number.isInteger(r?.heavyQueueMax) ? { heavyQueueMax: r.heavyQueueMax } : {}),
343
+ ...(rec?.placement ? { placement: rec.placement } : {}) };
147
344
  const depList = (Array.isArray(r?.dependencies) ? r.dependencies : []).filter((d) => Number.isSafeInteger(d) && d > 0 && d !== n);
148
345
  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); };
346
+ if (r?.sandbox && typeof r.sandbox === "object") {
347
+ const prev = sandboxUse.get(n) ?? { minutes: 0, cost_usd: 0, attempts: 0 };
348
+ 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;
349
+ 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 });
350
+ try { remote?.onUsage?.(n, r.sandbox, rec); } catch { /* the batch total still counts it */ }
351
+ }
352
+ if (sandboxUse.has(n)) row.sandbox = { ...sandboxUse.get(n) };
353
+ const done = () => { running.delete(n); runRecs.delete(n); releaseSlot(n); remoteRun.delete(n); };
150
354
  if (row.outcome === "dependency-open" && depList.length && (requeues.get(n) ?? 0) < MAX_REQUEUES) {
151
355
  // A scheduling fact, not a failure: add the edge, pull the dependency in, and run this issue again after it.
152
356
  requeues.set(n, (requeues.get(n) ?? 0) + 1);
@@ -155,6 +359,8 @@ export async function orchestrate({ issues, parallel = 3, runOne, readBody, pref
155
359
  // Dependencies already done but still open on GitHub: a delay instead of a hot relaunch loop.
156
360
  const allDone = depList.every((d) => results.has(d) && SATISFIED.has(results.get(d).outcome));
157
361
  requeue(n, allDone ? DEP_REQUEUE_DELAY_S * 1000 : 0); done(); save();
362
+ decided({ issue: n, what: `requeued behind ${depList.map((d) => `#${d}`).join(", ")} (Depends on edge added)`, why: firstLine(row.detail) || "the lane reported an open dependency",
363
+ alternative: "skip the issue and its dependents" });
158
364
  update({ ...row, detail: `requeued behind ${depList.map((d) => `#${d}`).join(", ")}` });
159
365
  return;
160
366
  }
@@ -162,8 +368,13 @@ export async function orchestrate({ issues, parallel = 3, runOne, readBody, pref
162
368
  const pol = deferPolicy(r, { now: now(), deferrals: deferrals.get(n) ?? 0, maxDeferrals });
163
369
  if (!pol.capped) {
164
370
  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();
371
+ // A usage limit is held per account; the hold, not a per-issue delay, gates the retry, so a lane on another
372
+ // account runs it at once. If the hold cannot be recorded, the reset delay applies to this issue instead.
373
+ let delayMs = pol.delayMs;
374
+ if (pol.hold) {
375
+ try { holds.set(r?.backendId ?? identity(), now() + pol.delayMs, `batch ${batchId ?? "?"} #${n}`); delayMs = 0; } catch { /* fail-open */ }
376
+ }
377
+ requeue(n, delayMs); done(); save();
167
378
  update({ ...row, detail: `requeued in ${Math.round(pol.delayMs / 1000)}s${pol.hold ? " (admissions held)" : ""}: ${r?.code ?? "deferred"}${r?.detail ? ` ${r.detail}` : ""}` });
168
379
  return;
169
380
  }
@@ -174,16 +385,59 @@ export async function orchestrate({ issues, parallel = 3, runOne, readBody, pref
174
385
  if ((engineErrors.get(n) ?? 0) < ENGINE_ERROR_RELAUNCHES) {
175
386
  engineErrors.set(n, (engineErrors.get(n) ?? 0) + 1);
176
387
  requeue(n, 0); done(); save();
388
+ decided({ issue: n, what: "relaunch once after engine-error", why: firstLine(row.detail) || "engine-error", alternative: "end the issue engine-error and skip its dependents" });
177
389
  update({ ...row, detail: `relaunching once: ${row.detail ?? ""}` });
178
390
  return;
179
391
  }
180
392
  }
181
393
  if (row.outcome === "decomposed" && subs.length) {
182
394
  await addIssues(subs);
395
+ decided({ issue: n, what: `added sub-issues ${subs.map((d) => `#${d}`).join(", ")} to the batch; dependents wait on them`, why: "the builder decomposed the issue",
396
+ alternative: "end the batch row decomposed and let dependents wait for a later batch" });
183
397
  for (const [m, ds] of deps) if (ds.includes(n)) deps.set(m, [...new Set(ds.flatMap((d) => (d === n ? subs : [d])))]);
184
398
  noteCrossLane();
185
399
  }
186
- results.set(n, row); done(); save(); update(row);
400
+ // #4267: a recovery pass ends here; a recoverable row under the attempt cap is requeued as a recovery lane.
401
+ const prior = recovering.get(n) ?? null;
402
+ if (prior) {
403
+ recovering.delete(n);
404
+ // #4298: the row reports the whole issue: earlier attempts' cost and minutes are summed into the recovery's.
405
+ row.cost_usd = Math.round(((Number(row.cost_usd) || 0) + (Number(prior.cost_usd) || 0)) * 1e6) / 1e6;
406
+ row.minutes = Math.round(((Number(row.minutes) || 0) + (Number(prior.minutes) || 0)) * 10) / 10;
407
+ if (row.outcome === "merged") row.recovered = prior.code;
408
+ else row.recovery = { code: prior.code, attempts: recoveries.get(n) ?? prior.attempt, outcome: row.outcome, ...(row.code ? { result_code: row.code } : {}), detail: firstLine(row.detail) };
409
+ // A recovery that failed for good keeps needs-human. The lane relabels needs-human/ci-pending itself; engine-error (after its
410
+ // relaunch) and deferred (no longer requeued) end here, so the runner does it, once. invalid/decomposed/dependency-*/merged never.
411
+ if (["engine-error", "deferred"].includes(row.outcome) && typeof relabelNeedsHuman === "function") {
412
+ try { await relabelNeedsHuman({ issue: n, evidence: `Recovery pass for \`${prior.code}\` (attempt ${prior.attempt}) did not merge: it ended \`${row.outcome}\`${row.code ? ` (\`${row.code}\`)` : ""}${firstLine(row.detail) ? `: ${firstLine(row.detail)}` : ""}. The issue stays \`needs-human\`.` }); } catch { /* best effort */ }
413
+ }
414
+ }
415
+ if (recoveryMax && (row.outcome === "needs-human" || row.outcome === "ci-pending")) {
416
+ let isConflicting = false;
417
+ if (row.outcome === "ci-pending" && typeof conflicting === "function" && Number.isInteger(row.pr)) {
418
+ try { isConflicting = (await conflicting(n, row.pr)) === true; } catch { isConflicting = false; }
419
+ }
420
+ const facts = { ...row, issueEdited: r?.issueEdited === true, conflicting: isConflicting };
421
+ const used = recoveries.get(n) ?? 0;
422
+ if (classifyNeedsHuman(facts) === "recoverable" && used < recoveryMax) {
423
+ const code = recoveryCodeOf(facts), attempt = used + 1;
424
+ recoveries.set(n, attempt); recovering.set(n, { code, attempt, cost_usd: Number(row.cost_usd) || 0, minutes: Number(row.minutes) || 0 });
425
+ requeue(n, 0); done(); save();
426
+ decided({ issue: n, what: `recovery lane for ${code} (attempt ${attempt}/${recoveryMax}); dependents stay queued`, why: firstLine(row.detail) || code,
427
+ alternative: "leave needs-human for the operator and skip the dependents" });
428
+ update({ ...row, outcome: "recovering", detail: `recovering (${code}), attempt ${attempt}/${recoveryMax}: ${firstLine(row.detail)}` });
429
+ return;
430
+ }
431
+ }
432
+ results.set(n, row);
433
+ if (row.outcome === "merged" && drift?.onMerged && r?.structure && String(rec?.lane ?? r?.lane ?? "").startsWith("milestone/")) {
434
+ save(); update(row);
435
+ // Before the slot frees: the batch never ends with a fold pending, and block_dispatch sees the new flags.
436
+ 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)}` }); }
437
+ done(); save();
438
+ return;
439
+ }
440
+ done(); save(); update(row);
187
441
  };
188
442
 
189
443
  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 +449,9 @@ export async function orchestrate({ issues, parallel = 3, runOne, readBody, pref
195
449
  return;
196
450
  }
197
451
  const lane = ln?.lane;
198
- const rec = { startedAt: now(), ...(lane ? { lane } : {}) };
452
+ const pl = placed.get(n);
453
+ placed.delete(n);
454
+ const rec = { startedAt: now(), ...(lane ? { lane } : {}), ...(remoteRun.has(n) ? { remote: true } : {}), ...(pl ? { placement: pl } : {}) };
199
455
  runRecs.set(n, rec); notBefore.delete(n); save();
200
456
  track(n, (async () => {
201
457
  // Pre-filter once per issue (a requeued issue is not re-checked); fail-open: an error means a normal lane.
@@ -204,7 +460,12 @@ export async function orchestrate({ issues, parallel = 3, runOne, readBody, pref
204
460
  let r;
205
461
  if (pf?.closed) r = { terminalReason: "invalid", cost_usd: 0, detail: `pre-filter: ${pf.detail}` };
206
462
  else {
207
- try { r = await runOne(n, lane, { onStart: (info) => { Object.assign(rec, info ?? {}); save(); } }); }
463
+ try {
464
+ r = await runOne(n, lane, { remote: !!rec.remote, ...(rec.placement ? { placement: rec.placement } : {}), ...(recovering.has(n) ? { recover: { code: recovering.get(n).code, attempt: recovering.get(n).attempt } } : {}), onStart: (info) => {
465
+ Object.assign(rec, info ?? {}); save();
466
+ if (reg && slotted.has(n) && info?.pid) reg.setLanePid(keyOf(n), info.pid);
467
+ } });
468
+ }
208
469
  catch (e) { r = { terminalReason: "engine-error", detail: e?.message ?? String(e) }; }
209
470
  }
210
471
  await settle(n, r, rec, pf);
@@ -214,8 +475,12 @@ export async function orchestrate({ issues, parallel = 3, runOne, readBody, pref
214
475
  // Lanes launched by an earlier dispatcher: re-attach to the live ones, queue the rest again (no result was recorded).
215
476
  for (const [n, rec] of [...runRecs]) {
216
477
  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)));
478
+ if (adopt && (rec?.pid || rec?.handle)) { try { p = await adopt(n, rec); } catch { p = null; } }
479
+ if (p && rec.remote) { remoteRun.add(n); track(n, Promise.resolve(p).then((r) => settle(n, r, rec, checked.get(n) ?? null))); }
480
+ else if (p) {
481
+ if (reg) { reg.adoptSlot(keyOf(n), { ...entryOf(n), lanePid: rec.pid }); slotted.add(n); }
482
+ track(n, Promise.resolve(p).then((r) => settle(n, r, rec, checked.get(n) ?? null)));
483
+ }
219
484
  else { runRecs.delete(n); if (!pending.includes(n) && !results.has(n)) pending.unshift(n); }
220
485
  }
221
486
  // A resume re-derives each unfinished issue's lane from its current milestone (#4153): a re-milestone takes effect.
@@ -239,40 +504,145 @@ export async function orchestrate({ issues, parallel = 3, runOne, readBody, pref
239
504
  if (bad !== undefined) { pending.splice(i, 1); skip(n, `dependency #${bad} ended ${results.get(bad).outcome}`); }
240
505
  }
241
506
  const t = now();
242
- if (holdUntil && t >= holdUntil) { holdUntil = null; save(); }
507
+ const holdUntil = heldUntil();
243
508
  let started = 0;
244
- if (!tripped && !holdUntil) {
509
+ driftHeld.clear();
510
+ const isReady = (n) => {
511
+ if (!((notBefore.get(n) ?? 0) <= t && edges(n).every((d) => results.has(d) && SATISFIED.has(results.get(d).outcome)))) return false;
512
+ if (!drift?.holds) return true;
513
+ let pkg = null;
514
+ try { pkg = drift.holds(n, { body: bodies.get(n) ?? "", lane: lanes.get(n)?.lane ?? null }); } catch { pkg = null; }
515
+ if (pkg) driftHeld.set(n, pkg);
516
+ return !pkg;
517
+ };
518
+ budgetHeld = false;
519
+ // #4183: a hook (owned-resource GC near the host ceiling) before lanes are admitted; its errors never stop the batch.
520
+ if (beforeAdmit && !tripped && !holdUntil && running.size < K() && pending.some(isReady)) { try { await beforeAdmit(); } catch { /* ignored */ } }
521
+ budgetStop = null;
522
+ const wanted = new Set(); // issues that stay offered in the host registry after this pass
523
+ if (!tripped && !holdUntil && budget) {
524
+ while (running.size < K()) {
525
+ const ready = pending.filter(isReady);
526
+ const n = pickNext(ready, { classes, share: budget.share, whenIdle: budget.whenIdle, started: dispatch.started, maint: dispatch.maint,
527
+ featuresPending: pending.some((p) => !clsOf(classes, p).maintenance) });
528
+ if (n === null) { budgetHeld = ready.length > 0; break; }
529
+ if (!grant(n, wanted)) break;
530
+ pending.splice(pending.indexOf(n), 1);
531
+ if (lanes.get(n)?.ok !== false) { dispatch.started++; if (clsOf(classes, n).maintenance) dispatch.maint++; }
532
+ start(n); started++;
533
+ }
534
+ } else if (!tripped && !holdUntil && reg) {
535
+ // Priority first, pending order within a priority; the free per-batch K is offered so the host queue keeps FIFO.
536
+ 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);
537
+ const want = ready.filter((n) => lanes.get(n)?.ok !== false && !isRemote(n)).slice(0, Math.max(0, K() - running.size));
538
+ offerFresh(want);
539
+ for (const n of want) wanted.add(n);
540
+ for (const n of ready) {
541
+ if (running.size >= K() || !grant(n, wanted)) break;
542
+ pending.splice(pending.indexOf(n), 1); start(n); started++;
543
+ }
544
+ } else if (!tripped && !holdUntil) {
245
545
  for (let i = 0; i < pending.length && running.size < K();) {
246
546
  const n = pending[i];
247
- 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++; }
547
+ if (isReady(n) && grant(n, new Set())) { pending.splice(i, 1); start(n); started++; }
248
548
  else i++;
249
549
  }
250
550
  }
551
+ if (reg) withdrawExcept(wanted);
251
552
  if (tripped && !running.size) break; // breaker: let running lanes finish, keep the rest pending for a resume
553
+ if (budgetStop && !running.size) break; // execution budget (stop): the same, the rest end held
252
554
  // Wake for a delayed issue, the end of a usage-limit hold, or (with a live K) a poll.
253
555
  const waits = pending.map((n) => notBefore.get(n) ?? 0).filter((x) => x > t);
254
556
  if (holdUntil) waits.push(holdUntil);
255
557
  let wake = waits.length ? Math.max(0, Math.min(...waits) - t) : null;
256
558
  if (getParallel && pending.length) wake = Math.min(wake ?? pollMs, pollMs);
559
+ if (offered.size && pending.length) wake = Math.min(wake ?? pollMs, pollMs); // a slot another batch frees
257
560
  if (tripped) wake = null;
258
561
  if (running.size) {
259
562
  if (wake === null) await Promise.race(running.values());
260
563
  else { const ac = new AbortController(); await Promise.race([...running.values(), sleep(wake, ac.signal)]); ac.abort(); }
261
564
  } else if (started) continue; // only lane-refused rows settled (synchronously): re-resolve skips first
565
+ else if (pending.length && offered.size) await sleep(wake); // waiting for a host slot
262
566
  else if (pending.length && waits.length) await sleep(Math.max(0, Math.min(...waits) - t));
567
+ else if (pending.length && budgetHeld) break; // maintenance the budget does not admit now: held for a later batch
568
+ else if (pending.length && driftHeld.size) break; // held by a drift flag (block_dispatch): pending for a later batch
263
569
  else if (pending.length) { // nothing runnable, nothing running, nothing waiting on time: a dependency cycle
264
570
  for (const n of pending.splice(0)) skip(n, "dependency cycle");
265
571
  save();
266
572
  }
267
573
  }
574
+ if (reg) withdrawExcept(new Set());
268
575
  save();
269
- return order.map((n) => results.get(n) ?? { issue: n, outcome: "held", pr: null, cost_usd: 0, minutes: 0, detail: tripped?.detail ?? "not run" });
576
+ 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") });
577
+ }
578
+
579
+ /** Max chars of one row's detail in the batch-report outcome lists (#4298). */
580
+ export const OUTCOME_LINE_MAX = 200;
581
+ /**
582
+ * #4267 batch-report lines after the table: `recovered: #A (ci.red) #B (base.conflict)`, one `unrecovered:` line per
583
+ * mechanical needs-human row (failed recovery or a terminal code), then one escalation list holding only human codes,
584
+ * asked once at the end of the batch. Empty when there is nothing to list.
585
+ */
586
+ export function formatOutcomeLists(rows) {
587
+ const out = [];
588
+ const one = (x) => String(x ?? "").split("\n")[0].slice(0, OUTCOME_LINE_MAX);
589
+ const rec = (rows ?? []).filter((r) => r?.recovered);
590
+ if (rec.length) out.push(`recovered: ${rec.map((r) => `#${r.issue} (${r.recovered})`).join(" ")}`);
591
+ for (const r of rows ?? []) {
592
+ if (!r || r.recovered) continue;
593
+ const human = r.outcome === "needs-human" && classifyNeedsHuman(r) === "human";
594
+ if (!human && (r.outcome === "needs-human" || r.recovery)) out.push(`unrecovered: #${r.issue} (${r.code ?? r.recovery?.code ?? "unclassified"}: ${one(r.recovery ? `recovery ${r.recovery.code} ended ${r.recovery.outcome}: ${r.recovery.detail}` : r.detail)})`);
595
+ }
596
+ const esc = (rows ?? []).filter((r) => r?.outcome === "needs-human" && classifyNeedsHuman(r) === "human");
597
+ if (esc.length) out.push(["escalations (human decisions, asked once):", ...esc.map((r) => `- #${r.issue} (${r.code}): ${one(r.detail)}`)].join("\n"));
598
+ return out;
270
599
  }
271
600
 
272
601
  export function formatTable(rows) {
273
- const head = ["issue", "outcome", "PR", "cost", "min"];
274
- 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)]);
602
+ // #4255: sandbox minutes and cost next to the token cost, only when some lane ran in a sandbox.
603
+ const sbx = rows.some((r) => r?.sandbox);
604
+ const head = ["issue", "outcome", "PR", "cost", ...(sbx ? ["sbx-min", "sbx-$"] : []), "min"];
605
+ 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)}`,
606
+ ...(sbx ? (r.sandbox ? [(Number(r.sandbox.minutes) || 0).toFixed(1), `$${(Number(r.sandbox.cost_usd) || 0).toFixed(2)}`] : ["-", "-"]) : []), String(r.minutes ?? 0)]);
275
607
  const w = head.map((h, i) => Math.max(h.length, ...body.map((b) => b[i].length)));
276
608
  const line = (c) => c.map((x, i) => x.padEnd(w[i])).join(" ").trimEnd();
277
609
  return [line(head), line(w.map((n) => "-".repeat(n))), ...body.map(line)].join("\n");
278
610
  }
611
+
612
+ /**
613
+ * #4169 batch-report line: wait time per phase summed over the rows' lane `waits` ({phase: ms}), and the heavy-step
614
+ * semaphore queue: `now` live from `heavyStats.queued`, `max` the longest queue a lane of this batch saw. Null when nothing waited or queued.
615
+ */
616
+ export function formatWaitSummary(rows, heavyStats = null) {
617
+ const by = {};
618
+ 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;
619
+ const seen = Math.max(0, ...(rows ?? []).map((r) => (Number.isInteger(r?.heavyQueueMax) ? r.heavyQueueMax : 0)));
620
+ const hasRows = Object.keys(by).length > 0, hasStats = heavyStats && typeof heavyStats === "object";
621
+ if (!hasRows && !seen && !(hasStats && heavyStats.queued > 0)) return null; // nothing waited, nothing queued: no line
622
+ const waits = hasRows ? Object.entries(by).sort(([a], [b]) => (a < b ? -1 : 1)).map(([p, ms]) => `${p} ${(ms / 60000).toFixed(1)}m`).join(", ") : "none";
623
+ return `waits by phase: ${waits}; heavy queue: now ${hasStats && Number.isInteger(heavyStats.queued) ? heavyStats.queued : "?"}, max ${seen}`;
624
+ }
625
+
626
+ /**
627
+ * #4255 batch-report line: sandbox lanes, minutes and cost next to those lanes' token cost, plus the execution budget
628
+ * (`budget` from parseExecution, `day` from openExecutionBudget `dayTotals`) when a cap is set. Null without sandbox usage.
629
+ */
630
+ export function formatSandboxSummary(rows, { budget = null, day = null } = {}) {
631
+ const sb = (rows ?? []).filter((r) => r?.sandbox);
632
+ if (!sb.length) return null;
633
+ const sum = (f) => sb.reduce((a, r) => a + (Number(f(r)) || 0), 0);
634
+ 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)})`];
635
+ const caps = [];
636
+ if (budget?.batchMinutes != null) caps.push(`batch ${budget.batchMinutes} min`);
637
+ if (budget?.batchUsd != null) caps.push(`batch $${budget.batchUsd}`);
638
+ if (budget?.dayMinutes != null) caps.push(`day ${budget.dayMinutes} min`);
639
+ if (budget?.dayUsd != null) caps.push(`day $${budget.dayUsd}`);
640
+ if (caps.length) {
641
+ let b = `budget: ${caps.join(", ")}`;
642
+ if (day?.ok) b += `; host today ${Number(day.minutes).toFixed(1)} min, $${Number(day.cost_usd).toFixed(2)}`;
643
+ const held = (rows ?? []).filter((r) => r?.outcome === "held" && String(r.detail ?? "").startsWith("execution budget")).length;
644
+ if (held) b += `; cap reached, ${held} held`;
645
+ parts.push(b);
646
+ }
647
+ return parts.join("; ");
648
+ }
@@ -10,7 +10,7 @@
10
10
  * ALREADY-FIXED/INVALID verdict with evidence closes the issue; everything else, and every error, goes to the normal lane.
11
11
  * All effects are injected (gh, git, runner, transitionLabel, projector?) so it is unit-testable.
12
12
  */
13
- import { capComment, sanitizeMd } from "./render.mjs";
13
+ import { capComment, sanitizeMd, noComments } from "./render.mjs";
14
14
  import { PREFILTER_SYSTEM, prefilterPrompt, parsePrefilterVerdict } from "./prompts.mjs";
15
15
 
16
16
  export const DEFAULT_PREFILTER_MODEL = "claude-haiku-5-5";
@@ -38,7 +38,7 @@ export function ciFailureRun(iss) {
38
38
  const PATH_RE = /(?:^|[\s`'"(\[])((?:\.{0,2}\/)?(?:[\w@.-]+\/)+[\w@.-]+\.[A-Za-z0-9]{1,8})(?::\d+(?:-\d+)?)?(?=$|[\s`'"),.:;\]])/gm;
39
39
  /** Repo-relative file paths the issue names (`dir/file.ext`, optionally `:line`); URLs and HTML comments are ignored. */
40
40
  export function namedPaths(text) {
41
- const s = sanitizeMd(text).replace(/<!--[\s\S]*?-->/g, " ").replace(/\b[a-z]+:\/\/\S+/gi, " ");
41
+ const s = noComments(sanitizeMd(text), " ").replace(/\b[a-z]+:\/\/\S+/gi, " ");
42
42
  return [...new Set([...s.matchAll(PATH_RE)].map((m) => m[1].replace(/^\.\//, "")).filter((p) => !p.startsWith("/") && !p.startsWith("../")))];
43
43
  }
44
44
 
@@ -112,6 +112,9 @@ describe("pre-filter (#4123)", () => {
112
112
 
113
113
  it("every named path missing on the lane is a candidate; any surviving path is not", async () => {
114
114
  assert.deepEqual(namedPaths("see `src/old/foo.js:12` and lib/bar.py, not https://x.io/a/b.js <!-- FORGE:STATE {\"a\":\"c/d.js\"} -->"), ["src/old/foo.js", "lib/bar.py"]);
115
+ // #4298: the scan-based noComments: a `--!>` closer ends a comment and an unclosed one runs to the end (the old
116
+ // one-pass regex kept both hidden paths).
117
+ assert.deepEqual(namedPaths("fix lib/a.js <!-- x/hidden.js --!> visible/e.js and <!-- unclosed c/d.js"), ["lib/a.js", "visible/e.js"]);
115
118
  const gone = world({ issues: { 5: { body: "crash in src/old/foo.js:12" } }, verdict: { verdict: "INVALID", evidence: ["src/old/foo.js removed in abc123"] } });
116
119
  assert.equal((await pf(gone, 5)).closed, true);
117
120
  assert.match(gone.runner[0].prompt, /do not exist on origin\/staging: src\/old\/foo\.js/);