forgedock 1.29.1 → 1.31.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/engine/outcome-codes.mjs +5 -2
- package/bin/engine/phases.mjs +20 -3
- package/bin/engine/runlog.mjs +82 -12
- package/bin/engine/telemetry-seam.mjs +11 -4
- package/bin/engine/telemetry-seam.test.mjs +18 -0
- package/bin/engine-cli.mjs +453 -66
- package/bin/engine.mjs +50 -38
- package/bin/forgedock.mjs +35 -3
- package/bin/gc.mjs +50 -17
- package/bin/labels.json +5 -0
- package/bin/lean/apishape.mjs +170 -0
- package/bin/lean/apishape.test.mjs +187 -0
- package/bin/lean/batch.mjs +32 -0
- package/bin/lean/batch.test.mjs +356 -2
- package/bin/lean/budget.mjs +89 -0
- package/bin/lean/budget.test.mjs +106 -0
- package/bin/lean/checks.mjs +32 -9
- package/bin/lean/checks.test.mjs +78 -1
- package/bin/lean/conformance.e2e.test.mjs +27 -5
- package/bin/lean/conformance.mjs +278 -22
- package/bin/lean/conformance.test.mjs +251 -5
- package/bin/lean/context.mjs +133 -55
- package/bin/lean/context.test.mjs +175 -9
- package/bin/lean/decompose.mjs +90 -0
- package/bin/lean/decompose.test.mjs +105 -0
- package/bin/lean/depcache.mjs +11 -7
- package/bin/lean/depcache.test.mjs +81 -0
- package/bin/lean/e2e.mjs +102 -0
- package/bin/lean/e2e.test.mjs +86 -0
- package/bin/lean/effects.mjs +55 -19
- package/bin/lean/enumerating.mjs +189 -0
- package/bin/lean/enumerating.test.mjs +97 -0
- package/bin/lean/explore.mjs +47 -0
- package/bin/lean/explore.test.mjs +61 -0
- package/bin/lean/facts.mjs +84 -0
- package/bin/lean/facts.test.mjs +66 -0
- package/bin/lean/fixround.mjs +90 -0
- package/bin/lean/fixround.test.mjs +117 -0
- package/bin/lean/heavy.mjs +35 -2
- package/bin/lean/heavy.test.mjs +32 -2
- package/bin/lean/lean.mjs +799 -127
- package/bin/lean/lean.test.mjs +1749 -37
- package/bin/lean/ledger.mjs +5 -3
- package/bin/lean/ledger.test.mjs +18 -0
- package/bin/lean/light.mjs +135 -0
- package/bin/lean/light.test.mjs +173 -0
- package/bin/lean/limits.test.mjs +36 -1
- package/bin/lean/orchestrate.mjs +341 -99
- package/bin/lean/pitfalls.mjs +136 -0
- package/bin/lean/pitfalls.test.mjs +117 -0
- package/bin/lean/postmerge.mjs +85 -0
- package/bin/lean/postmerge.test.mjs +79 -0
- package/bin/lean/precheck.mjs +180 -0
- package/bin/lean/precheck.test.mjs +205 -0
- package/bin/lean/prefilter.mjs +180 -9
- package/bin/lean/prefilter.test.mjs +175 -3
- package/bin/lean/preflight.mjs +238 -0
- package/bin/lean/preflight.test.mjs +108 -0
- package/bin/lean/probe.mjs +297 -0
- package/bin/lean/probe.test.mjs +210 -0
- package/bin/lean/prompts.mjs +134 -12
- package/bin/lean/recovery.test.mjs +75 -0
- package/bin/lean/render.mjs +7 -3
- package/bin/lean/resume.mjs +67 -0
- package/bin/lean/resume.test.mjs +84 -0
- package/bin/lean/review-trigger.mjs +12 -0
- package/bin/lean/review-trigger.test.mjs +17 -1
- package/bin/lean/routing.mjs +74 -0
- package/bin/lean/routing.test.mjs +67 -0
- package/bin/lean/sched.test.mjs +127 -1
- package/bin/lean/schedd.mjs +55 -9
- package/bin/lean/schedd.test.mjs +149 -3
- package/bin/lean/selfreview.mjs +37 -0
- package/bin/lean/selfreview.test.mjs +51 -0
- package/bin/lean/seqlease.mjs +40 -0
- package/bin/lean/seqlease.test.mjs +101 -0
- package/bin/lean/sequence.mjs +385 -0
- package/bin/lean/sequence.test.mjs +373 -0
- package/bin/lean/testmap.mjs +43 -6
- package/bin/lean/testmap.test.mjs +38 -1
- package/bin/lean/toolchain.mjs +60 -15
- package/bin/lean/toolchain.test.mjs +120 -3
- package/bin/lean/toolstats.mjs +72 -0
- package/bin/lean/toolstats.test.mjs +47 -0
- package/bin/lean/verifier.mjs +232 -0
- package/bin/lean/verifier.test.mjs +163 -0
- package/bin/lean/verify.mjs +77 -10
- package/bin/lean/verify.test.mjs +74 -3
- package/bin/review-staging.mjs +533 -0
- package/bin/runner.mjs +82 -13
- package/bin/stats/aggregate.mjs +87 -2
- package/bin/stats/efficiency.mjs +319 -0
- package/bin/stats/efficiency.test.mjs +233 -0
- package/bin/stats/github-source.mjs +8 -3
- package/bin/stats/index.mjs +69 -6
- package/bin/stats/local-source.mjs +86 -3
- package/bin/stats/query.mjs +48 -0
- package/bin/stats/render.mjs +22 -0
- package/bin/stats/stats.test.mjs +224 -5
- package/commands/orchestrate.md +6 -2
- package/commands/review-pr-agents/protocols.md +1 -1
- package/commands/review-pr-staging.md +84 -831
- package/commands/review-pr.md +1 -1
- package/commands/work-on/build/implement.md +1 -0
- package/commands/work-on/build/validate.md +11 -1
- package/docs/CONFIG.md +88 -6
- package/docs/OUTCOME-CODES.md +7 -4
- package/docs/RELEASE-NOTES.md +10 -0
- package/docs/STATS.md +53 -1
- package/docs/TELEMETRY.md +1 -1
- package/docs/spec/review-protocol.md +1 -1
- package/package.json +1 -1
- package/scripts/acceptance-red-green.sh +13 -4
- package/scripts/acceptance-red-green.test.sh +14 -0
- package/scripts/ci-local/lib.mjs +20 -0
- package/scripts/ci-local/plan.mjs +2 -12
- package/scripts/ci-local/scoping.mjs +10 -1
- package/scripts/ci-local.test.sh +2 -0
- package/scripts/fixtures/apishape/other/go-change/base/x.go +3 -0
- package/scripts/fixtures/apishape/other/go-change/head/x.go +3 -0
- package/scripts/fixtures/apishape/python/generator-function/base/seq.py +3 -0
- package/scripts/fixtures/apishape/python/generator-function/head/seq.py +2 -0
- package/scripts/fixtures/apishape/python/ichunked/base/more.py +17 -0
- package/scripts/fixtures/apishape/python/ichunked/head/more.py +20 -0
- package/scripts/fixtures/apishape/python/private-edit/base/calc.py +6 -0
- package/scripts/fixtures/apishape/python/private-edit/head/calc.py +9 -0
- package/scripts/fixtures/apishape/python/removed/base/ops.py +6 -0
- package/scripts/fixtures/apishape/python/removed/head/ops.py +2 -0
- package/scripts/fixtures/apishape/python/renamed/base/text.py +2 -0
- package/scripts/fixtures/apishape/python/renamed/head/text.py +2 -0
- package/scripts/fixtures/apishape/python/signature/base/shapes.py +2 -0
- package/scripts/fixtures/apishape/python/signature/head/shapes.py +2 -0
- package/scripts/fixtures/apishape/python/sync-async/base/client.py +7 -0
- package/scripts/fixtures/apishape/python/sync-async/head/client.py +7 -0
- package/scripts/fixtures/apishape/ts/generator-function/base/seq.js +3 -0
- package/scripts/fixtures/apishape/ts/generator-function/head/seq.js +5 -0
- package/scripts/fixtures/apishape/ts/private-edit/base/calc.ts +7 -0
- package/scripts/fixtures/apishape/ts/private-edit/head/calc.ts +9 -0
- package/scripts/fixtures/apishape/ts/removed/base/ops.ts +7 -0
- package/scripts/fixtures/apishape/ts/removed/head/ops.ts +3 -0
- package/scripts/fixtures/apishape/ts/renamed/base/text.ts +3 -0
- package/scripts/fixtures/apishape/ts/renamed/head/text.ts +3 -0
- package/scripts/fixtures/apishape/ts/signature/base/shapes.ts +3 -0
- package/scripts/fixtures/apishape/ts/signature/head/shapes.ts +3 -0
- package/scripts/fixtures/apishape/ts/sync-async/base/client.mjs +5 -0
- package/scripts/fixtures/apishape/ts/sync-async/head/client.mjs +5 -0
- package/scripts/forge-root.test.sh +2 -2
- package/scripts/publish-staging-sync.test.sh +62 -4
- package/scripts/review-finalize.sh +17 -6
- package/scripts/review-finalize.test.sh +47 -0
- package/scripts/review-pr-arguments.test.sh +13 -1
- package/scripts/review-pr-delta-wiring.test.sh +2 -2
- package/scripts/review-pr-gate2.test.sh +6 -14
- package/scripts/review-pr-staging-finding-filter.test.sh +4 -4
- package/scripts/review-pr-staging-scope.test.sh +39 -33
- package/scripts/review-prepare.sh +73 -6
- package/scripts/review-prepare.test.sh +85 -0
- package/scripts/review-staging-post.sh +293 -0
- package/scripts/review-staging-post.test.sh +143 -0
- package/scripts/review-staging-pre.sh +401 -0
- package/scripts/review-staging-pre.test.sh +150 -0
- package/scripts/symbol-index.mjs +26 -7
- package/scripts/symbol-index.py +55 -9
- package/scripts/test-map.sh +63 -17
- package/scripts/test-map.test.sh +55 -0
- package/scripts/trusted-comments.test.sh +1 -1
package/bin/lean/orchestrate.mjs
CHANGED
|
@@ -4,9 +4,12 @@
|
|
|
4
4
|
* Thin orchestrate: run core work-on for several issues with bounded concurrency, honouring `Depends on` / `Blocked by`
|
|
5
5
|
* among the given issues (wait for the dependency to merge; skip when it ends anywhere but merged/invalid).
|
|
6
6
|
* A `dependency-open` run is requeued behind its dependencies, pulling an out-of-set one into the batch; a
|
|
7
|
-
* `decomposed` run adds its sub-issues and its dependents wait on them instead (#4124).
|
|
7
|
+
* `decomposed` run adds its sub-issues and its dependents wait on them instead (#4124). A dependency that is parked
|
|
8
|
+
* (ended needs-human in this batch, or an out-of-set issue labelled needs-human per `issueLabels(n)`) is never pulled in:
|
|
9
|
+
* the issue ends `skipped` (`dependency #N needs-human`) and its dependents with it (#4387).
|
|
8
10
|
* No investigation, synthesis or agent spawning. `runOne(issue)` and `readBody(issue)` are injected.
|
|
9
11
|
* Optional `prefilter(issue, lane)` (#4123) runs in the lane slot before `runOne`: when it closed the issue, no lane starts.
|
|
12
|
+
* When it parked the issue (#4389, `parked: true`), no lane starts either and the row ends with the parked kind.
|
|
10
13
|
* Optional `resolveLane(issue)` (#4125) works out each issue's own lane (milestone branch or staging): an issue whose
|
|
11
14
|
* lane is refused ends `lane-refused` without a run, and a dependency edge between two lanes goes to `onCrossLane`.
|
|
12
15
|
*
|
|
@@ -15,9 +18,13 @@
|
|
|
15
18
|
* `deferred` is a scheduling fact, not an end: each typed code is requeued with its retry-at (`deferPolicy`), a usage
|
|
16
19
|
* limit holds admissions for that account until its reset, and non-host deferrals stop after `maxDeferrals`.
|
|
17
20
|
* 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.
|
|
21
|
+
* host; in memory when absent): admission checks the hold of `identity()`, the account the next lane would use. While held, the
|
|
22
|
+
* batch re-reads the store every `pollMs` (#4446), so `forgedock limits clear` admits on the next poll, not at the reset. An `engine-error` is
|
|
19
23
|
* relaunched once; `breaker` ({errors, window_min}) stops admissions after a burst and lets running lanes finish, the
|
|
20
24
|
* rest stay pending (outcome `held`) for a resume. `getParallel()` is read on every pass, so K can change live.
|
|
25
|
+
* `getAdded()` (#4408, `forgedock orchestrate --add`) is read on every pass too: issues it returns that are not in the batch
|
|
26
|
+
* join it through `addIssues` (the still-pending issues' `Depends on` are re-read first, so edges hold both ways), with one
|
|
27
|
+
* batch decision and one `added` row; while it is set, lanes running with an empty queue still wake every `pollMs`.
|
|
21
28
|
*
|
|
22
29
|
* Maintenance budget (#4197), off unless `maintenance: {share, whenIdle}` is passed: `classify(issue)` gives each issue
|
|
23
30
|
* `{maintenance, priority}` (persisted as `classes`) and admission follows `pickNext`: P0/P1 features, then a maintenance
|
|
@@ -59,14 +66,29 @@
|
|
|
59
66
|
* `maxAttempts`) under the same admission as any lane, so its dependents stay queued. Only a final lane relabels needs-human
|
|
60
67
|
* itself; a non-final attempt that ends needs-human/ci-pending without a successor is relabelled here, once (#4302). A recovery
|
|
61
68
|
* still queued when the batch exits (breaker, budget, hold, or a resume that does not relaunch it) is relabelled once with the
|
|
62
|
-
* full persisted lane reason (#4315)
|
|
69
|
+
* full persisted lane reason (#4315); also when the loop throws, and through `onExit(fn)` (the caller's signal handler
|
|
70
|
+
* awaits `fn(cause)`), each once (#4318). `relabelNeedsHuman` may return `false`/"failed" (not recorded, a resume retries)
|
|
71
|
+
* or "skipped" (closed or leased issue: recorded); any other result counts as relabelled.
|
|
63
72
|
* A merged recovery row carries `recovered: <code>`; any other end is final with `recovery` evidence. Human codes (`builder.blocked`) are never recovered. Every operational decision the runner takes
|
|
64
73
|
* (recovery, engine-error relaunch, dependency requeue, sub-issues added) goes to `decide({issue, what, why, alternative})`,
|
|
65
74
|
* fire-and-forget: the runner never waits on it.
|
|
75
|
+
* Daemon shutdown (#4454): an aborted `signal` stops the runner without ending the batch: nothing more is admitted, every wait
|
|
76
|
+
* wakes, running lanes are neither awaited nor stopped (they are detached), nothing is relabelled and the state is saved once
|
|
77
|
+
* with its `running` records, so a `--resume` re-adopts the lanes; then it throws `code: "batch.stopped"` (no rows, no report).
|
|
78
|
+
*
|
|
79
|
+
* Longest-first admission (#4409): `estimate(issue, {body})` (minutes; off when null, persisted as `estimates`) ranks the
|
|
80
|
+
* ready issues inside a priority level. All admission paths order them by priority (unchanged), then the head band
|
|
81
|
+
* (sub-issues of a decomposed lane, inserted at the front of the queue; with an estimate also issues queued longer than
|
|
82
|
+
* `starveMs`, oldest first), then the estimate plus the estimates of their in-batch dependents (critical path), descending,
|
|
83
|
+
* then queue order. A sub-issue's estimate is the parent's ÷ N, floored at the M default. Without `estimate` the order is
|
|
84
|
+
* the queue order except that decomposed sub-issues go first. A settled row carries `est_minutes` (estimated vs. actual).
|
|
66
85
|
*/
|
|
67
86
|
import { parseDependsOn } from "../engine/phases.mjs";
|
|
68
87
|
import { classifyNeedsHuman, recoveryCodeOf } from "../engine/outcome-codes.mjs";
|
|
69
88
|
import { recoveryEvidence } from "./render.mjs";
|
|
89
|
+
import { renderAlerts } from "../stats/render.mjs";
|
|
90
|
+
import { formatParkedLine, postDeployOnly } from "./prefilter.mjs";
|
|
91
|
+
import { laneSize, SIZES } from "./budget.mjs";
|
|
70
92
|
|
|
71
93
|
// A dependency that finished like this no longer blocks its dependents.
|
|
72
94
|
const SATISFIED = new Set(["merged", "invalid"]);
|
|
@@ -83,6 +105,40 @@ export const DEFER_BACKOFF_MAX_S = 30 * 60;
|
|
|
83
105
|
// A dependency-open whose dependencies already finished satisfied (GitHub not yet closed): wait before relaunching.
|
|
84
106
|
export const DEP_REQUEUE_DELAY_S = 60;
|
|
85
107
|
|
|
108
|
+
// #4409 lane duration estimates (minutes) without history; the ratio follows the S/M/L turn budgets.
|
|
109
|
+
export const EST_DEFAULT_MIN = Object.freeze({ S: 10, M: 25, L: 50 });
|
|
110
|
+
// A post-deploy-only follow-up starts no real lane (the pre-filter parks or closes it).
|
|
111
|
+
export const POST_DEPLOY_EST_MIN = 2;
|
|
112
|
+
// A ready issue queued longer than this joins the head band (starvation guard; only with an estimator).
|
|
113
|
+
export const DEFAULT_STARVE_MS = 30 * 60000;
|
|
114
|
+
// Merged runs of one repo and size needed before their median replaces the default.
|
|
115
|
+
export const HISTORY_MIN_SAMPLES = 3;
|
|
116
|
+
|
|
117
|
+
/** #4409: estimated lane minutes from facts: post-deploy-only body, else the `size:` label (unsized = M) -> history median or default. */
|
|
118
|
+
export function estimateLaneMinutes({ labels = [], body = "", history = null } = {}) {
|
|
119
|
+
if (postDeployOnly(body)) return POST_DEPLOY_EST_MIN;
|
|
120
|
+
const size = laneSize({ labels }) ?? "M";
|
|
121
|
+
const h = Number(history?.[size]);
|
|
122
|
+
return Number.isFinite(h) && h > 0 ? h : EST_DEFAULT_MIN[size];
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** #4409: {S?, M?, L?} median wall minutes of `repo`'s merged runs (summariseLog rows) per size, only sizes with enough samples. */
|
|
126
|
+
export function laneHistory(runs, repo) {
|
|
127
|
+
const by = {};
|
|
128
|
+
for (const r of runs ?? []) {
|
|
129
|
+
if (r?.repo !== repo || r?.reason !== "merged" || !SIZES.includes(r?.size) || !Number.isFinite(r?.wallMs)) continue;
|
|
130
|
+
(by[r.size] ??= []).push(r.wallMs / 60000);
|
|
131
|
+
}
|
|
132
|
+
const out = {};
|
|
133
|
+
for (const [s, xs] of Object.entries(by)) {
|
|
134
|
+
if (xs.length < HISTORY_MIN_SAMPLES) continue;
|
|
135
|
+
xs.sort((a, b) => a - b);
|
|
136
|
+
const m = xs.length >> 1;
|
|
137
|
+
out[s] = Math.round((xs.length % 2 ? xs[m] : (xs[m - 1] + xs[m]) / 2) * 10) / 10;
|
|
138
|
+
}
|
|
139
|
+
return out;
|
|
140
|
+
}
|
|
141
|
+
|
|
86
142
|
/** Declared dependencies of an issue body; the same parser as the engine's admission (`openDependency`). */
|
|
87
143
|
export function parseDeps(body, issue) {
|
|
88
144
|
return parseDependsOn(body, issue);
|
|
@@ -158,9 +214,10 @@ const toMap = (o) => new Map(Object.entries(o ?? {}).map(([k, v]) => [Number(k),
|
|
|
158
214
|
* @returns {Promise<Array<{issue:number, outcome:string, pr:?number, cost_usd:number, minutes:number, detail?:string}>>}
|
|
159
215
|
*/
|
|
160
216
|
export async function orchestrate({ issues, parallel = 3, runOne, readBody, prefilter = null, resolveLane = null, now = () => Date.now(), onUpdate = () => {}, onCrossLane = () => {},
|
|
161
|
-
store = null, adopt = null, getParallel = null, breaker = null, maxDeferrals = DEFAULT_MAX_DEFERRALS, sleep = cancellableSleep, pollMs = 5000,
|
|
217
|
+
store = null, adopt = null, getParallel = null, getAdded = null, breaker = null, maxDeferrals = DEFAULT_MAX_DEFERRALS, sleep = cancellableSleep, pollMs = 5000,
|
|
162
218
|
classify = null, maintenance = null, limits = null, identity = () => "default", batchId = null, drift = null, sched = null, priorityOf = null, remote = null, beforeAdmit = null,
|
|
163
|
-
recovery = null, decide = () => {}, conflicting = null, relabelNeedsHuman = null
|
|
219
|
+
recovery = null, decide = () => {}, conflicting = null, relabelNeedsHuman = null, onExit = null, issueLabels = null, getCancelled = null, stopLane = null,
|
|
220
|
+
estimate = null, starveMs = DEFAULT_STARVE_MS, signal = null }) {
|
|
164
221
|
const saved = store?.load?.() ?? null;
|
|
165
222
|
const order = saved ? [...saved.order] : [];
|
|
166
223
|
const set = new Set(order);
|
|
@@ -176,11 +233,16 @@ export async function orchestrate({ issues, parallel = 3, runOne, readBody, pref
|
|
|
176
233
|
const deferrals = toMap(saved?.deferrals);
|
|
177
234
|
const engineErrors = toMap(saved?.engineErrors);
|
|
178
235
|
const checked = toMap(saved?.prefilter); // issue -> pre-filter result
|
|
236
|
+
const estimates = toMap(saved?.estimates); // #4409: issue -> estimated lane minutes; only with `estimate`
|
|
237
|
+
const enqueued = toMap(saved?.enqueued); // #4409: issue -> when it last entered the queue (starvation guard)
|
|
238
|
+
const heads = new Set(saved?.heads ?? []); // #4409: decomposed sub-issues not started yet: the head of their priority band
|
|
179
239
|
let errorTimes = [...(saved?.errorTimes ?? [])];
|
|
180
240
|
// A legacy batch-local `holdUntil` (pre-#4190) names no account and is ignored: a same-account lane re-sets the hold.
|
|
181
241
|
const holds = limits ?? memoryLimits(now);
|
|
182
242
|
const heldUntil = () => { try { const id = identity(); const u = holds.holdUntil(id); return Number.isFinite(u) && u > now() ? u : null; } catch { return null; } };
|
|
183
243
|
let tripped = saved?.breaker ?? null;
|
|
244
|
+
// #4376: operator cancel (the batch `cancel` file through `getCancelled`); persisted, but the file stays the authority.
|
|
245
|
+
let cancelled = getCancelled ? (saved?.cancelled ?? null) : null;
|
|
184
246
|
const budget = maintenance && Number(maintenance.share) > 0 ? { share: Number(maintenance.share), whenIdle: !!maintenance.whenIdle } : null;
|
|
185
247
|
const classes = toMap(saved?.classes); // issue -> {maintenance, priority}; only with a maintenance budget
|
|
186
248
|
const dispatch = { started: saved?.dispatch?.started ?? 0, maint: saved?.dispatch?.maint ?? 0 };
|
|
@@ -217,14 +279,16 @@ export async function orchestrate({ issues, parallel = 3, runOne, readBody, pref
|
|
|
217
279
|
const decided = (d) => { try { Promise.resolve(decide?.(d)).catch(() => {}); } catch { /* best effort */ } };
|
|
218
280
|
const firstLine = (x) => String(x ?? "").split("\n")[0].slice(0, 300);
|
|
219
281
|
|
|
282
|
+
let halted = false; // #4454: stopped by `signal`; the batch's state is no longer this process's to write
|
|
220
283
|
const save = () => {
|
|
221
|
-
if (!store) return;
|
|
284
|
+
if (!store || halted) return;
|
|
222
285
|
store.save({ v: 1, order, deps: toObj(deps), lanes: toObj(lanes), crossSeen: [...crossSeen], results: toObj(results), running: toObj(runRecs),
|
|
223
286
|
pending, notBefore: toObj(notBefore), requeues: toObj(requeues), deferrals: toObj(deferrals), engineErrors: toObj(engineErrors),
|
|
224
287
|
prefilter: toObj(checked), errorTimes, breaker: tripped, ...(budget ? { classes: toObj(classes), dispatch: { ...dispatch } } : {}),
|
|
225
288
|
...(reg ? { priorities: toObj(priorities) } : {}), ...(sandboxUse.size ? { sandbox: toObj(sandboxUse) } : {}),
|
|
226
289
|
...(recoveries.size || recovering.size ? { recoveries: toObj(recoveries), recovering: toObj(recovering) } : {}),
|
|
227
|
-
...(relabelled.size ? { relabelled: [...relabelled] } : {}) })
|
|
290
|
+
...(relabelled.size ? { relabelled: [...relabelled] } : {}), ...(cancelled ? { cancelled } : {}),
|
|
291
|
+
...(estimate ? { estimates: toObj(estimates), enqueued: toObj(enqueued) } : {}), ...(heads.size ? { heads: [...heads] } : {}) });
|
|
228
292
|
};
|
|
229
293
|
const update = (row) => { store?.log?.(row); onUpdate(row); };
|
|
230
294
|
const classifyOne = async (n) => {
|
|
@@ -239,11 +303,19 @@ export async function orchestrate({ issues, parallel = 3, runOne, readBody, pref
|
|
|
239
303
|
try { p = priorityOf ? await priorityOf(n) : null; } catch { p = null; }
|
|
240
304
|
priorities.set(n, p !== null && p !== "" && Number.isFinite(Number(p)) ? Number(p) : 2);
|
|
241
305
|
};
|
|
306
|
+
// #4409: fail-open: a thrown or junk estimate is the M default.
|
|
307
|
+
const readEstimate = async (n, body) => {
|
|
308
|
+
if (!estimate || estimates.has(n)) return;
|
|
309
|
+
let v = null;
|
|
310
|
+
try { v = Number(await estimate(n, { body })); } catch { v = null; }
|
|
311
|
+
estimates.set(n, Number.isFinite(v) && v > 0 ? v : EST_DEFAULT_MIN.M);
|
|
312
|
+
};
|
|
242
313
|
const readDeps = async (n) => {
|
|
243
314
|
let body = "";
|
|
244
315
|
try { body = await readBody(n); } catch { /* unreadable: no ordering info, work-on's own check still applies */ }
|
|
245
316
|
if (drift?.holds) bodies.set(n, body);
|
|
246
317
|
deps.set(n, parseDeps(body, n));
|
|
318
|
+
await readEstimate(n, body);
|
|
247
319
|
await classifyOne(n);
|
|
248
320
|
await readPriority(n);
|
|
249
321
|
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); }
|
|
@@ -258,15 +330,31 @@ export async function orchestrate({ issues, parallel = 3, runOne, readBody, pref
|
|
|
258
330
|
onCrossLane({ issue: n, lane: a.lane, dependency: d, dependencyLane: b.lane });
|
|
259
331
|
}
|
|
260
332
|
};
|
|
261
|
-
|
|
333
|
+
// #4409: `head` (decomposed sub-issues) puts them at the front of the queue in their given order; a sub-issue's
|
|
334
|
+
// estimate is the parent's ÷ N, floored at the M default.
|
|
335
|
+
const addIssues = async (ns, { head = false, parent = null } = {}) => {
|
|
262
336
|
const fresh = ns.filter((d) => !set.has(d));
|
|
263
|
-
for (const d of fresh) {
|
|
264
|
-
|
|
337
|
+
for (const d of fresh) {
|
|
338
|
+
set.add(d); order.push(d);
|
|
339
|
+
if (estimate && parent !== null && estimates.has(parent)) estimates.set(d, Math.max(EST_DEFAULT_MIN.M, estimates.get(parent) / ns.length));
|
|
340
|
+
}
|
|
341
|
+
let at = 0;
|
|
342
|
+
for (const d of fresh) {
|
|
343
|
+
await readDeps(d);
|
|
344
|
+
if (head) { pending.splice(at++, 0, d); heads.add(d); } else pending.push(d);
|
|
345
|
+
enqueued.set(d, now());
|
|
346
|
+
}
|
|
265
347
|
noteCrossLane();
|
|
266
348
|
};
|
|
267
349
|
// New issues (a fresh batch, or extra issues passed to a resume) join the queue.
|
|
268
350
|
for (const n of issues) if (!set.has(n)) { set.add(n); order.push(n); pending.push(n); }
|
|
351
|
+
for (const n of pending) if (!enqueued.has(n)) enqueued.set(n, now());
|
|
269
352
|
for (const n of order) if (!deps.has(n)) await readDeps(n);
|
|
353
|
+
if (estimate) for (const n of order) { // a resume that enabled the estimator
|
|
354
|
+
if (estimates.has(n) || results.has(n)) continue;
|
|
355
|
+
let b = ""; try { b = await readBody(n); } catch { b = ""; }
|
|
356
|
+
await readEstimate(n, b);
|
|
357
|
+
}
|
|
270
358
|
if (budget) for (const n of order) if (!classes.has(n)) await classifyOne(n); // a resume that enabled the budget
|
|
271
359
|
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
|
|
272
360
|
if (reg) for (const n of order) if (!priorities.has(n)) await readPriority(n); // a resume of a pre-#4191 batch
|
|
@@ -278,6 +366,20 @@ export async function orchestrate({ issues, parallel = 3, runOne, readBody, pref
|
|
|
278
366
|
let fellBack = false;
|
|
279
367
|
const keyOf = (n) => `${sched?.batch ?? batchId ?? "?"}#${n}`;
|
|
280
368
|
const prioOf = (n) => priorities.get(n) ?? 2;
|
|
369
|
+
// #4409: priority, then the head band (decomposed sub-issues; with an estimator also starved issues, oldest first), then
|
|
370
|
+
// the estimate plus in-batch dependents' estimates descending, then queue order. Without an estimator: queue order.
|
|
371
|
+
const rank = (ready) => {
|
|
372
|
+
const t = now(), idx = new Map(pending.map((n, i) => [n, i]));
|
|
373
|
+
const starved = (n) => !!estimate && !heads.has(n) && t - (enqueued.get(n) ?? t) > starveMs;
|
|
374
|
+
const weight = (n) => {
|
|
375
|
+
if (!estimate) return 0;
|
|
376
|
+
let w = estimates.get(n) ?? 0;
|
|
377
|
+
for (const m of order) if (m !== n && !results.has(m) && edges(m).includes(n)) w += estimates.get(m) ?? 0;
|
|
378
|
+
return w;
|
|
379
|
+
};
|
|
380
|
+
return ready.map((n) => ({ n, p: prioOf(n), b: heads.has(n) || starved(n) ? 0 : 1, s: starved(n) ? enqueued.get(n) : 0, w: weight(n), i: idx.get(n) ?? 0 }))
|
|
381
|
+
.sort((a, b) => a.p - b.p || a.b - b.b || a.s - b.s || b.w - a.w || a.i - b.i).map((x) => x.n);
|
|
382
|
+
};
|
|
281
383
|
const entryOf = (n) => ({ key: keyOf(n), batch: sched?.batch ?? batchId ?? null, repo: sched?.repo ?? null, issue: n, priority: prioOf(n) });
|
|
282
384
|
const releaseSlot = (n) => { if (reg && slotted.delete(n)) reg.release(keyOf(n)); };
|
|
283
385
|
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); };
|
|
@@ -332,7 +434,7 @@ export async function orchestrate({ issues, parallel = 3, runOne, readBody, pref
|
|
|
332
434
|
};
|
|
333
435
|
|
|
334
436
|
const skip = (n, detail) => { results.set(n, { issue: n, outcome: "skipped", pr: null, cost_usd: 0, minutes: 0, detail }); update(results.get(n)); };
|
|
335
|
-
const requeue = (n, delayMs) => { pending.push(n); if (delayMs > 0) notBefore.set(n, now() + delayMs); else notBefore.delete(n); };
|
|
437
|
+
const requeue = (n, delayMs) => { pending.push(n); enqueued.set(n, now()); if (delayMs > 0) notBefore.set(n, now() + delayMs); else notBefore.delete(n); };
|
|
336
438
|
const noteEngineError = () => {
|
|
337
439
|
if (!breaker) return;
|
|
338
440
|
const t = now(), windowMs = breaker.window_min * 60000;
|
|
@@ -345,13 +447,20 @@ export async function orchestrate({ issues, parallel = 3, runOne, readBody, pref
|
|
|
345
447
|
|
|
346
448
|
// Apply one lane's result: requeue it (deferred, dependency-open, engine-error) or record its final row.
|
|
347
449
|
const settle = async (n, r, rec, pf) => {
|
|
450
|
+
if (halted) return; // #4454: a resume adopts the lane and settles it
|
|
348
451
|
// #4304: the pre-filter runs once per issue; a recovery pass's row adds the earlier attempt's cost, which already holds it.
|
|
349
452
|
const pfCost = recovering.has(n) ? 0 : (pf?.cost_usd ?? 0);
|
|
350
453
|
const row = { issue: n, outcome: r?.terminalReason ?? "engine-error", pr: r?.pr ?? null, cost_usd: (r?.cost_usd ?? 0) + pfCost, minutes: Math.round((now() - rec.startedAt) / 6000) / 10, detail: r?.detail, ...(r?.code ? { code: r.code } : {}), ...(pf ? { prefilter: pf } : {}),
|
|
351
454
|
...(r?.waits && typeof r.waits === "object" ? { waits: r.waits } : {}), ...(Number.isInteger(r?.heavyQueueMax) ? { heavyQueueMax: r.heavyQueueMax } : {}),
|
|
352
|
-
...(
|
|
455
|
+
...(Number.isFinite(r?.precheckMs) ? { precheck_ms: r.precheckMs } : {}), ...(Number.isInteger(r?.precheckDeferred) && r.precheckDeferred > 0 ? { precheck_deferred: r.precheckDeferred } : {}), // #4331
|
|
456
|
+
...(typeof r?.firstPass === "boolean" ? { first_pass: r.firstPass } : {}), // #4362
|
|
457
|
+
...(r?.turns && typeof r.turns === "object" ? { turns: r.turns } : {}), // #4392
|
|
458
|
+
...(r?.conformance && typeof r.conformance === "object" ? { conformance: r.conformance } : {}), // #4398
|
|
459
|
+
...(rec?.placement ? { placement: rec.placement } : {}), ...(estimates.has(n) ? { est_minutes: Math.round(estimates.get(n) * 10) / 10 } : {}) }; // #4409
|
|
353
460
|
const depList = (Array.isArray(r?.dependencies) ? r.dependencies : []).filter((d) => Number.isSafeInteger(d) && d > 0 && d !== n);
|
|
354
461
|
const subs = (Array.isArray(r?.subissues) ? r.subissues : []).filter((d) => Number.isSafeInteger(d) && d > 0 && d !== n);
|
|
462
|
+
if (subs.length) row.subissues = subs; // #4396: links a decomposed parent to its sub-lanes in the report
|
|
463
|
+
if (r?.decomposition && typeof r.decomposition === "object") row.decomposition = r.decomposition;
|
|
355
464
|
if (r?.sandbox && typeof r.sandbox === "object") {
|
|
356
465
|
const prev = sandboxUse.get(n) ?? { minutes: 0, cost_usd: 0, attempts: 0 };
|
|
357
466
|
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;
|
|
@@ -360,6 +469,26 @@ export async function orchestrate({ issues, parallel = 3, runOne, readBody, pref
|
|
|
360
469
|
}
|
|
361
470
|
if (sandboxUse.has(n)) row.sandbox = { ...sandboxUse.get(n) };
|
|
362
471
|
const done = () => { running.delete(n); runRecs.delete(n); releaseSlot(n); remoteRun.delete(n); };
|
|
472
|
+
if (row.outcome === "dependency-open" && depList.length) {
|
|
473
|
+
// #4387: a parked dependency is not pulled in again (fail-open on an unreadable label).
|
|
474
|
+
let parked;
|
|
475
|
+
for (const d of depList) {
|
|
476
|
+
if (results.get(d)?.outcome === "needs-human") { parked = d; break; }
|
|
477
|
+
if (set.has(d) || typeof issueLabels !== "function") continue;
|
|
478
|
+
let names = [];
|
|
479
|
+
try { names = (await issueLabels(d)) ?? []; } catch { names = []; }
|
|
480
|
+
if (Array.isArray(names) && names.includes("needs-human")) { parked = d; break; }
|
|
481
|
+
}
|
|
482
|
+
if (parked !== undefined) {
|
|
483
|
+
done();
|
|
484
|
+
const final = { ...row, outcome: "skipped", detail: `dependency #${parked} needs-human` };
|
|
485
|
+
results.set(n, final); save();
|
|
486
|
+
decided({ issue: n, what: `skipped: dependency #${parked} needs-human (not pulled into the batch)`, why: firstLine(row.detail) || "the lane reported an open dependency",
|
|
487
|
+
alternative: `pull #${parked} in and run it again` });
|
|
488
|
+
update(final);
|
|
489
|
+
return;
|
|
490
|
+
}
|
|
491
|
+
}
|
|
363
492
|
if (row.outcome === "dependency-open" && depList.length && (requeues.get(n) ?? 0) < MAX_REQUEUES) {
|
|
364
493
|
// A scheduling fact, not a failure: add the edge, pull the dependency in, and run this issue again after it.
|
|
365
494
|
requeues.set(n, (requeues.get(n) ?? 0) + 1);
|
|
@@ -373,6 +502,12 @@ export async function orchestrate({ issues, parallel = 3, runOne, readBody, pref
|
|
|
373
502
|
update({ ...row, detail: `requeued behind ${depList.map((d) => `#${d}`).join(", ")}` });
|
|
374
503
|
return;
|
|
375
504
|
}
|
|
505
|
+
// #4376: a lane stopped by a cancel is not a scheduling failure: requeued (held at the end), no deferral, no hold.
|
|
506
|
+
if (cancelled && (row.outcome === "deferred" || row.outcome === "engine-error")) {
|
|
507
|
+
requeue(n, 0); done(); save();
|
|
508
|
+
update({ ...row, detail: `stopped by cancel: ${r?.code ?? row.outcome}${r?.detail ? ` ${r.detail}` : ""}` });
|
|
509
|
+
return;
|
|
510
|
+
}
|
|
376
511
|
if (row.outcome === "deferred") {
|
|
377
512
|
const pol = deferPolicy(r, { now: now(), deferrals: deferrals.get(n) ?? 0, maxDeferrals });
|
|
378
513
|
if (!pol.capped) {
|
|
@@ -400,7 +535,7 @@ export async function orchestrate({ issues, parallel = 3, runOne, readBody, pref
|
|
|
400
535
|
}
|
|
401
536
|
}
|
|
402
537
|
if (row.outcome === "decomposed" && subs.length) {
|
|
403
|
-
await addIssues(subs);
|
|
538
|
+
await addIssues(subs, { head: true, parent: n }); // #4409: on the batch's critical path by construction
|
|
404
539
|
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",
|
|
405
540
|
alternative: "end the batch row decomposed and let dependents wait for a later batch" });
|
|
406
541
|
for (const [m, ds] of deps) if (ds.includes(n)) deps.set(m, [...new Set(ds.flatMap((d) => (d === n ? subs : [d])))]);
|
|
@@ -408,7 +543,8 @@ export async function orchestrate({ issues, parallel = 3, runOne, readBody, pref
|
|
|
408
543
|
}
|
|
409
544
|
// #4267: a recovery pass ends here; a recoverable row under the attempt cap is requeued as a recovery lane.
|
|
410
545
|
const prior = recovering.get(n) ?? null;
|
|
411
|
-
|
|
546
|
+
// #4318: the full persisted lane reason (capped by the comment writer), not its first 300 chars
|
|
547
|
+
const evidence = () => recoveryEvidence({ code: prior.code, attempt: prior.attempt, outcome: row.outcome, resultCode: row.code, detail: row.detail, full: true });
|
|
412
548
|
if (prior) {
|
|
413
549
|
recovering.delete(n);
|
|
414
550
|
// #4298: the row reports the whole issue: earlier attempts' cost and minutes are summed into the recovery's.
|
|
@@ -468,18 +604,24 @@ export async function orchestrate({ issues, parallel = 3, runOne, readBody, pref
|
|
|
468
604
|
if (relabelled.delete(n)) save(); // #4315: the recovery lane launches after all; it removes needs-human itself
|
|
469
605
|
const pl = placed.get(n);
|
|
470
606
|
placed.delete(n);
|
|
607
|
+
heads.delete(n); enqueued.delete(n);
|
|
471
608
|
const rec = { startedAt: now(), ...(lane ? { lane } : {}), ...(remoteRun.has(n) ? { remote: true } : {}), ...(pl ? { placement: pl } : {}) };
|
|
472
609
|
runRecs.set(n, rec); notBefore.delete(n); save();
|
|
473
610
|
track(n, (async () => {
|
|
474
611
|
// Pre-filter once per issue (a requeued issue is not re-checked); fail-open: an error means a normal lane.
|
|
475
612
|
if (prefilter && !checked.has(n)) { let pf = null; try { pf = await prefilter(n, lane); } catch { pf = null; } checked.set(n, pf); save(); }
|
|
476
613
|
const pf = checked.get(n) ?? null;
|
|
614
|
+
if (halted) return; // #4454: stopped before launch; no pid, so a resume queues it again
|
|
477
615
|
let r;
|
|
478
|
-
if (
|
|
616
|
+
if (cancelled) r = { terminalReason: "deferred", code: "run.interrupted", cost_usd: 0, detail: "cancelled before launch" }; // #4376
|
|
617
|
+
else if (pf?.closed) r = { terminalReason: "invalid", cost_usd: 0, detail: `pre-filter: ${pf.detail}` };
|
|
618
|
+
// #4389: a parked post-deploy follow-up starts no lane; it ends awaiting-promotion|awaiting-precondition, re-checked next batch.
|
|
619
|
+
else if (pf?.parked) r = { terminalReason: pf.kind, cost_usd: 0, detail: `pre-filter: ${pf.detail}` };
|
|
479
620
|
else {
|
|
480
621
|
try {
|
|
481
622
|
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, final: recovering.get(n).attempt >= recoveryMax } } : {}), onStart: (info) => {
|
|
482
623
|
Object.assign(rec, info ?? {}); save();
|
|
624
|
+
if (cancelled) stop(n, rec); // #4376: launched while the cancel arrived
|
|
483
625
|
if (reg && slotted.has(n) && info?.pid) reg.setLanePid(keyOf(n), info.pid);
|
|
484
626
|
} });
|
|
485
627
|
}
|
|
@@ -498,7 +640,7 @@ export async function orchestrate({ issues, parallel = 3, runOne, readBody, pref
|
|
|
498
640
|
if (reg) { reg.adoptSlot(keyOf(n), { ...entryOf(n), lanePid: rec.pid }); slotted.add(n); }
|
|
499
641
|
track(n, Promise.resolve(p).then((r) => settle(n, r, rec, checked.get(n) ?? null)));
|
|
500
642
|
}
|
|
501
|
-
else { runRecs.delete(n); if (!pending.includes(n) && !results.has(n)) pending.unshift(n); }
|
|
643
|
+
else { runRecs.delete(n); if (!pending.includes(n) && !results.has(n)) { pending.unshift(n); enqueued.set(n, now()); } }
|
|
502
644
|
}
|
|
503
645
|
// A resume re-derives each unfinished issue's lane from its current milestone (#4153): a re-milestone takes effect.
|
|
504
646
|
if (saved && resolveLane) {
|
|
@@ -511,101 +653,194 @@ export async function orchestrate({ issues, parallel = 3, runOne, readBody, pref
|
|
|
511
653
|
}
|
|
512
654
|
save();
|
|
513
655
|
|
|
514
|
-
const
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
if (
|
|
656
|
+
const heldDetail = (n) => cancelled ? "cancelled by operator" : tripped?.detail ?? (driftHeld.has(n) ? `drift: ${driftHeld.get(n)} flagged` : budgetStop ? `execution budget: ${budgetStop}` : budgetHeld ? "maintenance share" : "not run");
|
|
657
|
+
// #4315: a recovery queued but never launched (the batch stopped: breaker, budget, hold; or a resume that did not relaunch a
|
|
658
|
+
// lost run) left the issue without needs-human, since a non-final attempt does not relabel. Relabel it once, with the full reason.
|
|
659
|
+
// #4318: also on a throw out of the loop and on a signal (`onExit`); `cause` overrides the held reason. Runs once per process.
|
|
660
|
+
let exitRelabel = null;
|
|
661
|
+
const relabelQueued = (cause = null) => (exitRelabel ??= (async () => {
|
|
662
|
+
for (const [n, q] of recovering) {
|
|
663
|
+
if (running.has(n) || results.has(n) || relabelled.has(n) || typeof relabelNeedsHuman !== "function") continue;
|
|
664
|
+
const why = cause ?? (heldDetail(n) === "not run" ? "batch stopped" : heldDetail(n));
|
|
665
|
+
const evidence = recoveryEvidence({ code: q.code, attempt: q.prev_attempt, outcome: q.outcome, resultCode: q.result_code, detail: q.detail, full: true,
|
|
666
|
+
queued: q.attempt, cause: why, recoveryMax });
|
|
667
|
+
let res;
|
|
668
|
+
try { res = await relabelNeedsHuman({ issue: n, evidence }); } catch { res = "failed"; }
|
|
669
|
+
if (res !== false && res !== "failed") relabelled.add(n); // a failed label add is retried by a resume
|
|
522
670
|
}
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
671
|
+
save();
|
|
672
|
+
})());
|
|
673
|
+
if (typeof onExit === "function") { try { onExit(relabelQueued); } catch { /* best effort */ } }
|
|
674
|
+
|
|
675
|
+
// #4408: issues added to the running batch; returns how many joined. Re-reading the cumulative list is a no-op.
|
|
676
|
+
const drainAdds = async () => {
|
|
677
|
+
if (!getAdded) return 0;
|
|
678
|
+
let got = [];
|
|
679
|
+
try { got = getAdded() ?? []; } catch { got = []; }
|
|
680
|
+
const fresh = [...new Set((Array.isArray(got) ? got : []).filter((n) => Number.isSafeInteger(n) && n > 0 && !set.has(n)))];
|
|
681
|
+
if (!fresh.length) return 0;
|
|
682
|
+
// An edge an operator wrote into a pending issue after the batch started (it waits on an added issue) holds too.
|
|
683
|
+
for (const n of pending) {
|
|
684
|
+
let body;
|
|
685
|
+
try { body = await readBody(n); } catch { continue; }
|
|
686
|
+
if (drift?.holds) bodies.set(n, body);
|
|
687
|
+
deps.set(n, [...new Set([...(deps.get(n) ?? []), ...parseDeps(body, n)])]);
|
|
688
|
+
}
|
|
689
|
+
await addIssues(fresh);
|
|
690
|
+
save();
|
|
691
|
+
const list = fresh.map((n) => `#${n}`).join(", ");
|
|
692
|
+
decided({ issue: null, what: `added ${list} to the batch (forgedock orchestrate --add)`, why: "operator request", alternative: "start a second batch" });
|
|
693
|
+
update({ issue: null, outcome: "added", detail: list });
|
|
694
|
+
return fresh.length;
|
|
695
|
+
};
|
|
696
|
+
// #4376: one SIGTERM per running local lane (their teardown saves the work); remote lanes run to their result.
|
|
697
|
+
const stopSent = new Set();
|
|
698
|
+
function stop(n, rec) {
|
|
699
|
+
if (!stopLane || rec?.remote || stopSent.has(n)) return;
|
|
700
|
+
stopSent.add(n);
|
|
701
|
+
try { Promise.resolve(stopLane(n, rec)).catch(() => {}); } catch { /* best effort */ }
|
|
702
|
+
}
|
|
703
|
+
const checkCancel = () => {
|
|
704
|
+
if (!getCancelled) return;
|
|
705
|
+
let c = null;
|
|
706
|
+
try { c = getCancelled() ?? null; } catch { c = null; }
|
|
707
|
+
if (!c) { if (cancelled) { cancelled = null; save(); } return; }
|
|
708
|
+
const first = !cancelled;
|
|
709
|
+
cancelled = c;
|
|
710
|
+
if (first) {
|
|
711
|
+
save();
|
|
712
|
+
decided({ issue: null, what: "cancelled the batch (forgedock orchestrate --cancel)", why: "operator request", alternative: "stop the shared schedd" });
|
|
713
|
+
update({ issue: null, outcome: "cancelled", detail: "cancelled by operator (forgedock orchestrate --cancel)" });
|
|
714
|
+
if (reg) withdrawExcept(new Set());
|
|
715
|
+
}
|
|
716
|
+
for (const [n, rec] of runRecs) if (running.has(n)) stop(n, rec);
|
|
717
|
+
};
|
|
718
|
+
// #4454: the shutdown stop; every wait races it and cancels its sleep.
|
|
719
|
+
const aborted = signal ? new Promise((r) => { if (signal.aborted) r(); else signal.addEventListener("abort", () => r(), { once: true }); }) : null;
|
|
720
|
+
const nap = (ms) => {
|
|
721
|
+
const ac = new AbortController();
|
|
722
|
+
return Promise.race([sleep(ms, ac.signal), ...(aborted ? [aborted] : [])]).finally(() => ac.abort());
|
|
723
|
+
};
|
|
724
|
+
const race = (ps) => Promise.race([...ps, ...(aborted ? [aborted] : [])]);
|
|
725
|
+
const halt = () => {
|
|
726
|
+
if (reg) withdrawExcept(new Set());
|
|
727
|
+
save();
|
|
728
|
+
halted = true;
|
|
729
|
+
return Object.assign(new Error("batch stopped: schedd shutting down"), { code: "batch.stopped" });
|
|
730
|
+
};
|
|
731
|
+
const K = () => { let k = null; try { k = getParallel?.(); } catch { k = null; } return Number.isInteger(k) && k >= 1 ? k : parallel; };
|
|
732
|
+
try {
|
|
733
|
+
for (;;) {
|
|
734
|
+
if (signal?.aborted) throw halt();
|
|
735
|
+
await drainAdds();
|
|
736
|
+
checkCancel();
|
|
737
|
+
if (!pending.length && !running.size) break;
|
|
738
|
+
// Resolve skips first: a dependency that finished unsatisfied (or never can) skips its dependents.
|
|
739
|
+
for (let i = pending.length - 1; i >= 0; i--) {
|
|
740
|
+
const n = pending[i];
|
|
741
|
+
if (results.has(n)) { pending.splice(i, 1); continue; }
|
|
742
|
+
const bad = edges(n).find((d) => results.has(d) && !SATISFIED.has(results.get(d).outcome));
|
|
743
|
+
if (bad !== undefined) { pending.splice(i, 1); skip(n, `dependency #${bad} ended ${results.get(bad).outcome}`); }
|
|
550
744
|
}
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
745
|
+
const t = now();
|
|
746
|
+
const holdUntil = heldUntil();
|
|
747
|
+
let started = 0;
|
|
748
|
+
driftHeld.clear();
|
|
749
|
+
const isReady = (n) => {
|
|
750
|
+
if (!((notBefore.get(n) ?? 0) <= t && edges(n).every((d) => results.has(d) && SATISFIED.has(results.get(d).outcome)))) return false;
|
|
751
|
+
if (!drift?.holds) return true;
|
|
752
|
+
let pkg = null;
|
|
753
|
+
try { pkg = drift.holds(n, { body: bodies.get(n) ?? "", lane: lanes.get(n)?.lane ?? null }); } catch { pkg = null; }
|
|
754
|
+
if (pkg) driftHeld.set(n, pkg);
|
|
755
|
+
return !pkg;
|
|
756
|
+
};
|
|
757
|
+
budgetHeld = false;
|
|
758
|
+
// #4183: a hook (owned-resource GC near the host ceiling) before lanes are admitted; its errors never stop the batch.
|
|
759
|
+
if (beforeAdmit && !tripped && !cancelled && !holdUntil && running.size < K() && pending.some(isReady)) { try { await beforeAdmit(); } catch { /* ignored */ } }
|
|
760
|
+
if (signal?.aborted) throw halt();
|
|
761
|
+
budgetStop = null;
|
|
762
|
+
const wanted = new Set(); // issues that stay offered in the host registry after this pass
|
|
763
|
+
if (!tripped && !cancelled && !holdUntil && budget) {
|
|
764
|
+
while (running.size < K()) {
|
|
765
|
+
const ready = rank(pending.filter(isReady));
|
|
766
|
+
const n = pickNext(ready, { classes, share: budget.share, whenIdle: budget.whenIdle, started: dispatch.started, maint: dispatch.maint,
|
|
767
|
+
featuresPending: pending.some((p) => !clsOf(classes, p).maintenance) });
|
|
768
|
+
if (n === null) { budgetHeld = ready.length > 0; break; }
|
|
769
|
+
if (!grant(n, wanted)) break;
|
|
770
|
+
pending.splice(pending.indexOf(n), 1);
|
|
771
|
+
if (lanes.get(n)?.ok !== false) { dispatch.started++; if (clsOf(classes, n).maintenance) dispatch.maint++; }
|
|
772
|
+
start(n); started++;
|
|
773
|
+
}
|
|
774
|
+
} else if (!tripped && !cancelled && !holdUntil && reg) {
|
|
775
|
+
// Priority first, then `rank` within a priority; the free per-batch K is offered so the host queue keeps FIFO.
|
|
776
|
+
const ready = rank(pending.filter(isReady));
|
|
777
|
+
const want = ready.filter((n) => lanes.get(n)?.ok !== false && !isRemote(n)).slice(0, Math.max(0, K() - running.size));
|
|
778
|
+
offerFresh(want);
|
|
779
|
+
for (const n of want) wanted.add(n);
|
|
780
|
+
for (const n of ready) {
|
|
781
|
+
if (running.size >= K() || !grant(n, wanted)) break;
|
|
782
|
+
pending.splice(pending.indexOf(n), 1); start(n); started++;
|
|
783
|
+
}
|
|
784
|
+
} else if (!tripped && !cancelled && !holdUntil) {
|
|
785
|
+
for (const n of rank(pending.filter(isReady))) {
|
|
786
|
+
if (running.size >= K()) break;
|
|
787
|
+
if (grant(n, new Set())) { pending.splice(pending.indexOf(n), 1); start(n); started++; }
|
|
788
|
+
}
|
|
560
789
|
}
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
790
|
+
if (reg) withdrawExcept(wanted);
|
|
791
|
+
if (tripped && !running.size) { await drainAdds(); break; } // breaker: let running lanes finish, keep the rest pending for a resume
|
|
792
|
+
if (cancelled && !running.size) { await drainAdds(); break; } // #4376: cancel: the rest end held for a resume
|
|
793
|
+
if (budgetStop && !running.size) { await drainAdds(); break; } // execution budget (stop): the same, the rest end held
|
|
794
|
+
// Wake for a delayed issue, the end of a usage-limit hold, or (with a live K) a poll.
|
|
795
|
+
const waits = pending.map((n) => notBefore.get(n) ?? 0).filter((x) => x > t);
|
|
796
|
+
if (holdUntil) waits.push(holdUntil);
|
|
797
|
+
let wake = waits.length ? Math.max(0, Math.min(...waits) - t) : null;
|
|
798
|
+
if (holdUntil && pending.length) wake = Math.min(wake ?? pollMs, pollMs); // #4446: a cleared hold (forgedock limits clear) admits on the next poll
|
|
799
|
+
if (getParallel && pending.length) wake = Math.min(wake ?? pollMs, pollMs);
|
|
800
|
+
if (offered.size && pending.length) wake = Math.min(wake ?? pollMs, pollMs); // a slot another batch frees
|
|
801
|
+
if (getAdded && running.size) wake = Math.min(wake ?? pollMs, pollMs); // #4408: an add while only lanes run
|
|
802
|
+
if (tripped) wake = null;
|
|
803
|
+
if (getCancelled && running.size && !cancelled) wake = Math.min(wake ?? pollMs, pollMs); // #4376: notice a cancel while lanes run
|
|
804
|
+
if (running.size) {
|
|
805
|
+
if (wake === null) await race(running.values());
|
|
806
|
+
else { const ac = new AbortController(); await race([...running.values(), sleep(wake, ac.signal)]); ac.abort(); }
|
|
807
|
+
} else if (started) continue; // only lane-refused rows settled (synchronously): re-resolve skips first
|
|
808
|
+
else if (pending.length && offered.size) await nap(wake); // waiting for a host slot
|
|
809
|
+
else if (pending.length && waits.length) await nap(Math.min(wake, getCancelled ? pollMs : Infinity)); // wake is poll-capped by a hold or a live K; #4454: nap wakes on a stop
|
|
810
|
+
else if (pending.length && budgetHeld) { if (await drainAdds()) continue; break; } // maintenance the budget does not admit now: held for a later batch
|
|
811
|
+
else if (pending.length && driftHeld.size) { if (await drainAdds()) continue; break; } // held by a drift flag (block_dispatch): pending for a later batch
|
|
812
|
+
else if (pending.length) { // nothing runnable, nothing running, nothing waiting on time: a dependency cycle
|
|
813
|
+
for (const n of pending.splice(0)) skip(n, "dependency cycle");
|
|
814
|
+
save();
|
|
566
815
|
}
|
|
567
816
|
}
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
if (
|
|
571
|
-
|
|
572
|
-
const waits = pending.map((n) => notBefore.get(n) ?? 0).filter((x) => x > t);
|
|
573
|
-
if (holdUntil) waits.push(holdUntil);
|
|
574
|
-
let wake = waits.length ? Math.max(0, Math.min(...waits) - t) : null;
|
|
575
|
-
if (getParallel && pending.length) wake = Math.min(wake ?? pollMs, pollMs);
|
|
576
|
-
if (offered.size && pending.length) wake = Math.min(wake ?? pollMs, pollMs); // a slot another batch frees
|
|
577
|
-
if (tripped) wake = null;
|
|
578
|
-
if (running.size) {
|
|
579
|
-
if (wake === null) await Promise.race(running.values());
|
|
580
|
-
else { const ac = new AbortController(); await Promise.race([...running.values(), sleep(wake, ac.signal)]); ac.abort(); }
|
|
581
|
-
} else if (started) continue; // only lane-refused rows settled (synchronously): re-resolve skips first
|
|
582
|
-
else if (pending.length && offered.size) await sleep(wake); // waiting for a host slot
|
|
583
|
-
else if (pending.length && waits.length) await sleep(Math.max(0, Math.min(...waits) - t));
|
|
584
|
-
else if (pending.length && budgetHeld) break; // maintenance the budget does not admit now: held for a later batch
|
|
585
|
-
else if (pending.length && driftHeld.size) break; // held by a drift flag (block_dispatch): pending for a later batch
|
|
586
|
-
else if (pending.length) { // nothing runnable, nothing running, nothing waiting on time: a dependency cycle
|
|
587
|
-
for (const n of pending.splice(0)) skip(n, "dependency cycle");
|
|
588
|
-
save();
|
|
589
|
-
}
|
|
817
|
+
} catch (e) {
|
|
818
|
+
// #4318: an exception out of the loop still relabels the queued recoveries before it propagates (not a #4454 stop)
|
|
819
|
+
if (!halted) try { await relabelQueued(`batch failed: ${firstLine(e?.message ?? e)}`); } catch { /* best effort */ }
|
|
820
|
+
throw e;
|
|
590
821
|
}
|
|
591
822
|
if (reg) withdrawExcept(new Set());
|
|
592
|
-
|
|
593
|
-
// #4315: a recovery queued but never launched (the batch stopped: breaker, budget, hold; or a resume that did not relaunch a
|
|
594
|
-
// lost run) left the issue without needs-human, since a non-final attempt does not relabel. Relabel it once, with the full reason.
|
|
595
|
-
for (const [n, q] of recovering) {
|
|
596
|
-
if (running.has(n) || results.has(n) || relabelled.has(n) || typeof relabelNeedsHuman !== "function") continue;
|
|
597
|
-
const cause = heldDetail(n) === "not run" ? "batch stopped" : heldDetail(n);
|
|
598
|
-
const evidence = recoveryEvidence({ code: q.code, attempt: q.prev_attempt, outcome: q.outcome, resultCode: q.result_code, detail: q.detail, full: true,
|
|
599
|
-
queued: q.attempt, cause, recoveryMax });
|
|
600
|
-
try { await relabelNeedsHuman({ issue: n, evidence }); } catch { /* best effort */ }
|
|
601
|
-
relabelled.add(n);
|
|
602
|
-
}
|
|
823
|
+
await relabelQueued();
|
|
603
824
|
save();
|
|
604
825
|
return order.map((n) => results.get(n) ?? { issue: n, outcome: "held", pr: null, cost_usd: 0, minutes: 0, detail: heldDetail(n),
|
|
605
826
|
...(recovering.has(n) ? { recovery: { code: recovering.get(n).code, attempts: recoveries.get(n) ?? recovering.get(n).attempt, outcome: recovering.get(n).outcome ?? "needs-human",
|
|
606
827
|
...(recovering.get(n).result_code ? { result_code: recovering.get(n).result_code } : {}), detail: firstLine(recovering.get(n).detail ?? recovering.get(n).code) } } : {}) });
|
|
607
828
|
}
|
|
608
829
|
|
|
830
|
+
/**
|
|
831
|
+
* #4409 batch-report line: estimated vs. actual lane minutes over the rows that ran with an estimate (feeds the estimator).
|
|
832
|
+
* Null when no row has both.
|
|
833
|
+
*/
|
|
834
|
+
export function formatEstimateSummary(rows) {
|
|
835
|
+
const xs = (rows ?? []).filter((r) => Number.isFinite(r?.est_minutes) && Number(r?.minutes) > 0 && !["held", "skipped", "lane-refused"].includes(r.outcome));
|
|
836
|
+
if (!xs.length) return null;
|
|
837
|
+
const sum = (f) => xs.reduce((a, r) => a + f(r), 0);
|
|
838
|
+
const errs = xs.map((r) => Math.abs(r.minutes - r.est_minutes)).sort((a, b) => a - b);
|
|
839
|
+
const med = errs.length % 2 ? errs[errs.length >> 1] : (errs[errs.length / 2 - 1] + errs[errs.length / 2]) / 2;
|
|
840
|
+
const worst = xs.reduce((a, r) => (Math.abs(r.minutes - r.est_minutes) > Math.abs(a.minutes - a.est_minutes) ? r : a));
|
|
841
|
+
return `estimates: ${xs.length} lane${xs.length === 1 ? "" : "s"}, est ${sum((r) => r.est_minutes).toFixed(1)} min vs actual ${sum((r) => r.minutes).toFixed(1)} min; median |error| ${med.toFixed(1)} min; worst #${worst.issue} est ${worst.est_minutes} actual ${worst.minutes}`;
|
|
842
|
+
}
|
|
843
|
+
|
|
609
844
|
/** Max chars of one row's detail in the batch-report outcome lists (#4298). */
|
|
610
845
|
export const OUTCOME_LINE_MAX = 200;
|
|
611
846
|
/**
|
|
@@ -624,6 +859,8 @@ export function formatOutcomeLists(rows) {
|
|
|
624
859
|
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)})`);
|
|
625
860
|
}
|
|
626
861
|
const esc = (rows ?? []).filter((r) => r?.outcome === "needs-human" && classifyNeedsHuman(r) === "human");
|
|
862
|
+
const parked = formatParkedLine(rows); // #4389: one line for every parked post-deploy follow-up, never an escalation
|
|
863
|
+
if (parked) out.push(parked);
|
|
627
864
|
if (esc.length) out.push(["escalations (human decisions, asked once):", ...esc.map((r) => `- #${r.issue} (${r.code}): ${one(r.detail)}`)].join("\n"));
|
|
628
865
|
return out;
|
|
629
866
|
}
|
|
@@ -632,7 +869,7 @@ export function formatTable(rows) {
|
|
|
632
869
|
// #4255: sandbox minutes and cost next to the token cost, only when some lane ran in a sandbox.
|
|
633
870
|
const sbx = rows.some((r) => r?.sandbox);
|
|
634
871
|
const head = ["issue", "outcome", "PR", "cost", ...(sbx ? ["sbx-min", "sbx-$"] : []), "min"];
|
|
635
|
-
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)}`,
|
|
872
|
+
const body = rows.map((r) => [`#${r.issue}`, r.outcome + (["skipped", "held"].includes(r.outcome) && r.detail ? ` (${r.detail})` : r.prefilter?.closed || r.prefilter?.parked ? " (pre-filter)" : ""), r.pr ? `#${r.pr}` : "-", `$${(r.cost_usd ?? 0).toFixed(2)}`,
|
|
636
873
|
...(sbx ? (r.sandbox ? [(Number(r.sandbox.minutes) || 0).toFixed(1), `$${(Number(r.sandbox.cost_usd) || 0).toFixed(2)}`] : ["-", "-"]) : []), String(r.minutes ?? 0)]);
|
|
637
874
|
const w = head.map((h, i) => Math.max(h.length, ...body.map((b) => b[i].length)));
|
|
638
875
|
const line = (c) => c.map((x, i) => x.padEnd(w[i])).join(" ").trimEnd();
|
|
@@ -653,6 +890,11 @@ export function formatWaitSummary(rows, heavyStats = null) {
|
|
|
653
890
|
return `waits by phase: ${waits}; heavy queue: now ${hasStats && Number.isInteger(heavyStats.queued) ? heavyStats.queued : "?"}, max ${seen}`;
|
|
654
891
|
}
|
|
655
892
|
|
|
893
|
+
/** #4354 batch-report tail: the fail-soft alerts (stats computeAlerts) for the batch's repo; [] when none fired. */
|
|
894
|
+
export function formatAlertLines(alerts) {
|
|
895
|
+
return alerts?.length ? ["ALERTS", ...renderAlerts(alerts)] : [];
|
|
896
|
+
}
|
|
897
|
+
|
|
656
898
|
/**
|
|
657
899
|
* #4255 batch-report line: sandbox lanes, minutes and cost next to those lanes' token cost, plus the execution budget
|
|
658
900
|
* (`budget` from parseExecution, `day` from openExecutionBudget `dayTotals`) when a cap is set. Null without sandbox usage.
|