forgedock 1.24.4 → 1.25.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 +11 -0
- package/bin/engine/phases.mjs +22 -6
- package/bin/engine/runlog.mjs +1 -1
- package/bin/engine-cli.mjs +186 -22
- package/bin/engine.mjs +3 -1
- package/bin/forgedock.mjs +17 -5
- package/bin/lean/batch.mjs +147 -0
- package/bin/lean/batch.test.mjs +184 -0
- package/bin/lean/lane.mjs +85 -0
- package/bin/lean/lane.test.mjs +91 -0
- package/bin/lean/lean.mjs +139 -11
- package/bin/lean/lean.test.mjs +390 -12
- package/bin/lean/orchestrate.mjs +229 -29
- package/bin/lean/prefilter.mjs +148 -0
- package/bin/lean/prefilter.test.mjs +210 -0
- package/bin/lean/prompts.mjs +80 -3
- package/bin/lean/retry.mjs +2 -2
- package/bin/runner.mjs +25 -3
- package/commands/orchestrate.md +62 -21
- package/commands/review-pr-staging.md +28 -3
- package/docs/CONFIG.md +2 -0
- package/docs/OUTCOME-CODES.md +11 -4
- package/package.json +1 -1
- package/scripts/code-index.sh +61 -40
- package/scripts/code-index.test.sh +73 -0
- package/scripts/review-pr-staging-scope.test.sh +36 -1
- package/scripts/review-prepare.sh +16 -3
- package/scripts/review-prepare.test.sh +16 -0
package/bin/lean/orchestrate.mjs
CHANGED
|
@@ -1,68 +1,268 @@
|
|
|
1
1
|
// SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
|
|
2
2
|
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
3
3
|
/**
|
|
4
|
-
* Thin orchestrate: run core work-on for several issues with bounded concurrency, honouring
|
|
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
|
+
* 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).
|
|
6
8
|
* No investigation, synthesis or agent spawning. `runOne(issue)` and `readBody(issue)` are injected.
|
|
9
|
+
* Optional `prefilter(issue, lane)` (#4123) runs in the lane slot before `runOne`: when it closed the issue, no lane starts.
|
|
10
|
+
* Optional `resolveLane(issue)` (#4125) works out each issue's own lane (milestone branch or staging): an issue whose
|
|
11
|
+
* lane is refused ends `lane-refused` without a run, and a dependency edge between two lanes goes to `onCrossLane`.
|
|
12
|
+
*
|
|
13
|
+
* Durable batch runner (#4121): with a `store` every transition is persisted, so a restarted dispatcher continues the
|
|
14
|
+
* batch; lanes it launched that are still alive are re-attached through `adopt(issue, rec)` instead of relaunched.
|
|
15
|
+
* `deferred` is a scheduling fact, not an end: each typed code is requeued with its retry-at (`deferPolicy`), a usage
|
|
16
|
+
* limit holds all admissions until its reset, and non-host deferrals stop after `maxDeferrals`. An `engine-error` is
|
|
17
|
+
* relaunched once; `breaker` ({errors, window_min}) stops admissions after a burst and lets running lanes finish, the
|
|
18
|
+
* rest stay pending (outcome `held`) for a resume. `getParallel()` is read on every pass, so K can change live.
|
|
7
19
|
*/
|
|
8
|
-
|
|
20
|
+
import { parseDependsOn } from "../engine/phases.mjs";
|
|
21
|
+
|
|
9
22
|
// A dependency that finished like this no longer blocks its dependents.
|
|
10
23
|
const SATISFIED = new Set(["merged", "invalid"]);
|
|
24
|
+
// Times one issue may be requeued behind a dependency before its dependency-open row is final.
|
|
25
|
+
export const MAX_REQUEUES = 2;
|
|
26
|
+
// Non-host deferrals (anything but host.busy) one issue may take before its deferred row is final.
|
|
27
|
+
export const DEFAULT_MAX_DEFERRALS = 3;
|
|
28
|
+
// Relaunches of an issue whose lane ended engine-error.
|
|
29
|
+
export const ENGINE_ERROR_RELAUNCHES = 1;
|
|
30
|
+
export const HOST_BUSY_RETRY_S = 60;
|
|
31
|
+
export const RATE_LIMIT_FALLBACK_S = 15 * 60;
|
|
32
|
+
export const DEFER_BACKOFF_S = 120;
|
|
33
|
+
export const DEFER_BACKOFF_MAX_S = 30 * 60;
|
|
34
|
+
// A dependency-open whose dependencies already finished satisfied (GitHub not yet closed): wait before relaunching.
|
|
35
|
+
export const DEP_REQUEUE_DELAY_S = 60;
|
|
11
36
|
|
|
37
|
+
/** Declared dependencies of an issue body; the same parser as the engine's admission (`openDependency`). */
|
|
12
38
|
export function parseDeps(body, issue) {
|
|
13
|
-
return
|
|
39
|
+
return parseDependsOn(body, issue);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const posNum = (v) => { const n = Number(v); return Number.isFinite(n) && n > 0 ? n : null; };
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* When to run a `deferred` issue again. `deferrals` counts this issue's earlier non-host deferrals.
|
|
46
|
+
* @returns {{delayMs:number, host?:true, hold?:true} | {capped:true}}
|
|
47
|
+
*/
|
|
48
|
+
export function deferPolicy(r, { now, deferrals = 0, maxDeferrals = DEFAULT_MAX_DEFERRALS }) {
|
|
49
|
+
const code = r?.code ?? null;
|
|
50
|
+
if (code === "host.busy") return { delayMs: (posNum(r?.retryInSeconds) ?? HOST_BUSY_RETRY_S) * 1000, host: true };
|
|
51
|
+
const k = deferrals + 1;
|
|
52
|
+
if (k > maxDeferrals) return { capped: true };
|
|
53
|
+
if (code === "rate_limited") {
|
|
54
|
+
const at = posNum(r?.resetAtEpochMs) && r.resetAtEpochMs > now ? r.resetAtEpochMs : now + (posNum(r?.retryInSeconds) ?? RATE_LIMIT_FALLBACK_S) * 1000;
|
|
55
|
+
return { delayMs: at - now, hold: true };
|
|
56
|
+
}
|
|
57
|
+
if (posNum(r?.retryInSeconds)) return { delayMs: r.retryInSeconds * 1000 };
|
|
58
|
+
// lease-held: "leased by X until <ISO>"
|
|
59
|
+
const until = /\buntil (\d{4}-\d\d-\d\dT[\d:.]+Z)/.exec(String(r?.detail ?? ""))?.[1];
|
|
60
|
+
const at = until ? Date.parse(until) : NaN;
|
|
61
|
+
if (Number.isFinite(at) && at > now) return { delayMs: Math.min(at - now + 5000, DEFER_BACKOFF_MAX_S * 1000) };
|
|
62
|
+
return { delayMs: Math.min(DEFER_BACKOFF_S * 2 ** (k - 1), DEFER_BACKOFF_MAX_S) * 1000 };
|
|
14
63
|
}
|
|
15
64
|
|
|
65
|
+
const cancellableSleep = (ms, signal) => new Promise((res) => {
|
|
66
|
+
const t = setTimeout(res, ms);
|
|
67
|
+
signal?.addEventListener("abort", () => { clearTimeout(t); res(); }, { once: true });
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
const toObj = (m) => Object.fromEntries([...m].map(([k, v]) => [String(k), v]));
|
|
71
|
+
const toMap = (o) => new Map(Object.entries(o ?? {}).map(([k, v]) => [Number(k), v]));
|
|
72
|
+
|
|
16
73
|
/**
|
|
17
74
|
* @returns {Promise<Array<{issue:number, outcome:string, pr:?number, cost_usd:number, minutes:number, detail?:string}>>}
|
|
18
75
|
*/
|
|
19
|
-
export async function orchestrate({ issues, parallel = 3, runOne, readBody, now = () => Date.now(), onUpdate = () => {}
|
|
20
|
-
|
|
21
|
-
const
|
|
22
|
-
|
|
76
|
+
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 }) {
|
|
78
|
+
const saved = store?.load?.() ?? null;
|
|
79
|
+
const order = saved ? [...saved.order] : [];
|
|
80
|
+
const set = new Set(order);
|
|
81
|
+
const deps = toMap(saved?.deps); // issue -> declared dependencies (any issue); only those in `set` are edges
|
|
82
|
+
const lanes = toMap(saved?.lanes); // issue -> resolveLane result
|
|
83
|
+
const crossSeen = new Set(saved?.crossSeen ?? []);
|
|
84
|
+
const results = toMap(saved?.results); // issue -> final row
|
|
85
|
+
const running = new Map(); // issue -> promise
|
|
86
|
+
const runRecs = toMap(saved?.running); // issue -> {startedAt, lane, pid?}: persisted so a restart can adopt the lane
|
|
87
|
+
const pending = [...(saved?.pending ?? [])];
|
|
88
|
+
const notBefore = toMap(saved?.notBefore);
|
|
89
|
+
const requeues = toMap(saved?.requeues);
|
|
90
|
+
const deferrals = toMap(saved?.deferrals);
|
|
91
|
+
const engineErrors = toMap(saved?.engineErrors);
|
|
92
|
+
const checked = toMap(saved?.prefilter); // issue -> pre-filter result
|
|
93
|
+
let errorTimes = [...(saved?.errorTimes ?? [])];
|
|
94
|
+
let holdUntil = saved?.holdUntil ?? null;
|
|
95
|
+
let tripped = saved?.breaker ?? null;
|
|
96
|
+
|
|
97
|
+
const save = () => {
|
|
98
|
+
if (!store) return;
|
|
99
|
+
store.save({ v: 1, order, deps: toObj(deps), lanes: toObj(lanes), crossSeen: [...crossSeen], results: toObj(results), running: toObj(runRecs),
|
|
100
|
+
pending, notBefore: toObj(notBefore), requeues: toObj(requeues), deferrals: toObj(deferrals), engineErrors: toObj(engineErrors),
|
|
101
|
+
prefilter: toObj(checked), errorTimes, holdUntil, breaker: tripped });
|
|
102
|
+
};
|
|
103
|
+
const update = (row) => { store?.log?.(row); onUpdate(row); };
|
|
104
|
+
const readDeps = async (n) => {
|
|
23
105
|
let body = "";
|
|
24
106
|
try { body = await readBody(n); } catch { /* unreadable: no ordering info, work-on's own check still applies */ }
|
|
25
|
-
deps.set(n, parseDeps(body, n)
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
const
|
|
29
|
-
|
|
107
|
+
deps.set(n, parseDeps(body, n));
|
|
108
|
+
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
|
+
};
|
|
110
|
+
const edges = (n) => (deps.get(n) ?? []).filter((d) => set.has(d));
|
|
111
|
+
// A milestone issue waiting on a staging fix (or the reverse) never sees that fix on its own base: surface the edge.
|
|
112
|
+
const noteCrossLane = () => {
|
|
113
|
+
for (const n of order) for (const d of edges(n)) {
|
|
114
|
+
const a = lanes.get(n), b = lanes.get(d), key = `${n}<${d}`;
|
|
115
|
+
if (!a?.ok || !b?.ok || a.lane === b.lane || crossSeen.has(key)) continue;
|
|
116
|
+
crossSeen.add(key);
|
|
117
|
+
onCrossLane({ issue: n, lane: a.lane, dependency: d, dependencyLane: b.lane });
|
|
118
|
+
}
|
|
119
|
+
};
|
|
120
|
+
const addIssues = async (ns) => {
|
|
121
|
+
const fresh = ns.filter((d) => !set.has(d));
|
|
122
|
+
for (const d of fresh) { set.add(d); order.push(d); }
|
|
123
|
+
for (const d of fresh) { await readDeps(d); pending.push(d); }
|
|
124
|
+
noteCrossLane();
|
|
125
|
+
};
|
|
126
|
+
// New issues (a fresh batch, or extra issues passed to a resume) join the queue.
|
|
127
|
+
for (const n of issues) if (!set.has(n)) { set.add(n); order.push(n); pending.push(n); }
|
|
128
|
+
for (const n of order) if (!deps.has(n)) await readDeps(n);
|
|
129
|
+
noteCrossLane();
|
|
130
|
+
save();
|
|
131
|
+
|
|
132
|
+
const skip = (n, detail) => { results.set(n, { issue: n, outcome: "skipped", pr: null, cost_usd: 0, minutes: 0, detail }); update(results.get(n)); };
|
|
133
|
+
const requeue = (n, delayMs) => { pending.push(n); if (delayMs > 0) notBefore.set(n, now() + delayMs); else notBefore.delete(n); };
|
|
134
|
+
const noteEngineError = () => {
|
|
135
|
+
if (!breaker) return;
|
|
136
|
+
const t = now(), windowMs = breaker.window_min * 60000;
|
|
137
|
+
errorTimes = [...errorTimes.filter((x) => t - x < windowMs), t];
|
|
138
|
+
if (!tripped && errorTimes.length >= breaker.errors) {
|
|
139
|
+
tripped = { at: t, detail: `circuit breaker: ${errorTimes.length} engine-errors within ${breaker.window_min} min; admissions stopped` };
|
|
140
|
+
update({ issue: null, outcome: "breaker", detail: tripped.detail });
|
|
141
|
+
}
|
|
142
|
+
};
|
|
30
143
|
|
|
31
|
-
|
|
144
|
+
// Apply one lane's result: requeue it (deferred, dependency-open, engine-error) or record its final row.
|
|
145
|
+
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 } : {}) };
|
|
147
|
+
const depList = (Array.isArray(r?.dependencies) ? r.dependencies : []).filter((d) => Number.isSafeInteger(d) && d > 0 && d !== n);
|
|
148
|
+
const subs = (Array.isArray(r?.subissues) ? r.subissues : []).filter((d) => Number.isSafeInteger(d) && d > 0 && d !== n);
|
|
149
|
+
const done = () => { running.delete(n); runRecs.delete(n); };
|
|
150
|
+
if (row.outcome === "dependency-open" && depList.length && (requeues.get(n) ?? 0) < MAX_REQUEUES) {
|
|
151
|
+
// A scheduling fact, not a failure: add the edge, pull the dependency in, and run this issue again after it.
|
|
152
|
+
requeues.set(n, (requeues.get(n) ?? 0) + 1);
|
|
153
|
+
deps.set(n, [...new Set([...(deps.get(n) ?? []), ...depList])]);
|
|
154
|
+
await addIssues(depList);
|
|
155
|
+
// Dependencies already done but still open on GitHub: a delay instead of a hot relaunch loop.
|
|
156
|
+
const allDone = depList.every((d) => results.has(d) && SATISFIED.has(results.get(d).outcome));
|
|
157
|
+
requeue(n, allDone ? DEP_REQUEUE_DELAY_S * 1000 : 0); done(); save();
|
|
158
|
+
update({ ...row, detail: `requeued behind ${depList.map((d) => `#${d}`).join(", ")}` });
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
if (row.outcome === "deferred") {
|
|
162
|
+
const pol = deferPolicy(r, { now: now(), deferrals: deferrals.get(n) ?? 0, maxDeferrals });
|
|
163
|
+
if (!pol.capped) {
|
|
164
|
+
if (!pol.host) deferrals.set(n, (deferrals.get(n) ?? 0) + 1);
|
|
165
|
+
if (pol.hold) holdUntil = Math.max(holdUntil ?? 0, now() + pol.delayMs);
|
|
166
|
+
requeue(n, pol.delayMs); done(); save();
|
|
167
|
+
update({ ...row, detail: `requeued in ${Math.round(pol.delayMs / 1000)}s${pol.hold ? " (admissions held)" : ""}: ${r?.code ?? "deferred"}${r?.detail ? ` ${r.detail}` : ""}` });
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
row.detail = `gave up after ${deferrals.get(n) ?? 0} deferrals: ${r?.detail ?? r?.code ?? ""}`;
|
|
171
|
+
}
|
|
172
|
+
if (row.outcome === "engine-error") {
|
|
173
|
+
noteEngineError();
|
|
174
|
+
if ((engineErrors.get(n) ?? 0) < ENGINE_ERROR_RELAUNCHES) {
|
|
175
|
+
engineErrors.set(n, (engineErrors.get(n) ?? 0) + 1);
|
|
176
|
+
requeue(n, 0); done(); save();
|
|
177
|
+
update({ ...row, detail: `relaunching once: ${row.detail ?? ""}` });
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
if (row.outcome === "decomposed" && subs.length) {
|
|
182
|
+
await addIssues(subs);
|
|
183
|
+
for (const [m, ds] of deps) if (ds.includes(n)) deps.set(m, [...new Set(ds.flatMap((d) => (d === n ? subs : [d])))]);
|
|
184
|
+
noteCrossLane();
|
|
185
|
+
}
|
|
186
|
+
results.set(n, row); done(); save(); update(row);
|
|
187
|
+
};
|
|
188
|
+
|
|
189
|
+
const track = (n, p) => { running.set(n, p.catch((e) => settle(n, { terminalReason: "engine-error", detail: e?.message ?? String(e) }, runRecs.get(n), null))); };
|
|
32
190
|
const start = (n) => {
|
|
33
|
-
const
|
|
34
|
-
|
|
191
|
+
const ln = lanes.get(n);
|
|
192
|
+
if (ln && !ln.ok) { // settled synchronously: nothing runs, no lease, no comment
|
|
193
|
+
const row = { issue: n, outcome: "lane-refused", pr: null, cost_usd: 0, minutes: 0, detail: `${ln.code}: ${ln.detail}`, code: ln.code };
|
|
194
|
+
results.set(n, row); save(); update(row);
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
const lane = ln?.lane;
|
|
198
|
+
const rec = { startedAt: now(), ...(lane ? { lane } : {}) };
|
|
199
|
+
runRecs.set(n, rec); notBefore.delete(n); save();
|
|
200
|
+
track(n, (async () => {
|
|
201
|
+
// Pre-filter once per issue (a requeued issue is not re-checked); fail-open: an error means a normal lane.
|
|
202
|
+
if (prefilter && !checked.has(n)) { let pf = null; try { pf = await prefilter(n, lane); } catch { pf = null; } checked.set(n, pf); save(); }
|
|
203
|
+
const pf = checked.get(n) ?? null;
|
|
35
204
|
let r;
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
205
|
+
if (pf?.closed) r = { terminalReason: "invalid", cost_usd: 0, detail: `pre-filter: ${pf.detail}` };
|
|
206
|
+
else {
|
|
207
|
+
try { r = await runOne(n, lane, { onStart: (info) => { Object.assign(rec, info ?? {}); save(); } }); }
|
|
208
|
+
catch (e) { r = { terminalReason: "engine-error", detail: e?.message ?? String(e) }; }
|
|
209
|
+
}
|
|
210
|
+
await settle(n, r, rec, pf);
|
|
211
|
+
})());
|
|
41
212
|
};
|
|
42
213
|
|
|
214
|
+
// Lanes launched by an earlier dispatcher: re-attach to the live ones, queue the rest again (no result was recorded).
|
|
215
|
+
for (const [n, rec] of [...runRecs]) {
|
|
216
|
+
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)));
|
|
219
|
+
else { runRecs.delete(n); if (!pending.includes(n) && !results.has(n)) pending.unshift(n); }
|
|
220
|
+
}
|
|
221
|
+
save();
|
|
222
|
+
|
|
223
|
+
const K = () => { let k = null; try { k = getParallel?.(); } catch { k = null; } return Number.isInteger(k) && k >= 1 ? k : parallel; };
|
|
43
224
|
while (pending.length || running.size) {
|
|
44
225
|
// Resolve skips first: a dependency that finished unsatisfied (or never can) skips its dependents.
|
|
45
226
|
for (let i = pending.length - 1; i >= 0; i--) {
|
|
46
227
|
const n = pending[i];
|
|
47
|
-
|
|
228
|
+
if (results.has(n)) { pending.splice(i, 1); continue; }
|
|
229
|
+
const bad = edges(n).find((d) => results.has(d) && !SATISFIED.has(results.get(d).outcome));
|
|
48
230
|
if (bad !== undefined) { pending.splice(i, 1); skip(n, `dependency #${bad} ended ${results.get(bad).outcome}`); }
|
|
49
231
|
}
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
232
|
+
const t = now();
|
|
233
|
+
if (holdUntil && t >= holdUntil) { holdUntil = null; save(); }
|
|
234
|
+
let started = 0;
|
|
235
|
+
if (!tripped && !holdUntil) {
|
|
236
|
+
for (let i = 0; i < pending.length && running.size < K();) {
|
|
237
|
+
const n = pending[i];
|
|
238
|
+
if ((notBefore.get(n) ?? 0) <= t && edges(n).every((d) => results.has(d) && SATISFIED.has(results.get(d).outcome))) { pending.splice(i, 1); start(n); started++; }
|
|
239
|
+
else i++;
|
|
240
|
+
}
|
|
54
241
|
}
|
|
55
|
-
if (running.size)
|
|
56
|
-
|
|
242
|
+
if (tripped && !running.size) break; // breaker: let running lanes finish, keep the rest pending for a resume
|
|
243
|
+
// Wake for a delayed issue, the end of a usage-limit hold, or (with a live K) a poll.
|
|
244
|
+
const waits = pending.map((n) => notBefore.get(n) ?? 0).filter((x) => x > t);
|
|
245
|
+
if (holdUntil) waits.push(holdUntil);
|
|
246
|
+
let wake = waits.length ? Math.max(0, Math.min(...waits) - t) : null;
|
|
247
|
+
if (getParallel && pending.length) wake = Math.min(wake ?? pollMs, pollMs);
|
|
248
|
+
if (tripped) wake = null;
|
|
249
|
+
if (running.size) {
|
|
250
|
+
if (wake === null) await Promise.race(running.values());
|
|
251
|
+
else { const ac = new AbortController(); await Promise.race([...running.values(), sleep(wake, ac.signal)]); ac.abort(); }
|
|
252
|
+
} else if (started) continue; // only lane-refused rows settled (synchronously): re-resolve skips first
|
|
253
|
+
else if (pending.length && waits.length) await sleep(Math.max(0, Math.min(...waits) - t));
|
|
254
|
+
else if (pending.length) { // nothing runnable, nothing running, nothing waiting on time: a dependency cycle
|
|
57
255
|
for (const n of pending.splice(0)) skip(n, "dependency cycle");
|
|
256
|
+
save();
|
|
58
257
|
}
|
|
59
258
|
}
|
|
60
|
-
|
|
259
|
+
save();
|
|
260
|
+
return order.map((n) => results.get(n) ?? { issue: n, outcome: "held", pr: null, cost_usd: 0, minutes: 0, detail: tripped?.detail ?? "not run" });
|
|
61
261
|
}
|
|
62
262
|
|
|
63
263
|
export function formatTable(rows) {
|
|
64
264
|
const head = ["issue", "outcome", "PR", "cost", "min"];
|
|
65
|
-
const body = rows.map((r) => [`#${r.issue}`, r.outcome + (r.outcome
|
|
265
|
+
const body = rows.map((r) => [`#${r.issue}`, r.outcome + (["skipped", "held"].includes(r.outcome) && r.detail ? ` (${r.detail})` : r.prefilter?.closed ? " (pre-filter)" : ""), r.pr ? `#${r.pr}` : "-", `$${(r.cost_usd ?? 0).toFixed(2)}`, String(r.minutes ?? 0)]);
|
|
66
266
|
const w = head.map((h, i) => Math.max(h.length, ...body.map((b) => b[i].length)));
|
|
67
267
|
const line = (c) => c.map((x, i) => x.padEnd(w[i])).join(" ").trimEnd();
|
|
68
268
|
return [line(head), line(w.map((n) => "-".repeat(n))), ...body.map(line)].join("\n");
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
|
|
2
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
3
|
+
/**
|
|
4
|
+
* Already-fixed pre-filter (#4123): cheap checks before any lane starts, so an issue that a merged PR or a later green
|
|
5
|
+
* CI run already resolved does not cost a worktree, a lease and a builder session.
|
|
6
|
+
* 1. merged PRs (and lane commits) that claim to fix #N -> candidate
|
|
7
|
+
* 2. auto-filed CI-failure issue whose workflow is green again on the failing branch -> closed with the run link
|
|
8
|
+
* 3. every repo path the issue names is gone from origin/<lane> -> candidate
|
|
9
|
+
* A candidate gets ONE read-only, single-turn validation on a small model in the main checkout (no worktree). Only an
|
|
10
|
+
* ALREADY-FIXED/INVALID verdict with evidence closes the issue; everything else, and every error, goes to the normal lane.
|
|
11
|
+
* All effects are injected (gh, git, runner, transitionLabel, projector?) so it is unit-testable.
|
|
12
|
+
*/
|
|
13
|
+
import { capComment, sanitizeMd } from "./render.mjs";
|
|
14
|
+
import { PREFILTER_SYSTEM, prefilterPrompt, parsePrefilterVerdict } from "./prompts.mjs";
|
|
15
|
+
|
|
16
|
+
export const DEFAULT_PREFILTER_MODEL = "claude-haiku-5-5";
|
|
17
|
+
export const PREFILTER_MARKER = "FORGE:PREFILTER";
|
|
18
|
+
/** Spend avoided per pre-filter close when the batch has no full-lane invalid run to average (#4123: ~$13 / 21 lanes). */
|
|
19
|
+
export const DEFAULT_AVOIDED_USD = 0.62;
|
|
20
|
+
|
|
21
|
+
const esc = (s) => String(s).replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
22
|
+
/** `Fixes #N`, `closes owner/repo#N`, `resolved: #N`, or a lean-style title/subject ending `(#N)`. */
|
|
23
|
+
export function claimsFix(text, n) {
|
|
24
|
+
const s = String(text ?? "");
|
|
25
|
+
return new RegExp(`\\b(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?)\\b:?\\s+(?:[\\w.-]+/[\\w.-]+)?#${n}(?!\\d)`, "i").test(s)
|
|
26
|
+
|| new RegExp(`\\(#${n}\\)\\s*$`, "m").test(s);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const RUN_URL_RE = /https:\/\/github\.com\/([\w.-]+\/[\w.-]+)\/actions\/runs\/(\d+)/;
|
|
30
|
+
const CI_FAIL_RE = /\b(?:fail(?:s|ed|ing|ure)?|broken|red)\b/i;
|
|
31
|
+
/** The failing Actions run of an auto-filed CI-failure issue: failure wording plus a run link in the title or body. */
|
|
32
|
+
export function ciFailureRun(iss) {
|
|
33
|
+
const text = `${iss.title ?? ""}\n${iss.body ?? ""}`;
|
|
34
|
+
const m = RUN_URL_RE.exec(text);
|
|
35
|
+
return m && CI_FAIL_RE.test(text) ? { repo: m[1], id: Number(m[2]) } : null;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const PATH_RE = /(?:^|[\s`'"(\[])((?:\.{0,2}\/)?(?:[\w@.-]+\/)+[\w@.-]+\.[A-Za-z0-9]{1,8})(?::\d+(?:-\d+)?)?(?=$|[\s`'"),.:;\]])/gm;
|
|
39
|
+
/** Repo-relative file paths the issue names (`dir/file.ext`, optionally `:line`); URLs and HTML comments are ignored. */
|
|
40
|
+
export function namedPaths(text) {
|
|
41
|
+
const s = sanitizeMd(text).replace(/<!--[\s\S]*?-->/g, " ").replace(/\b[a-z]+:\/\/\S+/gi, " ");
|
|
42
|
+
return [...new Set([...s.matchAll(PATH_RE)].map((m) => m[1].replace(/^\.\//, "")).filter((p) => !p.startsWith("/") && !p.startsWith("../")))];
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* @returns {Promise<{prs:object[], commits:object[], ci:?object, missingPaths:string[], namedPaths:string[]}>}
|
|
47
|
+
*/
|
|
48
|
+
export async function collectSignals({ iss, repo, lane, io, cwd }) {
|
|
49
|
+
const n = iss.number;
|
|
50
|
+
const sig = { prs: [], commits: [], ci: null, missingPaths: [], namedPaths: [] };
|
|
51
|
+
try {
|
|
52
|
+
const found = JSON.parse(String(await io.gh(["search", "prs", `#${n}`, "--repo", repo, "--merged", "--json", "number,title,body,url", "--limit", "20"])) || "[]");
|
|
53
|
+
sig.prs = found.filter((p) => claimsFix(p.title, n) || claimsFix(p.body, n));
|
|
54
|
+
} catch { /* search unavailable: no PR signal */ }
|
|
55
|
+
try {
|
|
56
|
+
const log = String(await io.git(["log", `origin/${lane}`, "--max-count=50", "-E", `--grep=#${n}([^0-9]|$)`, "--format=%H%x09%s"], { cwd }));
|
|
57
|
+
sig.commits = log.split("\n").map((l) => l.split("\t")).filter(([h, s]) => h && s && !/^Merge /.test(s) && claimsFix(s, n)).map(([sha, subject]) => ({ sha, subject }));
|
|
58
|
+
} catch { /* no lane ref: no commit signal */ }
|
|
59
|
+
const run = ciFailureRun(iss);
|
|
60
|
+
if (run && run.repo.toLowerCase() === repo.toLowerCase()) {
|
|
61
|
+
try {
|
|
62
|
+
const failed = JSON.parse(String(await io.gh(["api", `repos/${repo}/actions/runs/${run.id}`])));
|
|
63
|
+
const branch = failed.head_branch || lane;
|
|
64
|
+
const latest = JSON.parse(String(await io.gh(["api", `repos/${repo}/actions/workflows/${failed.workflow_id}/runs?branch=${encodeURIComponent(branch)}&status=completed&per_page=1`])))?.workflow_runs?.[0];
|
|
65
|
+
if (latest && latest.id !== failed.id && latest.conclusion === "success" && Date.parse(latest.created_at) > Date.parse(failed.created_at)) {
|
|
66
|
+
sig.ci = { workflow: failed.name ?? latest.name ?? String(failed.workflow_id), branch, failedUrl: failed.html_url, greenUrl: latest.html_url, greenSha: latest.head_sha ?? "" };
|
|
67
|
+
}
|
|
68
|
+
} catch { /* run unreadable: no CI signal */ }
|
|
69
|
+
}
|
|
70
|
+
sig.namedPaths = namedPaths(iss.body);
|
|
71
|
+
if (sig.namedPaths.length) {
|
|
72
|
+
const missing = [];
|
|
73
|
+
for (const p of sig.namedPaths.slice(0, 20)) {
|
|
74
|
+
try { await io.git(["cat-file", "-e", `origin/${lane}:${p}`], { cwd }); } catch { missing.push(p); }
|
|
75
|
+
}
|
|
76
|
+
if (missing.length === Math.min(sig.namedPaths.length, 20)) sig.missingPaths = missing; // all gone: the code moved or was removed
|
|
77
|
+
}
|
|
78
|
+
return sig;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Pre-filter one issue. Never throws.
|
|
83
|
+
* @returns {Promise<{issue:number, closed:boolean, kind:?string, candidate:boolean, verdict:?string, cost_usd:number, detail:string}>}
|
|
84
|
+
*/
|
|
85
|
+
export async function prefilterIssue({ issue, repo, lane, cwd, io, model = DEFAULT_PREFILTER_MODEL, dryRun = false, agentId = null, maxMinutes = 5 }) {
|
|
86
|
+
const out = { issue, closed: false, kind: null, candidate: false, verdict: null, cost_usd: 0, detail: "" };
|
|
87
|
+
try {
|
|
88
|
+
const iss = JSON.parse(String(await io.gh(["issue", "view", String(issue), "-R", repo, "--json", "number,title,body,state,labels"])));
|
|
89
|
+
if (String(iss.state).toUpperCase() !== "OPEN") return { ...out, detail: "not open" };
|
|
90
|
+
if ((iss.labels ?? []).some((l) => ["needs-human", "workflow:merged"].includes(l?.name ?? l))) return { ...out, detail: "labelled, left to the lane" };
|
|
91
|
+
if (io.projector) {
|
|
92
|
+
let st = null;
|
|
93
|
+
try { st = await io.projector.readState(issue); } catch { /* unreadable: proceed */ }
|
|
94
|
+
if (st?.lease && st.lease.until > Date.now() && st.lease.by !== agentId) return { ...out, detail: `leased by ${st.lease.by}` };
|
|
95
|
+
}
|
|
96
|
+
const sig = await collectSignals({ iss, repo, lane, io, cwd });
|
|
97
|
+
const close = async (kind, body, detail) => {
|
|
98
|
+
if (!dryRun) {
|
|
99
|
+
await io.gh(["issue", "comment", String(issue), "-R", repo, "--body", capComment(`<!-- ${PREFILTER_MARKER} -->\n<!-- FORGE:VALIDATE -->\n${body}`)]);
|
|
100
|
+
await io.gh(["issue", "close", String(issue), "-R", repo, "--reason", "not planned"]);
|
|
101
|
+
try { await io.transitionLabel?.(issue, "invalid"); } catch { /* labels are best effort */ }
|
|
102
|
+
}
|
|
103
|
+
return { ...out, closed: !dryRun, kind, detail };
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
if (sig.ci) {
|
|
107
|
+
out.candidate = true;
|
|
108
|
+
const c = sig.ci;
|
|
109
|
+
return await close("ci-green", `## Pre-filter: CI failure no longer reproduces\n**Workflow:** ${c.workflow} on \`${c.branch}\`\n**Failing run:** ${c.failedUrl}\n**Latest run (green):** ${c.greenUrl}${c.greenSha ? ` at \`${c.greenSha.slice(0, 12)}\`` : ""}\n\nClosed without a lane. Reopen if the failure comes back.`,
|
|
110
|
+
`CI green again: ${c.greenUrl}`);
|
|
111
|
+
}
|
|
112
|
+
if (!sig.prs.length && !sig.commits.length && !sig.missingPaths.length) return { ...out, detail: "no already-fixed signal" };
|
|
113
|
+
out.candidate = true;
|
|
114
|
+
const why = [...sig.prs.map((p) => `PR #${p.number}`), ...sig.commits.map((c) => c.sha.slice(0, 12)), ...(sig.missingPaths.length ? ["named paths missing"] : [])].join(", ");
|
|
115
|
+
if (dryRun) return { ...out, detail: `candidate: ${why}` };
|
|
116
|
+
let r;
|
|
117
|
+
try {
|
|
118
|
+
r = await io.runner({ role: "prefilter", issue, cwd, model, resume: null, system: PREFILTER_SYSTEM, readOnly: true,
|
|
119
|
+
prompt: prefilterPrompt({ issue: iss, lane, signals: sig }), maxMs: Math.round(maxMinutes * 60000) });
|
|
120
|
+
} catch (e) { return { ...out, detail: `validation failed (${String(e?.message ?? e).slice(0, 120)}): lane` }; }
|
|
121
|
+
out.cost_usd = Number.isFinite(r?.costUsd) ? r.costUsd : 0;
|
|
122
|
+
const v = parsePrefilterVerdict(r?.text);
|
|
123
|
+
out.verdict = v?.verdict ?? null;
|
|
124
|
+
if (!v || v.verdict === "CONFIRMED" || !v.evidence.length) return { ...out, detail: `candidate (${why}) ${v ? (v.verdict === "CONFIRMED" ? "confirmed" : "without evidence") : "unparseable verdict"}: lane` };
|
|
125
|
+
const ev = v.evidence.map((x) => `- ${sanitizeMd(x).replace(/\s+/g, " ").slice(0, 400)}`).join("\n");
|
|
126
|
+
return await close("validated", `## Pre-filter: ${v.verdict}\n**Signal:** ${why}\n**Summary:** ${sanitizeMd(v.summary).replace(/\s+/g, " ").slice(0, 600) || "not reported"}\n\n**Evidence:**\n${ev}\n\nValidated by a single read-only check (${model}), without a lane. Reopen if this is wrong.`,
|
|
127
|
+
`${v.verdict.toLowerCase()} (${why})`);
|
|
128
|
+
} catch (e) {
|
|
129
|
+
return { ...out, detail: `pre-filter error (${String(e?.message ?? e).slice(0, 120)}): lane` };
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Per-issue hook for `orchestrate({ prefilter })`; `lane` is the issue's own resolved lane (#4125). */
|
|
134
|
+
export function makePrefilter(opts) {
|
|
135
|
+
return (issue, lane) => prefilterIssue({ ...opts, issue, ...(lane ? { lane } : {}) });
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** One report line: how many the pre-filter checked, flagged and closed, what validation cost, and the spend avoided. */
|
|
139
|
+
export function formatPrefilterSummary(rows) {
|
|
140
|
+
const pf = rows.filter((r) => r?.prefilter);
|
|
141
|
+
if (!pf.length) return "";
|
|
142
|
+
const closed = pf.filter((r) => r.prefilter.closed);
|
|
143
|
+
const lanes = rows.filter((r) => r && !r.prefilter?.closed && r.outcome === "invalid" && (r.cost_usd ?? 0) > 0);
|
|
144
|
+
const per = lanes.length ? lanes.reduce((a, r) => a + r.cost_usd, 0) / lanes.length : DEFAULT_AVOIDED_USD;
|
|
145
|
+
const spent = pf.reduce((a, r) => a + (r.prefilter.cost_usd ?? 0), 0);
|
|
146
|
+
const ci = closed.filter((r) => r.prefilter.kind === "ci-green").length;
|
|
147
|
+
return `pre-filter: ${pf.length} checked, ${pf.filter((r) => r.prefilter.candidate).length} candidate(s), ${closed.length} closed without a lane (${ci} CI green, ${closed.length - ci} validated); validation $${spent.toFixed(2)}, spend avoided ~$${Math.max(0, closed.length * per - spent).toFixed(2)} (${lanes.length ? "batch invalid-lane average" : "default"} $${per.toFixed(2)}/lane)`;
|
|
148
|
+
}
|