forgedock 1.42.0 → 1.43.1
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 +1 -0
- package/bin/engine-cli.mjs +91 -19
- package/bin/journey.mjs +2 -2
- package/bin/lean/autochildren.mjs +103 -0
- package/bin/lean/autochildren.test.mjs +126 -0
- package/bin/lean/batch.mjs +11 -3
- package/bin/lean/batch.test.mjs +66 -2
- package/bin/lean/conformance.mjs +140 -9
- package/bin/lean/conformance.test.mjs +164 -2
- package/bin/lean/effects.mjs +1 -1
- package/bin/lean/execution.test.mjs +9 -6
- package/bin/lean/fixround.test.mjs +9 -1
- package/bin/lean/guardgate.mjs +66 -7
- package/bin/lean/guardgate.test.mjs +109 -3
- package/bin/lean/heavy.test.mjs +11 -1
- package/bin/lean/issuewatch.mjs +2 -2
- package/bin/lean/lean.mjs +80 -24
- package/bin/lean/lean.test.mjs +119 -7
- package/bin/lean/orchestrate-blocked.test.mjs +16 -0
- package/bin/lean/orchestrate-children.test.mjs +83 -0
- package/bin/lean/orchestrate.mjs +51 -12
- package/bin/lean/postmerge.mjs +5 -2
- package/bin/lean/postmerge.test.mjs +7 -0
- package/bin/lean/preflight.mjs +21 -0
- package/bin/lean/preflight.test.mjs +15 -1
- package/bin/lean/probe.mjs +1 -1
- package/bin/lean/prompts.mjs +19 -4
- package/bin/lean/schedd.mjs +116 -19
- package/bin/lean/schedd.test.mjs +201 -2
- package/bin/lean/selftest-live.mjs +1 -1
- package/bin/lean/selftest-nightly.mjs +1 -1
- package/bin/lean/signoff.mjs +11 -1
- package/bin/lean/signoff.test.mjs +24 -1
- package/bin/lean/toolstate.mjs +19 -3
- package/bin/lean/toolstate.test.mjs +22 -0
- package/bin/lean/verify.mjs +9 -1
- package/bin/lean/verify.test.mjs +4 -0
- package/bin/pool/agent.mjs +1 -1
- package/bin/runner.mjs +1 -1
- package/commands/orchestrate.md +2 -2
- package/docs/CONFIG.md +8 -2
- package/docs/OUTCOME-CODES.md +1 -0
- package/docs/RELEASE-NOTES.md +3 -0
- package/package.json +1 -1
- package/scripts/ci-local/run.mjs +1 -1
- package/scripts/file-deferred-followups.sh +1 -0
- package/scripts/file-deferred-followups.test.sh +7 -0
- package/scripts/review-pr-staging-scope.test.sh +3 -3
- package/scripts/review-prepare.sh +3 -0
- package/scripts/review-staging-pre.sh +88 -43
- package/scripts/review-staging-pre.test.sh +42 -1
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
|
|
2
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
3
|
+
// #4965: the scheduler pass that adds epic and late children to a running batch.
|
|
4
|
+
import { describe, it } from "node:test";
|
|
5
|
+
import assert from "node:assert/strict";
|
|
6
|
+
import { orchestrate } from "./orchestrate.mjs";
|
|
7
|
+
|
|
8
|
+
const merged = { terminalReason: "merged" };
|
|
9
|
+
const until = async (f, ms = 3000) => { const t = Date.now(); while (!f()) { if (Date.now() - t > ms) throw new Error("timeout"); await new Promise((r) => setTimeout(r, 5)); } };
|
|
10
|
+
|
|
11
|
+
/** A batch with epic #1 running behind a gate; `list` is what the one list call returns. */
|
|
12
|
+
async function run({ mode, list, pollS = 0.001, elsewhere = [], epics = [], bodies = {}, extra = {} }) {
|
|
13
|
+
const started = [], log = [], decisions = [];
|
|
14
|
+
let calls = 0, open;
|
|
15
|
+
const g = new Promise((r) => { open = r; });
|
|
16
|
+
const done = orchestrate({ issues: [1], parallel: 3, pollMs: 5, trusted: () => true, readBody: async (n) => bodies[n] ?? "", onUpdate: (r) => log.push(r), decide: (d) => decisions.push(d),
|
|
17
|
+
autoChildren: { mode, pollS, epics }, getChildren: async () => { calls++; return { list: typeof list === "function" ? list() : list, elsewhere }; },
|
|
18
|
+
runOne: async (n) => { started.push(n); if (n === 1) await g; return merged; }, ...extra });
|
|
19
|
+
return { started, log, decisions, calls: () => calls, open, done };
|
|
20
|
+
}
|
|
21
|
+
const L = (...kids) => [{ number: 1, body: "", labels: [] }, ...kids.map((n) => ({ number: n, body: "**Parent**: #1", labels: [] }))];
|
|
22
|
+
|
|
23
|
+
describe("#4965 auto_add_children", () => {
|
|
24
|
+
it("epic: adds open children of a batch issue, one decision and one added row listing them", async () => {
|
|
25
|
+
const r = await run({ mode: "epic", list: L(2, 3), bodies: { 3: "Blocked by #2" } });
|
|
26
|
+
await until(() => r.started.includes(2) && r.started.includes(3));
|
|
27
|
+
r.open(); await r.done;
|
|
28
|
+
const added = r.log.filter((x) => x.outcome === "added");
|
|
29
|
+
assert.equal(added.length, 1);
|
|
30
|
+
assert.match(added[0].detail, /#2, #3; edges #3 blocked by #2/);
|
|
31
|
+
assert.equal(r.decisions.filter((d) => /auto_add_children/.test(d.what)).length, 1);
|
|
32
|
+
assert.ok(r.started.indexOf(2) < r.started.indexOf(3));
|
|
33
|
+
});
|
|
34
|
+
it("decomposed (default) and off never call getChildren", async () => {
|
|
35
|
+
for (const mode of ["decomposed", "off"]) {
|
|
36
|
+
const r = await run({ mode, list: L(2) });
|
|
37
|
+
await new Promise((x) => setTimeout(x, 40));
|
|
38
|
+
r.open(); await r.done;
|
|
39
|
+
assert.equal(r.calls(), 0, mode);
|
|
40
|
+
assert.deepEqual(r.started, [1]);
|
|
41
|
+
}
|
|
42
|
+
});
|
|
43
|
+
it("needs-human children and children live in another batch are not added", async () => {
|
|
44
|
+
const list = [...L(2, 3), { number: 4, body: "**Parent**: #1", labels: [{ name: "needs-human" }] }];
|
|
45
|
+
const r = await run({ mode: "all", list, elsewhere: [3] });
|
|
46
|
+
await until(() => r.started.includes(2));
|
|
47
|
+
await new Promise((x) => setTimeout(x, 40));
|
|
48
|
+
r.open(); await r.done;
|
|
49
|
+
assert.deepEqual([...r.started].sort(), [1, 2]);
|
|
50
|
+
});
|
|
51
|
+
it("an --epic root outside the batch adds its children; late children join on a later pass", async () => {
|
|
52
|
+
let list = [{ number: 50, body: "", labels: [] }, { number: 51, body: "**Parent**: #50", labels: [] }];
|
|
53
|
+
const r = await run({ mode: "epic", epics: [50], list: () => list });
|
|
54
|
+
await until(() => r.started.includes(51));
|
|
55
|
+
list = [...list, { number: 52, body: "**Parent**: #50", labels: [] }];
|
|
56
|
+
await until(() => r.started.includes(52));
|
|
57
|
+
r.open(); await r.done;
|
|
58
|
+
assert.equal(r.log.filter((x) => x.outcome === "added").length, 2);
|
|
59
|
+
});
|
|
60
|
+
it("a failing child scan is reported once as a decision, retried, and the batch still runs", async () => {
|
|
61
|
+
let calls = 0;
|
|
62
|
+
const r = await run({ mode: "epic", list: L(), extra: { getChildren: async () => { calls++; throw new Error("Unknown JSON field"); } } });
|
|
63
|
+
await new Promise((x) => setTimeout(x, 60));
|
|
64
|
+
r.open(); await r.done;
|
|
65
|
+
assert.ok(calls >= 2, "retried");
|
|
66
|
+
assert.equal(r.decisions.filter((d) => /child scan failed/.test(d.what)).length, 1);
|
|
67
|
+
assert.deepEqual(r.started, [1]);
|
|
68
|
+
});
|
|
69
|
+
it("scans at most once per poll window: one list call per pass", async () => {
|
|
70
|
+
const r = await run({ mode: "epic", list: L(), pollS: 3600 });
|
|
71
|
+
await new Promise((x) => setTimeout(x, 80));
|
|
72
|
+
r.open(); await r.done;
|
|
73
|
+
assert.equal(r.calls(), 1);
|
|
74
|
+
});
|
|
75
|
+
it("a decomposed lane's sub-issues join in decomposed|epic|all, not in off", async () => {
|
|
76
|
+
for (const [mode, joins] of [["decomposed", true], ["epic", true], ["all", true], ["off", false]]) {
|
|
77
|
+
const started = [];
|
|
78
|
+
await orchestrate({ issues: [1], parallel: 3, pollMs: 5, readBody: async () => "", autoChildren: { mode, pollS: 3600 }, getChildren: async () => ({ list: [], elsewhere: [] }),
|
|
79
|
+
runOne: async (n) => { started.push(n); return n === 1 ? { terminalReason: "decomposed", subissues: [2] } : merged; } });
|
|
80
|
+
assert.equal(started.includes(2), joins, mode);
|
|
81
|
+
}
|
|
82
|
+
});
|
|
83
|
+
});
|
package/bin/lean/orchestrate.mjs
CHANGED
|
@@ -19,13 +19,16 @@
|
|
|
19
19
|
* A seam calls `onLive()` before it waits on a live lane (#4504); an adopt that resolves first is settled (or requeued on null)
|
|
20
20
|
* before scheduling, so a live lane does not hold up the others.
|
|
21
21
|
* `deferred` is a scheduling fact, not an end: each typed code is requeued with its retry-at (`deferPolicy`), a usage
|
|
22
|
-
* limit holds admissions for that account until its reset, and deferrals other than a
|
|
23
|
-
* `sandbox.capacity`, #4528; `sandbox.unreachable`, #4788) stop after `maxDeferrals`.
|
|
22
|
+
* limit holds admissions for that account until its reset, and deferrals other than a wait (`host.busy`,
|
|
23
|
+
* `sandbox.capacity`, #4528; `sandbox.unreachable`, #4788; `rate_limited`, #4784) stop after `maxDeferrals`.
|
|
24
24
|
* Usage-limit holds (#4190) are keyed by backend identity in `limits` (the host store, shared by every batch on the
|
|
25
25
|
* host; in memory when absent): admission checks the hold of `identity()`, the account the next lane would use. While held, the
|
|
26
26
|
* batch re-reads the store every `pollMs` (#4446), so `forgedock limits clear` admits on the next poll, not at the reset. An `engine-error` is
|
|
27
27
|
* relaunched once; `breaker` ({errors, window_min}) stops admissions after a burst and lets running lanes finish, the
|
|
28
28
|
* rest stay pending (outcome `held`) for a resume. `getParallel()` is read on every pass, so K can change live.
|
|
29
|
+
* `getChildren()` + `autoChildren {mode, pollS, epics}` (#4965, `orchestration.auto_add_children`): with mode `epic`|`all`, at most once per `pollS`
|
|
30
|
+
* it reads one `{list, elsewhere}` and adds the open children (`**Parent**: #P`, sub-issues) of every batch issue and `epics` through the same
|
|
31
|
+
* path as `getAdded` (one decision and one `added` row listing the edges); mode `off` also stops the decomposed-lane join.
|
|
29
32
|
* `getAdded()` (#4408, `forgedock orchestrate --add`) is read on every pass too: issues it returns that are not in the batch
|
|
30
33
|
* join it through `addIssues` (the still-pending issues' `Depends on` are re-read first, so edges hold both ways), with one
|
|
31
34
|
* batch decision and one `added` row; while it is set, lanes running with an empty queue still wake every `pollMs`.
|
|
@@ -112,13 +115,14 @@ import { formatParkedLine, postDeployOnly } from "./prefilter.mjs";
|
|
|
112
115
|
import { laneSize, SIZES } from "./budget.mjs";
|
|
113
116
|
import { requeueKey, REQUEUABLE } from "./batch.mjs";
|
|
114
117
|
import { makeCapWait } from "./capwait.mjs";
|
|
118
|
+
import { joinsDecomposed, addsEpicChildren, selectChildren, edgeText, DEFAULT_POLL_S } from "./autochildren.mjs";
|
|
115
119
|
|
|
116
120
|
// A dependency that finished like this no longer blocks its dependents.
|
|
117
121
|
const SATISFIED = new Set(["merged", "invalid"]);
|
|
118
122
|
const WATCH_EVERY_MS = 60_000; // #4595
|
|
119
123
|
// Times one issue may be requeued behind a dependency before its dependency-open row is final.
|
|
120
124
|
export const MAX_REQUEUES = 2;
|
|
121
|
-
// Deferrals other than a
|
|
125
|
+
// Deferrals other than a wait (UNCAPPED_DEFERRALS, usage limit) one issue may take before its deferred row is final.
|
|
122
126
|
export const DEFAULT_MAX_DEFERRALS = 3;
|
|
123
127
|
// Relaunches of an issue whose lane ended engine-error.
|
|
124
128
|
export const ENGINE_ERROR_RELAUNCHES = 1;
|
|
@@ -176,18 +180,19 @@ export function parseDeps(body, issue) {
|
|
|
176
180
|
const posNum = (v) => { const n = Number(v); return Number.isFinite(n) && n > 0 ? n : null; };
|
|
177
181
|
|
|
178
182
|
/**
|
|
179
|
-
* When to run a `deferred` issue again. `deferrals` counts this issue's earlier capped deferrals (not UNCAPPED_DEFERRALS).
|
|
183
|
+
* When to run a `deferred` issue again. `deferrals` counts this issue's earlier capped deferrals (not UNCAPPED_DEFERRALS or a usage limit).
|
|
180
184
|
* @returns {{delayMs:number, host?:true, hold?:true} | {capped:true}}
|
|
181
185
|
*/
|
|
182
186
|
export function deferPolicy(r, { now, deferrals = 0, maxDeferrals = DEFAULT_MAX_DEFERRALS }) {
|
|
183
187
|
const code = r?.code ?? null;
|
|
184
188
|
if (UNCAPPED_DEFERRALS.has(code)) return { delayMs: (posNum(r?.retryInSeconds) ?? HOST_BUSY_RETRY_S) * 1000, host: true };
|
|
185
|
-
|
|
186
|
-
if (k > maxDeferrals) return { capped: true };
|
|
189
|
+
// #4784: a usage limit is a known-time wait gated by the account hold, never capped.
|
|
187
190
|
if (code === "rate_limited") {
|
|
188
191
|
const at = posNum(r?.resetAtEpochMs) && r.resetAtEpochMs > now ? r.resetAtEpochMs : now + (posNum(r?.retryInSeconds) ?? RATE_LIMIT_FALLBACK_S) * 1000;
|
|
189
192
|
return { delayMs: at - now, hold: true };
|
|
190
193
|
}
|
|
194
|
+
const k = deferrals + 1;
|
|
195
|
+
if (k > maxDeferrals) return { capped: true };
|
|
191
196
|
if (posNum(r?.retryInSeconds)) return { delayMs: r.retryInSeconds * 1000 };
|
|
192
197
|
// lease-held: "leased by X until <ISO>"
|
|
193
198
|
const until = /\buntil (\d{4}-\d\d-\d\dT[\d:.]+Z)/.exec(String(r?.detail ?? ""))?.[1];
|
|
@@ -248,7 +253,8 @@ export async function orchestrate({ issues, parallel = 3, runOne, readBody, pref
|
|
|
248
253
|
store = null, adopt = null, getParallel = null, getAdded = null, getRequeued = null, breaker = null, maxDeferrals = DEFAULT_MAX_DEFERRALS, sleep = cancellableSleep, pollMs = 5000,
|
|
249
254
|
classify = null, maintenance = null, limits = null, identity = () => "default", batchId = null, drift = null, integration = null, sched = null, priorityOf = null, remote = null, beforeAdmit = null, preflight = null,
|
|
250
255
|
recovery = null, decide = () => {}, conflicting = null, relabelNeedsHuman = null, onExit = null, issueLabels = null, issueState = null, getCancelled = null, getHeld = null, getBlocked = null, trusted = null, getGates = null, stopLane = null,
|
|
251
|
-
estimate = null, starveMs = DEFAULT_STARVE_MS, signal = null, dispatchCheck = null, syncParked = null, syncEveryMs = DEFAULT_SYNC_EVERY_MS, lifecycle = null
|
|
256
|
+
estimate = null, starveMs = DEFAULT_STARVE_MS, signal = null, dispatchCheck = null, syncParked = null, syncEveryMs = DEFAULT_SYNC_EVERY_MS, lifecycle = null,
|
|
257
|
+
getChildren = null, autoChildren = null }) {
|
|
252
258
|
const saved = store?.load?.() ?? null;
|
|
253
259
|
const order = saved ? [...saved.order] : [];
|
|
254
260
|
const set = new Set(order);
|
|
@@ -632,7 +638,7 @@ export async function orchestrate({ issues, parallel = 3, runOne, readBody, pref
|
|
|
632
638
|
try { capGated = Number.isInteger(remote?.capacity?.()?.free); } catch { capGated = false; }
|
|
633
639
|
}
|
|
634
640
|
if (!pol.capped) {
|
|
635
|
-
if (!pol.host) deferrals.set(n, (deferrals.get(n) ?? 0) + 1);
|
|
641
|
+
if (!pol.host && !pol.hold) deferrals.set(n, (deferrals.get(n) ?? 0) + 1);
|
|
636
642
|
// A usage limit is held per account; the hold, not a per-issue delay, gates the retry, so a lane on another
|
|
637
643
|
// account runs it at once. If the hold cannot be recorded, the reset delay applies to this issue instead.
|
|
638
644
|
let delayMs = pol.delayMs;
|
|
@@ -655,7 +661,7 @@ export async function orchestrate({ issues, parallel = 3, runOne, readBody, pref
|
|
|
655
661
|
return;
|
|
656
662
|
}
|
|
657
663
|
}
|
|
658
|
-
if (row.outcome === "decomposed" && subs.length) {
|
|
664
|
+
if (row.outcome === "decomposed" && subs.length && joinsDecomposed(autoChildren?.mode ?? "decomposed")) {
|
|
659
665
|
await addIssues(subs, { head: true, parent: n }); // #4409: on the batch's critical path by construction
|
|
660
666
|
decided({ issue: n, what: `added sub-issues ${subs.map((d) => `#${d}`).join(", ")} to the batch; dependents wait on them`, why: "the builder decomposed the issue",
|
|
661
667
|
alternative: "end the batch row decomposed and let dependents wait for a later batch" });
|
|
@@ -847,11 +853,12 @@ export async function orchestrate({ issues, parallel = 3, runOne, readBody, pref
|
|
|
847
853
|
|
|
848
854
|
// #4408: issues added to the running batch; returns how many joined. Re-reading the cumulative list is a no-op.
|
|
849
855
|
const drainAdds = async () => {
|
|
850
|
-
|
|
856
|
+
const kids = await drainChildren();
|
|
857
|
+
if (!getAdded) return kids;
|
|
851
858
|
let got = [];
|
|
852
859
|
try { got = getAdded() ?? []; } catch { got = []; }
|
|
853
860
|
const fresh = [...new Set((Array.isArray(got) ? got : []).filter((n) => Number.isSafeInteger(n) && n > 0 && !set.has(n)))];
|
|
854
|
-
if (!fresh.length) return
|
|
861
|
+
if (!fresh.length) return kids;
|
|
855
862
|
// An edge an operator wrote into a pending issue after the batch started (it waits on an added issue) holds too.
|
|
856
863
|
for (const n of pending) {
|
|
857
864
|
let body;
|
|
@@ -864,6 +871,38 @@ export async function orchestrate({ issues, parallel = 3, runOne, readBody, pref
|
|
|
864
871
|
const list = fresh.map((n) => `#${n}`).join(", ");
|
|
865
872
|
decided({ issue: null, what: `added ${list} to the batch (forgedock orchestrate --add)`, why: "operator request", alternative: "start a second batch" });
|
|
866
873
|
update({ issue: null, outcome: "added", detail: list });
|
|
874
|
+
return fresh.length + kids;
|
|
875
|
+
};
|
|
876
|
+
// #4965: epic and late children (`orchestration.auto_add_children` epic|all): at most one `getChildren()` list call per poll window.
|
|
877
|
+
let nextChildScan = 0, childScanFailed = false;
|
|
878
|
+
const drainChildren = async () => {
|
|
879
|
+
if (!getChildren || !addsEpicChildren(autoChildren?.mode)) return 0;
|
|
880
|
+
const t = now();
|
|
881
|
+
if (t < nextChildScan) return 0;
|
|
882
|
+
nextChildScan = t + Math.max(0, autoChildren.pollS ?? DEFAULT_POLL_S) * 1000;
|
|
883
|
+
let list, elsewhere;
|
|
884
|
+
try { ({ list, elsewhere } = await getChildren()); } catch (e) {
|
|
885
|
+
if (!childScanFailed) { childScanFailed = true; decided({ issue: null, what: `auto_add_children: child scan failed (${firstLine(e?.message ?? e)}); retried every ${autoChildren.pollS ?? DEFAULT_POLL_S}s`, why: "the open-issue list could not be read", alternative: "add the children by hand with forgedock orchestrate --add" }); }
|
|
886
|
+
return 0;
|
|
887
|
+
}
|
|
888
|
+
childScanFailed = false;
|
|
889
|
+
const fresh = selectChildren({ list, roots: [...set, ...(autoChildren.epics ?? [])], skip: [...set, ...(elsewhere ?? [])] })
|
|
890
|
+
.filter((n) => { try { return !(reg?.holder && reg.holder(entryOf(n)) != null); } catch { return true; } });
|
|
891
|
+
if (!fresh.length) return 0;
|
|
892
|
+
for (const n of pending) {
|
|
893
|
+
let body;
|
|
894
|
+
try { body = await readBody(n); } catch { continue; }
|
|
895
|
+
if (drift?.holds) bodies.set(n, body);
|
|
896
|
+
deps.set(n, [...new Set([...(deps.get(n) ?? []), ...(await declared(n, body))])]);
|
|
897
|
+
}
|
|
898
|
+
await addIssues(fresh);
|
|
899
|
+
save();
|
|
900
|
+
const list2 = fresh.map((n) => `#${n}`).join(", ");
|
|
901
|
+
const created = fresh.flatMap((n) => edges(n).map((d) => ({ from: n, to: d })));
|
|
902
|
+
for (const n of pending) if (!fresh.includes(n)) for (const d of edges(n)) if (fresh.includes(d)) created.push({ from: n, to: d });
|
|
903
|
+
const detail = created.length ? `${list2}; edges ${edgeText(created)}` : list2;
|
|
904
|
+
decided({ issue: null, what: `added ${list2} to the batch (orchestration.auto_add_children: ${autoChildren.mode})${created.length ? `; edges ${edgeText(created)}` : ""}`, why: "open children of a batch issue or epic", alternative: "add them by hand with forgedock orchestrate --add" });
|
|
905
|
+
update({ issue: null, outcome: "added", detail });
|
|
867
906
|
return fresh.length;
|
|
868
907
|
};
|
|
869
908
|
// #4550: `--requeue` entries (batch `requeue` file) not applied yet: a terminal issue and the dependents skipped behind it
|
|
@@ -1163,7 +1202,7 @@ export async function orchestrate({ issues, parallel = 3, runOne, readBody, pref
|
|
|
1163
1202
|
if (holdUntil && pending.length) wake = Math.min(wake ?? pollMs, pollMs); // #4446: a cleared hold (forgedock limits clear) admits on the next poll
|
|
1164
1203
|
if ((getParallel || getHeld) && pending.length) wake = Math.min(wake ?? pollMs, pollMs);
|
|
1165
1204
|
if (offered.size && pending.length) wake = Math.min(wake ?? pollMs, pollMs); // a slot another batch frees
|
|
1166
|
-
if ((getAdded || getRequeued) && running.size) wake = Math.min(wake ?? pollMs, pollMs); // #4408/#4550: an add or requeue while only lanes run
|
|
1205
|
+
if ((getAdded || getRequeued || (getChildren && addsEpicChildren(autoChildren?.mode))) && running.size) wake = Math.min(wake ?? pollMs, pollMs); // #4408/#4550: an add or requeue while only lanes run
|
|
1167
1206
|
if ((pfAll || pfRemote) && pending.length) wake = Math.min(wake ?? pollMs, pollMs); // #4866
|
|
1168
1207
|
if (getBlocked && pending.length && blockedHit.size) wake = Math.min(wake ?? pollMs, pollMs); // #4773: a removed label releases on the next tick
|
|
1169
1208
|
if (getHeld && running.size) wake = Math.min(wake ?? pollMs, pollMs); // #4592: a release while only lanes run
|
package/bin/lean/postmerge.mjs
CHANGED
|
@@ -15,6 +15,8 @@ import { exportNames, isTestPath } from "./enumerating.mjs";
|
|
|
15
15
|
const SOURCE_RE = /\.(?:[cm]?[jt]sx?|py)$/;
|
|
16
16
|
const SKIP_RE = /(?:^|\/)(?:node_modules|vendor|dist|build|coverage)\//;
|
|
17
17
|
const MANIFESTS = ["package.json", "pyproject.toml", "setup.py", "go.mod", "Cargo.toml"];
|
|
18
|
+
// entry points a standalone script defines by convention: never a shared export
|
|
19
|
+
const ENTRY_NAMES = new Set(["main", "cli"]);
|
|
18
20
|
const MIN_NAME = 3, MAX_NAMES = 50, CONVENTION = 3;
|
|
19
21
|
const lines = (s) => String(s ?? "").split("\n").map((x) => x.trim()).filter(Boolean);
|
|
20
22
|
const out = (git) => async (args) => { try { return String(await git(args)); } catch { return ""; } };
|
|
@@ -68,13 +70,14 @@ export async function duplicateExports({ git, cwd, base }) {
|
|
|
68
70
|
let names = 0;
|
|
69
71
|
for (const f of changed) {
|
|
70
72
|
const was = new Set(added.has(f) ? [] : declaredExports(await g(["show", `${base}:${f}`]), f));
|
|
71
|
-
const fresh = declaredExports(read(f), f).filter((n) => !was.has(n) && n.length >= MIN_NAME);
|
|
73
|
+
const fresh = declaredExports(read(f), f).filter((n) => !was.has(n) && n.length >= MIN_NAME && !ENTRY_NAMES.has(n));
|
|
72
74
|
const root = packageRoot(f, manifests);
|
|
75
|
+
if (root === "." && /^scripts\//.test(f)) continue; // standalone scripts, not a package
|
|
73
76
|
for (const name of fresh) {
|
|
74
77
|
if (++names > MAX_NAMES) return res;
|
|
75
78
|
const hits = lines(await g(["-c", "core.quotepath=off", "grep", "-l", "-w", "-e", name, "HEAD", "--", ...(root === "." ? [] : [`${root}/`])]))
|
|
76
79
|
.map((l) => l.replace(/^HEAD:/, ""))
|
|
77
|
-
.filter((p) => p !== f && keep(p) && packageRoot(p, manifests) === root);
|
|
80
|
+
.filter((p) => p !== f && keep(p) && packageRoot(p, manifests) === root && !(root === "." && /^scripts\//.test(p)));
|
|
78
81
|
const others = [];
|
|
79
82
|
for (const p of hits) if (declaredExports(read(p), p).includes(name)) others.push(p);
|
|
80
83
|
if (others.length && others.length < CONVENTION) res.push({ symbol: name, path: f, others: others.sort() });
|
|
@@ -51,6 +51,13 @@ describe("duplicate exports (#4406)", () => {
|
|
|
51
51
|
assert.deepEqual(await dups(d), []);
|
|
52
52
|
});
|
|
53
53
|
|
|
54
|
+
it("standalone scripts each defining main are not duplicate exports; a real duplicate still is", async () => {
|
|
55
|
+
const d = repo({ "scripts/openapi_diff.py": "def main():\n pass\n\ndef shared_helper():\n pass\n" }, { "scripts/seed_minimal.py": "def main():\n pass\n\ndef shared_helper():\n pass\n" });
|
|
56
|
+
assert.deepEqual(await dups(d), []);
|
|
57
|
+
const e = repo({ "svc/pyproject.toml": "", "svc/a.py": "def main():\n pass\n" }, { "svc/b.py": "def main():\n pass\n\ndef resolve_x():\n pass\n" });
|
|
58
|
+
assert.deepEqual(await dups(e), []);
|
|
59
|
+
});
|
|
60
|
+
|
|
54
61
|
it("Python modules of one package collide too; unreadable git yields []", async () => {
|
|
55
62
|
const d = repo({ "svc/pyproject.toml": "", "svc/a.py": "def resolve_tenant():\n pass\n" }, { "svc/b.py": "def resolve_tenant():\n pass\n" });
|
|
56
63
|
assert.deepEqual(await dups(d), [{ symbol: "resolve_tenant", path: "svc/b.py", others: ["svc/a.py"] }]);
|
package/bin/lean/preflight.mjs
CHANGED
|
@@ -131,6 +131,27 @@ export function preflightProbes(contract, { repoHas = () => false, cap = PREFLIG
|
|
|
131
131
|
return { probes: all.slice(0, cap), dropped: Math.max(0, all.length - cap) };
|
|
132
132
|
}
|
|
133
133
|
|
|
134
|
+
/**
|
|
135
|
+
* #4857: validate commands that touch a path under a configured forge.yaml `verification.test_runners` prefix
|
|
136
|
+
* ({"services/worker": "poetry -C services/worker run"}) without using that runner, one problem per validate id. Pure.
|
|
137
|
+
*/
|
|
138
|
+
export function testRunnerProblems(contract, runners) {
|
|
139
|
+
const entries = Object.entries(runners && typeof runners === "object" ? runners : {}).filter(([k, v]) => k && typeof v === "string" && v.trim());
|
|
140
|
+
const out = [];
|
|
141
|
+
for (const v of Array.isArray(contract?.validate) ? contract.validate : []) {
|
|
142
|
+
if (!v?.id || typeof v.command !== "string") continue;
|
|
143
|
+
const cmd = v.command.replace(/\s+/g, " ");
|
|
144
|
+
for (const [prefix, runner] of entries) {
|
|
145
|
+
const p = prefix.replace(/^\.?\/+|\/+$/g, "");
|
|
146
|
+
if (new RegExp(`(^|[\\s=:'"])(\\./)?${p.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}/`).test(cmd) && !cmd.includes(runner.trim().replace(/\s+/g, " "))) {
|
|
147
|
+
out.push(`${v.id}: touches ${p}/ but does not run through that path's configured test runner (forge.yaml verification.test_runners: \`${runner.trim()}\`); use it, with paths relative to its cwd`);
|
|
148
|
+
break;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
return out;
|
|
153
|
+
}
|
|
154
|
+
|
|
134
155
|
const FIX_HOW = "run the check through the repo's own toolchain (its virtualenv, lockfile-pinned runner or package scripts) or use a check that does not need it, never a global install";
|
|
135
156
|
/** One contract problem per miss: names the validate ids and the tool (or path, or env var), and how to fix the plan (never a global install). */
|
|
136
157
|
export function preflightProblems(misses) {
|
|
@@ -4,7 +4,7 @@ import { describe, it } from "node:test";
|
|
|
4
4
|
import assert from "node:assert/strict";
|
|
5
5
|
import { mkdirSync, writeFileSync } from "node:fs";
|
|
6
6
|
import { join } from "node:path";
|
|
7
|
-
import { shellSegments, pythonImports, nodeImports, preflightProbes, preflightProblems, runProbeMiss, unsetEnvVars, runPreflight, knownToContract } from "./preflight.mjs";
|
|
7
|
+
import { testRunnerProblems, shellSegments, pythonImports, nodeImports, preflightProbes, preflightProblems, runProbeMiss, unsetEnvVars, runPreflight, knownToContract } from "./preflight.mjs";
|
|
8
8
|
import { runCheckCommand } from "./effects.mjs";
|
|
9
9
|
import { repoTracks } from "./conformance.mjs";
|
|
10
10
|
import { tmpDir } from "../tests/_tmp.mjs";
|
|
@@ -106,3 +106,17 @@ describe("#4393 validate preflight: running the command on base", () => {
|
|
|
106
106
|
assert.deepEqual(r.misses.map((m) => [m.ids[0], m.tool]), [["V1", "absent-pkg-4393"]]);
|
|
107
107
|
});
|
|
108
108
|
});
|
|
109
|
+
|
|
110
|
+
describe("testRunnerProblems (#4857)", () => {
|
|
111
|
+
const runners = { "services/worker": "poetry -C services/worker run" };
|
|
112
|
+
it("flags a validate on a mapped path that skips its runner", () => {
|
|
113
|
+
const p = testRunnerProblems(C("python3 -m pytest services/worker/tests/test_a.py"), runners);
|
|
114
|
+
assert.equal(p.length, 1);
|
|
115
|
+
assert.match(p[0], /V1: touches services\/worker\//);
|
|
116
|
+
});
|
|
117
|
+
it("accepts the runner, other paths, and no config", () => {
|
|
118
|
+
assert.deepEqual(testRunnerProblems(C("poetry -C services/worker run pytest tests/test_a.py"), runners), []);
|
|
119
|
+
assert.deepEqual(testRunnerProblems(C("node --test bin/lean/x.test.mjs"), runners), []);
|
|
120
|
+
assert.deepEqual(testRunnerProblems(C("pytest services/worker/t.py"), null), []);
|
|
121
|
+
});
|
|
122
|
+
});
|
package/bin/lean/probe.mjs
CHANGED
|
@@ -179,7 +179,7 @@ export function probeEnv(allow = [], env = process.env) {
|
|
|
179
179
|
export function spawnProbe({ run, cwd, env, timeoutMs }) {
|
|
180
180
|
return new Promise((resolve) => {
|
|
181
181
|
let stdout = "", stderr = "", timedOut = false;
|
|
182
|
-
const child = nodeSpawn("sh", ["-c", run], { cwd, env, detached: true, stdio: ["ignore", "pipe", "pipe"] });
|
|
182
|
+
const child = nodeSpawn("sh", ["-c", run], { cwd, env, detached: true, windowsHide: true, stdio: ["ignore", "pipe", "pipe"] });
|
|
183
183
|
const cap = (s, d) => (s + d).slice(-OUTPUT_TAIL * 4);
|
|
184
184
|
child.stdout.on("data", (d) => { stdout = cap(stdout, d); });
|
|
185
185
|
child.stderr.on("data", (d) => { stderr = cap(stderr, d); });
|
package/bin/lean/prompts.mjs
CHANGED
|
@@ -43,7 +43,7 @@ export const syncChecks = (mode = "local") => (mode === "ci" ? `${LIGHT} (the en
|
|
|
43
43
|
/** CI failed on the pushed branch (#4167): one fix round in the builder's own session. */
|
|
44
44
|
export function ciFailurePrompt(failures, mode = "local") {
|
|
45
45
|
const list = failures.slice(0, 10).map((f) => `#### ${f.job ?? "check"}${f.url ? ` (${f.url})` : ""}\n${f.log ? `\`\`\`\n${f.log}\n\`\`\`` : "(no log available: open the url)"}`).join("\n\n");
|
|
46
|
-
return `CI failed on the pushed branch:\n\n${list}\n\nFix the cause in the code (or record a typed amendment with a why in ${RESULT_FILE}). ${rerun(mode)}, commit, and update ${IMPLEMENTATION_FILE} and ${RESULT_FILE}. Do not push or touch GitHub.`;
|
|
46
|
+
return `CI failed on the pushed branch:\n\n${list}\n\nFix the cause in the code (or record a typed amendment with a why in ${RESULT_FILE}). A failing check whose files this diff does not touch (a flaky or timing-dependent test) is not an amendment: name the check and why it is unrelated in ${IMPLEMENTATION_FILE} and change no code for it; unknown amendment kinds are rejected. ${rerun(mode)}, commit, and update ${IMPLEMENTATION_FILE} and ${RESULT_FILE}. Do not push or touch GitHub.`;
|
|
47
47
|
}
|
|
48
48
|
|
|
49
49
|
/** The repo's declared check commands (forge.yaml learned.test_commands) failed before the PR (#4240): fixed in-session. */
|
|
@@ -182,6 +182,15 @@ export const REVIEW_RUBRIC = `1. Fixed: does the diff resolve the issue as writt
|
|
|
182
182
|
3. Contract: is every deliverable changed as described, every preserve item intact and every validate command passing?
|
|
183
183
|
4. Scope: does the diff change only deliverable paths and tests?`;
|
|
184
184
|
|
|
185
|
+
/** #4959: reviewer-only (kept out of REVIEW_RUBRIC, which the builder prompt shares and its token budget caps). */
|
|
186
|
+
const VERDICT_RULE = "A criterion asking for a validation or finding verdict (VALIDATED, FALSE_POSITIVE, INCONCLUSIVE) is met by the recorded verdict here (read by the engine from the lane's investigation) together with a regression or repro test in the diff; it needs no prose in the diff. A gap you yourself call minor is not a failing row.";
|
|
187
|
+
|
|
188
|
+
/** #4959: the verdict word of an investigation note's \`**Verdict**:\` line (uppercased), or null. */
|
|
189
|
+
export function investigationVerdict(md) {
|
|
190
|
+
const m = /^\*\*Verdict\*\*:\s*([A-Za-z_-]+)/m.exec(String(md ?? ""));
|
|
191
|
+
return m ? m[1].toUpperCase() : null;
|
|
192
|
+
}
|
|
193
|
+
|
|
185
194
|
/** Builder call 2, same session: implement the frozen contract. */
|
|
186
195
|
export function builderImplementPrompt(mode = "local", { toolchain = null, sparse = null, facts = null } = {}) {
|
|
187
196
|
const remote = mode === "ci" || mode === "hybrid";
|
|
@@ -234,6 +243,12 @@ export function contextPackNote(pack) {
|
|
|
234
243
|
}
|
|
235
244
|
|
|
236
245
|
/** #4271: the bug-lane plan addition (lean.repro_test prefer on a bug/type:bug issue); "" when off, so the prompt is unchanged. */
|
|
246
|
+
/** #4857: validate-command rules for the plan prompt (the implement session resumes it), kept out of the shared templates (their token caps are guarded). */
|
|
247
|
+
export function unrunnableValidateNote(testRunners = null) {
|
|
248
|
+
const map = testRunners && typeof testRunners === "object" ? Object.entries(testRunners).map(([k, v]) => `${k}/ -> ${v}`).join("; ") : "";
|
|
249
|
+
return `\n\nValidate and repro commands must pass bash -n (no prose notes), select tests that exist, and run from the right cwd${map ? ` (forge.yaml verification.test_runners: ${map})` : ""}. An unrunnable validate (missing tool, wrong cwd, no tests selected) is never a block or "human" product-decision: run an equivalent through the repo's toolchain and record {"kind":"substitute-validate","validate":"V#","command":"<runnable equivalent>","why":""} in ${RESULT_FILE} amendments.`;
|
|
250
|
+
}
|
|
251
|
+
|
|
237
252
|
export function reproPlanNote(on) {
|
|
238
253
|
if (!on) return "";
|
|
239
254
|
return `\n\nThis issue is a bug. When it is testable, ${CONTRACT_FILE} must also carry "repro_test":{"path":"<test file>","id":"<test name>","command":"<exact command that runs only that test>"}: a test that fails on the base and passes after the fix. The engine proves it with scripts/acceptance-red-green.sh (head's changed test files are overlaid on the base) and fails the run if it is not red on base and green on head. When it is not testable (specs, docs, workflows, environment), write "repro_test":"none","repro_test_reason":"<one line why>" instead.`;
|
|
@@ -384,7 +399,7 @@ export const REVIEWER_SYSTEM = `You are an independent conformance reviewer of o
|
|
|
384
399
|
|
|
385
400
|
You receive the GitHub issue with its comments, the frozen contract (requirements quoted from the issue, deliverables, preserve constraints, validate commands) with typed amendments, the builder's result with validation output, the changed files, the diff, and a row skeleton with one row per contract id and changed file. The engine computes the verdict from your rows; a reply without rows is rejected, so fill the skeleton rather than composing rows: fill EVERY row (set each empty status, evidence and validated_by, and each null deferral_ok; keep the ids and files as given):
|
|
386
401
|
- issue_coverage: one row per requirement or acceptance criterion in the issue, quoted verbatim, saying whether the contract covers it, narrows it, or misses it. The skeleton lists the contract's quotes; add a row for any issue requirement the contract does not quote.
|
|
387
|
-
- amendment: a requirement with an \`amendment\` is judged against amendment.text and its why, not the quote; its issue_coverage row is covered when the amendment follows from its why. If it does not hold, mark the row partial with note "amendment: <what to change>".
|
|
402
|
+
- amendment: a requirement with an \`amendment\` is judged against amendment.text and its why, not the quote; its issue_coverage row is covered when the amendment follows from its why. A requirement with \`amended_from\` was contracted defer or post-deploy and flipped to deliver: compare amended_from.quote and amended_from.why with amendment.text side by side, and mark the row partial when the rewording drops the original work or only trivially restates it. If it does not hold, mark the row partial with note "amendment: <what to change>".
|
|
388
403
|
- requirements: one row per contract requirement id: implemented|partial|missing|deferred|post-deploy|not-applicable, with evidence as a backtick-quoted repo-relative path:line copied verbatim from the changed files, including (group), [slug] and [...slug] folders (e.g. \`apps/web/app/(console)/layout.tsx:12\`, not route.ts:12) and the validate id or test that proves it (validated_by). For a defer or post-deploy requirement add deferral_ok: true only if it is genuinely separate work or only checkable after a deploy, false if it belongs in this change, including an outcome criterion (a metric, rate or log expectation) this change could ship as a test, metric or alert, or a read-only production check possible now (bucket verify-now). For a verify out_of_band row (measured by the named job, not in this lane) set instrumented: true only when its budget test or bench is committed and that job is configured, with that path:line as evidence; false otherwise. Do not judge the measured result.
|
|
389
404
|
- post-deploy requirements: deferral_ok is false when the requirement has no \`check\` id, or its check is neither in .forge/post-deploy-checks.json on the default branch nor added by this change (the commands run from that file only).
|
|
390
405
|
- scope: one row per changed file, pre-filled with the engine's authorization: the deliverable id that authorizes it (deliverables may be directories or globs), "test", "incidental" (changelog, lockfile), or "none" with what it changes. Change it only if the diff shows the file does something its deliverable does not cover.
|
|
@@ -441,12 +456,12 @@ export function decisionNote(comments = []) {
|
|
|
441
456
|
return kept.length ? `\n\nBinding decisions posted after the contract froze (trusted; they override the issue text where they conflict, and the frozen contract still applies):\n${kept.join("\n---\n")}` : "";
|
|
442
457
|
}
|
|
443
458
|
|
|
444
|
-
export function reviewerPrompt({ issue, comments = [], contract, result, files = [], diff, stat = "", base = "", structure = "", ignore = [`${LEAN_DIR}/`], skeleton = null }) {
|
|
459
|
+
export function reviewerPrompt({ issue, comments = [], contract, result, files = [], diff, stat = "", base = "", structure = "", ignore = [`${LEAN_DIR}/`], skeleton = null, investigationVerdict = null }) {
|
|
445
460
|
const notes = comments.map((c) => String(c?.body ?? "")).filter((b) => !/^<!-- FORGE:/.test(b.trim()));
|
|
446
461
|
const decisions = comments.filter((c) => isDecision(c?.body) && isTrustedIssueComment(c)).map((c) => String(c.body)); // #4901: operator decisions bind the review; other FORGE: records stay out
|
|
447
462
|
notes.push(...decisions);
|
|
448
463
|
const sk = JSON.stringify(skeleton ?? reviewSkeleton(contract ?? {}, files, ignore), null, 1);
|
|
449
|
-
return `Issue #${issue.number}: ${issue.title}\n\n<issue_body>\n${cap(issue.body ?? "", 20000)}\n</issue_body>\n\n<issue_comments>\n${cap(notes.join("\n---\n"), 8000)}\n</issue_comments>\n\n<contract>\n${cap(JSON.stringify(contract ?? {}, null, 1), 12000)}\n</contract>\n\n<builder_result>\n${cap(JSON.stringify(result ?? {}, null, 1), 6000)}\n</builder_result>\n\n<changed_files>\n${files.join("\n")}\n</changed_files>\n\n<rubric>\n${REVIEW_RUBRIC}\n</rubric>\n\n<row_skeleton>\n${cap(sk, 30000)}\n</row_skeleton>\n\n<diff>\n${boundDiff(diff, { stat, base })}\n</diff>${structure ? `\n<structure>${String(structure).replace(/[\r\n]+/g, " ")}</structure>` : ""}`;
|
|
464
|
+
return `Issue #${issue.number}: ${issue.title}\n\n<issue_body>\n${cap(issue.body ?? "", 20000)}\n</issue_body>\n\n<issue_comments>\n${cap(notes.join("\n---\n"), 8000)}\n</issue_comments>\n\n<contract>\n${cap(JSON.stringify(contract ?? {}, null, 1), 12000)}\n</contract>\n\n<builder_result>\n${cap(JSON.stringify(result ?? {}, null, 1), 6000)}\n</builder_result>\n\n${investigationVerdict ? `<investigation_verdict>${investigationVerdict}</investigation_verdict>\n${VERDICT_RULE}\n\n` : ""}<changed_files>\n${files.join("\n")}\n</changed_files>\n\n<rubric>\n${REVIEW_RUBRIC}\n</rubric>\n\n<row_skeleton>\n${cap(sk, 30000)}\n</row_skeleton>\n\n<diff>\n${boundDiff(diff, { stat, base })}\n</diff>${structure ? `\n<structure>${String(structure).replace(/[\r\n]+/g, " ")}</structure>` : ""}`;
|
|
450
465
|
}
|
|
451
466
|
|
|
452
467
|
/**
|