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.
- package/README.md +4 -1
- package/bin/bootstrap.mjs +55 -0
- package/bin/engine/outcome-codes.mjs +13 -0
- package/bin/engine/phases.mjs +38 -2
- package/bin/engine/telemetry-seam.mjs +41 -6
- package/bin/engine/telemetry-seam.test.mjs +35 -0
- package/bin/engine-cli.mjs +616 -54
- package/bin/engine.mjs +13 -5
- package/bin/forgedock.mjs +169 -39
- package/bin/gc.mjs +580 -0
- package/bin/init-detect.mjs +68 -3
- package/bin/init-enrich.mjs +3 -0
- package/bin/init-repo.mjs +213 -0
- package/bin/journey.mjs +278 -43
- package/bin/lean/admission.mjs +33 -3
- package/bin/lean/batch.mjs +24 -5
- package/bin/lean/batch.test.mjs +54 -4
- package/bin/lean/checks.mjs +199 -0
- package/bin/lean/checks.test.mjs +183 -0
- package/bin/lean/conformance.mjs +235 -37
- package/bin/lean/conformance.test.mjs +143 -5
- package/bin/lean/credentials.mjs +29 -0
- package/bin/lean/depcache.mjs +312 -0
- package/bin/lean/depcache.test.mjs +298 -0
- package/bin/lean/drift.mjs +198 -0
- package/bin/lean/drift.test.mjs +223 -0
- package/bin/lean/effects.mjs +178 -16
- package/bin/lean/execution.e2e.test.mjs +81 -0
- package/bin/lean/execution.mjs +431 -0
- package/bin/lean/execution.test.mjs +583 -0
- package/bin/lean/heavy.mjs +289 -0
- package/bin/lean/heavy.test.mjs +207 -0
- package/bin/lean/lean.mjs +877 -151
- package/bin/lean/lean.test.mjs +1798 -28
- package/bin/lean/ledger.mjs +92 -0
- package/bin/lean/ledger.test.mjs +100 -0
- package/bin/lean/limits.mjs +58 -0
- package/bin/lean/limits.test.mjs +150 -0
- package/bin/lean/orchestrate.mjs +321 -19
- package/bin/lean/prompts.mjs +125 -17
- package/bin/lean/refactor.mjs +276 -0
- package/bin/lean/refactor.test.mjs +359 -0
- package/bin/lean/render.mjs +25 -6
- package/bin/lean/review-trigger.mjs +134 -0
- package/bin/lean/review-trigger.test.mjs +104 -0
- package/bin/lean/sandbox-provider.contract.test.mjs +257 -0
- package/bin/lean/sandbox-remote.mjs +142 -0
- package/bin/lean/sched.mjs +221 -0
- package/bin/lean/sched.test.mjs +310 -0
- package/bin/lean/schedd.mjs +247 -0
- package/bin/lean/schedd.test.mjs +180 -0
- package/bin/lean/toolchain.mjs +228 -0
- package/bin/lean/toolchain.test.mjs +154 -0
- package/bin/lean/verify.mjs +124 -0
- package/bin/lean/verify.test.mjs +118 -0
- package/bin/lean/worktree.mjs +69 -0
- package/bin/lean/worktree.test.mjs +141 -0
- package/bin/runner.mjs +53 -10
- package/bin/stats/github-source.mjs +1 -1
- package/bin/stats/index.mjs +14 -2
- package/bin/stats/query.mjs +72 -0
- package/bin/stats/stats.test.mjs +54 -0
- package/commands/adopt.md +3 -0
- package/commands/audit.md +5 -8
- package/commands/autopilot.md +2 -0
- package/commands/bootstrap.md +35 -0
- package/commands/cleanup.md +7 -22
- package/commands/forge-stats.md +2 -1
- package/commands/forgedock-init.md +125 -6
- package/commands/issue.md +89 -830
- package/commands/milestone.md +55 -6
- package/commands/orchestrate.md +10 -2
- package/commands/review-pr.md +2 -2
- package/commands/upgrade-deps.md +3 -3
- package/commands/work-on/build/validate.md +4 -0
- package/commands/work-on/close.md +2 -0
- package/commands/work-on/decompose.md +24 -6
- package/docs/CI.md +3 -3
- package/docs/CONFIG.md +227 -3
- package/docs/OUTCOME-CODES.md +11 -0
- package/docs/TELEMETRY.md +1 -1
- package/docs/site/command-reference.md +22 -0
- package/docs/site/getting-started.md +2 -0
- package/docs/site/start-a-new-project.md +327 -0
- package/package.json +1 -1
- package/scripts/affected-files-check.sh +91 -0
- package/scripts/affected-files-check.test.sh +80 -0
- package/scripts/bootstrap-create.mjs +393 -0
- package/scripts/bootstrap-validate.mjs +321 -0
- package/scripts/calibration.mjs +116 -0
- package/scripts/check-devdocs-freshness.sh +125 -0
- package/scripts/check-devdocs-freshness.test.sh +130 -0
- package/scripts/check-spec-markers.sh +3 -0
- package/scripts/check-spec-size.sh +29 -11
- package/scripts/check-spec-size.test.sh +27 -4
- package/scripts/ci-local/lib.mjs +8 -1
- package/scripts/ci-local/parallelism.test.mjs +27 -0
- package/scripts/ci-local/run.mjs +16 -5
- package/scripts/ci-local/scoping.mjs +82 -0
- package/scripts/ci-local.test.sh +14 -2
- package/scripts/fixtures/symbol-index/other/sample.rb +3 -0
- package/scripts/fixtures/symbol-index/py/pkg/__init__.py +1 -0
- package/scripts/fixtures/symbol-index/py/pkg/a.py +5 -0
- package/scripts/fixtures/symbol-index/py/pkg/b.py +14 -0
- package/scripts/fixtures/symbol-index/py/pkg/c.py +1 -0
- package/scripts/fixtures/symbol-index/py/sample.py +41 -0
- package/scripts/fixtures/symbol-index/ts/sample.ts +13 -0
- package/scripts/fixtures/symbol-move/gen_big.py +201 -0
- package/scripts/fixtures/symbol-move-ts/gen_big.mjs +56 -0
- package/scripts/forge-root.test.sh +1 -1
- package/scripts/hotspots.sh +100 -0
- package/scripts/hotspots.test.sh +101 -0
- package/scripts/issue-create.sh +197 -0
- package/scripts/issue-create.test.sh +166 -0
- package/scripts/maintenance-scan.sh +185 -0
- package/scripts/maintenance-scan.test.sh +89 -0
- package/scripts/milestone-drift.sh +206 -0
- package/scripts/milestone-drift.test.sh +120 -0
- package/scripts/northstar-docs.test.sh +68 -0
- package/scripts/refactor-verify.sh +368 -0
- package/scripts/refactor-verify.test.sh +202 -0
- package/scripts/review-prepare.sh +11 -9
- package/scripts/review-prepare.test.sh +14 -0
- package/scripts/structure-delta.sh +155 -0
- package/scripts/structure-delta.test.sh +136 -0
- package/scripts/symbol-index.mjs +253 -0
- package/scripts/symbol-index.py +354 -0
- package/scripts/symbol-index.test.sh +119 -0
- package/scripts/symbol-move-ts.test.sh +161 -0
- package/scripts/symbol-move.mjs +379 -0
- package/scripts/symbol-move.py +383 -0
- package/scripts/symbol-move.test.sh +168 -0
- package/templates/bootstrap/interview.md +34 -0
- package/templates/bootstrap/issue-body.md +42 -0
- package/templates/bootstrap/needs-human-issue.md +45 -0
- package/templates/bootstrap/stack-profiles.md +34 -0
- package/templates/bootstrap/technical-spike.md +75 -0
package/bin/lean/orchestrate.mjs
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
166
|
-
|
|
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);
|
|
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
|
|
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 {
|
|
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
|
-
|
|
452
|
+
const holdUntil = heldUntil();
|
|
234
453
|
let started = 0;
|
|
235
|
-
|
|
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 ((
|
|
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
|
-
|
|
265
|
-
const
|
|
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
|
+
}
|