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.
@@ -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 &lt;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
+ });
@@ -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
- if (!grant(n, wanted)) break;
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() || !grant(n, wanted)) break;
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] }) });
@@ -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. Anything unresolvable fails open.
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
- * @returns {Promise<{state:"not-applicable"|"unresolved"|"awaiting-promotion"|"awaiting-precondition"|"ready", parent:?number, shas:string[], default?:string, detail:string}>}
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 (gate.state === "awaiting-promotion" || gate.state === "awaiting-precondition") {
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
  }