forgedock 1.29.0 → 1.30.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/engine/outcome-codes.mjs +4 -2
- package/bin/engine/phases.mjs +20 -3
- package/bin/engine-cli.mjs +102 -38
- package/bin/engine.mjs +3 -1
- package/bin/lean/batch.test.mjs +62 -1
- package/bin/lean/conformance.mjs +129 -8
- package/bin/lean/conformance.test.mjs +77 -3
- package/bin/lean/context.mjs +32 -4
- package/bin/lean/context.test.mjs +71 -12
- package/bin/lean/effects.mjs +20 -6
- package/bin/lean/enumerating.mjs +189 -0
- package/bin/lean/enumerating.test.mjs +97 -0
- package/bin/lean/lean.mjs +207 -56
- package/bin/lean/lean.test.mjs +336 -9
- package/bin/lean/orchestrate.mjs +132 -83
- package/bin/lean/precheck.mjs +55 -0
- package/bin/lean/precheck.test.mjs +93 -0
- package/bin/lean/prefilter.mjs +96 -14
- package/bin/lean/prefilter.test.mjs +111 -2
- package/bin/lean/preflight.mjs +133 -0
- package/bin/lean/preflight.test.mjs +61 -0
- package/bin/lean/prompts.mjs +37 -3
- package/bin/lean/recovery.test.mjs +271 -7
- package/bin/lean/render.mjs +13 -0
- package/bin/lean/sequence.mjs +102 -0
- package/bin/lean/sequence.test.mjs +112 -0
- package/bin/lean/testmap.mjs +57 -3
- package/bin/lean/testmap.test.mjs +53 -1
- package/bin/lean/verify.mjs +7 -2
- package/bin/lean/verify.test.mjs +8 -2
- package/commands/deploy-pr.md +5 -4
- package/commands/orchestrate.md +7 -4
- package/commands/review-pr-staging.md +6 -1
- package/docs/CONFIG.md +8 -1
- package/docs/OUTCOME-CODES.md +6 -4
- package/docs/RELEASE-NOTES.md +10 -0
- package/package.json +1 -1
- package/scripts/acceptance-red-green.sh +13 -4
- package/scripts/acceptance-red-green.test.sh +14 -0
- package/scripts/code-index.sh +33 -33
- package/scripts/code-index.test.sh +21 -2
- package/scripts/fixtures/test-map/go/.github/workflows/ci.yml +2 -0
- package/scripts/repo-facts.test.sh +5 -5
- package/scripts/review-prepare.sh +28 -6
- package/scripts/review-prepare.test.sh +22 -2
- package/scripts/test-map.sh +186 -34
- package/scripts/test-map.test.sh +250 -7
- package/scripts/verify-phase-trail.test.sh +44 -44
- package/scripts/wait-ci-green.sh +1 -1
- package/scripts/wait-ci-green.test.sh +4 -0
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
|
|
2
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
3
|
+
// Fixture: a drizzle-style migrations dir (NNNN_name.sql + meta/_journal.json + meta/NNNN_snapshot.json) where the base
|
|
4
|
+
// gains 0007 while the lane adds its own 0007 (#4322).
|
|
5
|
+
import { describe, it } from "node:test";
|
|
6
|
+
import assert from "node:assert/strict";
|
|
7
|
+
import { execFileSync } from "node:child_process";
|
|
8
|
+
import { mkdirSync, writeFileSync } from "node:fs";
|
|
9
|
+
import { dirname, join } from "node:path";
|
|
10
|
+
import { tmpDir } from "../tests/_tmp.mjs";
|
|
11
|
+
import { sequenceCollisions, journalProblems, snapshotLane, verifyRenumber } from "./sequence.mjs";
|
|
12
|
+
import { sequencePrompt, sequenceSyncNote } from "./prompts.mjs";
|
|
13
|
+
|
|
14
|
+
const sh = (cwd, ...a) => execFileSync("git", a, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], env: { ...process.env, GIT_AUTHOR_NAME: "t", GIT_AUTHOR_EMAIL: "t@t", GIT_COMMITTER_NAME: "t", GIT_COMMITTER_EMAIL: "t@t" } });
|
|
15
|
+
const gitIn = (cwd) => async (a) => sh(cwd, ...a);
|
|
16
|
+
const put = (root, files) => { for (const [p, t] of Object.entries(files)) { mkdirSync(dirname(join(root, p)), { recursive: true }); writeFileSync(join(root, p), t); } };
|
|
17
|
+
const commit = (d, m) => { sh(d, "add", "-A"); sh(d, "commit", "-qm", m); };
|
|
18
|
+
const journal = (...tags) => `${JSON.stringify({ version: "7", entries: tags.map((tag, idx) => ({ idx, tag, when: 1000 + idx })) }, null, 2)}\n`;
|
|
19
|
+
|
|
20
|
+
const USAGE_SQL = "CREATE TABLE usage (id int);\n";
|
|
21
|
+
/** main: 0006 init; lane (from main): 0007_usage; then main gains 0007_billing_reconcile. */
|
|
22
|
+
function fixture() {
|
|
23
|
+
const d = tmpDir("seq-");
|
|
24
|
+
sh(d, "init", "-q", "-b", "main");
|
|
25
|
+
put(d, { "db/drizzle/0006_init.sql": "CREATE TABLE t (id int);\n", "db/drizzle/meta/_journal.json": journal("0006_init"), "db/drizzle/meta/0006_snapshot.json": '{"id":"s6"}\n' });
|
|
26
|
+
commit(d, "init");
|
|
27
|
+
sh(d, "checkout", "-qb", "lane");
|
|
28
|
+
put(d, { "db/drizzle/0007_usage.sql": USAGE_SQL, "db/drizzle/meta/_journal.json": journal("0006_init", "0007_usage"), "db/drizzle/meta/0007_snapshot.json": '{"id":"lane7","prevId":"s6"}\n' });
|
|
29
|
+
commit(d, "lane migration");
|
|
30
|
+
sh(d, "checkout", "-q", "main");
|
|
31
|
+
put(d, { "db/drizzle/0007_billing_reconcile.sql": "CREATE TABLE billing (id int);\n", "db/drizzle/meta/_journal.json": journal("0006_init", "0007_billing_reconcile"), "db/drizzle/meta/0007_snapshot.json": '{"id":"s7","prevId":"s6"}\n' });
|
|
32
|
+
commit(d, "base migration");
|
|
33
|
+
sh(d, "checkout", "-q", "lane");
|
|
34
|
+
return d;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** What the builder does when asked: merge the base, keep the base's journal/snapshot, rename the lane's SQL, regenerate. */
|
|
38
|
+
function renumber(d, { sql = null, tags = ["0006_init", "0007_billing_reconcile", "0008_usage"] } = {}) {
|
|
39
|
+
try { sh(d, "merge", "--no-edit", "main"); } catch { /* journal and snapshot conflict: resolved below */ }
|
|
40
|
+
sh(d, "checkout", "--theirs", "--", "db/drizzle/meta/_journal.json", "db/drizzle/meta/0007_snapshot.json");
|
|
41
|
+
sh(d, "mv", "db/drizzle/0007_usage.sql", "db/drizzle/0008_usage.sql");
|
|
42
|
+
if (sql) put(d, { "db/drizzle/0008_usage.sql": sql });
|
|
43
|
+
put(d, { "db/drizzle/meta/_journal.json": journal(...tags), "db/drizzle/meta/0008_snapshot.json": '{"id":"lane8","prevId":"s7"}\n' });
|
|
44
|
+
commit(d, "renumber");
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
describe("sequence-numbered migrations at hand-off (#4322)", () => {
|
|
48
|
+
it("detects the lane's 0007 against the base's 0007 (other name, and same snapshot path with other content) and proposes 0008", async () => {
|
|
49
|
+
const d = fixture();
|
|
50
|
+
const c = await sequenceCollisions({ git: gitIn(d), base: "main" });
|
|
51
|
+
assert.deepEqual(c.map((x) => [x.lane, x.base, x.next]), [
|
|
52
|
+
["db/drizzle/0007_usage.sql", ["db/drizzle/0007_billing_reconcile.sql"], "0008"],
|
|
53
|
+
["db/drizzle/meta/0007_snapshot.json", ["db/drizzle/meta/0007_snapshot.json"], "0008"],
|
|
54
|
+
]);
|
|
55
|
+
const p = sequencePrompt(c, "main");
|
|
56
|
+
assert.match(p, /git merge --no-edit origin\/main/);
|
|
57
|
+
assert.match(p, /db\/drizzle\/0007_usage\.sql -> number 0008 \(base has db\/drizzle\/0007_billing_reconcile\.sql\)/);
|
|
58
|
+
assert.match(p, /byte-identical[\s\S]*regenerate the snapshot and journal with the repo's own migration generator \(never hand- or text-merge the journal\)[\s\S]*strictly ordered/);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("a correct renumber verifies: SQL unchanged, snapshot regenerated, journal strictly ordered, no collision left", async () => {
|
|
62
|
+
const d = fixture();
|
|
63
|
+
const before = await snapshotLane({ git: gitIn(d), base: "main" });
|
|
64
|
+
assert.deepEqual(before.map((f) => f.path), ["db/drizzle/0007_usage.sql", "db/drizzle/meta/0007_snapshot.json"]);
|
|
65
|
+
renumber(d);
|
|
66
|
+
assert.deepEqual(await sequenceCollisions({ git: gitIn(d), base: "main" }), []);
|
|
67
|
+
assert.deepEqual(await verifyRenumber({ git: gitIn(d), base: "main", before }), { ok: true, problems: [] });
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("an edited SQL body, an unordered journal, or no renumber at all fails verification", async () => {
|
|
71
|
+
let d = fixture(); let before = await snapshotLane({ git: gitIn(d), base: "main" });
|
|
72
|
+
renumber(d, { sql: "CREATE TABLE usage (id bigint);\n" });
|
|
73
|
+
assert.match((await verifyRenumber({ git: gitIn(d), base: "main", before })).problems.join("\n"), /0007_usage\.sql: content changed/);
|
|
74
|
+
|
|
75
|
+
d = fixture(); before = await snapshotLane({ git: gitIn(d), base: "main" });
|
|
76
|
+
renumber(d, { tags: ["0006_init", "0008_usage", "0007_billing_reconcile"] });
|
|
77
|
+
const v = await verifyRenumber({ git: gitIn(d), base: "main", before });
|
|
78
|
+
assert.equal(v.ok, false);
|
|
79
|
+
assert.match(v.problems.join("\n"), /_journal\.json: 0007_billing_reconcile not numbered after 0008_usage/);
|
|
80
|
+
|
|
81
|
+
d = fixture(); before = await snapshotLane({ git: gitIn(d), base: "main" });
|
|
82
|
+
const left = await verifyRenumber({ git: gitIn(d), base: "main", before });
|
|
83
|
+
assert.match(left.problems.join("\n"), /0007_usage\.sql still shares number 0007 with db\/drizzle\/0007_billing_reconcile\.sql/);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it("a text-clean base merge still collides (no journal: e.g. app/migrations/0007_x.py), and later lane files move to keep the order", async () => {
|
|
87
|
+
const d = tmpDir("seq-");
|
|
88
|
+
sh(d, "init", "-q", "-b", "main");
|
|
89
|
+
put(d, { "app/migrations/0006_init.py": "x\n" }); commit(d, "init");
|
|
90
|
+
sh(d, "checkout", "-qb", "lane");
|
|
91
|
+
put(d, { "app/migrations/0007_a.py": "a\n", "app/migrations/0008_b.py": "b\n" }); commit(d, "lane");
|
|
92
|
+
sh(d, "checkout", "-q", "main");
|
|
93
|
+
put(d, { "app/migrations/0007_c.py": "c\n" }); commit(d, "base");
|
|
94
|
+
sh(d, "checkout", "-q", "lane");
|
|
95
|
+
sh(d, "merge", "--no-edit", "main");
|
|
96
|
+
const c = await sequenceCollisions({ git: gitIn(d), base: "main" });
|
|
97
|
+
assert.deepEqual(c.map((x) => [x.lane, x.base.length, x.next]), [["app/migrations/0007_a.py", 1, "0008"], ["app/migrations/0008_b.py", 0, "0009"]]);
|
|
98
|
+
assert.deepEqual(await journalProblems({ git: gitIn(d), dirs: ["app/migrations"] }), []);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it("regression guard: no numbered file added, or numbers above the base's, is no collision; a broken git never throws", async () => {
|
|
102
|
+
const d = fixture();
|
|
103
|
+
sh(d, "reset", "-q", "--hard", "main");
|
|
104
|
+
put(d, { "src/a.ts": "x\n", "db/drizzle/0008_new.sql": "y\n" }); commit(d, "lane2");
|
|
105
|
+
assert.deepEqual(await sequenceCollisions({ git: gitIn(d), base: "main" }), []);
|
|
106
|
+
const bad = async () => { throw new Error("boom"); };
|
|
107
|
+
assert.deepEqual(await sequenceCollisions({ git: bad, base: "main" }), []);
|
|
108
|
+
assert.equal(sequenceSyncNote(["src/a.ts", "package.json"]), "");
|
|
109
|
+
assert.match(sequenceSyncNote(["db/drizzle/meta/_journal.json"]), /do not text-merge/);
|
|
110
|
+
assert.match(sequenceSyncNote(["db/drizzle/0007_usage.sql"]), /Renumber the lane's files/);
|
|
111
|
+
});
|
|
112
|
+
});
|
package/bin/lean/testmap.mjs
CHANGED
|
@@ -6,7 +6,8 @@
|
|
|
6
6
|
* command) picks the checks for the lane's changed files; `.forge/repo-facts.json` (scripts/repo-facts.sh) is cited to
|
|
7
7
|
* the builder by path. The test map's commands are run, so it is read from the base ref, then the main checkout, never
|
|
8
8
|
* the builder-writable lane worktree (#4297); repo facts are looked up in the worktree, then the main checkout. A missing
|
|
9
|
-
* or invalid file is absent: the lane keeps forge.yaml `learned.test_commands` and today's prompts.
|
|
9
|
+
* or invalid file is absent: the lane keeps forge.yaml `learned.test_commands` and today's prompts. With a map, a learned
|
|
10
|
+
* command the map covers runs only when its globs match a changed file (`scopeChecks`, #4331).
|
|
10
11
|
*
|
|
11
12
|
* node bin/lean/testmap.mjs --map FILE --select FILE... print the selected commands, one per line
|
|
12
13
|
*/
|
|
@@ -52,13 +53,66 @@ export function selectCommands(map, files) {
|
|
|
52
53
|
for (const e of map?.entries ?? []) {
|
|
53
54
|
const res = e.globs.map((g) => globToRegex(g.replace(/^\.\//, "")));
|
|
54
55
|
if (!changed.some((f) => res.some((re) => re.test(f)))) continue;
|
|
55
|
-
const
|
|
56
|
-
const cmd = cwd ? `cd ${shellQuote(cwd)} && ${e.command.trim()}` : e.command.trim();
|
|
56
|
+
const cmd = entryCommand(e);
|
|
57
57
|
if (!out.includes(cmd)) out.push(cmd);
|
|
58
58
|
}
|
|
59
59
|
return out;
|
|
60
60
|
}
|
|
61
61
|
|
|
62
|
+
/**
|
|
63
|
+
* #4318: the dedupe key of a check command: whitespace collapsed, a leading `cd . && ` dropped, `<pm> run test` read as
|
|
64
|
+
* `<pm> test` (npm/pnpm/yarn/bun) and an `npx`/`bunx [--yes|-y]` prefix dropped, so one suite under two spellings runs once.
|
|
65
|
+
*/
|
|
66
|
+
export function checkKey(c) {
|
|
67
|
+
let k = String(c ?? "").trim().replace(/\s+/g, " ");
|
|
68
|
+
k = k.replace(/^cd \.\/? && /, "");
|
|
69
|
+
const cd = /^((?:cd|pushd) \S+ && )?(.*)$/.exec(k), pre = cd[1] ?? "";
|
|
70
|
+
const cmd = cd[2].replace(/^(?:npx|bunx)(?: (?:--yes|-y))? /, "").replace(/^(npm|pnpm|yarn|bun) run test(?= |$)/, "$1 test");
|
|
71
|
+
return pre + cmd;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* #4315: the pre-PR checks are the learned commands (in order) plus each map-selected command they lack; compared
|
|
76
|
+
* by `checkKey` (#4318), so the map can only add checks, never remove one; the first spelling seen is kept. `source`:
|
|
77
|
+
* "learned" when the map added nothing, "test-map" when there were no learned commands, else "test-map+learned".
|
|
78
|
+
* @returns {{commands:string[], source:string, added:number}}
|
|
79
|
+
*/
|
|
80
|
+
export function mergeChecks(selected, learned) {
|
|
81
|
+
const norm = checkKey;
|
|
82
|
+
const commands = [], seen = new Set();
|
|
83
|
+
const add = (c) => { const t = String(c ?? "").trim(), k = norm(t); if (!k || seen.has(k)) return false; seen.add(k); commands.push(t); return true; };
|
|
84
|
+
for (const c of Array.isArray(learned) ? learned : []) add(c);
|
|
85
|
+
const base = commands.length;
|
|
86
|
+
let added = 0;
|
|
87
|
+
for (const c of Array.isArray(selected) ? selected : []) if (add(c)) added++;
|
|
88
|
+
return { commands, added, source: !added ? "learned" : base ? "test-map+learned" : "test-map" };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* #4331: change-scoped pre-PR checks. A learned command the map covers (an entry's command, with its `cd <cwd> && `
|
|
93
|
+
* prefix, has the same `checkKey`) runs only when one of those entries has a glob matching a changed file; a learned
|
|
94
|
+
* command with no map entry always runs (the safe default); map-selected commands the learned list lacks are added
|
|
95
|
+
* as in `mergeChecks`. No map: the learned list unchanged. `source` is "learned" when the map added and dropped
|
|
96
|
+
* nothing, "test-map" when there were no learned commands, else "test-map+learned".
|
|
97
|
+
* @returns {{commands:string[], source:string, added:number, dropped:string[]}}
|
|
98
|
+
*/
|
|
99
|
+
export function scopeChecks(map, files, learned) {
|
|
100
|
+
const list = Array.isArray(learned) ? learned : [];
|
|
101
|
+
if (!map) { const m = mergeChecks([], list); return { ...m, dropped: [] }; }
|
|
102
|
+
const selected = selectCommands(map, files), hit = new Set(selected.map(checkKey));
|
|
103
|
+
const mapped = new Set((map.entries ?? []).map((e) => checkKey(entryCommand(e))));
|
|
104
|
+
const dropped = [], kept = [];
|
|
105
|
+
for (const c of list) { const k = checkKey(c); if (!k) continue; (mapped.has(k) && !hit.has(k) ? dropped : kept).push(String(c).trim()); }
|
|
106
|
+
const m = mergeChecks(selected, kept);
|
|
107
|
+
return { commands: m.commands, added: m.added, dropped, source: !m.added && !dropped.length ? "learned" : kept.length || dropped.length ? "test-map+learned" : "test-map" };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** An entry's command as `selectCommands` emits it (`cd <cwd> && cmd` for a non-root cwd). */
|
|
111
|
+
function entryCommand(e) {
|
|
112
|
+
const cwd = typeof e.cwd === "string" && e.cwd && e.cwd !== "." ? e.cwd : "";
|
|
113
|
+
return cwd ? `cd ${shellQuote(cwd)} && ${e.command.trim()}` : e.command.trim();
|
|
114
|
+
}
|
|
115
|
+
|
|
62
116
|
/** The path of the first repo-facts file under `dirs` that exists, or null; never throws. */
|
|
63
117
|
export async function factsPath(dirs, exists) {
|
|
64
118
|
if (typeof exists !== "function") return null;
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
3
3
|
import { describe, it } from "node:test";
|
|
4
4
|
import assert from "node:assert/strict";
|
|
5
|
-
import { parseTestMap, readTestMap, selectCommands, factsPath, TEST_MAP_FILE, REPO_FACTS_FILE } from "./testmap.mjs";
|
|
5
|
+
import { parseTestMap, readTestMap, selectCommands, mergeChecks, scopeChecks, checkKey, factsPath, TEST_MAP_FILE, REPO_FACTS_FILE } from "./testmap.mjs";
|
|
6
6
|
|
|
7
7
|
const MAP = {
|
|
8
8
|
version: 1,
|
|
@@ -56,3 +56,55 @@ describe("test map (#4272)", () => {
|
|
|
56
56
|
assert.equal(await factsPath(["/wt"], async () => { throw new Error("EACCES"); }), null);
|
|
57
57
|
});
|
|
58
58
|
});
|
|
59
|
+
|
|
60
|
+
describe("mergeChecks (#4315): the map can only add checks", () => {
|
|
61
|
+
it("a partial map keeps every learned command and adds the ones it selects that learned lacks", () => {
|
|
62
|
+
assert.deepEqual(mergeChecks(["npm test", "cd pkg && npm test"], ["make lint", "npm test"]),
|
|
63
|
+
{ commands: ["make lint", "npm test", "cd pkg && npm test"], added: 1, source: "test-map+learned" });
|
|
64
|
+
});
|
|
65
|
+
it("duplicates, whitespace variants included, appear once", () => {
|
|
66
|
+
assert.deepEqual(mergeChecks(["npm run lint", " npm run lint", "npm test"], ["npm run lint ", "npm run lint"]).commands, ["npm run lint", "npm test"]);
|
|
67
|
+
});
|
|
68
|
+
it("an empty selection keeps the learned commands; no learned commands keep the map's selection", () => {
|
|
69
|
+
assert.deepEqual(mergeChecks([], ["make lint"]), { commands: ["make lint"], added: 0, source: "learned" });
|
|
70
|
+
assert.deepEqual(mergeChecks(["npm test"], []), { commands: ["npm test"], added: 1, source: "test-map" });
|
|
71
|
+
assert.deepEqual(mergeChecks(undefined, undefined), { commands: [], added: 0, source: "learned" });
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
describe("mergeChecks (#4318): one suite under two spellings runs once", () => {
|
|
76
|
+
it("collapses <pm> run test / <pm> test, npx [--yes] X / X and cd . && X / X, keeping the first spelling", () => {
|
|
77
|
+
assert.deepEqual(mergeChecks(["pnpm test", "vitest", "make lint"], ["pnpm run test", "npx --yes vitest", "cd . && make lint"]),
|
|
78
|
+
{ commands: ["pnpm run test", "npx --yes vitest", "cd . && make lint"], added: 0, source: "learned" });
|
|
79
|
+
assert.deepEqual(mergeChecks(["cd pkg && npx vitest run", "cd other && vitest run"], ["cd pkg && vitest run"]).commands, ["cd pkg && vitest run", "cd other && vitest run"]);
|
|
80
|
+
});
|
|
81
|
+
it("checkKey keeps different suites apart", () => {
|
|
82
|
+
assert.notEqual(checkKey("npm run test:unit"), checkKey("npm test"));
|
|
83
|
+
assert.notEqual(checkKey("cd a && npm test"), checkKey("npm test"));
|
|
84
|
+
assert.equal(checkKey("bunx -y jest"), "jest");
|
|
85
|
+
});
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
describe("scopeChecks (#4331): a mapped learned command runs only when its globs match", () => {
|
|
89
|
+
const map = { version: 1, entries: [
|
|
90
|
+
{ globs: ["commands/**"], command: "bash scripts/check-a.sh", cwd: "." },
|
|
91
|
+
{ globs: ["bin/**"], command: "bash scripts/check-b.sh", cwd: "." },
|
|
92
|
+
{ globs: ["src/**"], command: "npm test", cwd: "pkg" },
|
|
93
|
+
{ globs: ["**/*.py"], command: "uv run pytest", cwd: "." }] };
|
|
94
|
+
const learned = ["bash scripts/check-a.sh", "bash scripts/check-b.sh", "make lint"];
|
|
95
|
+
it("a lane changing only commands/foo.md runs the matching mapped commands plus the unmapped ones", () => {
|
|
96
|
+
assert.deepEqual(scopeChecks(map, ["commands/foo.md"], learned),
|
|
97
|
+
{ commands: ["bash scripts/check-a.sh", "make lint"], added: 0, dropped: ["bash scripts/check-b.sh"], source: "test-map+learned" });
|
|
98
|
+
});
|
|
99
|
+
it("no map keeps the learned list; nothing added or dropped is source learned", () => {
|
|
100
|
+
assert.deepEqual(scopeChecks(null, ["commands/foo.md"], learned), { commands: learned, added: 0, dropped: [], source: "learned" });
|
|
101
|
+
assert.deepEqual(scopeChecks(map, ["commands/foo.md"], ["bash scripts/check-a.sh", "make lint"]).source, "learned");
|
|
102
|
+
});
|
|
103
|
+
it("matches by checkKey and by the entry's cwd prefix; map-selected commands are added", () => {
|
|
104
|
+
const r = scopeChecks(map, ["src/x.js", "a.py"], ["cd . && bash scripts/check-a.sh", "cd pkg && npm run test", "npm test"]);
|
|
105
|
+
assert.deepEqual(r.dropped, ["cd . && bash scripts/check-a.sh"]);
|
|
106
|
+
assert.deepEqual(r.commands, ["cd pkg && npm run test", "npm test", "uv run pytest"]);
|
|
107
|
+
assert.equal(r.source, "test-map+learned");
|
|
108
|
+
assert.deepEqual(scopeChecks(map, ["a.py"], []), { commands: ["uv run pytest"], added: 1, dropped: [], source: "test-map" });
|
|
109
|
+
});
|
|
110
|
+
});
|
package/bin/lean/verify.mjs
CHANGED
|
@@ -13,6 +13,9 @@ export const DEFAULT_SKIP_PATHS = Object.freeze(["*.md", "*.mdx", "*.rst", "*.tx
|
|
|
13
13
|
export const DEFAULT_MAX_ROUNDS = 2;
|
|
14
14
|
/** Per-command timeout of the pre-PR declared check commands (#4240, `verify.precheck_timeout_seconds`). */
|
|
15
15
|
export const DEFAULT_PRECHECK_TIMEOUT_SECONDS = 300;
|
|
16
|
+
/** Total budget of one round of pre-PR check commands (#4318, `verify.precheck_total_seconds`; 10 min since #4331, and
|
|
17
|
+
* forge.yaml `lean.precheck.max_minutes` overrides it). The rest of the round is deferred to CI. */
|
|
18
|
+
export const DEFAULT_PRECHECK_TOTAL_SECONDS = 600;
|
|
16
19
|
/** Total characters of failed-job log handed to the builder. */
|
|
17
20
|
export const LOG_CAP = 12000;
|
|
18
21
|
/** No run or check for the head after this long means CI is unavailable (local fallback). */
|
|
@@ -26,7 +29,7 @@ const OK = ["success", "neutral", "skipped"];
|
|
|
26
29
|
|
|
27
30
|
/**
|
|
28
31
|
* forge.yaml `verify:` section (block style; parseForgeYaml gives strings) → {mode, workflow, pathsInput, skipPaths,
|
|
29
|
-
* maxRounds, precheckTimeoutSeconds, checkRun}. Invalid values fall back to the defaults; mode defaults to local (today's
|
|
32
|
+
* maxRounds, precheckTimeoutSeconds, precheckTotalSeconds, checkRun}. Invalid values fall back to the defaults; mode defaults to local (today's
|
|
30
33
|
* behaviour); checkRun (`check_run`, #4270) is true only for the literal `true`.
|
|
31
34
|
*/
|
|
32
35
|
export function parseVerify(sec = {}) {
|
|
@@ -41,8 +44,10 @@ export function parseVerify(sec = {}) {
|
|
|
41
44
|
const maxRounds = str(s.max_rounds) !== "" && Number.isInteger(n) && n >= 0 && n <= 10 ? n : DEFAULT_MAX_ROUNDS;
|
|
42
45
|
const t = Number(str(s.precheck_timeout_seconds));
|
|
43
46
|
const precheckTimeoutSeconds = str(s.precheck_timeout_seconds) !== "" && Number.isInteger(t) && t >= 1 && t <= 3600 ? t : DEFAULT_PRECHECK_TIMEOUT_SECONDS;
|
|
47
|
+
const tt = Number(str(s.precheck_total_seconds));
|
|
48
|
+
const precheckTotalSeconds = str(s.precheck_total_seconds) !== "" && Number.isInteger(tt) && tt >= 1 && tt <= 7200 ? tt : DEFAULT_PRECHECK_TOTAL_SECONDS;
|
|
44
49
|
const checkRun = str(s.check_run).toLowerCase() === "true";
|
|
45
|
-
return { mode, workflow, pathsInput, skipPaths, maxRounds, precheckTimeoutSeconds, checkRun };
|
|
50
|
+
return { mode, workflow, pathsInput, skipPaths, maxRounds, precheckTimeoutSeconds, precheckTotalSeconds, checkRun };
|
|
46
51
|
}
|
|
47
52
|
|
|
48
53
|
const globRe = (g) => new RegExp(`^${g.split("**").map((p) => p.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, "[^/]*").replace(/\?/g, "[^/]")).join(".*")}$`);
|
package/bin/lean/verify.test.mjs
CHANGED
|
@@ -22,9 +22,9 @@ function fake(o = {}) {
|
|
|
22
22
|
|
|
23
23
|
describe("verify-by-CI config (#4167)", () => {
|
|
24
24
|
it("parseVerify defaults to local and rejects invalid values", () => {
|
|
25
|
-
assert.deepEqual(parseVerify({}), { mode: "local", workflow: null, pathsInput: null, skipPaths: [...DEFAULT_SKIP_PATHS], maxRounds: 2, precheckTimeoutSeconds: 300, checkRun: false });
|
|
25
|
+
assert.deepEqual(parseVerify({}), { mode: "local", workflow: null, pathsInput: null, skipPaths: [...DEFAULT_SKIP_PATHS], maxRounds: 2, precheckTimeoutSeconds: 300, precheckTotalSeconds: 600, checkRun: false });
|
|
26
26
|
const v = parseVerify({ mode: "ci", workflow: ".github/workflows/verify.yml", paths_input: "changed_paths", skip_paths: "*.md, docs/** ,", max_rounds: "3" });
|
|
27
|
-
assert.deepEqual(v, { mode: "ci", workflow: ".github/workflows/verify.yml", pathsInput: "changed_paths", skipPaths: ["*.md", "docs/**"], maxRounds: 3, precheckTimeoutSeconds: 300, checkRun: false });
|
|
27
|
+
assert.deepEqual(v, { mode: "ci", workflow: ".github/workflows/verify.yml", pathsInput: "changed_paths", skipPaths: ["*.md", "docs/**"], maxRounds: 3, precheckTimeoutSeconds: 300, precheckTotalSeconds: 600, checkRun: false });
|
|
28
28
|
assert.equal(parseVerify({ mode: "remote" }).mode, "local");
|
|
29
29
|
assert.equal(parseVerify({ workflow: "x; rm -rf /" }).workflow, null);
|
|
30
30
|
assert.equal(parseVerify({ max_rounds: "-1" }).maxRounds, 2);
|
|
@@ -122,6 +122,12 @@ describe("forgedock/verify check run (#4270)", () => {
|
|
|
122
122
|
const result = { validation: [{ id: "V1", exit: 0 }, { id: "V2", ci: true }], requirements: [{ id: "R1", status: "implemented" }] };
|
|
123
123
|
const out = (r) => JSON.parse(/```json\n([\s\S]*)\n```/.exec(r.text)[1]);
|
|
124
124
|
|
|
125
|
+
it("#4318 parseVerify precheck_total_seconds: default 600, integer 1-7200 accepted, others fall back", () => {
|
|
126
|
+
assert.equal(parseVerify({}).precheckTotalSeconds, 600);
|
|
127
|
+
assert.equal(parseVerify({ precheck_total_seconds: "1800" }).precheckTotalSeconds, 1800);
|
|
128
|
+
assert.equal(parseVerify({ precheck_total_seconds: "7200" }).precheckTotalSeconds, 7200);
|
|
129
|
+
for (const bad of ["0", "-5", "1.5", "abc", "7201"]) assert.equal(parseVerify({ precheck_total_seconds: bad }).precheckTotalSeconds, 600, bad);
|
|
130
|
+
});
|
|
125
131
|
it("parseVerify: check_run is true only for the literal true; default false", () => {
|
|
126
132
|
assert.equal(parseVerify({}).checkRun, false);
|
|
127
133
|
assert.equal(parseVerify({ check_run: "true" }).checkRun, true);
|
package/commands/deploy-pr.md
CHANGED
|
@@ -357,8 +357,9 @@ else
|
|
|
357
357
|
# Check current CI status
|
|
358
358
|
# Fail closed: valid fields only (`status`/`conclusion` do not exist on gh pr checks --json; the
|
|
359
359
|
# old query errored and defaulted to 0 = "no failures"). An unreadable result counts as failing.
|
|
360
|
+
# forgedock/verify (the lean engine's report-only gate summary) is not a code failure: ignored, as wait-ci-green.sh does.
|
|
360
361
|
CURRENT_CI_STATUS=$(gh pr checks "$PR_NUMBER" $GH_FLAG --json name,bucket \
|
|
361
|
-
--jq '[.[] | select(.bucket == "fail" or .bucket == "cancel")] | length' 2>/dev/null || echo "1")
|
|
362
|
+
--jq '[.[] | select(.name != "forgedock/verify") | select(.bucket == "fail" or .bucket == "cancel")] | length' 2>/dev/null || echo "1")
|
|
362
363
|
|
|
363
364
|
if [ "$CURRENT_CI_STATUS" -eq 0 ]; then
|
|
364
365
|
echo "CI gate: PASSED (no failing checks)"
|
|
@@ -378,9 +379,9 @@ else
|
|
|
378
379
|
|
|
379
380
|
CI_FIXES=$((CI_FIXES + 1))
|
|
380
381
|
|
|
381
|
-
# Re-check CI status after fix attempt
|
|
382
|
-
FAILING_AFTER=$(gh pr checks "$PR_NUMBER" $GH_FLAG --json name,
|
|
383
|
-
--jq '[.[] | select(.
|
|
382
|
+
# Re-check CI status after fix attempt (same fail-closed query as above)
|
|
383
|
+
FAILING_AFTER=$(gh pr checks "$PR_NUMBER" $GH_FLAG --json name,bucket \
|
|
384
|
+
--jq '[.[] | select(.name != "forgedock/verify") | select(.bucket == "fail" or .bucket == "cancel")] | length' 2>/dev/null || echo "1")
|
|
384
385
|
|
|
385
386
|
if [ "$FAILING_AFTER" -eq 0 ]; then
|
|
386
387
|
echo "CI gate: PASSED after $CI_ITER iteration(s)"
|
package/commands/orchestrate.md
CHANGED
|
@@ -38,7 +38,8 @@ Do this yourself from issue text and the code. Spawn no sub-agents here.
|
|
|
38
38
|
|
|
39
39
|
It closes, with the evidence on the issue and no lane, every issue that a merged PR already fixed (one
|
|
40
40
|
short read-only validation on a small model) or an auto-filed CI failure whose workflow is green again
|
|
41
|
-
(the green run link)
|
|
41
|
+
(the green run link), unless it is flaky (wording or `flaky` label), was reopened after a pre-filter close, or
|
|
42
|
+
the workflow failed and passed at the same SHA. It checks K issues at a time. Its last line is `lanes: #A #B ...`: continue with only
|
|
42
43
|
those issues. This is the only pre-filter in this command: lanes run `forgedock run-issue`, which does not
|
|
43
44
|
pre-filter again. Keep its
|
|
44
45
|
`pre-filter:` line for the report.
|
|
@@ -140,16 +141,18 @@ When a lane finishes (agent lanes; the batch runner applies the same rules itsel
|
|
|
140
141
|
wait on the sub-issues.
|
|
141
142
|
- `merged` or `invalid`: start the next ready issue.
|
|
142
143
|
- `needs-human` with a **recoverable code**: `ci.red`, `base.conflict` (merge conflict), `ci-pending` on a conflicting
|
|
143
|
-
PR, `contract.invalid` caused by an issue edited mid-run,
|
|
144
|
+
PR, `contract.invalid` caused by an issue edited mid-run, `builder.block_untyped` (an in-scope technical choice),
|
|
145
|
+
and the environment codes `env.tool_missing` (a validate command the host cannot run) and `env.repro_unrunnable`
|
|
146
|
+
(the repro harness could not run the repro test).
|
|
144
147
|
These are mechanical. **Do not ask for confirmation on them.** The batch runner recovers each one itself
|
|
145
148
|
(`orchestration.recovery.max_attempts`, default 1 per issue). It launches one recovery lane under the same lease, lane
|
|
146
149
|
and admission rules as any lane, with `run-issue --recover <code> [--recover-attempt n]` (core path only:
|
|
147
|
-
`--path full` rejects `--recover`). That lane:
|
|
150
|
+
`--path full` rejects `--recover`; recovery is off when `engine.path: full`). That lane:
|
|
148
151
|
- removes `needs-human`;
|
|
149
152
|
- resumes the open PR: merges the base, fixes the failed required checks from their logs, and pushes new commits only;
|
|
150
153
|
- re-queues the issue, keeping its dependents queued in the same batch.
|
|
151
154
|
|
|
152
|
-
Report what was recovered afterwards. A failed recovery stays `needs-human` with its evidence, exactly once (failed = `needs-human`, `ci-pending`, or a final `engine-error`/`deferred`; `invalid`, `decomposed` and `dependency-*` are not failures and never re-label),
|
|
155
|
+
Report what was recovered afterwards. A failed recovery stays `needs-human` with its evidence, re-labelled exactly once per issue, by the final attempt only (failed = `needs-human`, `ci-pending`, or a final `engine-error`/`deferred`; `invalid`, `decomposed` and `dependency-*` are not failures and never re-label). With `max_attempts > 1` a non-final attempt adds no label or comment: the runner either launches the next attempt or, when none follows, re-labels it once itself, so the label does not flap between attempts. There
|
|
153
156
|
is no second attempt beyond `max_attempts`.
|
|
154
157
|
- `needs-human` with a **human code** (`builder.blocked`: product-decision, spend, credentials, promotion, customer): an
|
|
155
158
|
external action agents cannot take, or a product decision too critical for agents. It is never auto-recovered. Read
|
|
@@ -660,8 +660,13 @@ git diff origin/$DEFAULT_BRANCH...origin/$STAGING_BRANCH | grep -iE "(api[_-]?ke
|
|
|
660
660
|
type forge_resolve >/dev/null 2>&1 || { setopt nonomatch 2>/dev/null || true; FORGE_SOURCED_ROOT='${CLAUDE_PLUGIN_ROOT}'; for _r in "$(cygpath -u "${FORGEDOCK_HOME:-}" 2>/dev/null || printf %s "${FORGEDOCK_HOME:-}")" '${CLAUDE_PLUGIN_ROOT}' "${FORGE_ROOT:-}" "${FORGE_HOME:-}" "$(readlink -f "$HOME/.claude/commands/work-on.md" 2>/dev/null | sed 's|/commands/work-on\.md$||')" "$(head -n 1 "${CODEX_HOME:-$HOME/.codex}/forge-home" 2>/dev/null)" "$HOME/.claude/plugins/marketplaces/forgedock" "${CLAUDE_CONFIG_DIR:-$HOME/.claude}"/plugins/cache/forgedock/forgedock/* "$HOME/.claude"/plugins/cache/forgedock/forgedock/*; do case "$_r" in /*) [ -f "$_r/scripts/lib/forge-resolve.sh" ] && { . "$_r/scripts/lib/forge-resolve.sh"; break; };; esac; done; type forge_resolve >/dev/null 2>&1 || forge_resolve() { :; }; }
|
|
661
661
|
. "$(forge_resolve lib/fence-bootstrap.sh)" || { echo "BLOCKED: ForgeDock helper lib/fence-bootstrap.sh not resolvable"; exit 1; } # FENCE_BOOTSTRAP
|
|
662
662
|
gh pr checks "$PR_NUMBER" -R "$GH_REPO" 2>&1
|
|
663
|
+
# forgedock/verify (the lean engine's report-only gate summary) is not a code failure: ignored, as wait-ci-green.sh does.
|
|
664
|
+
# Fail closed: an unreadable result counts as failing.
|
|
665
|
+
CI_FAILING=$(gh pr checks "$PR_NUMBER" -R "$GH_REPO" --json name,bucket \
|
|
666
|
+
--jq '[.[] | select(.name != "forgedock/verify") | select(.bucket == "fail" or .bucket == "cancel")] | length' 2>/dev/null || echo "1")
|
|
667
|
+
echo "CI_FAILING: $CI_FAILING"
|
|
663
668
|
```
|
|
664
|
-
|
|
669
|
+
`CI_FAILING` > 0 → BLOCK DEPLOY (unless autofixed in Phase 1F). A red `forgedock/verify` alone does not block.
|
|
665
670
|
|
|
666
671
|
### Phase 1F: CI Autofix
|
|
667
672
|
|
package/docs/CONFIG.md
CHANGED
|
@@ -590,11 +590,14 @@ Settings of the core work-on (`bin/lean/`) that change its contract.
|
|
|
590
590
|
```yaml
|
|
591
591
|
lean:
|
|
592
592
|
repro_test: prefer # off | prefer (default)
|
|
593
|
+
precheck:
|
|
594
|
+
max_minutes: 10 # optional pre-PR check round budget in minutes (1-120)
|
|
593
595
|
```
|
|
594
596
|
|
|
595
597
|
| Field | Type | Required | Description |
|
|
596
598
|
|-------|------|----------|-------------|
|
|
597
|
-
| `repro_test` | string | No | `prefer` (default): on an issue labelled `bug` or `type:bug`, the plan step must freeze a `repro_test` in the contract (`{"path","id","command"}`) when the bug is testable. Before review, the engine runs `scripts/acceptance-red-green.sh` for that command on the host in every verify mode: one targeted test, red on the base (head's changed test files overlaid) and green on head.
|
|
599
|
+
| `repro_test` | string | No | `prefer` (default): on an issue labelled `bug` or `type:bug`, the plan step must freeze a `repro_test` in the contract (`{"path","id","command"}`) when the bug is testable. Before review, the engine runs `scripts/acceptance-red-green.sh` for that command on the host in every verify mode: one targeted test, red on the base (head's changed test files overlaid) and green on head. A test that ran but is not red on the base and green on head fails the precheck as `conformance.unimplemented` (it blocks in shadow too) and goes back to the builder for one in-session fix round. A head the harness could not run at all (timeout, missing runner or dependencies) ends `needs-human` with the recoverable code `env.repro_unrunnable`, not a builder round: the batch runner's recovery lane reruns it through the repo's own toolchain (see docs/OUTCOME-CODES.md). The harness runs with the lane env (`TMPDIR`, toolchain) and the workspace dependency trees, and a repro test file that is new on head is run against the base sources. A bug that is not testable (specs, docs, workflows, environment) records `"repro_test":"none"` with a one-line `repro_test_reason` and the lane continues as before. `off` gives exactly the previous contract and precheck. Each proof emits a `REPRO_RED_GREEN` run-log event (`base`, `head`) and adds a trajectory decision. |
|
|
600
|
+
| `precheck.max_minutes` | number | No | Total budget in minutes (1–120) of one round of pre-PR check commands (#4331). The flat form `lean.precheck_max_minutes` also works and wins. When unset, `verify.precheck_total_seconds` applies (default 600 s). Check commands run change-scoped (a learned command with a test-map entry runs only when its globs match the changed files; unmapped commands always run), concurrently up to the host worker cap under one heavy slot, each timed (`ms` in the `PRECHECK_COMMANDS` run-log event). Commands not started when the budget runs out are recorded `deferred-to-ci` (an anomaly, not a failure) and left to CI. |
|
|
598
601
|
|
|
599
602
|
## `verify` (OPTIONAL)
|
|
600
603
|
|
|
@@ -608,6 +611,7 @@ verify:
|
|
|
608
611
|
skip_paths: "*.md,docs/**" # optional, comma-separated; default *.md,*.mdx,*.rst,*.txt,docs/**,devdocs/**
|
|
609
612
|
max_rounds: 2 # optional CI fix rounds before needs-human (default 2)
|
|
610
613
|
precheck_timeout_seconds: 300 # optional per-command timeout of the declared pre-PR checks (default 300)
|
|
614
|
+
precheck_total_seconds: 600 # optional total budget of one pre-PR check round (default 600)
|
|
611
615
|
check_run: true # optional; post the forgedock/verify check run (default false)
|
|
612
616
|
```
|
|
613
617
|
|
|
@@ -619,6 +623,7 @@ verify:
|
|
|
619
623
|
| `skip_paths` | string | No | Comma-separated globs. A glob without `/` matches the basename anywhere; `**` spans directories. When every changed file matches, the lane skips CI verification (a docs-only lane runs no CI matrix). |
|
|
620
624
|
| `max_rounds` | integer | No | Builder fix rounds for red CI (0–10, default 2). Also bounds the merge-time fix rounds and the declared-check fix rounds below, in every mode. |
|
|
621
625
|
| `precheck_timeout_seconds` | integer | No | Timeout of each declared check command (1–3600, default 300); a timed-out command counts as failed (exit 124). |
|
|
626
|
+
| `precheck_total_seconds` | integer | No | Total budget of one round of pre-PR check commands (1–7200, default 600; it was 1800 before 1.30.0). `lean.precheck.max_minutes` takes precedence. Commands not started within the budget are deferred to CI, not failed. |
|
|
622
627
|
| `check_run` | boolean | No | Default `false` (no Checks API call, behaviour unchanged). `true`: after each push of a reviewed head (PR open, base-sync, merge-time fix round) the engine posts one completed `forgedock/verify` check run on that head (forge#4270), `success` iff every deterministic gate the lane already ran passed: declared check commands, undeclared package edges, docs freshness, contract scope, validate ids, requirement status, hotspot growth, amendments. The output carries each gate's id, pass/fail and evidence as JSON; the run log records a `VERIFY` event. Report only: ForgeDock never makes it required and never waits on it (it is ignored by its own CI waits); requiring it is the repository's branch-protection choice. Creating check runs needs a token that may (a GitHub App with `checks: write`); a Checks API failure is logged as an anomaly and never fails the lane. |
|
|
623
628
|
|
|
624
629
|
**Flow** (`ci`/`hybrid`, `bin/lean/verify.mjs` `runCiVerify`):
|
|
@@ -821,6 +826,8 @@ decomposition:
|
|
|
821
826
|
|
|
822
827
|
**With `close_parent: false`** (default): unchanged. The parent stays open and is closed by C3 when the last child closes.
|
|
823
828
|
|
|
829
|
+
The lean (core) path reads the same key (#4337): with `close_parent: true` it closes the parent at decomposition as `not planned`, keeps `workflow:decomposed` and lists the sub-issues in the closing comment; dependents still wait on the sub-issues, and a trusted `FORGE:DECOMPOSED:REVOKED` reopens it. With the default `false` the lean parent stays open.
|
|
830
|
+
|
|
824
831
|
**Limitation**: `--retry` and engine resume refuse closed issues, so a closed decomposed parent cannot be re-run. It has nothing left to run; work on its open children instead.
|
|
825
832
|
|
|
826
833
|
**Commands that use this section**: `work-on` (`decompose` D6.5, `close` C3), `orchestrate`
|
package/docs/OUTCOME-CODES.md
CHANGED
|
@@ -47,6 +47,8 @@ An absent or unknown code is recorded as `unclassified`, with the raw reason kep
|
|
|
47
47
|
| `cli.timeout` | CLI child ran and was killed at the wall-clock limit; a normal attempt (consumes one of `maxAttempts`), usage recorded when the CLI emitted any | engine | `err.code` `CLI_TIMEOUT` |
|
|
48
48
|
| `cli.spawn_failed` | The CLI could not be started for a reason that repeats on every rerun (`E2BIG` argv too large, `ENOENT`/`EACCES`/`ENOEXEC` binary missing or not executable, `ENAMETOOLONG`). Core work-on ends `needs-human` with this code instead of `engine-error`, so `forgedock orchestrate` does not spend its relaunch on it | lean, engine | `err.code` `CLI_SPAWN_FAILED` (E2BIG) or `err.spawnDeterministic` (other errnos keep their `err.code`); `err.spawnErrno` holds the errno |
|
|
49
49
|
| `env.setup_failed` | forge.yaml `env.setup` (run once per fresh lane worktree, before the builder) exited non-zero, timed out or could not start. An environment error, not a build failure: the builder never ran. Core work-on ends `needs-human` with this code instead of `engine-error`, so `forgedock orchestrate` does not relaunch it; fix the setup command or the environment and rerun | lean | `io.runSetup` exit code / spawn error |
|
|
50
|
+
| `env.tool_missing` | Core work-on (forge#4323): a validate command the builder recorded as failed was rerun by the engine and could not run because the host lacks a tool or module (`command not found` / exit 127, Python `No module named x`, Node `Cannot find module|package 'x'` for a bare name the repo does not track). An environment error, not a conformance failure: no builder resume, no reviewer. Ends `needs-human` naming the tool; recoverable by `forgedock orchestrate` (the recovery lane runs the check through the repo's own toolchain). Skipped on a CI-verified head | lean | `gate()` rerun via `io.runCommand`, `{rc, output}` classified by `conformance.mjs` `missingTool` |
|
|
51
|
+
| `env.repro_unrunnable` | Core work-on (forge#4334): on a bug lane the frozen `repro_test` could not be run on head by `scripts/acceptance-red-green.sh` (`HEAD=UNRUNNABLE`: timeout, exit 126/127, unclassifiable output) or the harness returned no result. An environment error, not a conformance failure: no builder resume, no reviewer. Ends `needs-human`; recoverable by `forgedock orchestrate` (the recovery lane reruns the repro command through the repo's own toolchain and installed dependencies). Other non-proofs (passes on base, fails on head, base unrunnable) stay `conformance.unimplemented` | lean | `gate()` `io.redGreen` words, classified by `conformance.mjs` `reproFailures` |
|
|
50
52
|
| `sandbox.start_failed` | A `forgedock orchestrate` lane sandbox (forge.yaml `execution`, provider `container` or `remote`) could not start: bad image, missing endpoint or credential, refused launch. The lane never ran. Final `needs-human` row: not relaunched and not counted by the engine-error circuit breaker | orchestrate | `bin/lean/execution.mjs` `providerLanes`: `provider.start` threw without `retryable` |
|
|
51
53
|
| `sandbox.capacity` | The sandbox provider had no capacity for the lane (HTTP 429/503 from the remote transport). `deferred` with backoff (`Retry-After` when sent), bounded by `orchestration.max_deferrals` | orchestrate | `provider.start` threw with `err.retryable: true` |
|
|
52
54
|
| `lease.held` | Another live agent holds the issue lease | engine | Foreign live lease on acquire |
|
|
@@ -58,7 +60,7 @@ An absent or unknown code is recorded as `unclassified`, with the raw reason kep
|
|
|
58
60
|
| `lane.branch_missing` | Core work-on: the lane branch (e.g. `milestone/<slug>`) does not exist on origin; fail closed | lean | `git ls-remote --heads origin` |
|
|
59
61
|
| `lane.unresolved` | Core work-on: the lane could not be worked out (milestone or default branch unreadable, empty milestone slug, bad `feature_pattern`); fail closed | lean | Failed `gh` read or config |
|
|
60
62
|
| `invalid` | Issue is invalid or in a divergent closed state | investigate, engine | `INVESTIGATION:INVALID` marker, or live snapshot |
|
|
61
|
-
| `decomposed` | Issue was decomposed into sub-issues. In core work-on, the builder reported `block.kind: "split-required"`; the engine files the sub-issues (`**Parent**` and `**Depends on**` lines) and returns them as `subissues` for `forgedock orchestrate` to run | investigate, decompose, lean | `FORGE:DECOMPOSED` or the decomposition assessment; `result.json` `block.subissues` |
|
|
63
|
+
| `decomposed` | Issue was decomposed into sub-issues. In core work-on, the builder reported `block.kind: "split-required"`; the engine files the sub-issues (`**Parent**` and `**Depends on**` lines) and returns them as `subissues` for `forgedock orchestrate` to run. With `decomposition.close_parent: true` the parent is then closed `not planned` (label kept) and its dependents wait on the sub-issues | investigate, decompose, lean | `FORGE:DECOMPOSED` or the decomposition assessment; `result.json` `block.subissues` |
|
|
62
64
|
| `builder.blocked` | Core work-on: the builder reported an external action agents cannot take, or a product decision too critical for agents: `block.kind: "human"` with `why` one of `credentials`, `spend`, `promotion`, `customer`, `product-decision`. An upstream issue, a needed split, or prod data that monitoring access can read never count | lean | `result.json` status `blocked` + typed `block` |
|
|
63
65
|
| `builder.block_untyped` | Core work-on: the builder stayed `blocked` without a valid typed reason (missing `block`, a non-human `why` such as prod data, or a dependency that is not open) after one corrective resume | lean | `result.json` status `blocked`, twice |
|
|
64
66
|
| `builder.no_evidence` | Core work-on: the builder reported invalid/already-fixed without file:line evidence, so the issue was not closed | lean | `result.json` with empty `evidence` |
|
|
@@ -69,7 +71,7 @@ An absent or unknown code is recorded as `unclassified`, with the raw reason kep
|
|
|
69
71
|
| `review.incomplete` | Core work-on: the conformance review twice returned no complete rows (a generic PASS, a missing requirement/scope/preserve row, evidence in a file that does not exist); nothing merged | lean | `checkCompleteness` problems on both reviewer runs |
|
|
70
72
|
| `review.inconsistent` | Core work-on: the reviewer twice said NONCONFORMING without any failing row; the reviewer is re-asked, the builder's resume is not spent | lean | Verdict/row mismatch on both reviewer runs |
|
|
71
73
|
| `conformance.omission` | Core work-on: an issue requirement is missing from or narrowed by the frozen contract, after one builder resume | lean | Review `issue_coverage` row not `covered` |
|
|
72
|
-
| `conformance.unimplemented` | Core work-on: a contracted requirement is partial, missing or unvalidated, or a validate command failed, after one builder resume | lean | Precheck (result.json, validation) or review requirement row |
|
|
74
|
+
| `conformance.unimplemented` | Core work-on: a contracted requirement is partial, missing or unvalidated, or a validate command failed, after one builder resume (a validate the host cannot run for a missing tool or module is `env.tool_missing`, and a repro test the harness cannot run on head is `env.repro_unrunnable`, instead) | lean | Precheck (result.json, validation) or review requirement row |
|
|
73
75
|
| `conformance.scope_creep` | Core work-on: a changed file is not authorized by a deliverable or typed amendment, after one builder resume | lean | Precheck changed files or review `scope` row `none` |
|
|
74
76
|
| `conformance.untracked_deferral` | Core work-on: a requirement was deferred without a typed amendment or a tracking issue, after one builder resume | lean | Precheck or review requirement row `deferred` on a `deliver` requirement |
|
|
75
77
|
| `conformance.unjustified_deferral` | Core work-on: the reviewer judged a deferred or post-deploy requirement unjustified (it belongs in this change), after one builder resume; nothing is filed or merged | lean | Review requirement row `deferral_ok: false` |
|
|
@@ -93,11 +95,11 @@ An absent or unknown code is recorded as `unclassified`, with the raw reason kep
|
|
|
93
95
|
`forgedock orchestrate` recovers mechanical outcomes inside the batch:
|
|
94
96
|
|
|
95
97
|
- **recoverable**:
|
|
96
|
-
- needs-human with `ci.red`, `base.conflict` or `
|
|
98
|
+
- needs-human with `ci.red`, `base.conflict`, `builder.block_untyped`, `env.tool_missing` or `env.repro_unrunnable`;
|
|
97
99
|
- `contract.invalid` when the issue was edited mid-run (`issueEdited`);
|
|
98
100
|
- `ci-pending` on a conflicting PR, recovered as `base.conflict`.
|
|
99
101
|
|
|
100
|
-
The runner relaunches the issue once per `orchestration.recovery.max_attempts` (default 1) as a recovery lane (`run-issue --recover <code>`), under the normal admission rules. That lane removes the `needs-human` label, posts `FORGE:DECISION`, and resumes the open PR: base-sync, then CI fix rounds from the failed-check logs. Dependents stay queued. A merged recovery is reported as `recovered`. A failed one is final, keeps the recovery evidence on the row, and skips its dependents. A failed recovery stays `needs-human`: the lane re-adds the label (with a `FORGE:NEEDS_HUMAN` comment carrying the evidence) only for a final `needs-human` or `ci-pending
|
|
102
|
+
The runner relaunches the issue once per `orchestration.recovery.max_attempts` (default 1) as a recovery lane (`run-issue --recover <code>`), under the normal admission rules. That lane removes the `needs-human` label, posts `FORGE:DECISION`, and resumes the open PR: base-sync, then CI fix rounds from the failed-check logs. Dependents stay queued. A merged recovery is reported as `recovered`. A failed one is final, keeps the recovery evidence on the row, and skips its dependents. A failed recovery stays `needs-human`: the lane re-adds the label (with a `FORGE:NEEDS_HUMAN` comment carrying the evidence) only for a final `needs-human` or `ci-pending`, and only when it is the last attempt (`attempt == max_attempts`); a non-final attempt that ends `needs-human` or `ci-pending` without another attempt following is re-labelled once by the batch runner; the batch runner re-adds it, once, for a final `engine-error` or `deferred` after the recovery pass (those may be requeued, so the lane cannot know). `invalid`, `decomposed`, `dependency-*` and `merged` never re-label. In-batch recovery is off when `engine.path: full`: the runner passes no recovery to the batch, and `run-issue --path full` rejects `--recover`.
|
|
101
103
|
- **human**: `builder.blocked`, which is only reached by a typed human block: credentials, spend, promotion, customer or product-decision. It is never recovered, it skips dependents, and it is the only class in the report's escalation list.
|
|
102
104
|
- **terminal**: every other code. No recovery runs, dependents are skipped, and the row is reported as `unrecovered`.
|
|
103
105
|
|
package/docs/RELEASE-NOTES.md
CHANGED
|
@@ -2,6 +2,16 @@
|
|
|
2
2
|
|
|
3
3
|
Operator-facing notes for behavior changes that need action or awareness. Newest first.
|
|
4
4
|
|
|
5
|
+
## v1.30.0: environment failures are recoverable, scoped pre-PR checks, decomposed parents can close
|
|
6
|
+
|
|
7
|
+
- **New recoverable codes.** A validate command the host cannot run (missing interpreter, module or executable) ends `env.tool_missing`, and a repro test the harness could not run on head ends `env.repro_unrunnable`. Both were terminal `conformance.unimplemented` before. The batch runner recovers them like `ci.red` (see docs/OUTCOME-CODES.md).
|
|
8
|
+
- **Contract freeze is stricter.** Validate and repro commands that read `$TMPDIR` files they do not create are rejected at freeze, and validate commands are preflighted for their interpreters and modules (a miss asks the builder for one contract correction). Engine-run commands now get the builder's lane env (`TMPDIR`, toolchain), and the repro harness uses the workspace dependency trees and runs a new head test file against the base.
|
|
9
|
+
- **Pre-PR checks.** Learned check commands run change-scoped (when a test map covers them), concurrently and timed, within a round budget: `lean.precheck.max_minutes`, else `verify.precheck_total_seconds`, whose default drops from 1800 s to **600 s**. Commands left when the budget runs out are `deferred-to-ci`, not failed. Set `FORGE_HOST_PARALLELISM=1` if your check commands share state (cache files, a test database, a fixed port).
|
|
10
|
+
- **Decomposed parents.** The lean path honours `decomposition.close_parent`: with `true` a decomposed parent closes `not planned` right after decomposition (label kept, sub-issues listed); its dependents still wait on the sub-issues, and a trusted revoke reopens it. Default `false` keeps it open.
|
|
11
|
+
- **Hand-off.** Enumerating tests (exhaustiveness guards) are discovered for the change, and sequence-numbered migrations that collide with the base are renumbered by the builder.
|
|
12
|
+
|
|
13
|
+
Action: set `decomposition.close_parent: true` to close decomposed parents. Raise `lean.precheck.max_minutes` if your pre-PR checks legitimately need more than 10 minutes.
|
|
14
|
+
|
|
5
15
|
## v1.29.0: in-batch recovery and context pack on by default
|
|
6
16
|
|
|
7
17
|
- **In-batch recovery** (`orchestration.recovery.max_attempts`, default 1): `forgedock orchestrate` now launches one recovery lane per issue for a mechanical `needs-human` (`ci.red`, `base.conflict`, `ci-pending` on a conflicting PR, `contract.invalid` after a mid-run issue edit, `builder.block_untyped`). The lane removes `needs-human`, resumes the PR and re-queues the issue. A recovery that ends anything but `merged` re-applies `needs-human` with a recovery-evidence `FORGE:NEEDS_HUMAN` comment.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "forgedock",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.30.0",
|
|
4
4
|
"description": "Autonomous development pipeline for coding agents. GitHub as institutional memory, with native Claude Code and OpenCode entrypoints.",
|
|
5
5
|
"bin": {
|
|
6
6
|
"forgedock": "bin/forgedock.mjs"
|
|
@@ -6,13 +6,14 @@
|
|
|
6
6
|
# Usage: acceptance-red-green.sh --repo-path <worktree> --base-ref <ref> --test-cmd "<cmd>" [--timeout <sec>]
|
|
7
7
|
# Runs <cmd> in <worktree> (head) first. Only when head RAN does it create a detached base worktree at
|
|
8
8
|
# `git merge-base HEAD <base-ref>` (under mktemp -d, never inside the repo), overlay head's changed test files
|
|
9
|
-
# onto it,
|
|
9
|
+
# onto it, link head's untracked dependency trees (every node_modules next to a tracked package.json, a root .venv),
|
|
10
|
+
# and run <cmd> there. Each run is classified with classify-test-run.sh.
|
|
10
11
|
# Prints exactly one line on stdout: BASE=<FAILED|RAN|RAN_NOTHING|UNRUNNABLE|SKIPPED> HEAD=<FAILED|RAN|RAN_NOTHING|UNRUNNABLE>
|
|
11
12
|
# FAILED the run executed and failed
|
|
12
13
|
# RAN the run executed and passed
|
|
13
14
|
# RAN_NOTHING the runner exercised nothing
|
|
14
15
|
# UNRUNNABLE could not execute (timeout 124, not executable 126/127, worktree/overlay/classifier failure)
|
|
15
|
-
# SKIPPED base was not run because head did not PASS
|
|
16
|
+
# SKIPPED base was not run because head did not PASS (never a missing file: head's new test files are overlaid)
|
|
16
17
|
# Never prints a verdict on success of cleanup: the base worktree is removed on every exit path (trap + explicit remove).
|
|
17
18
|
# Callers map: HEAD=RAN BASE=FAILED -> proof; BASE=RAN -> passes on base (gate FAIL); anything else that is not a proof -> DEGRADED/FAIL.
|
|
18
19
|
# Exit: 0 line printed | 2 usage error (stdout empty so callers fail closed)
|
|
@@ -81,8 +82,16 @@ while IFS= read -r f; do
|
|
|
81
82
|
[ -f "$REPO/$f" ] && [ ! -L "$REPO/$f" ] || continue # deleted at head, or a symlink: never copied
|
|
82
83
|
mkdir -p "$BASE_WT/$(dirname "$f")" && cp -p "$REPO/$f" "$BASE_WT/$f" 2>/dev/null && [ -f "$BASE_WT/$f" ] || OVERLAY_OK=false
|
|
83
84
|
done <<< "$CHANGED"
|
|
84
|
-
# Untracked dependency
|
|
85
|
-
|
|
85
|
+
# Untracked dependency trees (never in git): link head's so a missing install is not mistaken for a red run (#4334):
|
|
86
|
+
# every node_modules next to a tracked package.json (workspace packages too, e.g. apps/web/node_modules) and a root .venv.
|
|
87
|
+
DEPS=$( { git -C "$REPO" ls-files -- package.json '*/package.json' 2>/dev/null | while IFS= read -r p; do
|
|
88
|
+
d=$(dirname "$p"); [ "$d" = "." ] && echo node_modules || echo "$d/node_modules"; done; echo .venv; } | sort -u )
|
|
89
|
+
while IFS= read -r t; do
|
|
90
|
+
[ -n "$t" ] || continue
|
|
91
|
+
case "$t" in */node_modules/*) continue ;; esac # a package.json inside an installed tree
|
|
92
|
+
[ -d "$REPO/$t" ] && [ ! -e "$BASE_WT/$t" ] && [ ! -L "$BASE_WT/$t" ] || continue
|
|
93
|
+
mkdir -p "$BASE_WT/$(dirname "$t")" 2>/dev/null && ln -s "$REPO/$t" "$BASE_WT/$t" 2>/dev/null
|
|
94
|
+
done <<< "$DEPS"
|
|
86
95
|
|
|
87
96
|
if [ "$OVERLAY_OK" != "true" ]; then BASE_RES=UNRUNNABLE; else BASE_RES=$(run_in "$BASE_WT"); fi
|
|
88
97
|
cleanup # explicit; the EXIT trap is the backstop
|
|
@@ -70,5 +70,19 @@ fi
|
|
|
70
70
|
out=$(bash "$RG" --repo-path "$R1" 2>/dev/null); rc=$?
|
|
71
71
|
{ [ "$rc" -eq 2 ] && [ -z "$out" ]; } && ok "missing args exit 2, empty stdout" || bad "usage: rc=$rc out='$out'"
|
|
72
72
|
|
|
73
|
+
# 11. workspace monorepo (#4334): a new test file in a package needs that package's untracked node_modules and the root
|
|
74
|
+
# .venv; base gets head's trees linked, so it runs and fails on the defect (not 127 on a missing install)
|
|
75
|
+
R5="$TMP/r5"; make_repo "$R5"
|
|
76
|
+
mkdir -p "$R5/apps/web"; printf '{}\n' > "$R5/package.json"; printf '{"name":"web"}\n' > "$R5/apps/web/package.json"
|
|
77
|
+
printf 'node_modules/\n.venv/\n' > "$R5/.gitignore"; git -C "$R5" add -A; git -C "$R5" commit -qm pkgs; git -C "$R5" update-ref refs/remotes/origin/main HEAD
|
|
78
|
+
fix_head "$R5"
|
|
79
|
+
mkdir -p "$R5/apps/web/node_modules/dep" "$R5/.venv/bin"
|
|
80
|
+
printf 'echo dep\n' > "$R5/apps/web/node_modules/dep/run.sh"; printf 'echo venv\n' > "$R5/.venv/bin/tool"
|
|
81
|
+
mkdir -p "$R5/apps/web/__tests__"
|
|
82
|
+
printf 'bash node_modules/dep/run.sh >/dev/null 2>&1 || exit 127\nbash ../../.venv/bin/tool >/dev/null 2>&1 || exit 127\ntest "$(bash ../../app.sh)" = good && echo "1 passed"\n' > "$R5/apps/web/__tests__/t.test.sh"
|
|
83
|
+
git -C "$R5" add -A; git -C "$R5" commit -qm test
|
|
84
|
+
check "workspace node_modules and .venv linked into base; new nested test overlaid" "BASE=FAILED HEAD=RAN" "$R5" "cd apps/web && bash __tests__/t.test.sh"
|
|
85
|
+
[ -d "$R5/apps/web/node_modules/dep" ] && [ -d "$R5/.venv/bin" ] && ok "head dependency trees survive base cleanup" || bad "head dependency trees removed by cleanup"
|
|
86
|
+
|
|
73
87
|
echo "$PASS passed, $FAILN failed"
|
|
74
88
|
[ "$FAILN" -eq 0 ]
|