forgedock 1.42.0 → 1.43.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/engine-cli.mjs +76 -17
- package/bin/lean/autochildren.mjs +103 -0
- package/bin/lean/autochildren.test.mjs +126 -0
- package/bin/lean/batch.mjs +8 -2
- package/bin/lean/batch.test.mjs +29 -2
- package/bin/lean/conformance.mjs +121 -8
- package/bin/lean/conformance.test.mjs +132 -2
- 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/lean.mjs +40 -16
- package/bin/lean/lean.test.mjs +66 -5
- 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/prompts.mjs +19 -4
- package/bin/lean/schedd.mjs +79 -15
- package/bin/lean/schedd.test.mjs +92 -2
- 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/commands/orchestrate.md +2 -2
- package/docs/CONFIG.md +8 -2
- package/docs/RELEASE-NOTES.md +3 -0
- package/package.json +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
package/bin/lean/schedd.test.mjs
CHANGED
|
@@ -8,11 +8,11 @@ import { homedir, tmpdir } from "node:os";
|
|
|
8
8
|
import { join } from "node:path";
|
|
9
9
|
import { tmpDir } from "../tests/_tmp.mjs";
|
|
10
10
|
import { openBatch, isLaneAlive, listBatches, SECRET_ENV } from "./batch.mjs";
|
|
11
|
-
import { startDaemon, scheddPaths, connectDaemon, request, SCHEDD_SCRIPT, mergeEnv, batchEnv, droppedEnv, sanitizePath, redactEnv, exitOnSignals } from "./schedd.mjs";
|
|
11
|
+
import { startDaemon, scheddPaths, connectDaemon, request, unitState, SCHEDD_SCRIPT, mergeEnv, batchEnv, droppedEnv, sanitizePath, redactEnv, exitOnSignals } from "./schedd.mjs";
|
|
12
12
|
import { schedDir } from "./sched.mjs";
|
|
13
13
|
import { readHeartbeat } from "./schedd-health.mjs";
|
|
14
14
|
import { formatTable, orchestrate } from "./orchestrate.mjs";
|
|
15
|
-
import { orchestrateFromCli } from "../engine-cli.mjs";
|
|
15
|
+
import { orchestrateFromCli, scheddFromCli } from "../engine-cli.mjs";
|
|
16
16
|
|
|
17
17
|
// #4503: this process, and the env its clients submit for their lanes, never resolve the host's `~/.forge/sched`.
|
|
18
18
|
process.env.FORGEDOCK_SCHED_DIR = tmpDir("schedd-home-");
|
|
@@ -637,6 +637,96 @@ describe("schedd due timer (#4627)", () => {
|
|
|
637
637
|
});
|
|
638
638
|
});
|
|
639
639
|
|
|
640
|
+
// #4775: a client never races a systemd-managed daemon; a stalled daemon is visible and bounded.
|
|
641
|
+
describe("schedd under load and restart (#4775)", () => {
|
|
642
|
+
const noSleep = () => Promise.resolve();
|
|
643
|
+
it("connectDaemon never spawns while the unit is active or activating, and starts an installed stopped unit instead of spawning", async () => {
|
|
644
|
+
for (const state of ["active", "activating", "reloading"]) {
|
|
645
|
+
let spawned = 0;
|
|
646
|
+
await assert.rejects(connectDaemon({ dir: tmpDir("schedd-unit-"), waitMs: 0, sleep: noSleep, spawnDaemon: () => { spawned++; }, unit: () => state, start: () => { spawned++; } }), /did not start/);
|
|
647
|
+
assert.equal(spawned, 0, `${state}: nothing is started`);
|
|
648
|
+
}
|
|
649
|
+
const calls = [];
|
|
650
|
+
await assert.rejects(connectDaemon({ dir: tmpDir("schedd-unit-"), waitMs: 0, sleep: noSleep, spawnDaemon: () => calls.push("spawn"), unit: () => "inactive", start: () => calls.push("unit") }), /did not start/);
|
|
651
|
+
assert.deepEqual(calls, ["unit"]);
|
|
652
|
+
});
|
|
653
|
+
|
|
654
|
+
it("connectDaemon spawns the daemon itself only when no unit is installed; spawnDaemon:null starts nothing", async () => {
|
|
655
|
+
const calls = [];
|
|
656
|
+
await assert.rejects(connectDaemon({ dir: tmpDir("schedd-unit-"), waitMs: 0, sleep: noSleep, spawnDaemon: () => calls.push("spawn"), unit: () => null, start: () => calls.push("unit") }), /did not start/);
|
|
657
|
+
assert.deepEqual(calls, ["spawn"]);
|
|
658
|
+
await assert.rejects(connectDaemon({ dir: tmpDir("schedd-unit-"), spawnDaemon: null, unit: () => { throw new Error("not consulted"); } }), { code: "ENOENT" });
|
|
659
|
+
});
|
|
660
|
+
|
|
661
|
+
it("unitState reads the systemctl state (also from a non-zero exit) and is null without a unit file", () => {
|
|
662
|
+
assert.equal(unitState({ exists: () => false }), null);
|
|
663
|
+
assert.equal(unitState({ exists: () => true, run: () => "activating\n" }), "activating");
|
|
664
|
+
assert.equal(unitState({ exists: () => true, run: () => { throw Object.assign(new Error("exit 3"), { stdout: "inactive\n" }); } }), "inactive");
|
|
665
|
+
assert.equal(unitState({ exists: () => true, run: () => { throw Object.assign(new Error("none"), { code: "ENOENT" }); } }), null);
|
|
666
|
+
});
|
|
667
|
+
|
|
668
|
+
it("request(timeoutMs) rejects a daemon that never answers instead of hanging", async () => {
|
|
669
|
+
const dir = tmpDir("schedd-mute-"), sock = scheddPaths(dir).sock;
|
|
670
|
+
const server = (await import("node:net")).createServer(() => { /* accepts, never answers */ });
|
|
671
|
+
await new Promise((r) => server.listen(sock, r));
|
|
672
|
+
try {
|
|
673
|
+
const c = await connectDaemon({ dir, spawnDaemon: null });
|
|
674
|
+
await assert.rejects(request(c, { op: "status" }, undefined, { timeoutMs: 80 }), { code: "schedd.timeout" });
|
|
675
|
+
} finally { server.close(); }
|
|
676
|
+
});
|
|
677
|
+
|
|
678
|
+
it("status reports per-batch last-progress age and pending adds, including an unhosted batch with un-admitted adds", async () => {
|
|
679
|
+
const root = tmpDir("schedd-st-b-"), dir = tmpDir("schedd-st-");
|
|
680
|
+
const mk = (id, files) => { mkdirSync(join(root, id), { recursive: true }); writeFileSync(join(root, id, "plan.json"), JSON.stringify({ daemon: true })); for (const [n, t] of Object.entries(files)) writeFileSync(join(root, id, n), t); };
|
|
681
|
+
mk("b-stuck", { "state.json": JSON.stringify({ order: [1] }), add: "[7,8]" });
|
|
682
|
+
mk("b-quiet", { "state.json": JSON.stringify({ order: [1] }) });
|
|
683
|
+
const d = await startDaemon({ dir, root, runBatch: async () => ({}), resumeOnStart: false, log: () => {} });
|
|
684
|
+
try {
|
|
685
|
+
const st = await request(await connectDaemon({ dir, spawnDaemon: null }), { op: "status" });
|
|
686
|
+
assert.equal(st.batches.length, 1, "a quiet unhosted batch is not listed");
|
|
687
|
+
const [b] = st.batches;
|
|
688
|
+
assert.equal(b.id, "b-stuck");
|
|
689
|
+
assert.equal(b.running, false);
|
|
690
|
+
assert.deepEqual(b.pendingAdds, [7, 8]);
|
|
691
|
+
assert.ok(b.lastProgressAgeSec >= 0 && b.lastProgressAgeSec < 60);
|
|
692
|
+
} finally { await d.close(); }
|
|
693
|
+
});
|
|
694
|
+
|
|
695
|
+
it("a batch whose start stalls does not block another batch's request, and the stalled request answers an error", async () => {
|
|
696
|
+
const root = tmpDir("schedd-stall-b-"), dir = tmpDir("schedd-stall-");
|
|
697
|
+
for (const id of ["b-hung", "b-ok"]) { mkdirSync(join(root, id), { recursive: true }); writeFileSync(join(root, id, "plan.json"), JSON.stringify({ daemon: true })); writeFileSync(join(root, id, "state.json"), JSON.stringify({ order: [1] })); }
|
|
698
|
+
const d = await startDaemon({ dir, root, resumeOnStart: false, admitMs: 150, log: () => {},
|
|
699
|
+
runBatch: ({ argv, sink }) => (argv[1] === "b-hung" ? new Promise(() => {}) : Promise.resolve().then(() => { sink.batch({ id: argv[1], dir: join(root, argv[1]) }); return {}; })) });
|
|
700
|
+
try {
|
|
701
|
+
const ask = async (id) => request(await connectDaemon({ dir, spawnDaemon: null }), { op: "attach", id }).catch((e) => e);
|
|
702
|
+
const hung = ask("b-hung");
|
|
703
|
+
const ok = await ask("b-ok");
|
|
704
|
+
assert.notEqual(ok instanceof Error && /did not start/.test(ok.message), true, "the healthy batch is served");
|
|
705
|
+
const e = await hung;
|
|
706
|
+
assert.match(e.message, /did not start within 150 ms/);
|
|
707
|
+
} finally { await d.close(); }
|
|
708
|
+
});
|
|
709
|
+
|
|
710
|
+
it("the unit stands by while another daemon holds the lock instead of exiting 0 (no Restart=always loop)", async () => {
|
|
711
|
+
const dir = tmpDir("schedd-sb-"), root = tmpDir("schedd-sb-b-");
|
|
712
|
+
const owner = spawn(process.execPath, ["-e", "setTimeout(() => {}, 60000)"], { stdio: "ignore" });
|
|
713
|
+
const gone = new Promise((r) => owner.on("exit", r));
|
|
714
|
+
mkdirSync(dir, { recursive: true });
|
|
715
|
+
writeFileSync(scheddPaths(dir).lock, String(owner.pid)); // a live daemon of another process
|
|
716
|
+
const slept = [];
|
|
717
|
+
process.env.FORGEDOCK_SCHEDD_IDLE_MS = "100"; // the successor daemon exits when idle, so the call returns
|
|
718
|
+
process.env.FORGEDOCK_BATCH_DIR = root;
|
|
719
|
+
try {
|
|
720
|
+
const r = await Promise.race([
|
|
721
|
+
scheddFromCli([], { scheddDir: dir, standby: true, standbyMs: 1, sleep: async () => { slept.push(1); if (slept.length === 3) { owner.kill("SIGKILL"); await gone; } }, processOn: () => {}, exit: () => {}, batchDeps: {}, due: null }),
|
|
722
|
+
new Promise((res) => setTimeout(() => res("timeout"), 5000)),
|
|
723
|
+
]);
|
|
724
|
+
assert.ok(slept.length >= 3, "waited for the owner before retrying");
|
|
725
|
+
assert.notEqual(r, "timeout");
|
|
726
|
+
} finally { delete process.env.FORGEDOCK_SCHEDD_IDLE_MS; delete process.env.FORGEDOCK_BATCH_DIR; owner.kill("SIGKILL"); }
|
|
727
|
+
});
|
|
728
|
+
});
|
|
729
|
+
|
|
640
730
|
describe("#4762 win32 socket path", () => {
|
|
641
731
|
it("win32 derives a stable per-dir named pipe; other platforms keep the unix socket file", () => {
|
|
642
732
|
const a = scheddPaths("/tmp/a", "win32"), b = scheddPaths("/tmp/b", "win32");
|
package/bin/lean/toolstate.mjs
CHANGED
|
@@ -51,14 +51,30 @@ export async function unstageToolState(git) {
|
|
|
51
51
|
return stray.length ? (await git(["diff", "--cached", "--name-only"])).trim() !== "" : staged.length > 0;
|
|
52
52
|
}
|
|
53
53
|
|
|
54
|
+
const REQUIREMENTS_EXPORT = /^requirements([-_.][\w.-]+)?\.txt$/;
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* #4959: `requirements*.txt` files the branch added to a Poetry/uv project (pyproject [tool.poetry], or poetry.lock / uv.lock at
|
|
58
|
+
* the base) that the base does not track and the contract does not list (`keep`): an export generated to run local tests.
|
|
59
|
+
*/
|
|
60
|
+
export async function strayGenerated(git, ref, added, keep = []) {
|
|
61
|
+
const cands = added.filter((p) => REQUIREMENTS_EXPORT.test(base(p)) && !keep.includes(p));
|
|
62
|
+
if (!cands.length) return [];
|
|
63
|
+
const tracked = zlist(await git(["ls-tree", "-r", "--name-only", "-z", ref]));
|
|
64
|
+
const managed = tracked.some((f) => /(^|\/)(poetry|uv)\.lock$/.test(f))
|
|
65
|
+
|| (await Promise.all(tracked.filter((f) => /(^|\/)pyproject\.toml$/.test(f)).map((f) => git(["show", `${ref}:${f}`]).catch(() => "")))).some((t) => /^\[tool\.poetry[\].]/m.test(String(t)));
|
|
66
|
+
return managed ? cands : [];
|
|
67
|
+
}
|
|
68
|
+
|
|
54
69
|
/**
|
|
55
70
|
* Remove from the index (and commit) the deny-listed files this branch added against `base`. Returns the removed paths ([]
|
|
56
|
-
* when the branch is clean).
|
|
71
|
+
* when the branch is clean). `keep`: contract deliverable paths, never removed as generated files. Never throws: a failure
|
|
72
|
+
* leaves the branch as it was and the push proceeds.
|
|
57
73
|
*/
|
|
58
|
-
export async function untrackToolState({ git, base: ref, ev = () => {} }) {
|
|
74
|
+
export async function untrackToolState({ git, base: ref, ev = () => {}, keep = [] }) {
|
|
59
75
|
try {
|
|
60
76
|
const added = zlist(await git(["diff", "--diff-filter=A", "--name-only", "-z", `${ref}...HEAD`]));
|
|
61
|
-
const stray = await strayPaths(git, added);
|
|
77
|
+
const stray = [...await strayPaths(git, added), ...await strayGenerated(git, ref, added, keep)];
|
|
62
78
|
if (!stray.length) return [];
|
|
63
79
|
await git(["rm", "--cached", "-r", "-q", "--ignore-unmatch", "--", ...stray]);
|
|
64
80
|
await git(["commit", "--no-verify", "-m", "chore: untrack lane tool state (#4590)", "-m", stray.slice(0, 20).join("\n")]);
|
|
@@ -112,3 +112,25 @@ describe("lane scratch is untracked and cleared (#4880)", () => {
|
|
|
112
112
|
assert.equal(execFileSync("git", ["ls-files", ".forge-lean"], { cwd: root, encoding: "utf8" }).trim(), "");
|
|
113
113
|
});
|
|
114
114
|
});
|
|
115
|
+
|
|
116
|
+
describe("generated requirements.txt is untracked in a Poetry project (#4959)", () => {
|
|
117
|
+
const lane = (poetry, files) => {
|
|
118
|
+
const d = tmpDir("toolstate-poetry-");
|
|
119
|
+
sh(d, "init", "-q", "-b", "main"); sh(d, "config", "user.email", "t@t"); sh(d, "config", "user.name", "t");
|
|
120
|
+
put(d, "pyproject.toml", poetry ? "[tool.poetry]\nname = \"x\"\n" : "[project]\nname = \"x\"\n");
|
|
121
|
+
sh(d, "add", "-A"); sh(d, "commit", "-q", "-m", "base"); sh(d, "checkout", "-q", "-b", "lane");
|
|
122
|
+
for (const f of files) put(d, f);
|
|
123
|
+
sh(d, "add", "-A"); sh(d, "commit", "-q", "-m", "lane");
|
|
124
|
+
return d;
|
|
125
|
+
};
|
|
126
|
+
it("drops an added requirements.txt, keeps product files and contract-listed ones", async () => {
|
|
127
|
+
const d = lane(true, ["services/worker/requirements.txt", "services/worker/app.py", "services/api/requirements.txt"]);
|
|
128
|
+
const removed = await untrackToolState({ git: gitIn(d), base: "main", keep: ["services/api/requirements.txt"] });
|
|
129
|
+
assert.deepEqual(removed, ["services/worker/requirements.txt"]);
|
|
130
|
+
assert.deepEqual(sh(d, "diff", "--name-only", "main...HEAD").trim().split("\n"), ["services/api/requirements.txt", "services/worker/app.py"]);
|
|
131
|
+
});
|
|
132
|
+
it("a non-Poetry project is untouched", async () => {
|
|
133
|
+
const d = lane(false, ["requirements.txt"]);
|
|
134
|
+
assert.deepEqual(await untrackToolState({ git: gitIn(d), base: "main" }), []);
|
|
135
|
+
});
|
|
136
|
+
});
|
package/bin/lean/verify.mjs
CHANGED
|
@@ -60,7 +60,15 @@ export function parseVerify(sec = {}) {
|
|
|
60
60
|
const flaky = [...new Set(rawFlaky.map((x) => String(x ?? "").trim().replace(/^(["'])(.*)\1$/s, "$2").trim()).filter(Boolean))];
|
|
61
61
|
const duplicateExports = str(s.duplicate_exports).toLowerCase() !== "false";
|
|
62
62
|
const invariants = [...new Set((Array.isArray(s.invariants) ? s.invariants : []).map((x) => String(x ?? "").trim()).filter(Boolean))];
|
|
63
|
-
|
|
63
|
+
// #4792: guard_command maps an extension (`.py` or `py`) to a test-file command template; read by engine-cli readYamlMap; the key is omitted when empty so unconfigured repos parse as before
|
|
64
|
+
const guardCommand = {};
|
|
65
|
+
if (s.guard_command && typeof s.guard_command === "object" && !Array.isArray(s.guard_command)) {
|
|
66
|
+
for (const [k, v] of Object.entries(s.guard_command)) {
|
|
67
|
+
const ext = String(k).trim().toLowerCase().replace(/^\.?/, "."), cmd = str(v);
|
|
68
|
+
if (ext.length > 1 && cmd) guardCommand[ext] = cmd;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
return { mode, workflow, pathsInput, skipPaths, maxRounds, precheckTimeoutSeconds, precheckTotalSeconds, checkRun, checks, postMerge, flaky, duplicateExports, invariants, ...(Object.keys(guardCommand).length ? { guardCommand } : {}), e2e: parseE2e(s) };
|
|
64
72
|
}
|
|
65
73
|
|
|
66
74
|
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,6 +22,10 @@ function fake(o = {}) {
|
|
|
22
22
|
}
|
|
23
23
|
|
|
24
24
|
describe("verify-by-CI config (#4167)", () => {
|
|
25
|
+
it("parseVerify normalises guard_command extensions and drops empty values (#4792)", () => {
|
|
26
|
+
assert.deepEqual(parseVerify({ guard_command: { PY: " scripts/pytest {path} ", ".js": "", "": "x" } }).guardCommand, { ".py": "scripts/pytest {path}" });
|
|
27
|
+
assert.equal("guardCommand" in parseVerify({ guard_command: "nope" }), false);
|
|
28
|
+
});
|
|
25
29
|
it("parseVerify defaults to local and rejects invalid values", () => {
|
|
26
30
|
assert.deepEqual(parseVerify({}), { mode: "local", workflow: null, pathsInput: null, skipPaths: [...DEFAULT_SKIP_PATHS], maxRounds: 2, precheckTimeoutSeconds: 300, precheckTotalSeconds: 600, checkRun: false, checks: null, postMerge: null, flaky: [], duplicateExports: true, invariants: [], e2e: parseE2e({}) });
|
|
27
31
|
const v = parseVerify({ mode: "ci", workflow: ".github/workflows/verify.yml", paths_input: "changed_paths", skip_paths: "*.md, docs/** ,", max_rounds: "3" });
|
package/commands/orchestrate.md
CHANGED
|
@@ -103,10 +103,10 @@ remaining maintenance once no feature is pending; otherwise the rest ends `held
|
|
|
103
103
|
`forgedock orchestrate --set-parallel <id> K`. Issues filed while a batch runs join it with
|
|
104
104
|
`forgedock orchestrate --add <id> N...` (or `--resume <id> N...`), under its K and DAG; never start a second batch
|
|
105
105
|
for them. After a decision (a `FORGE:DECISION` posted and `needs-human` removed), a blocked issue that already ended in the batch
|
|
106
|
-
(`needs-human`, `invalid`, `skipped`, `dependency-*`, `ci-pending`) returns to it with
|
|
106
|
+
(`needs-human`, `invalid`, `skipped`, `dependency-*`, `ci-pending`, `deferred`) returns to it with
|
|
107
107
|
`forgedock orchestrate --requeue <id> N... --reason "..."` (refused while it is labelled `needs-human` or closed; skipped dependents
|
|
108
108
|
return to pending with it; a finished batch is reopened); `--add` on a `skipped` issue re-queues it (other ended issues still report `already in batch`). A dependency-skipped issue shows as `waiting on #X` while the batch runs (the blocker is re-checked each pass; merged or closed re-admits it, closed as not planned keeps it skipped) and ends `skipped` only if the blocker never resolved. Its pre-filter still closes already-fixed ones. The runner requeues `deferred` lanes by code (host busy and a full sandbox pool after their
|
|
109
|
-
retry-in, uncapped, a usage limit holds admissions for that account (backend identity) until the reset, across every batch on
|
|
109
|
+
retry-in, uncapped, a usage limit holds admissions for that account (backend identity) until the reset, never capped, across every batch on
|
|
110
110
|
the host; `forgedock limits show|clear <id>|--all` lists or drops holds; transient merge/push/idle failures back off; other
|
|
111
111
|
deferrals stop after `orchestration.max_deferrals`), relaunches an `engine-error` once, and stops admitting after
|
|
112
112
|
`orchestration.breaker` engine-errors (`errors` within `window_min`; held issues continue on `--resume`). Lanes are
|
package/docs/CONFIG.md
CHANGED
|
@@ -439,8 +439,11 @@ orchestration:
|
|
|
439
439
|
| `breaker.errors` / `breaker.window_min` | integer | No | `forgedock orchestrate` circuit breaker (#4121): after `errors` engine-errors within `window_min` minutes the batch stops admitting lanes, lets running lanes finish, and keeps the rest pending for `--resume`. `errors: 0` turns it off. Default: 3 in 30 |
|
|
440
440
|
| `max_deferrals` | integer | No | `forgedock orchestrate`: requeues of one issue after a non-host `deferred` (usage limit, merge/push transient, CLI idle, lease held, gh unavailable) before its row is final. `host.busy` and `sandbox.capacity` (sandbox pool full) deferrals are not capped. Default: 3 |
|
|
441
441
|
| `blocked_label` | string | No | `forgedock orchestrate` (#4773): an open issue carrying this label is admitted to the batch but not dispatched; removing the label releases it on the next scheduler pass. A `Blocked by #N` / `Depends on #N` line in the body of a trusted author (OWNER, MEMBER, COLLABORATOR or a bot) is a DAG edge to #N, and `--add` prints the edges it created. Empty turns the label off. Default: `blocked` |
|
|
442
|
+
| `auto_add_children` | string | No | `forgedock orchestrate` (#4965): which children a running batch adds by itself. `off` (no auto-adds; a decomposed lane's sub-issues stay out too), `decomposed` (a decomposed lane's sub-issues join, as before), `epic` (also the open children of every batch issue and of each `--epic N`, found by a `**Parent**: #P` line, the `parent` field or GitHub sub-issues, including children filed after the batch started) or `all`. Children labelled `needs-human` and children another live batch of the repo still holds are not added. Each pass that adds posts one batch decision and one `added` row listing the children and edges. `forgedock orchestrate --auto-children <mode>` overrides it per batch and `--resume` keeps it. Default: `decomposed` |
|
|
443
|
+
| `auto_add_children_poll_s` | integer | No | Seconds between child scans for `epic`/`all` (one `gh issue list` per scan). Default: `120` |
|
|
442
444
|
| `global_parallel` | integer | No | `forgedock orchestrate` (#4191): host-wide cap on running lanes across every batch and repo on the host, enforced through the host lane registry (`~/.forge/sched/`, or `FORGEDOCK_SCHED_DIR`). A freed slot goes to the highest-priority queued issue (`priority:P<n>` / `P<n>` labels, unlabelled P2); within a priority, slots are shared across repos by weight (#4193: `forgedock sched weight <owner/repo> N`, `--clear` resets; default equal, FIFO inside a repo), and priority always dominates weights; running lanes are never preempted and `--parallel` K still bounds each batch. `forgedock sched cap N` overrides it live for the whole host (`--clear` reverts); `forgedock sched show` lists the weights, slots and the queue. Default: unset (no global cap) |
|
|
443
445
|
| `recovery.max_attempts` | integer | No | `forgedock orchestrate` in-batch recovery (#4267): how many recovery lanes the batch launches per issue for a `needs-human` with a mechanical code (`ci.red`, `base.conflict`, `ci-pending` on a conflicting PR, `contract.invalid` after a mid-run issue edit, `builder.block_untyped`). A recovery lane (`run-issue --recover <code>`) removes `needs-human`, posts a `FORGE:DECISION`, resumes the open PR and re-queues the issue with its dependents. If it ends anything but `merged`, the issue is labelled `needs-human` again with a recovery-evidence `FORGE:NEEDS_HUMAN` comment. Human codes are never recovered. Flat form: `recovery_max_attempts`. Default: 1; `0` disables in-batch recovery (a mechanical `needs-human` is final, as before). |
|
|
446
|
+
| `recovery.mechanical_max_attempts` | integer | No | `forgedock orchestrate` (#4725): the separate recovery budget of mechanical `needs-human` rows (`env.*`, `base.conflict`, a conflicting PR); never below `recovery.max_attempts`. Flat form: `recovery_mechanical_max_attempts`. Default: 2, so with the default config such a row gets 2 recovery lanes, not 1. |
|
|
444
447
|
| `base_sync.enabled` | boolean | No | `forgedock orchestrate` parked-PR base sync (#4525): every 10 min while the batch runs, an open PR of a `needs-human` row is merged with its moved base in a throwaway worktree. A clean merge, or one whose only conflicts are hunks where both sides purely added lines in a `union_globs` file, is pushed as a plain merge commit (never forced). Any other conflict is aborted, the branch left alone, and its paths are posted once per base sha as a `FORGE:DECISION` on the issue. Sequence/`_journal.json` files are never resolved here. Flat form: `base_sync_enabled`. Default: `true`; `false` opts out. |
|
|
445
448
|
| `base_sync.union_globs` | string | No | Comma-separated globs (`*` within a segment, `**` across; no `/` matches the file name anywhere) of well-known append-only files (registries, changelogs) whose two-sided line additions the base sync keeps both of. Flat form: `base_sync_union_globs`. Default: none (every conflict is reported). |
|
|
446
449
|
| `cascade.policy` | string | No | `all` \| `balanced` \| `conservative`. Default: `balanced` |
|
|
@@ -455,7 +458,7 @@ orchestration:
|
|
|
455
458
|
| `cascade.convergence_window` | positive integer | No | Consecutive ratio observations at or above 1.0 before warning. Default: 3 |
|
|
456
459
|
| `cascade.amplification_breaker` | `on` \| `off` | No | Pause P3-and-below cascade admission while the findings-spawned / merged-units ratio stays at or above 1.0 for `convergence_window` units. Counts all cascade findings (not only same-lineage); P1/P2 unaffected; independent of `cascade.policy`, so `policy: all` does not disable it. Paused P3s route to bounded P3 batches. Default: `on`. Opt out with `off` (boolean `false` is accepted as `off`, `true` as `on`). |
|
|
457
460
|
|
|
458
|
-
**Upgrade note — in-batch recovery is on by default** (1.29.0): without `orchestration.recovery.max_attempts`, `forgedock orchestrate` launches one recovery lane per issue for a mechanical `needs-human` before reporting it. To keep the previous behavior (every `needs-human` final), set `orchestration.recovery.max_attempts: 0`.
|
|
461
|
+
**Upgrade note — in-batch recovery is on by default** (1.29.0): without `orchestration.recovery.max_attempts`, `forgedock orchestrate` launches one recovery lane per issue for a mechanical `needs-human` before reporting it. To keep the previous behavior (every `needs-human` final), set `orchestration.recovery.max_attempts: 0`. The budget of mechanical rows (`env.*`, `base.conflict`, conflicting PR) is `orchestration.recovery.mechanical_max_attempts` (default 2, #4725); set it to `0` as well to turn those off.
|
|
459
462
|
|
|
460
463
|
**Upgrade note — `cascade.amplification_breaker` is on by default**: existing configs that do not set this key now get the breaker automatically. During a batch, once the findings-spawned / merged-units ratio stays at or above 1.0 for `convergence_window` units (it can trip at exactly 1.0 over 3 units), P3-and-below cascade admission pauses; paused P3s are routed to bounded P3 batches rather than dispatched individually. P1/P2 are unaffected. To keep the previous behavior, set `orchestration.cascade.amplification_breaker: off` (or `false`). See [RELEASE-NOTES.md](RELEASE-NOTES.md) for the release that introduced this default.
|
|
461
464
|
|
|
@@ -602,7 +605,7 @@ engine:
|
|
|
602
605
|
| `cli_max_minutes` | number | No | Absolute ceiling for a core builder session (code `cli.timeout`), replacing the fixed 60-minute kill. A session that keeps making progress is never idle-killed. Default 180. On either kill, uncommitted work is committed as `wip(forge): checkpoint after <reason>` and the lane branch is pushed (with the push retry), the builder retries once from the checkpoint, and a second kill ends `deferred`; a rerun resumes from the branch. |
|
|
603
606
|
| `turn_budget_s` / `turn_budget_m` / `turn_budget_l` | number | No | Core implement turn budget per lane size (#4392). Size comes from a `size:S|M|L` issue label, else the frozen contract's distinct deliverable paths (S ≤ 2, M ≤ 5, else L). The build call and its context/timeout restarts share the budget; at the limit the session is stopped, checkpointed, and one fresh wrap-up session of max(5, budget/5) turns either finishes or writes `status: budget-exceeded` with a digest. A stop or overrun ends with the non-human `budget-exceeded` reason and code `budget.exceeded` (no `needs-human` label, forge#4505), which `forgedock orchestrate` recovers once (continue from the checkpoint or split); a batch that does not recover it labels it `needs-human` once. Lane results carry `turns: {size, budget, build, total, exceeded}`, and the batch report prints `turns by size`. Defaults 25 / 60 / 100; 0 disables that size. An L lane's budget scales with the contract: +10 turns per distinct deliverable path above 5 (capped at +2x `turn_budget_l`), and a `budget.exceeded` recovery of an L lane gets a further +50% of `turn_budget_l` (#4587). |
|
|
604
607
|
| `lane_limits` | map | No | Cap each core lane's whole process tree (#4115, #4690), under `pipeline:` or `engine:`: `tasks_max` (default `1024`; `0`/`off` disables), `memory_max` (for example `3G`) and `cpu_quota` (for example `150%`). Where `systemd-run --user --scope` works (probed once) every lane's builder CLI is spawned as `systemd-run --user --scope -q --unit=forgedock-lane-... -p TasksMax=... -p MemoryMax=... -p CPUQuota=... -- claude ...` by default. `memory_max` has no default: lanes get no `MemoryMax` unless it is set (`off` also disables; #4740). A lane that hits `TasksMax` or `MemoryMax` is stopped and ends `needs-human` with `env.lane_limit` (not an engine-error, so the breaker ignores it). Where scopes are unavailable the run logs one line saying lanes are uncapped and the limits are ignored. Write it nested or flat (`lane_limits_tasks_max`, `lane_limits_memory_max`, `lane_limits_cpu_quota`). While a builder runs the engine polls host load and memory every 15 s against the `admission` thresholds: over them it SIGSTOPs the lane's process group, under them SIGCONT, and never pauses longer than 10 minutes. |
|
|
605
|
-
| `admission` | map | No | Host-resource admission for the core work-on (#4105), checked before the lease, worktree or any GitHub call. When a check fails the run ends `deferred` (code `host.busy`, exit 75) with a last line such as `issue #N → deferred (host busy: mem 1.2G < 2G; retry in 60s)`; no lease is taken and nothing is posted. Keys: `enabled` (default `true`; `false` disables admission), `min_free_mem_mb` (default 2048; `MemAvailable` from `/proc/meminfo`, else `os.freemem()`), `min_free_tmp_mb` (default 1024; free space of the run TMPDIR filesystem, `~/.forge/tmp`, and of `/tmp`, via `statfs`), `max_load_per_core` (default 4; 1-minute load average divided by cores). Write it nested (`engine:` then `admission:` with the keys indented) or flat as `admission_enabled`, `admission_min_free_mem_mb`, `admission_min_free_tmp_mb`, `admission_max_load_per_core`; the parser reads one nested level into `engine`. `/orchestrate` requeues a host-busy lane and lowers K by 1 for 5 minutes. |
|
|
608
|
+
| `admission` | map | No | Host-resource admission for the core work-on (#4105), checked before the lease, worktree or any GitHub call. When a check fails the run ends `deferred` (code `host.busy`, exit 75) with a last line such as `issue #N → deferred (host busy: mem 1.2G < 2G; retry in 60s)`; no lease is taken and nothing is posted. Keys: `enabled` (default `true`; `false` disables admission), `min_free_mem_mb` (default 2048; `MemAvailable` from `/proc/meminfo`, else `os.freemem()`), `min_free_tmp_mb` (default 1024; free space of the run TMPDIR filesystem, `~/.forge/tmp`, and of `/tmp`, via `statfs`), `max_load_per_core` (default 4; 1-minute load average divided by cores), `lane_cpu_reservation` (default 1) and `lane_mem_mb` (default 2048). The last two feed the live local lane cap (#4777), on by default: `min(--parallel / global_parallel, max(1, min(floor(cores / lane_cpu_reservation), floor((MemAvailable - min_free_mem_mb) / lane_mem_mb))))`, re-probed at every admission attempt, so `--parallel 12` on a 4-core / 8 GB host runs fewer concurrent local lanes. Remote sandbox lanes are exempt; `enabled: false` removes the cap. The cap reads the `forge.yaml` of the batch's repo, also when the batch runs under the schedd daemon. Write it nested (`engine:` then `admission:` with the keys indented) or flat as `admission_enabled`, `admission_min_free_mem_mb`, `admission_min_free_tmp_mb`, `admission_max_load_per_core`; the parser reads one nested level into `engine`. `/orchestrate` requeues a host-busy lane and lowers K by 1 for 5 minutes. |
|
|
606
609
|
| `merge_wait` | string | No | Core work-on merge gate. `required` (default): wait only for the status checks the base branch requires: the union of classic branch protection (`gh api repos/{o}/{r}/branches/{base}/protection/required_status_checks`, contexts and checks) and repository rulesets (`gh api repos/{o}/{r}/rules/branches/{base}`, `required_status_checks` rules); with neither (404 or empty) merge right after review PASS. A merge rejected by base branch policy (PR `MERGEABLE` but `BLOCKED`) is waited on and retried within the CI budget, then left `ci-pending`; it is never base-synced and never `base.conflict`. A failing required check is `needs-human`; a pending one is polled with backoff and never `needs-human`. `all`: wait for every check (`scripts/wait-ci-green.sh`). `none`: merge right after review. Always `--match-head-commit` at the reviewed head. The policy used is recorded in the `FORGE:WORK-ON` comment. |
|
|
607
610
|
| `context_pack` | string | No | Context pack (#4275). Before the plan call the core compiles the issue's GitHub/git neighbourhood into a small, budgeted pack: linked issues and PRs (parent, `Depends on`, body links, same-repo cross-references, siblings), prior attempts, merged PRs on the issue's files that were reverted or whose decisions/findings name a symbol from the issue, structured `FORGE:FACTS` records of earlier lanes on the issue's files (rejected approaches with why, hot spots, decisions, conventions, baselines; #4401), and repo-fact file pointers (found in the lane worktree or, for untracked caches, the main checkout; #4400). `shadow` (default): the pack is compiled, posted on the issue as a collapsed `FORGE:CONTEXT` comment and logged as a `CONTEXT_PACK` event; the builder prompt is unchanged. `on`: it is also appended to the plan prompt as hints. `off`: nothing is gathered, posted or logged. `ab` (#4444): the A/B experiment. Each issue gets a stable arm, `on` or `shadow`, from a hash of `repo#issue`, logged as that mode with `context_ab: true`; the default stays `shadow`. The pack never enters the text contract quotes are checked against. Values read from linked issues' `FORGE:STATE` are kept only when they are outcome codes. Compare with `forgedock stats --context-ab` (per lane type and mode: first-pass yield, turns and cache-read per merge, rework rate, and recovery lanes' implement attempt against the first run on the same issue). Unless `off`, a resumed or recovery lane also gets a bounded resume digest of its last run in the builder prompt (end reason, validate results per command, deliver requirements, files changed; logged as `RESUME_DIGEST`; #4400), whatever the mode. |
|
|
608
611
|
| `context_pack_sources` | string | No | Comma list of the pack sources to use: `graph`, `attempts`, `pitfalls`, `ledger`, `facts` (earlier lanes' `FORGE:FACTS` on the issue's files plus repo-fact file pointers). Default: all five. |
|
|
@@ -880,6 +883,8 @@ verify:
|
|
|
880
883
|
duplicate_exports: false # optional; turn off the same-app duplicate-export check (default true)
|
|
881
884
|
invariants: # optional block list; repo-wide check commands that always run before the PR
|
|
882
885
|
- pnpm check:registry
|
|
886
|
+
guard_command: # optional; per-extension command for the guard gate's base test files
|
|
887
|
+
".py": "scripts/pytest {path}"
|
|
883
888
|
e2e: auto # optional e2e merge gate: auto (default) | always | never
|
|
884
889
|
e2e_label: full-ci # PR label that makes the repo's CI run e2e (label path)
|
|
885
890
|
e2e_checks: "e2e" # check names of the e2e jobs the label path waits on
|
|
@@ -900,6 +905,7 @@ verify:
|
|
|
900
905
|
| `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. |
|
|
901
906
|
| `checks` | string | No | Comma-separated check names that gate CI verification without `workflow` (forge#4387). Overrides the required-check set; other checks are reported, not gating. |
|
|
902
907
|
| `invariants` | list | No | Block list of cheap repo-wide check commands (#4585), e.g. the suites that list every table, export, key or namespace (cross-tenant registry, export and purge coverage, i18n status, boundary tests). They run in the pre-PR check round in every verify mode, whichever tests the test map selected: never scoped away by `.forge/test-map.json`, deduplicated against the learned commands, and subject to the same round budget and per-command timeout. A failure goes to the builder in the precheck fix round like a failed declared check. The plan and implement prompts also carry an invariants checklist built from the list (a new table, export or key must join every registry that enumerates its kind). `forgedock check` and the light lane run them too. Absent: nothing changes. |
|
|
908
|
+
| `guard_command` | map | No | Guard gate (#4693) test-file command per extension (#4792), e.g. `".py": "scripts/pytest {path}"` for a repo whose tests need a managed environment. Consulted before the contract's validate commands and the hard-coded `node --test` / `python -m pytest -q` fallbacks. `{path}` is replaced by the repo-relative path of the guard's base copy (`<name>__guardbase.<ext>`); a template without `{path}` gets it appended. Runs from the worktree root. Keys may be written with or without the leading dot. Absent: unchanged. |
|
|
903
909
|
| `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. |
|
|
904
910
|
| `post_merge` | string | No | Command run in the lane worktree on the merged tree after each base-sync merge, before the push (forge#4406). Default: the typecheck rows of the lane's change-scoped checks (`bin/lean/checks.mjs`), in `local` and `hybrid` mode only; `ci` mode runs no host typecheck unless `post_merge` is set. A failure resumes the builder with the output, up to `max_rounds` rounds, and the fixed head is re-reviewed; still failing is a trajectory anomaly and CI stays the gate. |
|
|
905
911
|
| `duplicate_exports` | boolean | No | Default `true`: at hand-off and after each base-sync merge, names the lane newly exports that another non-test module of the same app or package (nearest `package.json`/`pyproject.toml`/`setup.py`/`go.mod`/`Cargo.toml`) already declares go back to the builder once ("rename or reuse", both paths); one left over is an anomaly (forge#4406). Re-exports, default exports and names 3+ other modules declare (framework conventions) are ignored. `false` turns the check off. |
|
package/docs/RELEASE-NOTES.md
CHANGED
|
@@ -7,6 +7,9 @@ Operator-facing notes for behavior changes that need action or awareness. Newest
|
|
|
7
7
|
- **New command** `forgedock selftest [--static] | --live [--repo <owner/sandbox>] [--pr N]`. It runs a live contract harness against a disposable sandbox repo, plus a static check of every `gh --json` field list. See [SELFTEST.md](SELFTEST.md).
|
|
8
8
|
- **Opt-in nightly run:** `selftest.nightly_interval_hours` makes the schedd daemon start a detached live selftest on that schedule and file one issue on failure. Restart schedd after upgrading to pick it up.
|
|
9
9
|
- **Gate-marker check ignored:** the `Check for FORGE gate markers` check is no longer read as a CI failure by `review-staging`, `/review-pr-staging` or `/deploy-pr`. It only echoes the latest gate marker, so after a GATE_FAILURE it blocked its own GATE_PASS (#4941).
|
|
10
|
+
- **`blocked` label now holds issues (#4773):** `orchestration.blocked_label` defaults to `blocked`. After upgrading, an open issue in a batch that carries a label named `blocked` is admitted but not dispatched until the label is removed. If you use `blocked` for another meaning, set `blocked_label: ""` (turns it off) or another label name. See [CONFIG.md](CONFIG.md#orchestration-optional) and `forge.yaml.example`.
|
|
11
|
+
- **Host lane cap is on by default (#4777):** local lanes are bounded by cores and free memory (`engine.lane_cpu_reservation`, `engine.lane_mem_mb`), so `--parallel 12` on a small host runs fewer concurrent local lanes. Remote sandbox lanes are exempt; `admission: enabled: false` removes the cap.
|
|
12
|
+
- **Mechanical recovery budget is 2 (#4725):** `orchestration.recovery.mechanical_max_attempts` defaults to 2 while `recovery.max_attempts` stays 1, so `env.*`, `base.conflict` and conflicting-PR rows get 2 recovery lanes.
|
|
10
13
|
|
|
11
14
|
### Live selftest gates the staging->main release review (when a sandbox repo is set)
|
|
12
15
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "forgedock",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.43.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"
|
|
@@ -374,6 +374,7 @@ file_class() {
|
|
|
374
374
|
{
|
|
375
375
|
printf '## Problem\n\n'
|
|
376
376
|
printf 'Deferred from #%s: the items below were declared out of scope in its contract and are tracked here, in one issue per class (%s), instead of surfacing later as review findings.\n\n' "$PARENT" "$CLS"
|
|
377
|
+
printf '**Parent**: #%s\n\n' "$PARENT"
|
|
377
378
|
if [ "$CLS" = "external" ]; then
|
|
378
379
|
printf 'External prerequisite of #%s: these items wait on something outside the repo (credentials, an account, a third party).' "$PARENT"
|
|
379
380
|
[ -n "${FORGE_EXTERNAL_PREREQ:-}" ] && printf ' Linked prerequisite: #%s.' "${FORGE_EXTERNAL_PREREQ#\#}"
|
|
@@ -331,5 +331,12 @@ printf -- 'x/y.sh\towner: [bucket:owner] spend\t\tpost-deploy\n' > "$T/ow.tsv"
|
|
|
331
331
|
bash "$S" --parent 10 --repo o/r --items "$T/ow.tsv" >/dev/null 2>&1
|
|
332
332
|
check "owner bucket never lands in post-deploy, even when the row says so" "grep -q 'FORGE:DEFERRED_CLASS: external' '$T/created.901.body' && ! grep -q POSTDEPLOY_ROLLUP '$T/created.901.body'"
|
|
333
333
|
|
|
334
|
+
# a parent that is itself an open follow-up and is the issue the lane closes: a NEW follow-up links it, its body is never edited
|
|
335
|
+
reset
|
|
336
|
+
printf '{"number":20,"state":"open","author_association":"OWNER","user":{"type":"User"},"body":"## Acceptance Criteria\\n\\n- [ ] `R1: old`\\n<!-- FORGE:DEFERRED_FROM: #10 -->"}\n' > "$T/issue.20.json"
|
|
337
|
+
printf -- '-\tdefer: later\t\t\tR2: wire `pd.py` in\n' > "$T/fu.tsv"
|
|
338
|
+
OUT=$(bash "$S" --parent 20 --repo o/r --items "$T/fu.tsv"); RC=$?
|
|
339
|
+
check "follow-up parent: new follow-up issue, parent body unchanged" "[ $RC -eq 0 ] && [ \$(grep -c '^issue create' '$GH_LOG') -eq 1 ] && ! grep -q '^issue edit 20' '$GH_LOG' && [ ! -e '$ISSUE_BODY.edited' ] && grep -q '^\*\*Parent\*\*: #20\$' '$T/created.901.body' && grep -q 'FORGE:DEFERRED_FROM: #20' '$T/created.901.body' && printf '%s\n' \"\$OUT\" | grep -q '^R2: wire pd.py in.901\$'"
|
|
340
|
+
|
|
334
341
|
echo "passed=$PASS failed=$FAIL"
|
|
335
342
|
[ "$FAIL" -eq 0 ]
|
|
@@ -235,7 +235,7 @@ OUT9=$(credit9 "$H"); N9=$(printf '%s' "${OUT9#*|}" | wc -w)
|
|
|
235
235
|
# spec text guards: the resolver is reused, never copied; carried record accepted; the unchanged cross-PR and merge-resolution paths are still present
|
|
236
236
|
grep -q 'review-delta.sh' "$PRE" && grep -q 'REVIEW-CARRIED' "$PRE" && ok || bad "review-staging-pre.sh must call review-delta.sh and accept the REVIEW-CARRIED record"
|
|
237
237
|
if grep -qE 'merge-base --is-ancestor.*REVIEWED_SHA_STALE|diff_shape\(\)' "$SPEC" "$PRE"; then bad "spec must not carry a second copy of the resolver logic"; else ok; fi
|
|
238
|
-
grep -qF 'printf '"'"'%s\n'"'"' "$_FILES" | sort -u | sed "s|^|$pr |" >> "$_PF"' "$PRE" && grep -qF 'git diff-tree --no-commit-id --name-only -r --cc "$c"' "$PRE" && ok || bad "CROSS_PR_FILES accumulation and --cc merge scoping must stay intact"
|
|
238
|
+
grep -qF 'printf '"'"'%s\n'"'"' "$_FILES" | sort -u | sed "s|^|$pr |" > "$_D/pf"' "$PRE" && grep -qF 'cat "$_W/$pr/pf" >> "$_PF"' "$PRE" && grep -qF 'git diff-tree --no-commit-id --name-only -r --cc "$c"' "$PRE" && ok || bad "CROSS_PR_FILES accumulation and --cc merge scoping must stay intact"
|
|
239
239
|
[ "$(grep -c '# FENCE_BOOTSTRAP$' "$SPEC" | head -1)" -ge 3 ] && ok || bad "FENCE_BOOTSTRAP stubs must remain"
|
|
240
240
|
# an unresolvable helper fails the fence closed (exit 1), never an unsourced bootstrap
|
|
241
241
|
OUT8=$( ( unset FORGEDOCK_HOME FORGE_HOME FORGE_ROOT CLAUDE_PLUGIN_ROOT CLAUDE_CONFIG_DIR CODEX_HOME FORGE_SOURCED_ROOT FORGEDOCK_MARKETPLACE; export HOME="$TMP/nohome"; cd "$TMP" && bash -c "$(cat "$TMP/boot.sh"); echo reached" ) 2>&1 )
|
|
@@ -305,8 +305,8 @@ OUT11=$(scope11 "28668 5 6")
|
|
|
305
305
|
[ "$(printf '%s\n' "$OUT11" | head -1)" = "scoped 1" ] && ok || bad "an issue number in the bundle list must be skipped, not fall open (got '$OUT11')"
|
|
306
306
|
case "$OUT11" in *"#28668 is an issue"*) ok ;; *) bad "0B.1 must announce the skipped issue (got '$OUT11')" ;; esac
|
|
307
307
|
OUT11=$(scope11 "5 77")
|
|
308
|
-
[ "$(printf '%s\n' "$OUT11" | head -1
|
|
309
|
-
case "$OUT11" in *"
|
|
308
|
+
[ "$(printf '%s\n' "$OUT11" | head -1)" = "scoped 1" ] && ok || bad "a genuine read failure on one PR must degrade that PR only and keep scope (got '$OUT11')"
|
|
309
|
+
case "$OUT11" in *"scope degraded for PR #77"*"not a confirmed issue"*) ok ;; *) bad "a degraded PR must print the PR and reason (got '$OUT11')" ;; esac
|
|
310
310
|
else echo "review-pr-staging-scope.test.sh: git missing, delta section skipped"; fi
|
|
311
311
|
echo "review-pr-staging-scope.test.sh: passed=$PASS failed=$FAILN"
|
|
312
312
|
[ "$FAILN" -eq 0 ]
|
|
@@ -601,6 +601,7 @@ if [ "$REVIEW_MODE" != "BASE_SYNC_ONLY" ]; then
|
|
|
601
601
|
echo "Domain files (${domain}): ${files:-none}"
|
|
602
602
|
else echo "Code index not available — agent will use grep exploration"; fi
|
|
603
603
|
}
|
|
604
|
+
echo "review-prepare: code index phase done; building per-domain diff slices" >&2
|
|
604
605
|
IDX_AUTH=$(build_index_slice auth); IDX_BILLING=$(build_index_slice billing); IDX_DATABASE=$(build_index_slice database)
|
|
605
606
|
IDX_API=$(build_index_slice api); IDX_FRONTEND=$(build_index_slice frontend); IDX_INFRA=$(build_index_slice infrastructure)
|
|
606
607
|
|
|
@@ -639,6 +640,7 @@ if [ "$REVIEW_MODE" != "BASE_SYNC_ONLY" ]; then
|
|
|
639
640
|
printf '%s' "$paths" > "$TMP_DIR/slice-paths" || { echo "review-prepare: cannot write $TMP_DIR/slice-paths" >&2; exit 2; }
|
|
640
641
|
SLICES_JSON=$(printf '%s' "$SLICES_JSON" | jq -c --arg d "$dom" --arg df "$df" --rawfile p "$TMP_DIR/slice-paths" '. + {($d): {paths: ($p | split("\n") | map(select(length > 0))), diff_file: $df}}')
|
|
641
642
|
}
|
|
643
|
+
echo "review-prepare: slicing the diff ($(printf '%s\n' "$SLICE_FILES_SRC" | sed '/^$/d' | wc -l | tr -d ' ') file(s))" >&2
|
|
642
644
|
slice security ALL ALL
|
|
643
645
|
slice auth '^diff --git.*/(auth|session|jwt|oauth|permission|middleware|login|token)' "auth|session|login|logout|jwt|oauth|token|user|permission|middleware"
|
|
644
646
|
slice billing '^diff --git.*/(billing|payment|credit|pricing|stripe|subscription|invoice|charge|refund)' "billing|credit|payment|charge|refund|subscription|pricing|tier"
|
|
@@ -649,6 +651,7 @@ if [ "$REVIEW_MODE" != "BASE_SYNC_ONLY" ]; then
|
|
|
649
651
|
slice infra '^diff --git.*/(docker|nginx|infra|\.github|deploy|ci|traefik|k8s|helm)' "docker|nginx|traefik|\.github|Makefile|deploy|infra"
|
|
650
652
|
slice scraper '^diff --git.*/(scrape|scraper|stealth|browser_pool|headless|anti_bot|captcha|crawl)' "scrape|crawler|stealth|browser.*pool|headless|anti.bot|detection.*keyword|captcha"
|
|
651
653
|
slice spec-cli '^diff --git a/(commands|bin)/' "^(commands|bin)/"
|
|
654
|
+
echo "review-prepare: diff slices done" >&2
|
|
652
655
|
fi
|
|
653
656
|
|
|
654
657
|
# --- emit ---
|
|
@@ -14,7 +14,8 @@
|
|
|
14
14
|
# gh read blocks (TYPE=gate-read-failure, never waived by an override): no GATE_PASS without real reads (forge#4470).
|
|
15
15
|
# scope 0B.1: writes DIR/<N>_scope.env (printf %q, read by 7A), DIR/<N>_bundle-pr-files.txt and DIR/<N>_scope.json
|
|
16
16
|
# {scope_mode, reviewed_pr_count, reviewed_commits, unreviewed_commits, unreviewed_files, cross_pr_files,
|
|
17
|
-
# deploy_wiring_files}; prints the one-line SCOPE_MODE summary.
|
|
17
|
+
# deploy_wiring_files, degraded_prs}; prints the one-line SCOPE_MODE summary. Per-PR reads run FORGE_SCOPE_PARALLELISM (default 8) at a time
|
|
18
|
+
# and retry; a PR whose read still fails is degraded (its commits stay unreviewed) and the rest stay scoped. Global failures widen to full.
|
|
18
19
|
# Exit: 2 on a usage error.
|
|
19
20
|
#
|
|
20
21
|
# The `# BEGIN <name>` ... `# END <name>` blocks below are byte-identical copies of the spec's blocks ({GH_REPO} -> $GH_REPO);
|
|
@@ -106,7 +107,11 @@ MERGE_PRS=$(git log origin/$DEFAULT_BRANCH..origin/$STAGING_BRANCH --merges --on
|
|
|
106
107
|
# Merge-subject numbers are PRs by construction; an unclassifiable number is kept (fail open: it may be a PR). forge#4127
|
|
107
108
|
# BEGIN bundle-pr-kind
|
|
108
109
|
# Prints "pr" or "issue" for issue number ${2} in repo ${1}; prints nothing and returns 1 when the read fails or is malformed.
|
|
109
|
-
|
|
110
|
+
# A number's kind never changes, so a successful answer is cached under the run scratch dir ($_SD/gh-cache): a rerun after a killed run skips the read (#4960).
|
|
111
|
+
bundle_pr_kind() { local k c="${_SD:-${FORGE_SCRATCHPAD:-}}"; [ -z "$c" ] || c="$c/gh-cache/kind-$(printf '%s' "${1}" | tr '/' '_')-${2}"
|
|
112
|
+
[ -z "$c" ] || ! [ -f "$c" ] || { k=$(cat "$c" 2>/dev/null); case "$k" in pr|issue) printf '%s' "$k"; return 0 ;; esac; }
|
|
113
|
+
k=$(gh_retry gh api "repos/${1}/issues/${2}" --jq 'if .pull_request then "pr" else "issue" end' 2>/dev/null) || return 1; case "$k" in pr|issue) ;; *) return 1 ;; esac
|
|
114
|
+
[ -z "$c" ] || { mkdir -p "$(dirname "$c")" 2>/dev/null && printf '%s' "$k" > "$c" 2>/dev/null; }; printf '%s' "$k"; }
|
|
110
115
|
# END bundle-pr-kind
|
|
111
116
|
SUBJECT_PRS=""; SKIPPED_ISSUES=""
|
|
112
117
|
for n in $BUNDLE_PRS; do
|
|
@@ -332,49 +337,89 @@ bundle_delta_credit() {
|
|
|
332
337
|
# END bundle-delta-credit
|
|
333
338
|
# BEGIN bundle-pr-kind
|
|
334
339
|
# Prints "pr" or "issue" for issue number ${2} in repo ${1}; prints nothing and returns 1 when the read fails or is malformed.
|
|
335
|
-
|
|
340
|
+
# A number's kind never changes, so a successful answer is cached under the run scratch dir ($_SD/gh-cache): a rerun after a killed run skips the read (#4960).
|
|
341
|
+
bundle_pr_kind() { local k c="${_SD:-${FORGE_SCRATCHPAD:-}}"; [ -z "$c" ] || c="$c/gh-cache/kind-$(printf '%s' "${1}" | tr '/' '_')-${2}"
|
|
342
|
+
[ -z "$c" ] || ! [ -f "$c" ] || { k=$(cat "$c" 2>/dev/null); case "$k" in pr|issue) printf '%s' "$k"; return 0 ;; esac; }
|
|
343
|
+
k=$(gh_retry gh api "repos/${1}/issues/${2}" --jq 'if .pull_request then "pr" else "issue" end' 2>/dev/null) || return 1; case "$k" in pr|issue) ;; *) return 1 ;; esac
|
|
344
|
+
[ -z "$c" ] || { mkdir -p "$(dirname "$c")" 2>/dev/null && printf '%s' "$k" > "$c" 2>/dev/null; }; printf '%s' "$k"; }
|
|
336
345
|
# END bundle-pr-kind
|
|
337
346
|
# BEGIN bundle-scope-loop
|
|
338
|
-
_WHY=""
|
|
347
|
+
_WHY=""; DEGRADED_PRS=""
|
|
348
|
+
# scope_one_pr PR DIR: every read for one bundle PR, run in its own subshell. Writes DIR/status ("ok", "issue" or "degraded <why>") and, for ok,
|
|
349
|
+
# DIR/pf (the "<pr> <file>" lines), DIR/reviewed (0|1), DIR/commits, DIR/merges. A failed read is retried (gh_retry) and then degrades THIS PR only:
|
|
350
|
+
# it is credited no commits, so they stay UNREVIEWED and are reviewed (widening, never narrowing); it never changes the bundle's scope mode (#4960).
|
|
351
|
+
scope_one_pr() {
|
|
352
|
+
pr="$1"; _D="$2"
|
|
353
|
+
_degrade() { printf 'degraded %s\n' "$1" > "$_D/status"; }
|
|
354
|
+
# A number 0A could not classify may be an issue (subjects cite issues): a confirmed issue is skipped (forge#4127).
|
|
355
|
+
_J=$(gh_retry gh pr view "$pr" -R $GH_REPO --json headRefOid,mergeCommit,commits,state,baseRefName,labels 2>/dev/null) || {
|
|
356
|
+
if [ "$(bundle_pr_kind "$GH_REPO" "$pr")" = issue ]; then echo issue > "$_D/status"; else _degrade "gh pr view failed and #$pr is not a confirmed issue"; fi
|
|
357
|
+
return 0; }
|
|
358
|
+
_HEAD=$(printf '%s' "$_J" | jq -r '.headRefOid') || { _degrade "unreadable pr view output"; return 0; }
|
|
359
|
+
# a malformed head id would make the sha match accept any comment (fail open): this PR gets no credit
|
|
360
|
+
case "$_HEAD" in *[!0-9a-f]*|"") _degrade "malformed head id"; return 0 ;; esac
|
|
361
|
+
[ "${#_HEAD}" -eq 40 ] || [ "${#_HEAD}" -eq 64 ] || { _degrade "malformed head id"; return 0; }
|
|
362
|
+
_HEAD7=$(printf '%s' "$_HEAD" | cut -c1-7)
|
|
363
|
+
# Capture each gh read into a variable FIRST and test its own exit status: in a pipeline `||` only sees the last stage (no pipefail),
|
|
364
|
+
# so a failed gh read piped into another command would silently look like "no comments" / "no files".
|
|
365
|
+
_CM=$(gh_retry gh api --paginate "repos/$GH_REPO/issues/$pr/comments" 2>/dev/null) || { _degrade "comments read failed"; return 0; }
|
|
366
|
+
_FILES=$(gh_retry gh pr diff "$pr" -R $GH_REPO --name-only 2>/dev/null) || { _degrade "file list read failed"; return 0; }
|
|
367
|
+
# Reviewed = trusted FORGE:REVIEW_ROUTE (a real review mode) whose sha is the PR's FINAL head, plus at least one review-agent comment
|
|
368
|
+
# carrying the full-length `Reviewed-SHA: <head>` line and a findings block (same predicate as review-pr.md Phase 5; a 7-char substring or a comment with no findings block never counts), and no degraded/blocked review marker. A PR pushed to after its review has a stale sha: the exact-head gate gives it no credit, and only the delta branch below (shared resolver `scripts/review-delta.sh`) may credit the commits an earlier trusted complete panel covered or a trusted `FORGE:REVIEW-CARRIED` base sync carried; everything else stays UNREVIEWED.
|
|
369
|
+
_ROUTE=$(printf '%s' "$_CM" | bash "$TRUSTED_SCRIPT" count "^<!-- FORGE:REVIEW_ROUTE mode=(?!spec-evolution-blocked)[^ ]+ spec=review-pr.md sha=${_HEAD7} -->") || { _degrade "marker count failed"; return 0; }
|
|
370
|
+
_AGENT=$(printf '%s' "$_CM" | bash "$TRUSTED_SCRIPT" count "^(?=[\\s\\S]*<!-- REVIEW-FINDINGS-START -->[\\s\\S]*<!-- REVIEW-FINDINGS-END -->)<!-- FORGE:REVIEW-AGENT:[a-z-]+ -->[\\s\\S]*(^|\\n)Reviewed-SHA: ${_HEAD}(\\r?\\n|$)") || { _degrade "marker count failed"; return 0; }
|
|
371
|
+
_DEGRADED=$(printf '%s' "$_CM" | bash "$TRUSTED_SCRIPT" count "^<!-- FORGE:(REVIEW_DEGRADED|REVIEW_BLOCKED)") || { _degrade "marker count failed"; return 0; }
|
|
372
|
+
case "$_ROUTE$_AGENT$_DEGRADED" in ''|*[!0-9]*) _degrade "malformed marker count"; return 0 ;; esac
|
|
373
|
+
_NC=$(printf '%s' "$_J" | jq '.commits|length') || { _degrade "unreadable commit list"; return 0; }
|
|
374
|
+
_ALLC=$(printf '%s' "$_J" | jq -r '[.commits[].oid, .mergeCommit.oid // empty] | join(" ")') || { _degrade "unreadable commit list"; return 0; }
|
|
375
|
+
_MERGEC=$(printf '%s' "$_J" | jq -r '.mergeCommit.oid // empty') || { _degrade "unreadable commit list"; return 0; }
|
|
376
|
+
_REV=0; _COMMITS=""; _MERGES=""
|
|
377
|
+
if [ "$_ROUTE" -ge 1 ] && [ "$_AGENT" -ge 1 ] && [ "$_DEGRADED" -eq 0 ] && [ "$_NC" -lt 100 ]; then
|
|
378
|
+
_REV=1; _COMMITS="$_ALLC"; _MERGES="$_MERGEC"
|
|
379
|
+
elif [ "$_DEGRADED" -eq 0 ] && [ "$_NC" -lt 100 ]; then
|
|
380
|
+
# Stale head: the exact-head gate failed, so ask the shared resolver how much of the PR an earlier trusted complete panel covered. Additive only: it can move commits out of UNREVIEWED, never into it.
|
|
381
|
+
bundle_delta_credit
|
|
382
|
+
if [ "$DELTA_FULL" -eq 1 ]; then _REV=1; _COMMITS="$_ALLC"; _MERGES="$_MERGEC"
|
|
383
|
+
elif [ -n "$DELTA_COMMITS" ]; then _COMMITS="$DELTA_COMMITS" # the PR's merge commit is not credited: it contains the post-review commits
|
|
384
|
+
fi
|
|
385
|
+
fi
|
|
386
|
+
printf '%s\n' "$_REV" > "$_D/reviewed"; printf '%s' "$_COMMITS" > "$_D/commits"; printf '%s' "$_MERGES" > "$_D/merges"
|
|
387
|
+
printf '%s\n' "$_FILES" | sort -u | sed "s|^|$pr |" > "$_D/pf"
|
|
388
|
+
echo ok > "$_D/status"
|
|
389
|
+
}
|
|
339
390
|
if [ -n "$TRUSTED_SCRIPT" ] && [ -n "${ALL_PR_NUMBERS:-}" ] && [ -n "${PR_NUMBER:-}" ] && [ -n "${GH_REPO:-}" ]; then
|
|
340
391
|
SCOPE_MODE=scoped
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
REVIEWED_COMMITS="$REVIEWED_COMMITS$DELTA_COMMITS" # the PR's merge commit is not credited: it contains the post-review commits
|
|
373
|
-
fi
|
|
374
|
-
fi
|
|
375
|
-
printf '%s\n' "$_FILES" | sort -u | sed "s|^|$pr |" >> "$_PF"
|
|
376
|
-
done
|
|
377
|
-
[ "$SCOPE_MODE" = scoped ] || echo "SCOPE_MODE=full: scoping fell open at bundle PR #$pr (${_WHY:-a gh read or marker count failed or returned malformed data})"
|
|
392
|
+
_W=$(mktemp -d "$_SD/scope-work.XXXXXX") || { SCOPE_MODE=full; _WHY="cannot create a scratch dir under $_SD"; }
|
|
393
|
+
if [ "$SCOPE_MODE" = scoped ]; then
|
|
394
|
+
_P="${FORGE_SCOPE_PARALLELISM:-8}"; case "$_P" in ''|*[!0-9]*|0) _P=8 ;; esac
|
|
395
|
+
_TOT=$(echo $ALL_PR_NUMBERS | wc -w | tr -d ' '); _N=0; _ACTIVE=0; _NEXT=25
|
|
396
|
+
echo "0B.1: reading $_TOT bundle PR(s), $_P at a time" >&2
|
|
397
|
+
for pr in $ALL_PR_NUMBERS; do
|
|
398
|
+
mkdir -p "$_W/$pr"
|
|
399
|
+
( scope_one_pr "$pr" "$_W/$pr" ) &
|
|
400
|
+
_ACTIVE=$((_ACTIVE+1)); _N=$((_N+1))
|
|
401
|
+
if [ "$_ACTIVE" -ge "$_P" ]; then wait; _ACTIVE=0; fi
|
|
402
|
+
if [ "$_N" -ge "$_NEXT" ]; then echo "0B.1: $_N/$_TOT PR(s) dispatched" >&2; _NEXT=$((_NEXT+25)); fi
|
|
403
|
+
done
|
|
404
|
+
wait
|
|
405
|
+
echo "0B.1: all $_TOT PR read(s) done; merging" >&2
|
|
406
|
+
for pr in $ALL_PR_NUMBERS; do
|
|
407
|
+
_S=$(cat "$_W/$pr/status" 2>/dev/null) || _S="" # a subshell that died leaves no status: degrade this PR
|
|
408
|
+
case "$_S" in
|
|
409
|
+
ok)
|
|
410
|
+
REVIEWED_PR_COUNT=$((REVIEWED_PR_COUNT + $(cat "$_W/$pr/reviewed")))
|
|
411
|
+
REVIEWED_COMMITS="$REVIEWED_COMMITS $(cat "$_W/$pr/commits")"
|
|
412
|
+
REVIEWED_MERGES="$REVIEWED_MERGES $(cat "$_W/$pr/merges")"
|
|
413
|
+
cat "$_W/$pr/pf" >> "$_PF" ;;
|
|
414
|
+
issue) echo "0B.1: #$pr is an issue, not a bundle PR: skipped" ;;
|
|
415
|
+
*) _WHY="${_S#degraded }"; [ -n "$_S" ] || _WHY="scope read of #$pr produced no result"
|
|
416
|
+
DEGRADED_PRS="$DEGRADED_PRS $pr"
|
|
417
|
+
echo "0B.1: scope degraded for PR #$pr ($_WHY): its commits stay UNREVIEWED; scope stays scoped for the rest" ;;
|
|
418
|
+
esac
|
|
419
|
+
done
|
|
420
|
+
rm -rf "$_W"
|
|
421
|
+
fi
|
|
422
|
+
[ "$SCOPE_MODE" = scoped ] || echo "SCOPE_MODE=full: scoping fell open (${_WHY:-a scope read failed})"
|
|
378
423
|
fi
|
|
379
424
|
# END bundle-scope-loop
|
|
380
425
|
if [ "$SCOPE_MODE" = "scoped" ]; then
|
|
@@ -415,7 +460,7 @@ _jw() { printf '%s\n' "${1:-}" | tr ' ' '\n' | sed '/^$/d' | jq -R . | jq -s .;
|
|
|
415
460
|
_jn() { printf '%s\n' "${1:-}" | sed '/^$/d' | jq -R . | jq -s .; }
|
|
416
461
|
jq -n --arg mode "$SCOPE_MODE" --argjson n "${REVIEWED_PR_COUNT:-0}" \
|
|
417
462
|
--argjson rc "$(_jw "$REVIEWED_COMMITS")" --argjson uc "$(_jw "$UNREVIEWED_COMMITS")" \
|
|
418
|
-
--argjson uf "$(_jn "${UNREVIEWED_FILES:-}")" --argjson cf "$(_jn "${CROSS_PR_FILES:-}")" --argjson dw "$(_jn "${DEPLOY_WIRING_FILES:-}")" \
|
|
419
|
-
'{scope_mode: $mode, reviewed_pr_count: $n, reviewed_commits: $rc, unreviewed_commits: $uc, unreviewed_files: $uf, cross_pr_files: $cf, deploy_wiring_files: $dw}' \
|
|
463
|
+
--argjson uf "$(_jn "${UNREVIEWED_FILES:-}")" --argjson cf "$(_jn "${CROSS_PR_FILES:-}")" --argjson dw "$(_jn "${DEPLOY_WIRING_FILES:-}")" --argjson dp "$(_jw "$DEGRADED_PRS" | jq -c "map(tonumber)")" \
|
|
464
|
+
'{scope_mode: $mode, reviewed_pr_count: $n, reviewed_commits: $rc, unreviewed_commits: $uc, unreviewed_files: $uf, cross_pr_files: $cf, deploy_wiring_files: $dw, degraded_prs: $dp}' \
|
|
420
465
|
> "$_SD/${PR_NUMBER}_scope.json" || { echo "review-staging-pre: could not write ${PR_NUMBER}_scope.json" >&2; exit 1; }
|
|
421
466
|
exit 0
|