forgedock 1.31.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-cli.mjs +51 -9
- package/bin/hooks/reread-nudge.mjs +65 -0
- package/bin/lean/batch.test.mjs +44 -1
- package/bin/lean/context.mjs +12 -1
- package/bin/lean/context.test.mjs +18 -1
- package/bin/lean/effects.mjs +13 -2
- package/bin/lean/lean.mjs +67 -15
- package/bin/lean/lean.test.mjs +177 -1
- package/bin/lean/lightlane.mjs +219 -0
- package/bin/lean/lightlane.test.mjs +237 -0
- package/bin/lean/orchestrate.mjs +45 -6
- package/bin/lean/precheck.test.mjs +5 -0
- package/bin/lean/prefilter.mjs +69 -8
- package/bin/lean/prefilter.test.mjs +109 -2
- package/bin/lean/prompts.mjs +3 -0
- package/bin/lean/sched.mjs +16 -1
- package/bin/lean/sched.test.mjs +48 -0
- package/bin/lean/schedd.test.mjs +21 -0
- package/bin/stats/efficiency.mjs +22 -6
- package/bin/stats/efficiency.test.mjs +35 -2
- package/bin/stats/index.mjs +25 -3
- package/bin/stats/local-source.mjs +59 -5
- package/bin/stats/query.mjs +133 -3
- package/bin/stats/stats.test.mjs +181 -5
- package/docs/CONFIG.md +3 -3
- package/docs/STATS.md +13 -0
- package/docs/eval/corpus/lightlane-v1.json +55 -0
- package/docs/eval/lightlane-bench.md +91 -0
- package/package.json +1 -1
- package/scripts/bench-lightlane.mjs +286 -0
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
|
|
2
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
3
|
+
import { describe, it } from "node:test";
|
|
4
|
+
import assert from "node:assert/strict";
|
|
5
|
+
import { deriveLightContract, runLightStep, runLightTests, parseNumstat, apiShapeReasons, upgradeNote, lightBuildPrompt, renderLiteReceipt } from "./lightlane.mjs";
|
|
6
|
+
import { validateContract } from "./conformance.mjs";
|
|
7
|
+
|
|
8
|
+
const TITLE = "foo crashes on null input";
|
|
9
|
+
const ISSUE = `${TITLE}\n\nfoo crashes on null input in src/foo.js.\n\n- foo must handle a null argument without throwing.\n- Existing callers of foo keep working.`;
|
|
10
|
+
const RT = { path: "test/foo.test.js", id: "foo handles null", command: "node --test test/foo.test.js" };
|
|
11
|
+
const RESULT = { status: "fixed", summary: "guard null", requirement: "foo must handle a null argument without throwing.", repro_test: RT, tests: [{ command: "node --test test/foo.test.js", result: "pass" }, { command: "node --test test/bar.test.js", result: "pass" }] };
|
|
12
|
+
const FILES = ["src/foo.js", "test/foo.test.js"];
|
|
13
|
+
const validate = (c) => validateContract(c, ISSUE, { repro: true });
|
|
14
|
+
|
|
15
|
+
describe("deriveLightContract (#4450)", () => {
|
|
16
|
+
it("quotes the builder's verbatim sentence, delivers the changed non-test files, validates the repro and the targeted tests", () => {
|
|
17
|
+
const c = deriveLightContract({ title: TITLE, issueText: ISSUE, result: RESULT, files: [...FILES, ".forge-lean/result.json"] });
|
|
18
|
+
assert.deepEqual(c.requirements, [{ id: "R1", quote: "foo must handle a null argument without throwing.", disposition: "deliver" }]);
|
|
19
|
+
assert.deepEqual(c.deliverables.map((d) => d.path), ["src/foo.js"]);
|
|
20
|
+
assert.deepEqual(c.validate, [{ id: "V1", command: "node --test test/foo.test.js" }, { id: "V2", command: "node --test test/bar.test.js" }]);
|
|
21
|
+
assert.deepEqual(c.repro_test, RT);
|
|
22
|
+
assert.equal(validate(c).ok, true, validate(c).problems.join("; "));
|
|
23
|
+
});
|
|
24
|
+
it("a sentence that is not in the issue falls back to the title; no repro test is repro_test none with a reason", () => {
|
|
25
|
+
const c = deriveLightContract({ title: TITLE, issueText: ISSUE, result: { ...RESULT, requirement: "make foo better in every way possible", repro_test: undefined }, files: FILES });
|
|
26
|
+
assert.equal(c.requirements[0].quote, TITLE);
|
|
27
|
+
assert.equal(c.repro_test, "none");
|
|
28
|
+
assert.ok(c.repro_test_reason);
|
|
29
|
+
assert.deepEqual(c.validate.map((v) => v.command), ["node --test test/foo.test.js", "node --test test/bar.test.js"]);
|
|
30
|
+
});
|
|
31
|
+
it("a test-only diff has no deliverable, so the derived contract is invalid", () => {
|
|
32
|
+
const c = deriveLightContract({ title: TITLE, issueText: ISSUE, result: RESULT, files: ["test/foo.test.js"] });
|
|
33
|
+
assert.equal(validate(c).ok, false);
|
|
34
|
+
});
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
describe("parseNumstat / apiShapeReasons / upgradeNote", () => {
|
|
38
|
+
it("numstat rows, binary as 0, renames to the new path, the engine dir skipped", () => {
|
|
39
|
+
assert.deepEqual(parseNumstat("3\t1\tsrc/foo.js\n-\t-\timg.png\n2\t0\tsrc/{a.js => b.js}\n1\t1\told.js => new.js\n4\t0\t.forge-lean/result.json\n").files, [
|
|
40
|
+
{ path: "src/foo.js", added: 3, deleted: 1 }, { path: "img.png", added: 0, deleted: 0 }, { path: "src/b.js", added: 2, deleted: 0 }, { path: "new.js", added: 1, deleted: 1 }]);
|
|
41
|
+
});
|
|
42
|
+
it("none is no reason; changes and unknown files are reasons; a missing result is unknown", () => {
|
|
43
|
+
assert.deepEqual(apiShapeReasons({ status: "none", changes: [], unknown: [] }), []);
|
|
44
|
+
assert.deepEqual(apiShapeReasons({ status: "changed", changes: [{ file: "a.py", symbol: "ichunked", change: "generator-function" }], unknown: [] }), ["a.py: ichunked generator-function"]);
|
|
45
|
+
assert.deepEqual(apiShapeReasons({ status: "unknown", changes: [], unknown: [{ file: "x.go", lang: "go", reason: "unsupported-language" }] }), ["x.go: unknown (go), unsupported-language"]);
|
|
46
|
+
assert.equal(apiShapeReasons(null).length, 1);
|
|
47
|
+
});
|
|
48
|
+
it("the upgrade note lists the reasons, escapes markup and is empty without reasons", () => {
|
|
49
|
+
assert.equal(upgradeNote([]), "");
|
|
50
|
+
const n = upgradeNote([{ code: "diff_lines", detail: "90 non-test lines (max 40) <!-- x -->" }]);
|
|
51
|
+
assert.match(n, /Light-lane upgrade/);
|
|
52
|
+
assert.match(n, /- diff_lines: 90 non-test lines \(max 40\)/);
|
|
53
|
+
assert.ok(!n.includes("<!--"));
|
|
54
|
+
});
|
|
55
|
+
it("the light prompt asks for test first, fix, targeted tests and the derivation fields", () => {
|
|
56
|
+
const p = lightBuildPrompt(7);
|
|
57
|
+
assert.match(p, /failing regression test first/);
|
|
58
|
+
assert.match(p, /"requirement":/);
|
|
59
|
+
assert.match(p, /"repro_test":/);
|
|
60
|
+
assert.match(p, /"status":"upgrade"/);
|
|
61
|
+
assert.match(p, /Host rules/);
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
/** A light step whose effects are fakes; `o` overrides any of them. */
|
|
66
|
+
function step(o = {}) {
|
|
67
|
+
const calls = { build: 0, fix: 0, tests: 0, checkpoint: 0 };
|
|
68
|
+
const ctx = {
|
|
69
|
+
title: TITLE, issueText: ISSUE, riskPaths: [], cfg: { mode: "on" }, validate,
|
|
70
|
+
build: async () => { calls.build++; },
|
|
71
|
+
readResult: async () => o.result ?? RESULT,
|
|
72
|
+
checkpoint: async () => { calls.checkpoint++; },
|
|
73
|
+
changedFiles: async () => o.files ?? FILES,
|
|
74
|
+
numstat: async () => o.numstat ?? "5\t1\tsrc/foo.js\n20\t0\ttest/foo.test.js\n",
|
|
75
|
+
runTests: async (cmds) => { calls.tests++; const rc = (o.testRcs ?? [])[calls.tests - 1] ?? 0; return cmds.map((command) => ({ command, rc, output: rc ? "not ok 1" : "" })); },
|
|
76
|
+
fixTests: async () => { calls.fix++; },
|
|
77
|
+
reproOf: async () => o.repro ?? { base: "FAILED", head: "RAN" },
|
|
78
|
+
apiShape: async () => (o.api === undefined ? { status: "none", changes: [], unknown: [] } : o.api),
|
|
79
|
+
...(o.ctx ?? {}),
|
|
80
|
+
};
|
|
81
|
+
return { ctx, calls };
|
|
82
|
+
}
|
|
83
|
+
const codes = (r) => r.reasons.map((x) => x.code);
|
|
84
|
+
|
|
85
|
+
describe("runLightStep (#4450)", () => {
|
|
86
|
+
it("nothing triggers: one build, the derived contract, no fix round", async () => {
|
|
87
|
+
const { ctx, calls } = step();
|
|
88
|
+
const r = await runLightStep(ctx);
|
|
89
|
+
assert.equal(r.upgrade, false, JSON.stringify(r.reasons));
|
|
90
|
+
assert.equal(calls.build, 1); assert.equal(calls.fix, 0); assert.equal(calls.checkpoint, 1);
|
|
91
|
+
assert.deepEqual(r.contract.deliverables.map((d) => d.path), ["src/foo.js"]);
|
|
92
|
+
});
|
|
93
|
+
const cases = [
|
|
94
|
+
["diff_lines", { numstat: "60\t0\tsrc/foo.js\n" }],
|
|
95
|
+
["diff_files", { numstat: "1\t0\tsrc/a.js\n1\t0\tsrc/b.js\n1\t0\tsrc/c.js\n", files: ["src/a.js", "src/b.js", "src/c.js", "test/foo.test.js"] }],
|
|
96
|
+
["repro_not_red", { repro: { base: "PASSED", head: "RAN" } }],
|
|
97
|
+
["tests_failed_twice", { testRcs: [1, 1] }],
|
|
98
|
+
["risk_path", { numstat: "1\t0\tsrc/auth/login.js\n", files: ["src/auth/login.js", "test/foo.test.js"] }],
|
|
99
|
+
["api_shape", { api: { status: "changed", changes: [{ file: "src/foo.js", symbol: "foo", change: "signature" }], unknown: [] } }],
|
|
100
|
+
["api_shape", { api: null }],
|
|
101
|
+
["not_fixed", { result: { status: "upgrade", summary: "too big" } }],
|
|
102
|
+
["no_commit", { files: [] }],
|
|
103
|
+
["contract_invalid", { files: ["test/foo.test.js"], numstat: "3\t0\ttest/foo.test.js\n" }],
|
|
104
|
+
["light_error", { ctx: { numstat: async () => { throw new Error("git broke"); } } }],
|
|
105
|
+
];
|
|
106
|
+
for (const [code, o] of cases) {
|
|
107
|
+
it(`upgrades on ${code}${o.api === null ? " (no detector result)" : ""}`, async () => {
|
|
108
|
+
const r = await runLightStep(step(o).ctx);
|
|
109
|
+
assert.equal(r.upgrade, true);
|
|
110
|
+
assert.ok(codes(r).includes(code), codes(r).join(","));
|
|
111
|
+
assert.equal(r.contract, null);
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
it("one failed test run gets one fix round and stays light when the rerun passes", async () => {
|
|
115
|
+
const { ctx, calls } = step({ testRcs: [1, 0] });
|
|
116
|
+
const r = await runLightStep(ctx);
|
|
117
|
+
assert.equal(r.upgrade, false); assert.equal(calls.fix, 1); assert.equal(r.testFailures, 1);
|
|
118
|
+
});
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
describe("#4451 light prechecks run concurrently, capped by host parallelism", () => {
|
|
122
|
+
/** fake commands that each wait for an explicit release; records start/end order and the max in flight */
|
|
123
|
+
const fake = () => {
|
|
124
|
+
const log = [], gates = new Map();
|
|
125
|
+
let live = 0, max = 0;
|
|
126
|
+
const run = ({ command }) => new Promise((resolve) => {
|
|
127
|
+
live++; max = Math.max(max, live); log.push(`start ${command}`);
|
|
128
|
+
gates.set(command, () => { live--; log.push(`end ${command}`); resolve({ rc: command === "b" ? 1 : 0, output: command }); });
|
|
129
|
+
});
|
|
130
|
+
const tick = () => new Promise((r) => setImmediate(r));
|
|
131
|
+
return { run, log, gates, tick, max: () => max };
|
|
132
|
+
};
|
|
133
|
+
it("parallelism >= count: every command starts before the first finishes; results in input order", async () => {
|
|
134
|
+
const f = fake();
|
|
135
|
+
const p = runLightTests({ commands: ["a", "b", "c"], parallelism: 4, run: f.run });
|
|
136
|
+
await f.tick();
|
|
137
|
+
assert.deepEqual(f.log, ["start a", "start b", "start c"]);
|
|
138
|
+
for (const c of ["c", "a", "b"]) { f.gates.get(c)(); await f.tick(); }
|
|
139
|
+
const r = await p;
|
|
140
|
+
assert.deepEqual(r, [{ command: "a", rc: 0, output: "a" }, { command: "b", rc: 1, output: "b" }, { command: "c", rc: 0, output: "c" }]);
|
|
141
|
+
assert.equal(f.max(), 3);
|
|
142
|
+
});
|
|
143
|
+
it("parallelism 2 of 4: never more than 2 in flight; the next starts only when a slot frees", async () => {
|
|
144
|
+
const f = fake();
|
|
145
|
+
const p = runLightTests({ commands: ["a", "b", "c", "d"], parallelism: 2, run: f.run });
|
|
146
|
+
await f.tick();
|
|
147
|
+
assert.deepEqual(f.log, ["start a", "start b"]);
|
|
148
|
+
f.gates.get("b")(); await f.tick();
|
|
149
|
+
assert.deepEqual(f.log.slice(2), ["end b", "start c"]);
|
|
150
|
+
for (const c of ["a", "c"]) { f.gates.get(c)(); await f.tick(); }
|
|
151
|
+
f.gates.get("d")();
|
|
152
|
+
assert.equal((await p).length, 4);
|
|
153
|
+
assert.equal(f.max(), 2);
|
|
154
|
+
});
|
|
155
|
+
it("parallelism 1 runs them one after another; a timeout (rc 124) stays a failure, never deferred", async () => {
|
|
156
|
+
const order = [];
|
|
157
|
+
let t = 0; // a clock that advances between commands (a wall clock does too, in a slow run)
|
|
158
|
+
const r = await runLightTests({ commands: ["x", "y"], parallelism: 1, now: () => (t += 1000), run: async ({ command }) => { order.push(command); return { rc: command === "y" ? 124 : 0, output: "" }; } });
|
|
159
|
+
assert.deepEqual(order, ["x", "y"]);
|
|
160
|
+
assert.deepEqual(r.map((x) => x.rc), [0, 124]);
|
|
161
|
+
});
|
|
162
|
+
it("each held command takes one acquire slot and releases it", async () => {
|
|
163
|
+
let held = 0, peak = 0;
|
|
164
|
+
const acquire = async () => { held++; peak = Math.max(peak, held); return { release: () => { held--; } }; };
|
|
165
|
+
await runLightTests({ commands: ["a", "b", "c"], parallelism: 2, acquire, run: async () => ({ rc: 0, output: "" }) });
|
|
166
|
+
assert.equal(held, 0); assert.ok(peak <= 2);
|
|
167
|
+
});
|
|
168
|
+
it("runLightStep starts the repro, numstat and API-shape gates before any of them resolves", async () => {
|
|
169
|
+
const started = [], pending = [];
|
|
170
|
+
const later = (name, v) => () => new Promise((res) => { started.push(name); pending.push(() => res(v)); });
|
|
171
|
+
const { ctx } = step({ ctx: { reproOf: later("repro", { base: "FAILED", head: "RAN" }), numstat: later("numstat", "5\t1\tsrc/foo.js\n"), apiShape: later("api", { status: "none", changes: [], unknown: [] }) } });
|
|
172
|
+
const p = runLightStep(ctx);
|
|
173
|
+
for (let i = 0; i < 20 && started.length < 3; i++) await new Promise((r) => setImmediate(r));
|
|
174
|
+
assert.deepEqual([...started].sort(), ["api", "numstat", "repro"], "all three in flight together");
|
|
175
|
+
pending.forEach((f) => f());
|
|
176
|
+
assert.equal((await p).upgrade, false);
|
|
177
|
+
});
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
describe("renderLiteReceipt (#4452)", () => {
|
|
181
|
+
const ELIG = { mode: "on", verdict: "eligible", eligible: true, light: true, reasons: [] };
|
|
182
|
+
|
|
183
|
+
it("renders diff stat, red/green result, gates, true cost and eligibility reason for a light lane", async () => {
|
|
184
|
+
const r = await runLightStep(step().ctx);
|
|
185
|
+
const body = renderLiteReceipt({ lr: r, eligibility: ELIG, costUsd: 0.4321, contractHash: "c0ffee12" });
|
|
186
|
+
assert.match(body, /^## Light lane receipt/);
|
|
187
|
+
assert.match(body, /\*\*Outcome:\*\* light/);
|
|
188
|
+
assert.match(body, /\*\*Diff:\*\* 2 file\(s\), \+25\/-1 \(non-test: 1 file\(s\), 6 line\(s\)\)/);
|
|
189
|
+
assert.match(body, /\*\*Red\/green:\*\* base FAILED → head RAN \(red on base\)/);
|
|
190
|
+
assert.match(body, /\*\*Gates:\*\*\n - targeted tests: 2 command\(s\), passed/);
|
|
191
|
+
assert.match(body, /`node --test test\/foo\.test\.js`/);
|
|
192
|
+
assert.match(body, /API shape: unchanged/);
|
|
193
|
+
assert.match(body, /contract: derived, valid \(`c0ffee12`\)/);
|
|
194
|
+
assert.match(body, /\*\*Cost:\*\* \$0\.4321 \(true per-session cost, #4407 deltas\)/);
|
|
195
|
+
assert.match(body, /\*\*Eligibility:\*\* eligible \(mode on\): repro or expected behaviour/);
|
|
196
|
+
assert.ok(!/Upgrade reasons/.test(body));
|
|
197
|
+
assert.ok(!/<!--|undefined|NaN/.test(body), body);
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
it("an upgraded lane lists the gates that fired and the upgrade reasons; free text is sanitised", async () => {
|
|
201
|
+
const api = { status: "changed", changes: [{ file: "src/foo.js", symbol: "foo", change: "sync-async" }], unknown: [] };
|
|
202
|
+
const r = await runLightStep(step({ api, testRcs: [1, 1], repro: { base: "PASSED", head: "RAN" } }).ctx);
|
|
203
|
+
assert.equal(r.upgrade, true);
|
|
204
|
+
const elig = { mode: "on", verdict: "eligible", reasons: [{ code: "no_repro", detail: "x <!-- y --> <b>" }] };
|
|
205
|
+
const body = renderLiteReceipt({ lr: r, eligibility: elig, costUsd: 1.5 });
|
|
206
|
+
assert.match(body, /^## Light lane upgraded to the full lane/);
|
|
207
|
+
assert.match(body, /\*\*Outcome:\*\* upgraded/);
|
|
208
|
+
assert.match(body, /base PASSED → head RAN \(not red on base\)/);
|
|
209
|
+
assert.match(body, /targeted tests: 2 command\(s\), failed twice/);
|
|
210
|
+
assert.match(body, /API shape: 1 change\(s\)\/unknown\n {4}- src\/foo\.js: foo sync-async/);
|
|
211
|
+
assert.match(body, /contract: not derived \(upgraded\)/);
|
|
212
|
+
assert.match(body, /\*\*Upgrade reasons:\*\*[\s\S]*- api_shape: [\s\S]*- repro_not_red: [\s\S]*- tests_failed_twice: /);
|
|
213
|
+
assert.match(body, /- no_repro: x y <b>/);
|
|
214
|
+
assert.ok(!/<!--|--!?>/.test(body));
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
it("an early upgrade (no commit) renders n/a, never undefined", async () => {
|
|
218
|
+
const r = await runLightStep(step({ files: [] }).ctx);
|
|
219
|
+
const body = renderLiteReceipt({ lr: r, eligibility: null, costUsd: null });
|
|
220
|
+
assert.match(body, /\*\*Diff:\*\* n\/a/);
|
|
221
|
+
assert.match(body, /\*\*Red\/green:\*\* n\/a/);
|
|
222
|
+
assert.match(body, /targeted tests: not run/);
|
|
223
|
+
assert.match(body, /API shape: not checked/);
|
|
224
|
+
assert.match(body, /\*\*Cost:\*\* n\/a/);
|
|
225
|
+
assert.match(body, /- no_commit: /);
|
|
226
|
+
assert.ok(!/undefined|NaN/.test(body), body);
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
it("runLightStep returns the receipt inputs alongside its #4450 fields", async () => {
|
|
230
|
+
const r = await runLightStep(step().ctx);
|
|
231
|
+
assert.deepEqual(r.diff, { files: [{ path: "src/foo.js", added: 5, deleted: 1 }, { path: "test/foo.test.js", added: 20, deleted: 0 }] });
|
|
232
|
+
assert.deepEqual(r.apiShape, []);
|
|
233
|
+
assert.deepEqual(r.testCommands, ["node --test test/foo.test.js", "node --test test/bar.test.js"]);
|
|
234
|
+
assert.equal(r.testRuns, 2);
|
|
235
|
+
for (const k of ["upgrade", "reasons", "contract", "files", "repro", "testFailures"]) assert.ok(k in r, k);
|
|
236
|
+
});
|
|
237
|
+
});
|
package/bin/lean/orchestrate.mjs
CHANGED
|
@@ -41,6 +41,13 @@
|
|
|
41
41
|
* `priorities`) ranks it host-wide, and the batch's ready issues are tried in priority order. Ready issues it cannot
|
|
42
42
|
* start yet stay offered in the registry (polled every `pollMs`) and are withdrawn once no longer ready; a settled lane
|
|
43
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.
|
|
44
51
|
*
|
|
45
52
|
* Remote lanes (#4253), off unless `remote: {max, isRemote(issue)}` is passed (execution.mjs): a lane `isRemote` places
|
|
46
53
|
* in a sandbox takes no host registry slot; it starts only while fewer than `max` remote lanes of this batch run (the
|
|
@@ -217,7 +224,7 @@ export async function orchestrate({ issues, parallel = 3, runOne, readBody, pref
|
|
|
217
224
|
store = null, adopt = null, getParallel = null, getAdded = null, breaker = null, maxDeferrals = DEFAULT_MAX_DEFERRALS, sleep = cancellableSleep, pollMs = 5000,
|
|
218
225
|
classify = null, maintenance = null, limits = null, identity = () => "default", batchId = null, drift = null, sched = null, priorityOf = null, remote = null, beforeAdmit = null,
|
|
219
226
|
recovery = null, decide = () => {}, conflicting = null, relabelNeedsHuman = null, onExit = null, issueLabels = null, getCancelled = null, stopLane = null,
|
|
220
|
-
estimate = null, starveMs = DEFAULT_STARVE_MS, signal = null }) {
|
|
227
|
+
estimate = null, starveMs = DEFAULT_STARVE_MS, signal = null, dispatchCheck = null }) {
|
|
221
228
|
const saved = store?.load?.() ?? null;
|
|
222
229
|
const order = saved ? [...saved.order] : [];
|
|
223
230
|
const set = new Set(order);
|
|
@@ -380,7 +387,7 @@ export async function orchestrate({ issues, parallel = 3, runOne, readBody, pref
|
|
|
380
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 }))
|
|
381
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);
|
|
382
389
|
};
|
|
383
|
-
const entryOf = (n) => ({ key: keyOf(n), batch: sched?.batch ?? batchId ?? null, repo: sched?.repo ?? null, issue: n, priority: prioOf(n) });
|
|
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 } : {}) });
|
|
384
391
|
const releaseSlot = (n) => { if (reg && slotted.delete(n)) reg.release(keyOf(n)); };
|
|
385
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); };
|
|
386
393
|
const withdrawExcept = (keep) => {
|
|
@@ -407,10 +414,28 @@ export async function orchestrate({ issues, parallel = 3, runOne, readBody, pref
|
|
|
407
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"}` });
|
|
408
415
|
budgetHit = reason;
|
|
409
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
|
+
};
|
|
410
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.
|
|
411
431
|
const grant = (n, wanted) => {
|
|
412
432
|
if (lanes.get(n)?.ok === false) return true;
|
|
413
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
|
+
}
|
|
414
439
|
if (p?.where === "remote") {
|
|
415
440
|
const adm = admitRemote();
|
|
416
441
|
if (adm.ok) {
|
|
@@ -426,6 +451,7 @@ export async function orchestrate({ issues, parallel = 3, runOne, readBody, pref
|
|
|
426
451
|
}
|
|
427
452
|
if (!reg) { if (p) placed.set(n, p); return true; }
|
|
428
453
|
const r = reg.tryAcquire(keyOf(n), entryOf(n), sched.cap);
|
|
454
|
+
if (!r?.ok && r?.conflict != null) return busy(n, r.conflict, wanted);
|
|
429
455
|
if (!r?.ok) { offered.add(n); wanted.add(n); return false; }
|
|
430
456
|
offered.delete(n); slotted.add(n);
|
|
431
457
|
if (p) placed.set(n, p);
|
|
@@ -452,6 +478,7 @@ export async function orchestrate({ issues, parallel = 3, runOne, readBody, pref
|
|
|
452
478
|
const pfCost = recovering.has(n) ? 0 : (pf?.cost_usd ?? 0);
|
|
453
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 } : {}),
|
|
454
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
|
|
455
482
|
...(Number.isFinite(r?.precheckMs) ? { precheck_ms: r.precheckMs } : {}), ...(Number.isInteger(r?.precheckDeferred) && r.precheckDeferred > 0 ? { precheck_deferred: r.precheckDeferred } : {}), // #4331
|
|
456
483
|
...(typeof r?.firstPass === "boolean" ? { first_pass: r.firstPass } : {}), // #4362
|
|
457
484
|
...(r?.turns && typeof r.turns === "object" ? { turns: r.turns } : {}), // #4392
|
|
@@ -604,17 +631,24 @@ export async function orchestrate({ issues, parallel = 3, runOne, readBody, pref
|
|
|
604
631
|
if (relabelled.delete(n)) save(); // #4315: the recovery lane launches after all; it removes needs-human itself
|
|
605
632
|
const pl = placed.get(n);
|
|
606
633
|
placed.delete(n);
|
|
607
|
-
heads.delete(n); enqueued.delete(n);
|
|
634
|
+
heads.delete(n); enqueued.delete(n); busyNoted.delete(n);
|
|
608
635
|
const rec = { startedAt: now(), ...(lane ? { lane } : {}), ...(remoteRun.has(n) ? { remote: true } : {}), ...(pl ? { placement: pl } : {}) };
|
|
609
636
|
runRecs.set(n, rec); notBefore.delete(n); save();
|
|
610
637
|
track(n, (async () => {
|
|
611
638
|
// Pre-filter once per issue (a requeued issue is not re-checked); fail-open: an error means a normal lane.
|
|
612
639
|
if (prefilter && !checked.has(n)) { let pf = null; try { pf = await prefilter(n, lane); } catch { pf = null; } checked.set(n, pf); save(); }
|
|
613
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
|
+
}
|
|
614
647
|
if (halted) return; // #4454: stopped before launch; no pid, so a resume queues it again
|
|
615
648
|
let r;
|
|
616
649
|
if (cancelled) r = { terminalReason: "deferred", code: "run.interrupted", cost_usd: 0, detail: "cancelled before launch" }; // #4376
|
|
617
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}` };
|
|
618
652
|
// #4389: a parked post-deploy follow-up starts no lane; it ends awaiting-promotion|awaiting-precondition, re-checked next batch.
|
|
619
653
|
else if (pf?.parked) r = { terminalReason: pf.kind, cost_usd: 0, detail: `pre-filter: ${pf.detail}` };
|
|
620
654
|
else {
|
|
@@ -766,7 +800,9 @@ export async function orchestrate({ issues, parallel = 3, runOne, readBody, pref
|
|
|
766
800
|
const n = pickNext(ready, { classes, share: budget.share, whenIdle: budget.whenIdle, started: dispatch.started, maint: dispatch.maint,
|
|
767
801
|
featuresPending: pending.some((p) => !clsOf(classes, p).maintenance) });
|
|
768
802
|
if (n === null) { budgetHeld = ready.length > 0; break; }
|
|
769
|
-
|
|
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;
|
|
770
806
|
pending.splice(pending.indexOf(n), 1);
|
|
771
807
|
if (lanes.get(n)?.ok !== false) { dispatch.started++; if (clsOf(classes, n).maintenance) dispatch.maint++; }
|
|
772
808
|
start(n); started++;
|
|
@@ -778,13 +814,16 @@ export async function orchestrate({ issues, parallel = 3, runOne, readBody, pref
|
|
|
778
814
|
offerFresh(want);
|
|
779
815
|
for (const n of want) wanted.add(n);
|
|
780
816
|
for (const n of ready) {
|
|
781
|
-
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;
|
|
782
821
|
pending.splice(pending.indexOf(n), 1); start(n); started++;
|
|
783
822
|
}
|
|
784
823
|
} else if (!tripped && !cancelled && !holdUntil) {
|
|
785
824
|
for (const n of rank(pending.filter(isReady))) {
|
|
786
825
|
if (running.size >= K()) break;
|
|
787
|
-
if (grant(n, new Set())) { pending.splice(pending.indexOf(n), 1); start(n); started++; }
|
|
826
|
+
if (grant(n, new Set()) === true) { pending.splice(pending.indexOf(n), 1); start(n); started++; }
|
|
788
827
|
}
|
|
789
828
|
}
|
|
790
829
|
if (reg) withdrawExcept(wanted);
|
|
@@ -118,6 +118,11 @@ describe("formatPrecheckSummary (#4331)", () => {
|
|
|
118
118
|
assert.deepEqual(rows.map((r) => [r.issue, r.precheck_ms, r.precheck_deferred]), [[1, 90000, 1], [2, 30000, undefined], [3, undefined, undefined]]);
|
|
119
119
|
assert.equal(formatPrecheckSummary(rows), "precheck: 2.0m total over 2 lanes, 1 deferred to CI");
|
|
120
120
|
});
|
|
121
|
+
it("#4451: orchestrate rows carry the lane's wall_ms and model_ms, so wall vs model time can be measured", async () => {
|
|
122
|
+
const rows = await orchestrate({ issues: [1, 2], parallel: 1, readBody: async () => "",
|
|
123
|
+
runOne: async (n) => ({ terminalReason: "merged", ...({ 1: { wall_ms: 90000, model_ms: 60000 } })[n] }) });
|
|
124
|
+
assert.deepEqual(rows.map((r) => [r.issue, r.wall_ms, r.model_ms]), [[1, 90000, 60000], [2, undefined, undefined]]);
|
|
125
|
+
});
|
|
121
126
|
it("#4362: orchestrate rows carry the lane's firstPass as first_pass for the batch first-pass line", async () => {
|
|
122
127
|
const rows = await orchestrate({ issues: [1, 2, 3], parallel: 1, readBody: async () => "",
|
|
123
128
|
runOne: async (n) => ({ terminalReason: "merged", ...({ 1: { firstPass: true }, 2: { firstPass: false } })[n] }) });
|
package/bin/lean/prefilter.mjs
CHANGED
|
@@ -12,7 +12,9 @@
|
|
|
12
12
|
* ALREADY-FIXED/INVALID verdict with evidence closes the issue; everything else, and every error, goes to the normal lane.
|
|
13
13
|
* Post-deploy gate (#4389), with `defaultBranch`: a `FORGE:DEFERRED_FROM: #P` follow-up whose checklist items are all
|
|
14
14
|
* post-deploy is parked (`parked: true`, label `awaiting-promotion`, no lane) while a merge SHA of #P is not an ancestor
|
|
15
|
-
* of origin/<default>, or while forge.yaml `post_deploy.precondition_cmd` exits 1.
|
|
15
|
+
* of origin/<default>, or while forge.yaml `post_deploy.precondition_cmd` exits 1. With `post_deploy.min_age` (#4425) it also
|
|
16
|
+
* stays parked (`awaiting-min-age`) until that long (or the longest window an item's why states) after the fix landed on
|
|
17
|
+
* the default branch. Anything unresolvable fails open.
|
|
16
18
|
* All effects are injected (gh, git, runner, transitionLabel, projector?, execCode?) so it is unit-testable.
|
|
17
19
|
*/
|
|
18
20
|
import { execFile } from "node:child_process";
|
|
@@ -196,12 +198,52 @@ function execCode(cmd, args, opts) {
|
|
|
196
198
|
try { execFile(cmd, args, opts, (err) => res(err ? (typeof err.code === "number" ? err.code : -1) : 0)); } catch { res(-1); }
|
|
197
199
|
});
|
|
198
200
|
}
|
|
201
|
+
// #4425: post_deploy.min_age soak window. Deterministic: a strict duration parse, and git for the landing time.
|
|
202
|
+
const UNIT_MS = { m: 60e3, h: 3600e3, d: 86400e3, w: 604800e3 };
|
|
203
|
+
/** forge.yaml `post_deploy.min_age` ("30m", "24h", "7d", "2w"; a bare number is minutes, so "0" is allowed) -> ms, or null. */
|
|
204
|
+
export function parseMinAge(s) {
|
|
205
|
+
if (s == null) return null;
|
|
206
|
+
const m = /^(\d+(?:\.\d+)?)\s*([mhdw]?)$/i.exec(String(s).trim());
|
|
207
|
+
return m ? Math.round(Number(m[1]) * UNIT_MS[(m[2] || "m").toLowerCase()]) : null;
|
|
208
|
+
}
|
|
209
|
+
const WINDOW_RE = /\b(\d+(?:\.\d+)?)\s*(h|hrs?|hours?|d|days?|w|wks?|weeks?)\b/gi;
|
|
210
|
+
const POST_DEPLOY_ITEM_RE = /^`[^`]*`\s+(?:\*\*known-flaw\*\*\s+)?[—-]+\s*post-deploy:(.*)$/;
|
|
211
|
+
/** Per post-deploy checklist item: the largest duration its why states (ms), or null when it states none. */
|
|
212
|
+
export function itemWindowsMs(body) {
|
|
213
|
+
return noComments(String(body ?? ""), " ").split("\n").map((l) => CHECK_RE.exec(l)?.[1]).filter((x) => x != null)
|
|
214
|
+
.map((t) => POST_DEPLOY_ITEM_RE.exec(t)).filter(Boolean).map((m) => {
|
|
215
|
+
const ms = [...m[1].matchAll(WINDOW_RE)].map((w) => Number(w[1]) * UNIT_MS[w[2][0].toLowerCase()]);
|
|
216
|
+
return ms.length ? Math.round(Math.max(...ms)) : null;
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
/**
|
|
220
|
+
* Unix seconds when `sha` reached origin/<def>: the oldest first-parent commit of <def> that descends from it (the merge
|
|
221
|
+
* that brought it in), or the SHA's own commit time when it is on the mainline itself. Null on any git error.
|
|
222
|
+
*/
|
|
223
|
+
export async function landedAt({ sha, def, io, cwd }) {
|
|
224
|
+
try {
|
|
225
|
+
// --first-parent with --ancestry-path drops merges that brought the SHA in through a second parent (git 2.55): two walks
|
|
226
|
+
const range = `${sha}..origin/${def}`;
|
|
227
|
+
const below = new Set(String(await io.git(["rev-list", "--ancestry-path", range], { cwd })).trim().split(/\s+/).filter(Boolean));
|
|
228
|
+
const chain = String(await io.git(["log", "--first-parent", "--format=%H %P %ct", range], { cwd })).trim().split("\n").map((l) => l.trim().split(/\s+/));
|
|
229
|
+
const oldest = chain.filter((c) => below.has(c[0])).at(-1);
|
|
230
|
+
let own = !oldest;
|
|
231
|
+
if (!own) {
|
|
232
|
+
const full = /^[0-9a-f]{40}$/i.test(sha) ? sha : String(await io.git(["rev-parse", `${sha}^{commit}`], { cwd })).trim();
|
|
233
|
+
own = oldest[1]?.toLowerCase() === full.toLowerCase();
|
|
234
|
+
}
|
|
235
|
+
const ct = own ? String(await io.git(["log", "-1", "--format=%ct", sha], { cwd })).trim() : oldest.at(-1);
|
|
236
|
+
return /^\d+$/.test(ct) ? Number(ct) : null;
|
|
237
|
+
} catch { return null; }
|
|
238
|
+
}
|
|
239
|
+
const fmtAge = (ms) => (ms % UNIT_MS.d === 0 && ms ? `${ms / UNIT_MS.d}d` : ms % UNIT_MS.h === 0 && ms ? `${ms / UNIT_MS.h}h` : `${Math.round(ms / UNIT_MS.m)}m`);
|
|
199
240
|
/**
|
|
200
241
|
* #4389: never throws, no model call. `defaultBranch` is a name or an (async) function resolving it (and fetching it).
|
|
201
|
-
*
|
|
242
|
+
* #4425: `minAge` (ms, opt-in) parks `awaiting-min-age` until max(item window ?? minAge) after the fix landed on <default>.
|
|
243
|
+
* @returns {Promise<{state:"not-applicable"|"unresolved"|"awaiting-promotion"|"awaiting-min-age"|"awaiting-precondition"|"ready", parent:?number, shas:string[], default?:string, eligibleAt?:string, detail:string}>}
|
|
202
244
|
* Fail-open: no merge SHA, a git answer other than yes/no, an unknown default branch or a hook error give "unresolved".
|
|
203
245
|
*/
|
|
204
|
-
export async function postDeployGate({ iss, repo, lane, defaultBranch, cwd, io, preconditionCmd = null }) {
|
|
246
|
+
export async function postDeployGate({ iss, repo, lane, defaultBranch, cwd, io, preconditionCmd = null, minAge = null, now = null }) {
|
|
205
247
|
const parent = deferredFrom(iss?.body);
|
|
206
248
|
const res = (state, detail, extra = {}) => ({ state, parent, shas: [], detail, ...extra });
|
|
207
249
|
if (!parent || !postDeployOnly(iss.body)) return res("not-applicable", "");
|
|
@@ -218,6 +260,20 @@ export async function postDeployGate({ iss, repo, lane, defaultBranch, cwd, io,
|
|
|
218
260
|
if (!on) off.push(sha);
|
|
219
261
|
}
|
|
220
262
|
if (off.length) return res("awaiting-promotion", `fix ${off.map((s) => s.slice(0, 12)).join(", ")} of #${parent} is not on ${def}${lane ? ` (lane ${lane})` : ""}`, { shas, default: def });
|
|
263
|
+
if (minAge != null) {
|
|
264
|
+
const required = Math.max(0, ...itemWindowsMs(iss.body).map((w) => w ?? minAge));
|
|
265
|
+
let landed = 0;
|
|
266
|
+
for (const sha of shas) {
|
|
267
|
+
const t = await landedAt({ sha, def, io, cwd });
|
|
268
|
+
if (t == null) return res("unresolved", `landing time of ${sha.slice(0, 12)} on ${def} unknown`, { shas, default: def });
|
|
269
|
+
landed = Math.max(landed, t);
|
|
270
|
+
}
|
|
271
|
+
const at = now ?? io.now?.() ?? Date.now(), until = landed * 1000 + required;
|
|
272
|
+
if (at < until) {
|
|
273
|
+
const eligibleAt = new Date(until).toISOString();
|
|
274
|
+
return res("awaiting-min-age", `fix of #${parent} on ${def} since ${new Date(landed * 1000).toISOString()}; min age ${fmtAge(required)} until ${eligibleAt}`, { shas, default: def, eligibleAt });
|
|
275
|
+
}
|
|
276
|
+
}
|
|
221
277
|
if (preconditionCmd) {
|
|
222
278
|
const code = await (io.execCode ?? execCode)("sh", ["-c", preconditionCmd, "precondition", String(iss.number)],
|
|
223
279
|
{ cwd, timeout: 60000, env: { ...process.env, FORGE_ISSUE: String(iss.number) } });
|
|
@@ -252,12 +308,12 @@ async function markParked({ issue, repo, io, had, gate }) {
|
|
|
252
308
|
} catch { /* the comment and the report still show it */ }
|
|
253
309
|
}
|
|
254
310
|
if (had) return;
|
|
255
|
-
const what = gate.state === "awaiting-promotion" ? "awaiting promotion" : "awaiting its post-deploy precondition";
|
|
311
|
+
const what = gate.state === "awaiting-promotion" ? "awaiting promotion" : gate.state === "awaiting-min-age" ? "awaiting its post-deploy soak window" : "awaiting its post-deploy precondition";
|
|
256
312
|
const body = `<!-- ${PRECONDITION_MARKER} -->\n## Parked: ${what}\n${sanitizeMd(gate.detail)}.\n\nEvery item here is a post-deploy check, so no lane was started. The next orchestrate or triage run checks again and launches this issue once the precondition holds.`;
|
|
257
313
|
try { await io.gh(["issue", "comment", String(issue), "-R", repo, "--body", capComment(body)]); } catch { /* the label still shows it */ }
|
|
258
314
|
}
|
|
259
315
|
|
|
260
|
-
/** #4389 batch-report lines: one `awaiting promotion:` line (and one `awaiting precondition:` line) for the parked rows, or "". */
|
|
316
|
+
/** #4389 batch-report lines: one `awaiting promotion:` line (and one `awaiting min age:` / `awaiting precondition:` line) for the parked rows, or "". */
|
|
261
317
|
export function formatParkedLine(rows) {
|
|
262
318
|
const parked = (rows ?? []).filter((r) => r?.prefilter?.parked);
|
|
263
319
|
const uniq = (xs) => [...new Set(xs.filter(Boolean))];
|
|
@@ -267,6 +323,11 @@ export function formatParkedLine(rows) {
|
|
|
267
323
|
const lanes = uniq(promo.map((r) => r.prefilter.lane)), defs = uniq(promo.map((r) => r.prefilter.default));
|
|
268
324
|
out.push(`awaiting promotion: ${promo.map((r) => `#${r.issue}`).join(" ")} (fixes on ${lanes.join(", ") || "the lane"}, not on ${defs.join(", ") || "the default branch"})`);
|
|
269
325
|
}
|
|
326
|
+
const soak = parked.filter((r) => r.prefilter.kind === "awaiting-min-age"); // #4425
|
|
327
|
+
if (soak.length) {
|
|
328
|
+
const defs = uniq(soak.map((r) => r.prefilter.default));
|
|
329
|
+
out.push(`awaiting min age: ${soak.map((r) => `#${r.issue}`).join(" ")} (fix on ${defs.join(", ") || "the default branch"}, soak window not elapsed)`);
|
|
330
|
+
}
|
|
270
331
|
const pre = parked.filter((r) => r.prefilter.kind === "awaiting-precondition");
|
|
271
332
|
if (pre.length) out.push(`awaiting precondition: ${pre.map((r) => `#${r.issue}`).join(" ")} (post_deploy.precondition_cmd not ready)`);
|
|
272
333
|
return out.join("\n");
|
|
@@ -276,7 +337,7 @@ export function formatParkedLine(rows) {
|
|
|
276
337
|
* Pre-filter one issue. Never throws.
|
|
277
338
|
* @returns {Promise<{issue:number, closed:boolean, kind:?string, candidate:boolean, verdict:?string, cost_usd:number, detail:string}>}
|
|
278
339
|
*/
|
|
279
|
-
export async function prefilterIssue({ issue, repo, lane, cwd, io, model = DEFAULT_PREFILTER_MODEL, dryRun = false, agentId = null, maxMinutes = 5, selfLogins = null, defaultBranch = null, preconditionCmd = null }) {
|
|
340
|
+
export async function prefilterIssue({ issue, repo, lane, cwd, io, model = DEFAULT_PREFILTER_MODEL, dryRun = false, agentId = null, maxMinutes = 5, selfLogins = null, defaultBranch = null, preconditionCmd = null, minAge = null }) {
|
|
280
341
|
const out = { issue, closed: false, kind: null, candidate: false, verdict: null, cost_usd: 0, detail: "" };
|
|
281
342
|
try {
|
|
282
343
|
const iss = JSON.parse(String(await io.gh(["issue", "view", String(issue), "-R", repo, "--json", "number,title,body,state,labels,comments"])));
|
|
@@ -292,9 +353,9 @@ export async function prefilterIssue({ issue, repo, lane, cwd, io, model = DEFAU
|
|
|
292
353
|
if (st?.lease && st.lease.until > Date.now() && st.lease.by !== agentId) return { ...out, detail: `leased by ${st.lease.by}` };
|
|
293
354
|
}
|
|
294
355
|
if (gated) {
|
|
295
|
-
const gate = await postDeployGate({ iss, repo, lane, defaultBranch, cwd, io, preconditionCmd });
|
|
356
|
+
const gate = await postDeployGate({ iss, repo, lane, defaultBranch, cwd, io, preconditionCmd, minAge });
|
|
296
357
|
const had = names.includes(AWAITING_PROMOTION_LABEL);
|
|
297
|
-
if (
|
|
358
|
+
if (["awaiting-promotion", "awaiting-min-age", "awaiting-precondition"].includes(gate.state)) {
|
|
298
359
|
if (!dryRun) await markParked({ issue, repo, io, had, gate });
|
|
299
360
|
return { ...out, parked: true, kind: gate.state, parent: gate.parent, lane, default: gate.default, detail: gate.detail };
|
|
300
361
|
}
|