forgedock 1.30.0 → 1.32.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 +3 -2
- 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 +409 -38
- package/bin/engine.mjs +47 -37
- package/bin/forgedock.mjs +35 -3
- package/bin/gc.mjs +50 -17
- package/bin/hooks/reread-nudge.mjs +65 -0
- 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 +338 -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 +150 -15
- package/bin/lean/conformance.test.mjs +175 -3
- package/bin/lean/context.mjs +128 -44
- package/bin/lean/context.test.mjs +157 -5
- 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 +48 -15
- 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 +712 -134
- package/bin/lean/lean.test.mjs +1626 -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/lightlane.mjs +219 -0
- package/bin/lean/lightlane.test.mjs +237 -0
- package/bin/lean/limits.test.mjs +36 -1
- package/bin/lean/orchestrate.mjs +303 -41
- 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 +138 -13
- package/bin/lean/precheck.test.mjs +118 -1
- package/bin/lean/prefilter.mjs +220 -5
- package/bin/lean/prefilter.test.mjs +260 -4
- package/bin/lean/preflight.mjs +108 -3
- package/bin/lean/preflight.test.mjs +48 -1
- package/bin/lean/probe.mjs +297 -0
- package/bin/lean/probe.test.mjs +210 -0
- package/bin/lean/prompts.mjs +104 -13
- 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.mjs +16 -1
- package/bin/lean/sched.test.mjs +175 -1
- package/bin/lean/schedd.mjs +55 -9
- package/bin/lean/schedd.test.mjs +170 -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 +296 -13
- package/bin/lean/sequence.test.mjs +263 -2
- 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 +72 -10
- package/bin/lean/verify.test.mjs +68 -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 +335 -0
- package/bin/stats/efficiency.test.mjs +266 -0
- package/bin/stats/github-source.mjs +8 -3
- package/bin/stats/index.mjs +91 -6
- package/bin/stats/local-source.mjs +145 -8
- package/bin/stats/query.mjs +181 -3
- package/bin/stats/render.mjs +22 -0
- package/bin/stats/stats.test.mjs +403 -8
- package/commands/orchestrate.md +3 -1
- 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 +80 -5
- package/docs/OUTCOME-CODES.md +2 -1
- package/docs/STATS.md +66 -1
- package/docs/TELEMETRY.md +1 -1
- package/docs/eval/corpus/lightlane-v1.json +55 -0
- package/docs/eval/lightlane-bench.md +91 -0
- package/docs/spec/review-protocol.md +1 -1
- package/package.json +1 -1
- package/scripts/bench-lightlane.mjs +286 -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/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
|
|
@@ -34,6 +41,13 @@
|
|
|
34
41
|
* `priorities`) ranks it host-wide, and the batch's ready issues are tried in priority order. Ready issues it cannot
|
|
35
42
|
* start yet stay offered in the registry (polled every `pollMs`) and are withdrawn once no longer ready; a settled lane
|
|
36
43
|
* releases its slot, an adopted one re-records it.
|
|
44
|
+
* Per-issue lock (#4481), on with `sched.exclusive` (registry entries ask `exclusive: true`): when another batch holds a live slot for the same repo and issue (`tryAcquire` `conflict`, or
|
|
45
|
+
* `registry.holder` for a lane placed remote), the issue is not started: it is retried after `pollMs` with one `deferred`
|
|
46
|
+
* row naming that batch (no deferral count, no breaker), and admission goes on with the next ready issue.
|
|
47
|
+
*
|
|
48
|
+
* Dispatch check (#4481), off unless `dispatchCheck(issue)` is passed: it runs before EVERY lane start (first and
|
|
49
|
+
* requeued, unlike the once-per-issue pre-filter) and returns null (run) or `{outcome: "merged", pr}` / `{outcome:
|
|
50
|
+
* "invalid"}` for an issue already closed or whose PR merged; the row ends with that outcome and no lane starts. Fail-open.
|
|
37
51
|
*
|
|
38
52
|
* Remote lanes (#4253), off unless `remote: {max, isRemote(issue)}` is passed (execution.mjs): a lane `isRemote` places
|
|
39
53
|
* in a sandbox takes no host registry slot; it starts only while fewer than `max` remote lanes of this batch run (the
|
|
@@ -65,10 +79,23 @@
|
|
|
65
79
|
* 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
|
|
66
80
|
* (recovery, engine-error relaunch, dependency requeue, sub-issues added) goes to `decide({issue, what, why, alternative})`,
|
|
67
81
|
* fire-and-forget: the runner never waits on it.
|
|
82
|
+
* Daemon shutdown (#4454): an aborted `signal` stops the runner without ending the batch: nothing more is admitted, every wait
|
|
83
|
+
* wakes, running lanes are neither awaited nor stopped (they are detached), nothing is relabelled and the state is saved once
|
|
84
|
+
* with its `running` records, so a `--resume` re-adopts the lanes; then it throws `code: "batch.stopped"` (no rows, no report).
|
|
85
|
+
*
|
|
86
|
+
* Longest-first admission (#4409): `estimate(issue, {body})` (minutes; off when null, persisted as `estimates`) ranks the
|
|
87
|
+
* ready issues inside a priority level. All admission paths order them by priority (unchanged), then the head band
|
|
88
|
+
* (sub-issues of a decomposed lane, inserted at the front of the queue; with an estimate also issues queued longer than
|
|
89
|
+
* `starveMs`, oldest first), then the estimate plus the estimates of their in-batch dependents (critical path), descending,
|
|
90
|
+
* then queue order. A sub-issue's estimate is the parent's ÷ N, floored at the M default. Without `estimate` the order is
|
|
91
|
+
* the queue order except that decomposed sub-issues go first. A settled row carries `est_minutes` (estimated vs. actual).
|
|
68
92
|
*/
|
|
69
93
|
import { parseDependsOn } from "../engine/phases.mjs";
|
|
70
94
|
import { classifyNeedsHuman, recoveryCodeOf } from "../engine/outcome-codes.mjs";
|
|
71
95
|
import { recoveryEvidence } from "./render.mjs";
|
|
96
|
+
import { renderAlerts } from "../stats/render.mjs";
|
|
97
|
+
import { formatParkedLine, postDeployOnly } from "./prefilter.mjs";
|
|
98
|
+
import { laneSize, SIZES } from "./budget.mjs";
|
|
72
99
|
|
|
73
100
|
// A dependency that finished like this no longer blocks its dependents.
|
|
74
101
|
const SATISFIED = new Set(["merged", "invalid"]);
|
|
@@ -85,6 +112,40 @@ export const DEFER_BACKOFF_MAX_S = 30 * 60;
|
|
|
85
112
|
// A dependency-open whose dependencies already finished satisfied (GitHub not yet closed): wait before relaunching.
|
|
86
113
|
export const DEP_REQUEUE_DELAY_S = 60;
|
|
87
114
|
|
|
115
|
+
// #4409 lane duration estimates (minutes) without history; the ratio follows the S/M/L turn budgets.
|
|
116
|
+
export const EST_DEFAULT_MIN = Object.freeze({ S: 10, M: 25, L: 50 });
|
|
117
|
+
// A post-deploy-only follow-up starts no real lane (the pre-filter parks or closes it).
|
|
118
|
+
export const POST_DEPLOY_EST_MIN = 2;
|
|
119
|
+
// A ready issue queued longer than this joins the head band (starvation guard; only with an estimator).
|
|
120
|
+
export const DEFAULT_STARVE_MS = 30 * 60000;
|
|
121
|
+
// Merged runs of one repo and size needed before their median replaces the default.
|
|
122
|
+
export const HISTORY_MIN_SAMPLES = 3;
|
|
123
|
+
|
|
124
|
+
/** #4409: estimated lane minutes from facts: post-deploy-only body, else the `size:` label (unsized = M) -> history median or default. */
|
|
125
|
+
export function estimateLaneMinutes({ labels = [], body = "", history = null } = {}) {
|
|
126
|
+
if (postDeployOnly(body)) return POST_DEPLOY_EST_MIN;
|
|
127
|
+
const size = laneSize({ labels }) ?? "M";
|
|
128
|
+
const h = Number(history?.[size]);
|
|
129
|
+
return Number.isFinite(h) && h > 0 ? h : EST_DEFAULT_MIN[size];
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** #4409: {S?, M?, L?} median wall minutes of `repo`'s merged runs (summariseLog rows) per size, only sizes with enough samples. */
|
|
133
|
+
export function laneHistory(runs, repo) {
|
|
134
|
+
const by = {};
|
|
135
|
+
for (const r of runs ?? []) {
|
|
136
|
+
if (r?.repo !== repo || r?.reason !== "merged" || !SIZES.includes(r?.size) || !Number.isFinite(r?.wallMs)) continue;
|
|
137
|
+
(by[r.size] ??= []).push(r.wallMs / 60000);
|
|
138
|
+
}
|
|
139
|
+
const out = {};
|
|
140
|
+
for (const [s, xs] of Object.entries(by)) {
|
|
141
|
+
if (xs.length < HISTORY_MIN_SAMPLES) continue;
|
|
142
|
+
xs.sort((a, b) => a - b);
|
|
143
|
+
const m = xs.length >> 1;
|
|
144
|
+
out[s] = Math.round((xs.length % 2 ? xs[m] : (xs[m - 1] + xs[m]) / 2) * 10) / 10;
|
|
145
|
+
}
|
|
146
|
+
return out;
|
|
147
|
+
}
|
|
148
|
+
|
|
88
149
|
/** Declared dependencies of an issue body; the same parser as the engine's admission (`openDependency`). */
|
|
89
150
|
export function parseDeps(body, issue) {
|
|
90
151
|
return parseDependsOn(body, issue);
|
|
@@ -160,9 +221,10 @@ const toMap = (o) => new Map(Object.entries(o ?? {}).map(([k, v]) => [Number(k),
|
|
|
160
221
|
* @returns {Promise<Array<{issue:number, outcome:string, pr:?number, cost_usd:number, minutes:number, detail?:string}>>}
|
|
161
222
|
*/
|
|
162
223
|
export async function orchestrate({ issues, parallel = 3, runOne, readBody, prefilter = null, resolveLane = null, now = () => Date.now(), onUpdate = () => {}, onCrossLane = () => {},
|
|
163
|
-
store = null, adopt = null, getParallel = null, breaker = null, maxDeferrals = DEFAULT_MAX_DEFERRALS, sleep = cancellableSleep, pollMs = 5000,
|
|
224
|
+
store = null, adopt = null, getParallel = null, getAdded = null, breaker = null, maxDeferrals = DEFAULT_MAX_DEFERRALS, sleep = cancellableSleep, pollMs = 5000,
|
|
164
225
|
classify = null, maintenance = null, limits = null, identity = () => "default", batchId = null, drift = null, sched = null, priorityOf = null, remote = null, beforeAdmit = null,
|
|
165
|
-
recovery = null, decide = () => {}, conflicting = null, relabelNeedsHuman = null, onExit = null
|
|
226
|
+
recovery = null, decide = () => {}, conflicting = null, relabelNeedsHuman = null, onExit = null, issueLabels = null, getCancelled = null, stopLane = null,
|
|
227
|
+
estimate = null, starveMs = DEFAULT_STARVE_MS, signal = null, dispatchCheck = null }) {
|
|
166
228
|
const saved = store?.load?.() ?? null;
|
|
167
229
|
const order = saved ? [...saved.order] : [];
|
|
168
230
|
const set = new Set(order);
|
|
@@ -178,11 +240,16 @@ export async function orchestrate({ issues, parallel = 3, runOne, readBody, pref
|
|
|
178
240
|
const deferrals = toMap(saved?.deferrals);
|
|
179
241
|
const engineErrors = toMap(saved?.engineErrors);
|
|
180
242
|
const checked = toMap(saved?.prefilter); // issue -> pre-filter result
|
|
243
|
+
const estimates = toMap(saved?.estimates); // #4409: issue -> estimated lane minutes; only with `estimate`
|
|
244
|
+
const enqueued = toMap(saved?.enqueued); // #4409: issue -> when it last entered the queue (starvation guard)
|
|
245
|
+
const heads = new Set(saved?.heads ?? []); // #4409: decomposed sub-issues not started yet: the head of their priority band
|
|
181
246
|
let errorTimes = [...(saved?.errorTimes ?? [])];
|
|
182
247
|
// A legacy batch-local `holdUntil` (pre-#4190) names no account and is ignored: a same-account lane re-sets the hold.
|
|
183
248
|
const holds = limits ?? memoryLimits(now);
|
|
184
249
|
const heldUntil = () => { try { const id = identity(); const u = holds.holdUntil(id); return Number.isFinite(u) && u > now() ? u : null; } catch { return null; } };
|
|
185
250
|
let tripped = saved?.breaker ?? null;
|
|
251
|
+
// #4376: operator cancel (the batch `cancel` file through `getCancelled`); persisted, but the file stays the authority.
|
|
252
|
+
let cancelled = getCancelled ? (saved?.cancelled ?? null) : null;
|
|
186
253
|
const budget = maintenance && Number(maintenance.share) > 0 ? { share: Number(maintenance.share), whenIdle: !!maintenance.whenIdle } : null;
|
|
187
254
|
const classes = toMap(saved?.classes); // issue -> {maintenance, priority}; only with a maintenance budget
|
|
188
255
|
const dispatch = { started: saved?.dispatch?.started ?? 0, maint: saved?.dispatch?.maint ?? 0 };
|
|
@@ -219,14 +286,16 @@ export async function orchestrate({ issues, parallel = 3, runOne, readBody, pref
|
|
|
219
286
|
const decided = (d) => { try { Promise.resolve(decide?.(d)).catch(() => {}); } catch { /* best effort */ } };
|
|
220
287
|
const firstLine = (x) => String(x ?? "").split("\n")[0].slice(0, 300);
|
|
221
288
|
|
|
289
|
+
let halted = false; // #4454: stopped by `signal`; the batch's state is no longer this process's to write
|
|
222
290
|
const save = () => {
|
|
223
|
-
if (!store) return;
|
|
291
|
+
if (!store || halted) return;
|
|
224
292
|
store.save({ v: 1, order, deps: toObj(deps), lanes: toObj(lanes), crossSeen: [...crossSeen], results: toObj(results), running: toObj(runRecs),
|
|
225
293
|
pending, notBefore: toObj(notBefore), requeues: toObj(requeues), deferrals: toObj(deferrals), engineErrors: toObj(engineErrors),
|
|
226
294
|
prefilter: toObj(checked), errorTimes, breaker: tripped, ...(budget ? { classes: toObj(classes), dispatch: { ...dispatch } } : {}),
|
|
227
295
|
...(reg ? { priorities: toObj(priorities) } : {}), ...(sandboxUse.size ? { sandbox: toObj(sandboxUse) } : {}),
|
|
228
296
|
...(recoveries.size || recovering.size ? { recoveries: toObj(recoveries), recovering: toObj(recovering) } : {}),
|
|
229
|
-
...(relabelled.size ? { relabelled: [...relabelled] } : {}) })
|
|
297
|
+
...(relabelled.size ? { relabelled: [...relabelled] } : {}), ...(cancelled ? { cancelled } : {}),
|
|
298
|
+
...(estimate ? { estimates: toObj(estimates), enqueued: toObj(enqueued) } : {}), ...(heads.size ? { heads: [...heads] } : {}) });
|
|
230
299
|
};
|
|
231
300
|
const update = (row) => { store?.log?.(row); onUpdate(row); };
|
|
232
301
|
const classifyOne = async (n) => {
|
|
@@ -241,11 +310,19 @@ export async function orchestrate({ issues, parallel = 3, runOne, readBody, pref
|
|
|
241
310
|
try { p = priorityOf ? await priorityOf(n) : null; } catch { p = null; }
|
|
242
311
|
priorities.set(n, p !== null && p !== "" && Number.isFinite(Number(p)) ? Number(p) : 2);
|
|
243
312
|
};
|
|
313
|
+
// #4409: fail-open: a thrown or junk estimate is the M default.
|
|
314
|
+
const readEstimate = async (n, body) => {
|
|
315
|
+
if (!estimate || estimates.has(n)) return;
|
|
316
|
+
let v = null;
|
|
317
|
+
try { v = Number(await estimate(n, { body })); } catch { v = null; }
|
|
318
|
+
estimates.set(n, Number.isFinite(v) && v > 0 ? v : EST_DEFAULT_MIN.M);
|
|
319
|
+
};
|
|
244
320
|
const readDeps = async (n) => {
|
|
245
321
|
let body = "";
|
|
246
322
|
try { body = await readBody(n); } catch { /* unreadable: no ordering info, work-on's own check still applies */ }
|
|
247
323
|
if (drift?.holds) bodies.set(n, body);
|
|
248
324
|
deps.set(n, parseDeps(body, n));
|
|
325
|
+
await readEstimate(n, body);
|
|
249
326
|
await classifyOne(n);
|
|
250
327
|
await readPriority(n);
|
|
251
328
|
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); }
|
|
@@ -260,15 +337,31 @@ export async function orchestrate({ issues, parallel = 3, runOne, readBody, pref
|
|
|
260
337
|
onCrossLane({ issue: n, lane: a.lane, dependency: d, dependencyLane: b.lane });
|
|
261
338
|
}
|
|
262
339
|
};
|
|
263
|
-
|
|
340
|
+
// #4409: `head` (decomposed sub-issues) puts them at the front of the queue in their given order; a sub-issue's
|
|
341
|
+
// estimate is the parent's ÷ N, floored at the M default.
|
|
342
|
+
const addIssues = async (ns, { head = false, parent = null } = {}) => {
|
|
264
343
|
const fresh = ns.filter((d) => !set.has(d));
|
|
265
|
-
for (const d of fresh) {
|
|
266
|
-
|
|
344
|
+
for (const d of fresh) {
|
|
345
|
+
set.add(d); order.push(d);
|
|
346
|
+
if (estimate && parent !== null && estimates.has(parent)) estimates.set(d, Math.max(EST_DEFAULT_MIN.M, estimates.get(parent) / ns.length));
|
|
347
|
+
}
|
|
348
|
+
let at = 0;
|
|
349
|
+
for (const d of fresh) {
|
|
350
|
+
await readDeps(d);
|
|
351
|
+
if (head) { pending.splice(at++, 0, d); heads.add(d); } else pending.push(d);
|
|
352
|
+
enqueued.set(d, now());
|
|
353
|
+
}
|
|
267
354
|
noteCrossLane();
|
|
268
355
|
};
|
|
269
356
|
// New issues (a fresh batch, or extra issues passed to a resume) join the queue.
|
|
270
357
|
for (const n of issues) if (!set.has(n)) { set.add(n); order.push(n); pending.push(n); }
|
|
358
|
+
for (const n of pending) if (!enqueued.has(n)) enqueued.set(n, now());
|
|
271
359
|
for (const n of order) if (!deps.has(n)) await readDeps(n);
|
|
360
|
+
if (estimate) for (const n of order) { // a resume that enabled the estimator
|
|
361
|
+
if (estimates.has(n) || results.has(n)) continue;
|
|
362
|
+
let b = ""; try { b = await readBody(n); } catch { b = ""; }
|
|
363
|
+
await readEstimate(n, b);
|
|
364
|
+
}
|
|
272
365
|
if (budget) for (const n of order) if (!classes.has(n)) await classifyOne(n); // a resume that enabled the budget
|
|
273
366
|
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
|
|
274
367
|
if (reg) for (const n of order) if (!priorities.has(n)) await readPriority(n); // a resume of a pre-#4191 batch
|
|
@@ -280,7 +373,21 @@ export async function orchestrate({ issues, parallel = 3, runOne, readBody, pref
|
|
|
280
373
|
let fellBack = false;
|
|
281
374
|
const keyOf = (n) => `${sched?.batch ?? batchId ?? "?"}#${n}`;
|
|
282
375
|
const prioOf = (n) => priorities.get(n) ?? 2;
|
|
283
|
-
|
|
376
|
+
// #4409: priority, then the head band (decomposed sub-issues; with an estimator also starved issues, oldest first), then
|
|
377
|
+
// the estimate plus in-batch dependents' estimates descending, then queue order. Without an estimator: queue order.
|
|
378
|
+
const rank = (ready) => {
|
|
379
|
+
const t = now(), idx = new Map(pending.map((n, i) => [n, i]));
|
|
380
|
+
const starved = (n) => !!estimate && !heads.has(n) && t - (enqueued.get(n) ?? t) > starveMs;
|
|
381
|
+
const weight = (n) => {
|
|
382
|
+
if (!estimate) return 0;
|
|
383
|
+
let w = estimates.get(n) ?? 0;
|
|
384
|
+
for (const m of order) if (m !== n && !results.has(m) && edges(m).includes(n)) w += estimates.get(m) ?? 0;
|
|
385
|
+
return w;
|
|
386
|
+
};
|
|
387
|
+
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 }))
|
|
388
|
+
.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);
|
|
389
|
+
};
|
|
390
|
+
const entryOf = (n) => ({ key: keyOf(n), batch: sched?.batch ?? batchId ?? null, repo: sched?.repo ?? null, issue: n, priority: prioOf(n), ...(sched?.exclusive ? { exclusive: true } : {}) });
|
|
284
391
|
const releaseSlot = (n) => { if (reg && slotted.delete(n)) reg.release(keyOf(n)); };
|
|
285
392
|
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); };
|
|
286
393
|
const withdrawExcept = (keep) => {
|
|
@@ -307,10 +414,28 @@ export async function orchestrate({ issues, parallel = 3, runOne, readBody, pref
|
|
|
307
414
|
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"}` });
|
|
308
415
|
budgetHit = reason;
|
|
309
416
|
};
|
|
417
|
+
// #4481: another batch runs a lane for `n` now: retry after a poll, one row (and decision) per other batch.
|
|
418
|
+
const busyNoted = new Map(); // issue -> the other batch last reported
|
|
419
|
+
const busy = (n, other, wanted) => {
|
|
420
|
+
const delayMs = Math.max(pollMs, 1);
|
|
421
|
+
notBefore.set(n, now() + delayMs); offered.delete(n); wanted.delete(n);
|
|
422
|
+
if (busyNoted.get(n) !== other) {
|
|
423
|
+
busyNoted.set(n, other); save();
|
|
424
|
+
decided({ issue: n, what: `deferred: batch ${other} runs a lane for #${n}`, why: "one lane per issue on the host (two batches include it)", alternative: "run a second lane on the same branch" });
|
|
425
|
+
update({ issue: n, outcome: "deferred", pr: null, cost_usd: 0, minutes: 0, detail: `#${n} has a live lane in batch ${other}; retried in ${Math.round(delayMs / 1000)}s` });
|
|
426
|
+
}
|
|
427
|
+
return "busy";
|
|
428
|
+
};
|
|
310
429
|
// A host slot for `n` (a lane-refused issue runs nothing and needs none); refused: `n` stays queued in the registry.
|
|
430
|
+
// true: start it; false: no capacity now; "busy" (#4481): another batch runs `n`, try the next issue.
|
|
311
431
|
const grant = (n, wanted) => {
|
|
312
432
|
if (lanes.get(n)?.ok === false) return true;
|
|
313
433
|
let p = placeOf(n);
|
|
434
|
+
if (p?.where === "remote" && reg?.holder) {
|
|
435
|
+
let other = null;
|
|
436
|
+
try { other = reg.holder(entryOf(n)) ?? null; } catch { other = null; }
|
|
437
|
+
if (other !== null) return busy(n, other, wanted);
|
|
438
|
+
}
|
|
314
439
|
if (p?.where === "remote") {
|
|
315
440
|
const adm = admitRemote();
|
|
316
441
|
if (adm.ok) {
|
|
@@ -326,6 +451,7 @@ export async function orchestrate({ issues, parallel = 3, runOne, readBody, pref
|
|
|
326
451
|
}
|
|
327
452
|
if (!reg) { if (p) placed.set(n, p); return true; }
|
|
328
453
|
const r = reg.tryAcquire(keyOf(n), entryOf(n), sched.cap);
|
|
454
|
+
if (!r?.ok && r?.conflict != null) return busy(n, r.conflict, wanted);
|
|
329
455
|
if (!r?.ok) { offered.add(n); wanted.add(n); return false; }
|
|
330
456
|
offered.delete(n); slotted.add(n);
|
|
331
457
|
if (p) placed.set(n, p);
|
|
@@ -334,7 +460,7 @@ export async function orchestrate({ issues, parallel = 3, runOne, readBody, pref
|
|
|
334
460
|
};
|
|
335
461
|
|
|
336
462
|
const skip = (n, detail) => { results.set(n, { issue: n, outcome: "skipped", pr: null, cost_usd: 0, minutes: 0, detail }); update(results.get(n)); };
|
|
337
|
-
const requeue = (n, delayMs) => { pending.push(n); if (delayMs > 0) notBefore.set(n, now() + delayMs); else notBefore.delete(n); };
|
|
463
|
+
const requeue = (n, delayMs) => { pending.push(n); enqueued.set(n, now()); if (delayMs > 0) notBefore.set(n, now() + delayMs); else notBefore.delete(n); };
|
|
338
464
|
const noteEngineError = () => {
|
|
339
465
|
if (!breaker) return;
|
|
340
466
|
const t = now(), windowMs = breaker.window_min * 60000;
|
|
@@ -347,14 +473,21 @@ export async function orchestrate({ issues, parallel = 3, runOne, readBody, pref
|
|
|
347
473
|
|
|
348
474
|
// Apply one lane's result: requeue it (deferred, dependency-open, engine-error) or record its final row.
|
|
349
475
|
const settle = async (n, r, rec, pf) => {
|
|
476
|
+
if (halted) return; // #4454: a resume adopts the lane and settles it
|
|
350
477
|
// #4304: the pre-filter runs once per issue; a recovery pass's row adds the earlier attempt's cost, which already holds it.
|
|
351
478
|
const pfCost = recovering.has(n) ? 0 : (pf?.cost_usd ?? 0);
|
|
352
479
|
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 } : {}),
|
|
353
480
|
...(r?.waits && typeof r.waits === "object" ? { waits: r.waits } : {}), ...(Number.isInteger(r?.heavyQueueMax) ? { heavyQueueMax: r.heavyQueueMax } : {}),
|
|
481
|
+
...(Number.isFinite(r?.wall_ms) ? { wall_ms: r.wall_ms } : {}), ...(Number.isFinite(r?.model_ms) ? { model_ms: r.model_ms } : {}), // #4451
|
|
354
482
|
...(Number.isFinite(r?.precheckMs) ? { precheck_ms: r.precheckMs } : {}), ...(Number.isInteger(r?.precheckDeferred) && r.precheckDeferred > 0 ? { precheck_deferred: r.precheckDeferred } : {}), // #4331
|
|
355
|
-
...(
|
|
483
|
+
...(typeof r?.firstPass === "boolean" ? { first_pass: r.firstPass } : {}), // #4362
|
|
484
|
+
...(r?.turns && typeof r.turns === "object" ? { turns: r.turns } : {}), // #4392
|
|
485
|
+
...(r?.conformance && typeof r.conformance === "object" ? { conformance: r.conformance } : {}), // #4398
|
|
486
|
+
...(rec?.placement ? { placement: rec.placement } : {}), ...(estimates.has(n) ? { est_minutes: Math.round(estimates.get(n) * 10) / 10 } : {}) }; // #4409
|
|
356
487
|
const depList = (Array.isArray(r?.dependencies) ? r.dependencies : []).filter((d) => Number.isSafeInteger(d) && d > 0 && d !== n);
|
|
357
488
|
const subs = (Array.isArray(r?.subissues) ? r.subissues : []).filter((d) => Number.isSafeInteger(d) && d > 0 && d !== n);
|
|
489
|
+
if (subs.length) row.subissues = subs; // #4396: links a decomposed parent to its sub-lanes in the report
|
|
490
|
+
if (r?.decomposition && typeof r.decomposition === "object") row.decomposition = r.decomposition;
|
|
358
491
|
if (r?.sandbox && typeof r.sandbox === "object") {
|
|
359
492
|
const prev = sandboxUse.get(n) ?? { minutes: 0, cost_usd: 0, attempts: 0 };
|
|
360
493
|
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;
|
|
@@ -363,6 +496,26 @@ export async function orchestrate({ issues, parallel = 3, runOne, readBody, pref
|
|
|
363
496
|
}
|
|
364
497
|
if (sandboxUse.has(n)) row.sandbox = { ...sandboxUse.get(n) };
|
|
365
498
|
const done = () => { running.delete(n); runRecs.delete(n); releaseSlot(n); remoteRun.delete(n); };
|
|
499
|
+
if (row.outcome === "dependency-open" && depList.length) {
|
|
500
|
+
// #4387: a parked dependency is not pulled in again (fail-open on an unreadable label).
|
|
501
|
+
let parked;
|
|
502
|
+
for (const d of depList) {
|
|
503
|
+
if (results.get(d)?.outcome === "needs-human") { parked = d; break; }
|
|
504
|
+
if (set.has(d) || typeof issueLabels !== "function") continue;
|
|
505
|
+
let names = [];
|
|
506
|
+
try { names = (await issueLabels(d)) ?? []; } catch { names = []; }
|
|
507
|
+
if (Array.isArray(names) && names.includes("needs-human")) { parked = d; break; }
|
|
508
|
+
}
|
|
509
|
+
if (parked !== undefined) {
|
|
510
|
+
done();
|
|
511
|
+
const final = { ...row, outcome: "skipped", detail: `dependency #${parked} needs-human` };
|
|
512
|
+
results.set(n, final); save();
|
|
513
|
+
decided({ issue: n, what: `skipped: dependency #${parked} needs-human (not pulled into the batch)`, why: firstLine(row.detail) || "the lane reported an open dependency",
|
|
514
|
+
alternative: `pull #${parked} in and run it again` });
|
|
515
|
+
update(final);
|
|
516
|
+
return;
|
|
517
|
+
}
|
|
518
|
+
}
|
|
366
519
|
if (row.outcome === "dependency-open" && depList.length && (requeues.get(n) ?? 0) < MAX_REQUEUES) {
|
|
367
520
|
// A scheduling fact, not a failure: add the edge, pull the dependency in, and run this issue again after it.
|
|
368
521
|
requeues.set(n, (requeues.get(n) ?? 0) + 1);
|
|
@@ -376,6 +529,12 @@ export async function orchestrate({ issues, parallel = 3, runOne, readBody, pref
|
|
|
376
529
|
update({ ...row, detail: `requeued behind ${depList.map((d) => `#${d}`).join(", ")}` });
|
|
377
530
|
return;
|
|
378
531
|
}
|
|
532
|
+
// #4376: a lane stopped by a cancel is not a scheduling failure: requeued (held at the end), no deferral, no hold.
|
|
533
|
+
if (cancelled && (row.outcome === "deferred" || row.outcome === "engine-error")) {
|
|
534
|
+
requeue(n, 0); done(); save();
|
|
535
|
+
update({ ...row, detail: `stopped by cancel: ${r?.code ?? row.outcome}${r?.detail ? ` ${r.detail}` : ""}` });
|
|
536
|
+
return;
|
|
537
|
+
}
|
|
379
538
|
if (row.outcome === "deferred") {
|
|
380
539
|
const pol = deferPolicy(r, { now: now(), deferrals: deferrals.get(n) ?? 0, maxDeferrals });
|
|
381
540
|
if (!pol.capped) {
|
|
@@ -403,7 +562,7 @@ export async function orchestrate({ issues, parallel = 3, runOne, readBody, pref
|
|
|
403
562
|
}
|
|
404
563
|
}
|
|
405
564
|
if (row.outcome === "decomposed" && subs.length) {
|
|
406
|
-
await addIssues(subs);
|
|
565
|
+
await addIssues(subs, { head: true, parent: n }); // #4409: on the batch's critical path by construction
|
|
407
566
|
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",
|
|
408
567
|
alternative: "end the batch row decomposed and let dependents wait for a later batch" });
|
|
409
568
|
for (const [m, ds] of deps) if (ds.includes(n)) deps.set(m, [...new Set(ds.flatMap((d) => (d === n ? subs : [d])))]);
|
|
@@ -472,18 +631,31 @@ export async function orchestrate({ issues, parallel = 3, runOne, readBody, pref
|
|
|
472
631
|
if (relabelled.delete(n)) save(); // #4315: the recovery lane launches after all; it removes needs-human itself
|
|
473
632
|
const pl = placed.get(n);
|
|
474
633
|
placed.delete(n);
|
|
634
|
+
heads.delete(n); enqueued.delete(n); busyNoted.delete(n);
|
|
475
635
|
const rec = { startedAt: now(), ...(lane ? { lane } : {}), ...(remoteRun.has(n) ? { remote: true } : {}), ...(pl ? { placement: pl } : {}) };
|
|
476
636
|
runRecs.set(n, rec); notBefore.delete(n); save();
|
|
477
637
|
track(n, (async () => {
|
|
478
638
|
// Pre-filter once per issue (a requeued issue is not re-checked); fail-open: an error means a normal lane.
|
|
479
639
|
if (prefilter && !checked.has(n)) { let pf = null; try { pf = await prefilter(n, lane); } catch { pf = null; } checked.set(n, pf); save(); }
|
|
480
640
|
const pf = checked.get(n) ?? null;
|
|
641
|
+
// #4481: every start re-checks GitHub: a closed issue or a merged PR ends the row without a lane (fail-open).
|
|
642
|
+
let gone = null;
|
|
643
|
+
if (dispatchCheck && !cancelled && !pf?.closed && !pf?.parked) {
|
|
644
|
+
try { gone = (await dispatchCheck(n)) ?? null; } catch { gone = null; }
|
|
645
|
+
if (!["merged", "invalid"].includes(gone?.outcome)) gone = null;
|
|
646
|
+
}
|
|
647
|
+
if (halted) return; // #4454: stopped before launch; no pid, so a resume queues it again
|
|
481
648
|
let r;
|
|
482
|
-
if (
|
|
649
|
+
if (cancelled) r = { terminalReason: "deferred", code: "run.interrupted", cost_usd: 0, detail: "cancelled before launch" }; // #4376
|
|
650
|
+
else if (pf?.closed) r = { terminalReason: "invalid", cost_usd: 0, detail: `pre-filter: ${pf.detail}` };
|
|
651
|
+
else if (gone) r = { terminalReason: gone.outcome, pr: Number.isInteger(gone.pr) ? gone.pr : null, cost_usd: 0, detail: `skipped at dispatch: ${gone.detail ?? gone.outcome}` };
|
|
652
|
+
// #4389: a parked post-deploy follow-up starts no lane; it ends awaiting-promotion|awaiting-precondition, re-checked next batch.
|
|
653
|
+
else if (pf?.parked) r = { terminalReason: pf.kind, cost_usd: 0, detail: `pre-filter: ${pf.detail}` };
|
|
483
654
|
else {
|
|
484
655
|
try {
|
|
485
656
|
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) => {
|
|
486
657
|
Object.assign(rec, info ?? {}); save();
|
|
658
|
+
if (cancelled) stop(n, rec); // #4376: launched while the cancel arrived
|
|
487
659
|
if (reg && slotted.has(n) && info?.pid) reg.setLanePid(keyOf(n), info.pid);
|
|
488
660
|
} });
|
|
489
661
|
}
|
|
@@ -502,7 +674,7 @@ export async function orchestrate({ issues, parallel = 3, runOne, readBody, pref
|
|
|
502
674
|
if (reg) { reg.adoptSlot(keyOf(n), { ...entryOf(n), lanePid: rec.pid }); slotted.add(n); }
|
|
503
675
|
track(n, Promise.resolve(p).then((r) => settle(n, r, rec, checked.get(n) ?? null)));
|
|
504
676
|
}
|
|
505
|
-
else { runRecs.delete(n); if (!pending.includes(n) && !results.has(n)) pending.unshift(n); }
|
|
677
|
+
else { runRecs.delete(n); if (!pending.includes(n) && !results.has(n)) { pending.unshift(n); enqueued.set(n, now()); } }
|
|
506
678
|
}
|
|
507
679
|
// A resume re-derives each unfinished issue's lane from its current milestone (#4153): a re-milestone takes effect.
|
|
508
680
|
if (saved && resolveLane) {
|
|
@@ -515,7 +687,7 @@ export async function orchestrate({ issues, parallel = 3, runOne, readBody, pref
|
|
|
515
687
|
}
|
|
516
688
|
save();
|
|
517
689
|
|
|
518
|
-
const heldDetail = (n) => tripped?.detail ?? (driftHeld.has(n) ? `drift: ${driftHeld.get(n)} flagged` : budgetStop ? `execution budget: ${budgetStop}` : budgetHeld ? "maintenance share" : "not run");
|
|
690
|
+
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");
|
|
519
691
|
// #4315: a recovery queued but never launched (the batch stopped: breaker, budget, hold; or a resume that did not relaunch a
|
|
520
692
|
// lost run) left the issue without needs-human, since a non-final attempt does not relabel. Relabel it once, with the full reason.
|
|
521
693
|
// #4318: also on a throw out of the loop and on a signal (`onExit`); `cause` overrides the held reason. Runs once per process.
|
|
@@ -534,9 +706,69 @@ export async function orchestrate({ issues, parallel = 3, runOne, readBody, pref
|
|
|
534
706
|
})());
|
|
535
707
|
if (typeof onExit === "function") { try { onExit(relabelQueued); } catch { /* best effort */ } }
|
|
536
708
|
|
|
709
|
+
// #4408: issues added to the running batch; returns how many joined. Re-reading the cumulative list is a no-op.
|
|
710
|
+
const drainAdds = async () => {
|
|
711
|
+
if (!getAdded) return 0;
|
|
712
|
+
let got = [];
|
|
713
|
+
try { got = getAdded() ?? []; } catch { got = []; }
|
|
714
|
+
const fresh = [...new Set((Array.isArray(got) ? got : []).filter((n) => Number.isSafeInteger(n) && n > 0 && !set.has(n)))];
|
|
715
|
+
if (!fresh.length) return 0;
|
|
716
|
+
// An edge an operator wrote into a pending issue after the batch started (it waits on an added issue) holds too.
|
|
717
|
+
for (const n of pending) {
|
|
718
|
+
let body;
|
|
719
|
+
try { body = await readBody(n); } catch { continue; }
|
|
720
|
+
if (drift?.holds) bodies.set(n, body);
|
|
721
|
+
deps.set(n, [...new Set([...(deps.get(n) ?? []), ...parseDeps(body, n)])]);
|
|
722
|
+
}
|
|
723
|
+
await addIssues(fresh);
|
|
724
|
+
save();
|
|
725
|
+
const list = fresh.map((n) => `#${n}`).join(", ");
|
|
726
|
+
decided({ issue: null, what: `added ${list} to the batch (forgedock orchestrate --add)`, why: "operator request", alternative: "start a second batch" });
|
|
727
|
+
update({ issue: null, outcome: "added", detail: list });
|
|
728
|
+
return fresh.length;
|
|
729
|
+
};
|
|
730
|
+
// #4376: one SIGTERM per running local lane (their teardown saves the work); remote lanes run to their result.
|
|
731
|
+
const stopSent = new Set();
|
|
732
|
+
function stop(n, rec) {
|
|
733
|
+
if (!stopLane || rec?.remote || stopSent.has(n)) return;
|
|
734
|
+
stopSent.add(n);
|
|
735
|
+
try { Promise.resolve(stopLane(n, rec)).catch(() => {}); } catch { /* best effort */ }
|
|
736
|
+
}
|
|
737
|
+
const checkCancel = () => {
|
|
738
|
+
if (!getCancelled) return;
|
|
739
|
+
let c = null;
|
|
740
|
+
try { c = getCancelled() ?? null; } catch { c = null; }
|
|
741
|
+
if (!c) { if (cancelled) { cancelled = null; save(); } return; }
|
|
742
|
+
const first = !cancelled;
|
|
743
|
+
cancelled = c;
|
|
744
|
+
if (first) {
|
|
745
|
+
save();
|
|
746
|
+
decided({ issue: null, what: "cancelled the batch (forgedock orchestrate --cancel)", why: "operator request", alternative: "stop the shared schedd" });
|
|
747
|
+
update({ issue: null, outcome: "cancelled", detail: "cancelled by operator (forgedock orchestrate --cancel)" });
|
|
748
|
+
if (reg) withdrawExcept(new Set());
|
|
749
|
+
}
|
|
750
|
+
for (const [n, rec] of runRecs) if (running.has(n)) stop(n, rec);
|
|
751
|
+
};
|
|
752
|
+
// #4454: the shutdown stop; every wait races it and cancels its sleep.
|
|
753
|
+
const aborted = signal ? new Promise((r) => { if (signal.aborted) r(); else signal.addEventListener("abort", () => r(), { once: true }); }) : null;
|
|
754
|
+
const nap = (ms) => {
|
|
755
|
+
const ac = new AbortController();
|
|
756
|
+
return Promise.race([sleep(ms, ac.signal), ...(aborted ? [aborted] : [])]).finally(() => ac.abort());
|
|
757
|
+
};
|
|
758
|
+
const race = (ps) => Promise.race([...ps, ...(aborted ? [aborted] : [])]);
|
|
759
|
+
const halt = () => {
|
|
760
|
+
if (reg) withdrawExcept(new Set());
|
|
761
|
+
save();
|
|
762
|
+
halted = true;
|
|
763
|
+
return Object.assign(new Error("batch stopped: schedd shutting down"), { code: "batch.stopped" });
|
|
764
|
+
};
|
|
537
765
|
const K = () => { let k = null; try { k = getParallel?.(); } catch { k = null; } return Number.isInteger(k) && k >= 1 ? k : parallel; };
|
|
538
766
|
try {
|
|
539
|
-
|
|
767
|
+
for (;;) {
|
|
768
|
+
if (signal?.aborted) throw halt();
|
|
769
|
+
await drainAdds();
|
|
770
|
+
checkCancel();
|
|
771
|
+
if (!pending.length && !running.size) break;
|
|
540
772
|
// Resolve skips first: a dependency that finished unsatisfied (or never can) skips its dependents.
|
|
541
773
|
for (let i = pending.length - 1; i >= 0; i--) {
|
|
542
774
|
const n = pending[i];
|
|
@@ -558,63 +790,72 @@ export async function orchestrate({ issues, parallel = 3, runOne, readBody, pref
|
|
|
558
790
|
};
|
|
559
791
|
budgetHeld = false;
|
|
560
792
|
// #4183: a hook (owned-resource GC near the host ceiling) before lanes are admitted; its errors never stop the batch.
|
|
561
|
-
if (beforeAdmit && !tripped && !holdUntil && running.size < K() && pending.some(isReady)) { try { await beforeAdmit(); } catch { /* ignored */ } }
|
|
793
|
+
if (beforeAdmit && !tripped && !cancelled && !holdUntil && running.size < K() && pending.some(isReady)) { try { await beforeAdmit(); } catch { /* ignored */ } }
|
|
794
|
+
if (signal?.aborted) throw halt();
|
|
562
795
|
budgetStop = null;
|
|
563
796
|
const wanted = new Set(); // issues that stay offered in the host registry after this pass
|
|
564
|
-
if (!tripped && !holdUntil && budget) {
|
|
797
|
+
if (!tripped && !cancelled && !holdUntil && budget) {
|
|
565
798
|
while (running.size < K()) {
|
|
566
|
-
const ready = pending.filter(isReady);
|
|
799
|
+
const ready = rank(pending.filter(isReady));
|
|
567
800
|
const n = pickNext(ready, { classes, share: budget.share, whenIdle: budget.whenIdle, started: dispatch.started, maint: dispatch.maint,
|
|
568
801
|
featuresPending: pending.some((p) => !clsOf(classes, p).maintenance) });
|
|
569
802
|
if (n === null) { budgetHeld = ready.length > 0; break; }
|
|
570
|
-
|
|
803
|
+
const g = grant(n, wanted);
|
|
804
|
+
if (g === "busy") continue; // #4481: not ready any more this pass (notBefore), so `ready` drops it
|
|
805
|
+
if (!g) break;
|
|
571
806
|
pending.splice(pending.indexOf(n), 1);
|
|
572
807
|
if (lanes.get(n)?.ok !== false) { dispatch.started++; if (clsOf(classes, n).maintenance) dispatch.maint++; }
|
|
573
808
|
start(n); started++;
|
|
574
809
|
}
|
|
575
|
-
} else if (!tripped && !holdUntil && reg) {
|
|
576
|
-
// Priority first,
|
|
577
|
-
const ready = pending.filter(isReady)
|
|
810
|
+
} else if (!tripped && !cancelled && !holdUntil && reg) {
|
|
811
|
+
// Priority first, then `rank` within a priority; the free per-batch K is offered so the host queue keeps FIFO.
|
|
812
|
+
const ready = rank(pending.filter(isReady));
|
|
578
813
|
const want = ready.filter((n) => lanes.get(n)?.ok !== false && !isRemote(n)).slice(0, Math.max(0, K() - running.size));
|
|
579
814
|
offerFresh(want);
|
|
580
815
|
for (const n of want) wanted.add(n);
|
|
581
816
|
for (const n of ready) {
|
|
582
|
-
if (running.size >= K()
|
|
817
|
+
if (running.size >= K()) break;
|
|
818
|
+
const g = grant(n, wanted);
|
|
819
|
+
if (g === "busy") continue; // #4481
|
|
820
|
+
if (!g) break;
|
|
583
821
|
pending.splice(pending.indexOf(n), 1); start(n); started++;
|
|
584
822
|
}
|
|
585
|
-
} else if (!tripped && !holdUntil) {
|
|
586
|
-
for (
|
|
587
|
-
|
|
588
|
-
if (
|
|
589
|
-
else i++;
|
|
823
|
+
} else if (!tripped && !cancelled && !holdUntil) {
|
|
824
|
+
for (const n of rank(pending.filter(isReady))) {
|
|
825
|
+
if (running.size >= K()) break;
|
|
826
|
+
if (grant(n, new Set()) === true) { pending.splice(pending.indexOf(n), 1); start(n); started++; }
|
|
590
827
|
}
|
|
591
828
|
}
|
|
592
829
|
if (reg) withdrawExcept(wanted);
|
|
593
|
-
if (tripped && !running.size) break; // breaker: let running lanes finish, keep the rest pending for a resume
|
|
594
|
-
if (
|
|
830
|
+
if (tripped && !running.size) { await drainAdds(); break; } // breaker: let running lanes finish, keep the rest pending for a resume
|
|
831
|
+
if (cancelled && !running.size) { await drainAdds(); break; } // #4376: cancel: the rest end held for a resume
|
|
832
|
+
if (budgetStop && !running.size) { await drainAdds(); break; } // execution budget (stop): the same, the rest end held
|
|
595
833
|
// Wake for a delayed issue, the end of a usage-limit hold, or (with a live K) a poll.
|
|
596
834
|
const waits = pending.map((n) => notBefore.get(n) ?? 0).filter((x) => x > t);
|
|
597
835
|
if (holdUntil) waits.push(holdUntil);
|
|
598
836
|
let wake = waits.length ? Math.max(0, Math.min(...waits) - t) : null;
|
|
837
|
+
if (holdUntil && pending.length) wake = Math.min(wake ?? pollMs, pollMs); // #4446: a cleared hold (forgedock limits clear) admits on the next poll
|
|
599
838
|
if (getParallel && pending.length) wake = Math.min(wake ?? pollMs, pollMs);
|
|
600
839
|
if (offered.size && pending.length) wake = Math.min(wake ?? pollMs, pollMs); // a slot another batch frees
|
|
840
|
+
if (getAdded && running.size) wake = Math.min(wake ?? pollMs, pollMs); // #4408: an add while only lanes run
|
|
601
841
|
if (tripped) wake = null;
|
|
842
|
+
if (getCancelled && running.size && !cancelled) wake = Math.min(wake ?? pollMs, pollMs); // #4376: notice a cancel while lanes run
|
|
602
843
|
if (running.size) {
|
|
603
|
-
if (wake === null) await
|
|
604
|
-
else { const ac = new AbortController(); await
|
|
844
|
+
if (wake === null) await race(running.values());
|
|
845
|
+
else { const ac = new AbortController(); await race([...running.values(), sleep(wake, ac.signal)]); ac.abort(); }
|
|
605
846
|
} else if (started) continue; // only lane-refused rows settled (synchronously): re-resolve skips first
|
|
606
|
-
else if (pending.length && offered.size) await
|
|
607
|
-
else if (pending.length && waits.length) await
|
|
608
|
-
else if (pending.length && budgetHeld) break; // maintenance the budget does not admit now: held for a later batch
|
|
609
|
-
else if (pending.length && driftHeld.size) break; // held by a drift flag (block_dispatch): pending for a later batch
|
|
847
|
+
else if (pending.length && offered.size) await nap(wake); // waiting for a host slot
|
|
848
|
+
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
|
|
849
|
+
else if (pending.length && budgetHeld) { if (await drainAdds()) continue; break; } // maintenance the budget does not admit now: held for a later batch
|
|
850
|
+
else if (pending.length && driftHeld.size) { if (await drainAdds()) continue; break; } // held by a drift flag (block_dispatch): pending for a later batch
|
|
610
851
|
else if (pending.length) { // nothing runnable, nothing running, nothing waiting on time: a dependency cycle
|
|
611
852
|
for (const n of pending.splice(0)) skip(n, "dependency cycle");
|
|
612
853
|
save();
|
|
613
854
|
}
|
|
614
855
|
}
|
|
615
856
|
} catch (e) {
|
|
616
|
-
// #4318: an exception out of the loop still relabels the queued recoveries before it propagates
|
|
617
|
-
try { await relabelQueued(`batch failed: ${firstLine(e?.message ?? e)}`); } catch { /* best effort */ }
|
|
857
|
+
// #4318: an exception out of the loop still relabels the queued recoveries before it propagates (not a #4454 stop)
|
|
858
|
+
if (!halted) try { await relabelQueued(`batch failed: ${firstLine(e?.message ?? e)}`); } catch { /* best effort */ }
|
|
618
859
|
throw e;
|
|
619
860
|
}
|
|
620
861
|
if (reg) withdrawExcept(new Set());
|
|
@@ -625,6 +866,20 @@ export async function orchestrate({ issues, parallel = 3, runOne, readBody, pref
|
|
|
625
866
|
...(recovering.get(n).result_code ? { result_code: recovering.get(n).result_code } : {}), detail: firstLine(recovering.get(n).detail ?? recovering.get(n).code) } } : {}) });
|
|
626
867
|
}
|
|
627
868
|
|
|
869
|
+
/**
|
|
870
|
+
* #4409 batch-report line: estimated vs. actual lane minutes over the rows that ran with an estimate (feeds the estimator).
|
|
871
|
+
* Null when no row has both.
|
|
872
|
+
*/
|
|
873
|
+
export function formatEstimateSummary(rows) {
|
|
874
|
+
const xs = (rows ?? []).filter((r) => Number.isFinite(r?.est_minutes) && Number(r?.minutes) > 0 && !["held", "skipped", "lane-refused"].includes(r.outcome));
|
|
875
|
+
if (!xs.length) return null;
|
|
876
|
+
const sum = (f) => xs.reduce((a, r) => a + f(r), 0);
|
|
877
|
+
const errs = xs.map((r) => Math.abs(r.minutes - r.est_minutes)).sort((a, b) => a - b);
|
|
878
|
+
const med = errs.length % 2 ? errs[errs.length >> 1] : (errs[errs.length / 2 - 1] + errs[errs.length / 2]) / 2;
|
|
879
|
+
const worst = xs.reduce((a, r) => (Math.abs(r.minutes - r.est_minutes) > Math.abs(a.minutes - a.est_minutes) ? r : a));
|
|
880
|
+
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}`;
|
|
881
|
+
}
|
|
882
|
+
|
|
628
883
|
/** Max chars of one row's detail in the batch-report outcome lists (#4298). */
|
|
629
884
|
export const OUTCOME_LINE_MAX = 200;
|
|
630
885
|
/**
|
|
@@ -643,6 +898,8 @@ export function formatOutcomeLists(rows) {
|
|
|
643
898
|
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)})`);
|
|
644
899
|
}
|
|
645
900
|
const esc = (rows ?? []).filter((r) => r?.outcome === "needs-human" && classifyNeedsHuman(r) === "human");
|
|
901
|
+
const parked = formatParkedLine(rows); // #4389: one line for every parked post-deploy follow-up, never an escalation
|
|
902
|
+
if (parked) out.push(parked);
|
|
646
903
|
if (esc.length) out.push(["escalations (human decisions, asked once):", ...esc.map((r) => `- #${r.issue} (${r.code}): ${one(r.detail)}`)].join("\n"));
|
|
647
904
|
return out;
|
|
648
905
|
}
|
|
@@ -651,7 +908,7 @@ export function formatTable(rows) {
|
|
|
651
908
|
// #4255: sandbox minutes and cost next to the token cost, only when some lane ran in a sandbox.
|
|
652
909
|
const sbx = rows.some((r) => r?.sandbox);
|
|
653
910
|
const head = ["issue", "outcome", "PR", "cost", ...(sbx ? ["sbx-min", "sbx-$"] : []), "min"];
|
|
654
|
-
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)}`,
|
|
911
|
+
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)}`,
|
|
655
912
|
...(sbx ? (r.sandbox ? [(Number(r.sandbox.minutes) || 0).toFixed(1), `$${(Number(r.sandbox.cost_usd) || 0).toFixed(2)}`] : ["-", "-"]) : []), String(r.minutes ?? 0)]);
|
|
656
913
|
const w = head.map((h, i) => Math.max(h.length, ...body.map((b) => b[i].length)));
|
|
657
914
|
const line = (c) => c.map((x, i) => x.padEnd(w[i])).join(" ").trimEnd();
|
|
@@ -672,6 +929,11 @@ export function formatWaitSummary(rows, heavyStats = null) {
|
|
|
672
929
|
return `waits by phase: ${waits}; heavy queue: now ${hasStats && Number.isInteger(heavyStats.queued) ? heavyStats.queued : "?"}, max ${seen}`;
|
|
673
930
|
}
|
|
674
931
|
|
|
932
|
+
/** #4354 batch-report tail: the fail-soft alerts (stats computeAlerts) for the batch's repo; [] when none fired. */
|
|
933
|
+
export function formatAlertLines(alerts) {
|
|
934
|
+
return alerts?.length ? ["ALERTS", ...renderAlerts(alerts)] : [];
|
|
935
|
+
}
|
|
936
|
+
|
|
675
937
|
/**
|
|
676
938
|
* #4255 batch-report line: sandbox lanes, minutes and cost next to those lanes' token cost, plus the execution budget
|
|
677
939
|
* (`budget` from parseExecution, `day` from openExecutionBudget `dayTotals`) when a cap is set. Null without sandbox usage.
|