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