forgedock 1.45.0 → 1.47.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 +2 -1
- package/bin/engine-cli.mjs +22 -8
- package/bin/lean/conformance.e2e.test.mjs +20 -0
- package/bin/lean/conformance.mjs +99 -9
- package/bin/lean/conformance.test.mjs +121 -4
- package/bin/lean/context.mjs +108 -6
- package/bin/lean/context.test.mjs +84 -2
- package/bin/lean/coupled.mjs +38 -0
- package/bin/lean/coupled.test.mjs +57 -0
- package/bin/lean/decision-engine.mjs +17 -2
- package/bin/lean/decision-engine.test.mjs +21 -0
- package/bin/lean/drift.mjs +3 -2
- package/bin/lean/drift.test.mjs +23 -1
- package/bin/lean/effects.mjs +3 -2
- package/bin/lean/execution.mjs +41 -8
- package/bin/lean/execution.test.mjs +72 -2
- package/bin/lean/integration.mjs +2 -2
- package/bin/lean/integration.test.mjs +22 -1
- package/bin/lean/issuewatch-decisions.mjs +175 -0
- package/bin/lean/issuewatch-decisions.test.mjs +353 -0
- package/bin/lean/issuewatch-release.test.mjs +3 -1
- package/bin/lean/issuewatch.mjs +67 -10
- package/bin/lean/issuewatch.test.mjs +18 -0
- package/bin/lean/lane.mjs +13 -0
- package/bin/lean/lane.test.mjs +28 -1
- package/bin/lean/lean.mjs +64 -17
- package/bin/lean/lean.test.mjs +95 -0
- package/bin/lean/limits.mjs +6 -2
- package/bin/lean/limits.test.mjs +86 -0
- package/bin/lean/milestonesync.mjs +2 -2
- package/bin/lean/milestonesync.test.mjs +22 -0
- package/bin/lean/orchestrate.mjs +79 -9
- package/bin/lean/probe.mjs +4 -2
- package/bin/lean/probe.test.mjs +4 -0
- package/bin/lean/prompts.mjs +18 -11
- package/bin/lean/rotator.mjs +96 -0
- package/bin/lean/rotator.test.mjs +143 -0
- package/bin/lean/routecheck.mjs +48 -0
- package/bin/lean/routecheck.test.mjs +69 -0
- package/bin/lean/salvage-resume.mjs +26 -0
- package/bin/lean/salvage-resume.test.mjs +51 -0
- package/bin/lean/sandbox-provider.contract.test.mjs +9 -0
- package/bin/lean/sandbox-remote.mjs +4 -2
- package/bin/lean/verify.mjs +2 -1
- package/bin/review-staging.mjs +20 -3
- package/commands/deploy-info.md +4 -4
- package/commands/issue.md +6 -1
- package/commands/review-pr-agents/frontend.md +1 -0
- package/commands/review-pr-agents/infra.md +2 -0
- package/commands/review-pr-staging.md +2 -0
- package/commands/work-on/build/context.md +7 -1
- package/commands/work-on/investigate.md +15 -3
- package/docs/CONFIG.md +22 -3
- package/docs/DECISIONS.md +18 -7
- package/docs/OUTCOME-CODES.md +2 -1
- package/docs/RELEASE-NOTES.md +1 -0
- package/package.json +1 -1
- package/scripts/file-deferred-followups.sh +125 -28
- package/scripts/file-deferred-followups.test.sh +49 -0
- package/scripts/issue-create.sh +7 -0
- package/scripts/issue-create.test.sh +13 -0
- package/scripts/issue-dedup.sh +5 -4
- package/scripts/issue-dedup.test.sh +15 -0
- package/scripts/owner-route-parity.test.sh +32 -0
- package/scripts/prior-attempts.mjs +72 -0
- package/scripts/prior-attempts.test.mjs +52 -0
- package/scripts/review-finalize.sh +35 -4
- package/scripts/review-finalize.test.sh +10 -0
- package/scripts/review-prepare.sh +16 -4
- package/scripts/review-prepare.test.sh +8 -0
- package/scripts/review-roster.sh +3 -1
- package/scripts/review-roster.test.sh +30 -2
- package/scripts/review-staging-pre.sh +8 -6
- package/scripts/review-staging-pre.test.sh +11 -1
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
|
|
2
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
3
|
+
/**
|
|
4
|
+
* #5087: route reachability. A fix that adds an HTTP route handler can pass every test while the production ingress sends
|
|
5
|
+
* the path to another service. forge.yaml `topology.route_reachability` is a repo-supplied command that takes the added
|
|
6
|
+
* route paths as arguments and exits non-zero when the production ingress does not deliver them to the service that
|
|
7
|
+
* implements them. Core stays repo-agnostic (#1347): it only derives the paths and runs the command. Absent key = no check.
|
|
8
|
+
*/
|
|
9
|
+
import { OUTCOME_CODES } from "../engine/outcome-codes.mjs";
|
|
10
|
+
|
|
11
|
+
const APP_ROUTE_RE = /(?:^|\/)app\/(?:(.*)\/)?route\.(?:[cm]?[jt]sx?)$/;
|
|
12
|
+
const PAGES_API_RE = /(?:^|\/)pages\/(api(?:\/.*)?)\.(?:[cm]?[jt]sx?)$/;
|
|
13
|
+
|
|
14
|
+
/** URL path of one Next.js route handler file (app router `route.ts`, pages router `pages/api`), or null. Route groups `(x)` and slots `@x` are not URL segments. */
|
|
15
|
+
export function routePathOf(file) {
|
|
16
|
+
const f = String(file ?? "").replace(/\\/g, "/");
|
|
17
|
+
const app = APP_ROUTE_RE.exec(f);
|
|
18
|
+
if (app) return `/${(app[1] ?? "").split("/").filter((s) => s && !/^\(.*\)$/.test(s) && !s.startsWith("@")).join("/")}`;
|
|
19
|
+
const pg = PAGES_API_RE.exec(f);
|
|
20
|
+
if (pg) return `/${pg[1].replace(/\/index$/, "")}`;
|
|
21
|
+
return null;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Sorted unique URL paths of the route-handler files in `files`. */
|
|
25
|
+
export function routePathsFromFiles(files) {
|
|
26
|
+
return [...new Set((files ?? []).map(routePathOf).filter(Boolean))].sort();
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const sq = (s) => `'${String(s).replace(/'/g, `'\\''`)}'`;
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Blocking failures (conformance.unimplemented, kind route_unreachable) when the declared command exits non-zero for the
|
|
33
|
+
* added route paths. [] when no command is declared or no route was added. A command that cannot start (rc 127) is
|
|
34
|
+
* reported too: a declared blocking check that never ran is no evidence. `run` is ({command}) => {rc, output}
|
|
35
|
+
* (a `{r: {rc, output}}` wrapper is unwrapped).
|
|
36
|
+
*/
|
|
37
|
+
export async function routeReachabilityFailures({ command, paths, run }) {
|
|
38
|
+
const cmd = typeof command === "string" ? command.trim() : "";
|
|
39
|
+
if (!cmd || !paths?.length) return [];
|
|
40
|
+
let r;
|
|
41
|
+
try { r = await run({ command: `${cmd} ${paths.map(sq).join(" ")}` }); r = r?.r ?? r; } catch (e) { r = { rc: 127, output: String(e?.message ?? e) }; }
|
|
42
|
+
if (r?.rc === 0) return [];
|
|
43
|
+
const tail = String(r?.output ?? "").trim().split("\n").slice(-12).join("\n").slice(0, 1500);
|
|
44
|
+
return [{
|
|
45
|
+
code: OUTCOME_CODES.CONFORMANCE_UNIMPLEMENTED, kind: "route_unreachable",
|
|
46
|
+
detail: `forge.yaml topology.route_reachability (\`${cmd}\`) exited ${r?.rc ?? "?"} for the new route(s) ${paths.join(", ")}: the production ingress does not deliver them to the service that implements them; fix the route or the ingress, citing the ingress rule${tail ? `:\n${tail}` : ""}`,
|
|
47
|
+
}];
|
|
48
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { describe, it } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { spawnSync } from "node:child_process";
|
|
4
|
+
import { writeFileSync } from "node:fs";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import { routePathOf, routePathsFromFiles, routeReachabilityFailures } from "./routecheck.mjs";
|
|
7
|
+
import { readEnvConfig } from "../engine-cli.mjs";
|
|
8
|
+
import { tmpDir } from "../tests/_tmp.mjs";
|
|
9
|
+
|
|
10
|
+
// Stand-in for AlterLab's scripts/generate-traefik-config.sh: the api-router sends /api/v1/* to FastAPI;
|
|
11
|
+
// only /api/v1/batch and /api/v1/b2b/jobs go to web (#5087).
|
|
12
|
+
const dir = tmpDir("routecheck-");
|
|
13
|
+
const ingress = join(dir, "check-ingress.sh");
|
|
14
|
+
writeFileSync(ingress, `#!/usr/bin/env bash
|
|
15
|
+
rc=0
|
|
16
|
+
for p in "$@"; do
|
|
17
|
+
case "$p" in
|
|
18
|
+
/api/v1/batch|/api/v1/batch/*|/api/v1/b2b/jobs|/api/v1/b2b/jobs/*) ;;
|
|
19
|
+
/api/v1/*) echo "unreachable: $p is routed to api (FastAPI) by api-router, not web" >&2; rc=1 ;;
|
|
20
|
+
esac
|
|
21
|
+
done
|
|
22
|
+
exit $rc
|
|
23
|
+
`);
|
|
24
|
+
const run = async ({ command }) => { const r = spawnSync("bash", ["-c", command], { encoding: "utf8" }); return { rc: r.status, output: `${r.stdout}${r.stderr}` }; };
|
|
25
|
+
const SIGNUP = ["web/src/app/(site)/api/v1/agent/signup/pending/route.ts", "web/src/app/(site)/api/v1/agent/signup/by-code/[code]/route.ts"];
|
|
26
|
+
|
|
27
|
+
describe("#5087 route reachability", () => {
|
|
28
|
+
it("maps Next.js handler files to URL paths", () => {
|
|
29
|
+
assert.equal(routePathOf(SIGNUP[0]), "/api/v1/agent/signup/pending");
|
|
30
|
+
assert.equal(routePathOf(SIGNUP[1]), "/api/v1/agent/signup/by-code/[code]");
|
|
31
|
+
assert.equal(routePathOf("web/src/pages/api/health/index.ts"), "/api/health");
|
|
32
|
+
assert.equal(routePathOf("web/src/app/route.ts"), "/");
|
|
33
|
+
assert.equal(routePathOf("web/src/app/page.tsx"), null);
|
|
34
|
+
assert.equal(routePathOf("bin/lean/routecheck.mjs"), null);
|
|
35
|
+
assert.deepEqual(routePathsFromFiles([...SIGNUP, SIGNUP[0], "README.md"]), ["/api/v1/agent/signup/by-code/[code]", "/api/v1/agent/signup/pending"]);
|
|
36
|
+
});
|
|
37
|
+
it("fixture ingress routes /api/v1/agent/signup to FastAPI and fails the check", async () => {
|
|
38
|
+
const f = await routeReachabilityFailures({ command: `bash ${ingress}`, paths: routePathsFromFiles(SIGNUP), run });
|
|
39
|
+
assert.equal(f.length, 1);
|
|
40
|
+
assert.equal(f[0].code, "conformance.unimplemented");
|
|
41
|
+
assert.equal(f[0].kind, "route_unreachable");
|
|
42
|
+
assert.match(f[0].detail, /signup\/pending/);
|
|
43
|
+
});
|
|
44
|
+
it("passes a route the ingress delivers to web", async () => {
|
|
45
|
+
assert.deepEqual(await routeReachabilityFailures({ command: `bash ${ingress}`, paths: ["/api/v1/batch"], run }), []);
|
|
46
|
+
});
|
|
47
|
+
it("a passing command wrapped as {r: {rc, output}} (lean io.runCommand shape) is not reported as a failure", async () => {
|
|
48
|
+
const wrapped = async (a) => ({ r: await run(a) });
|
|
49
|
+
assert.deepEqual(await routeReachabilityFailures({ command: `bash ${ingress}`, paths: ["/api/v1/batch"], run: wrapped }), []);
|
|
50
|
+
assert.equal((await routeReachabilityFailures({ command: `bash ${ingress}`, paths: ["/api/v1/agent/signup/pending"], run: wrapped })).length, 1);
|
|
51
|
+
});
|
|
52
|
+
it("is a no-op without a declared command or without added routes", async () => {
|
|
53
|
+
const never = async () => { throw new Error("must not run"); };
|
|
54
|
+
assert.deepEqual(await routeReachabilityFailures({ command: undefined, paths: ["/api/v1/x"], run: never }), []);
|
|
55
|
+
assert.deepEqual(await routeReachabilityFailures({ command: "false", paths: [], run: never }), []);
|
|
56
|
+
});
|
|
57
|
+
it("a command that cannot run is reported, not swallowed", async () => {
|
|
58
|
+
const f = await routeReachabilityFailures({ command: "x", paths: ["/a"], run: async () => { throw new Error("boom"); } });
|
|
59
|
+
assert.equal(f[0].kind, "route_unreachable");
|
|
60
|
+
assert.match(f[0].detail, /exited 127/);
|
|
61
|
+
});
|
|
62
|
+
it("forge.yaml topology.route_reachability is read; repos without topology get no key", () => {
|
|
63
|
+
const withKey = tmpDir("rc-yaml-"), without = tmpDir("rc-yaml-");
|
|
64
|
+
writeFileSync(join(withKey, "forge.yaml"), "topology:\n route_reachability: \"bash scripts/check-ingress.sh\"\n");
|
|
65
|
+
writeFileSync(join(without, "forge.yaml"), "project: x/y\n");
|
|
66
|
+
assert.equal(readEnvConfig(withKey).routeReachability, "bash scripts/check-ingress.sh");
|
|
67
|
+
assert.equal("routeReachability" in readEnvConfig(without), false);
|
|
68
|
+
});
|
|
69
|
+
});
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
|
|
2
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
3
|
+
/**
|
|
4
|
+
* #5063: pick the salvage ref a rerun resumes from. The DCO and exit rescues push to `forge-salvage/<issue>-<sha10>`
|
|
5
|
+
* (#4958, #5047). The suffix is a sha, so the newest ref is chosen by commit time, not by name. When the lane branch
|
|
6
|
+
* already exists on origin, only a ref strictly ahead of it (descending from origin/<branch>) can be resumed without
|
|
7
|
+
* losing either side; a behind or diverged ref is ignored.
|
|
8
|
+
*/
|
|
9
|
+
const ok = async (git, args, cwd) => { try { await git(args, { cwd }); return true; } catch { return false; } };
|
|
10
|
+
|
|
11
|
+
/** @returns {Promise<{ref: string, sha: string} | null>} */
|
|
12
|
+
export async function pickSalvage({ io, cwd, issue, branch, hasBranch }) {
|
|
13
|
+
const out = String(await io.git(["ls-remote", "--heads", "origin", `refs/heads/forge-salvage/${issue}-*`], { cwd }));
|
|
14
|
+
const refs = out.split("\n").map((l) => l.trim().split(/\s+/)).filter((p) => p[1]).map(([sha, ref]) => ({ sha, ref }));
|
|
15
|
+
if (!refs.length) return null;
|
|
16
|
+
if (refs.length > 1 || hasBranch) await io.git(["fetch", "origin", `+refs/heads/forge-salvage/${issue}-*:refs/remotes/origin/forge-salvage/${issue}-*`], { cwd });
|
|
17
|
+
// release #5068 BUG-2/SPEC-1: compare against the live lane branch; a missing or stale local origin/<branch> would ignore a usable salvage or resume one that is behind the remote
|
|
18
|
+
if (hasBranch) await io.git(["fetch", "origin", `+refs/heads/${branch}:refs/remotes/origin/${branch}`], { cwd });
|
|
19
|
+
for (const r of refs) r.ct = Number(String(await io.git(["log", "-1", "--format=%ct", r.sha], { cwd }).catch(() => "")).trim()) || 0;
|
|
20
|
+
refs.sort((a, b) => b.ct - a.ct || (a.ref < b.ref ? 1 : -1));
|
|
21
|
+
for (const r of refs) {
|
|
22
|
+
if (!hasBranch) return r;
|
|
23
|
+
if (await ok(io.git, ["merge-base", "--is-ancestor", `origin/${branch}`, r.sha], cwd) && !(await ok(io.git, ["merge-base", "--is-ancestor", r.sha, `origin/${branch}`], cwd))) return r;
|
|
24
|
+
}
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
|
|
2
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
3
|
+
import { describe, it } from "node:test";
|
|
4
|
+
import assert from "node:assert/strict";
|
|
5
|
+
import { pickSalvage } from "./salvage-resume.mjs";
|
|
6
|
+
|
|
7
|
+
/** refs: [{ sha, name, ct }]; ahead: shas strictly ahead of origin/<branch>; behind: shas that are ancestors of it. */
|
|
8
|
+
const world = ({ refs = [], ahead = [], behind = [] } = {}) => {
|
|
9
|
+
const calls = [];
|
|
10
|
+
const git = async (a) => {
|
|
11
|
+
calls.push(a);
|
|
12
|
+
if (a[0] === "ls-remote") return refs.map((r) => `${r.sha}\trefs/heads/forge-salvage/7-${r.name}`).join("\n");
|
|
13
|
+
if (a[0] === "log") return String(refs.find((r) => r.sha === a[3])?.ct ?? "");
|
|
14
|
+
if (a[0] === "merge-base") {
|
|
15
|
+
if (a[2].startsWith("origin/") ? ahead.includes(a[3]) : behind.includes(a[2])) return "";
|
|
16
|
+
throw new Error("not an ancestor");
|
|
17
|
+
}
|
|
18
|
+
return "";
|
|
19
|
+
};
|
|
20
|
+
return { io: { git }, calls };
|
|
21
|
+
};
|
|
22
|
+
const pick = (w, hasBranch) => pickSalvage({ io: w.io, cwd: ".", issue: 7, branch: "b", hasBranch });
|
|
23
|
+
|
|
24
|
+
describe("pickSalvage (#5063)", () => {
|
|
25
|
+
it("no salvage refs: null", async () => assert.equal(await pick(world(), false), null));
|
|
26
|
+
it("newest by commit time, not by sha suffix", async () => {
|
|
27
|
+
const w = world({ refs: [{ sha: "f", name: "ffff", ct: 10 }, { sha: "a", name: "aaaa", ct: 20 }] });
|
|
28
|
+
assert.equal((await pick(w, false)).ref, "refs/heads/forge-salvage/7-aaaa");
|
|
29
|
+
});
|
|
30
|
+
it("branch exists: a salvage ref strictly ahead of it is picked", async () => {
|
|
31
|
+
const w = world({ refs: [{ sha: "a", name: "aaaa", ct: 20 }], ahead: ["a"] });
|
|
32
|
+
assert.equal((await pick(w, true)).ref, "refs/heads/forge-salvage/7-aaaa");
|
|
33
|
+
});
|
|
34
|
+
it("branch exists: behind or diverged refs are ignored, an older usable one is kept", async () => {
|
|
35
|
+
const w = world({ refs: [{ sha: "n", name: "new", ct: 30 }, { sha: "b", name: "behind", ct: 20 }, { sha: "o", name: "old", ct: 10 }], ahead: ["o"], behind: ["b"] });
|
|
36
|
+
assert.equal((await pick(w, true)).ref, "refs/heads/forge-salvage/7-old");
|
|
37
|
+
assert.equal(await pick(world({ refs: [{ sha: "d", name: "div", ct: 5 }] }), true), null);
|
|
38
|
+
});
|
|
39
|
+
it("branch exists: the lane branch is fetched before the ancestry check (release #5068 BUG-2)", async () => {
|
|
40
|
+
const w = world({ refs: [{ sha: "a", name: "aaaa", ct: 20 }], ahead: ["a"] });
|
|
41
|
+
await pick(w, true);
|
|
42
|
+
const fetchAt = w.calls.findIndex((a) => a[0] === "fetch" && a.includes("+refs/heads/b:refs/remotes/origin/b"));
|
|
43
|
+
const baseAt = w.calls.findIndex((a) => a[0] === "merge-base");
|
|
44
|
+
assert.ok(fetchAt >= 0 && fetchAt < baseAt, JSON.stringify(w.calls));
|
|
45
|
+
});
|
|
46
|
+
it("no lane branch: the lane branch is not fetched", async () => {
|
|
47
|
+
const w = world({ refs: [{ sha: "a", name: "aaaa", ct: 20 }] });
|
|
48
|
+
await pick(w, false);
|
|
49
|
+
assert.ok(!w.calls.some((a) => a[0] === "fetch" && a.includes("+refs/heads/b:refs/remotes/origin/b")));
|
|
50
|
+
});
|
|
51
|
+
});
|
|
@@ -189,6 +189,15 @@ describe("remote adapter (#4254)", () => {
|
|
|
189
189
|
await assert.rejects(() => q.status(h), /ECONNRESET/); // #4672: a transport error propagates; providerLanes retries it for the unreachable window
|
|
190
190
|
});
|
|
191
191
|
|
|
192
|
+
it("#5095 httpTransport: a 500 is transient (polled with backoff) and a 5xx carries Retry-After; only 429/503 are retryable", async () => {
|
|
193
|
+
const reply5xx = (status, ra) => async () => ({ status, ok: false, text: async () => "", headers: { get: (h) => (h === "retry-after" ? ra : null) } });
|
|
194
|
+
const t = httpTransport({ endpoint: "https://pool.example", fetchFn: reply5xx(500, "7") });
|
|
195
|
+
await assert.rejects(t.launch({ id: "x" }), (e) => e.status === 500 && e.transient === true && e.retryable !== true && e.retryInSeconds === 7);
|
|
196
|
+
await assert.rejects(t.status("x"), (e) => e.transient === true);
|
|
197
|
+
const t504 = httpTransport({ endpoint: "https://pool.example", fetchFn: reply5xx(504, null) });
|
|
198
|
+
await assert.rejects(t504.launch({ id: "x" }), (e) => e.status === 504 && e.transient === true && e.retryable !== true && e.retryInSeconds === undefined);
|
|
199
|
+
});
|
|
200
|
+
|
|
192
201
|
it("#5053 httpTransport: a 401 on status is transient and names its hop; a launch 401 is not transient", async () => {
|
|
193
202
|
const reply = (status, body) => ({ status, ok: status < 300, text: async () => JSON.stringify(body), headers: { get: () => null } });
|
|
194
203
|
const t = httpTransport({ endpoint: "https://pool.example", token: TOKEN, fetchFn: async () => reply(401, { error: "unauthorized", hop: "agent", agent: "a1", reason: "agent auth: router token rejected by agent" }) });
|
|
@@ -68,8 +68,10 @@ export function httpTransport({ endpoint, token = null, fetchFn = globalThis.fet
|
|
|
68
68
|
e.status = r.status;
|
|
69
69
|
if (hop) e.hop = hop;
|
|
70
70
|
if (reason) e.reason = reason;
|
|
71
|
-
if ([502, 503, 504].includes(r.status)) e.transient = true; // #4672: a router restart or gateway blip, retried by the lane poll
|
|
72
|
-
if (r.status === 429 || r.status === 503)
|
|
71
|
+
if ([500, 502, 503, 504].includes(r.status)) e.transient = true; // #4672: a router restart or gateway blip, retried by the lane poll; #5095: 500 too
|
|
72
|
+
if (r.status === 429 || r.status === 503) e.retryable = true;
|
|
73
|
+
// #5095: Retry-After is honoured on any 429/5xx, so a launch 5xx (sandbox.transient) backs off as the pool asks
|
|
74
|
+
if (r.status === 429 || r.status >= 500) { const n = Number(r.retryAfter); if (Number.isFinite(n) && n > 0) e.retryInSeconds = n; }
|
|
73
75
|
return e;
|
|
74
76
|
};
|
|
75
77
|
const p = (id) => `/sandboxes/${encodeURIComponent(id)}`;
|
package/bin/lean/verify.mjs
CHANGED
|
@@ -68,7 +68,8 @@ export function parseVerify(sec = {}) {
|
|
|
68
68
|
if (ext.length > 1 && cmd) guardCommand[ext] = cmd;
|
|
69
69
|
}
|
|
70
70
|
}
|
|
71
|
-
|
|
71
|
+
const precheckBlock = str(s.precheck_block).toLowerCase() === "true"; // #5079: a still-failing declared check stops the lane (ci.red)
|
|
72
|
+
return { mode, workflow, pathsInput, skipPaths, maxRounds, precheckTimeoutSeconds, precheckTotalSeconds, checkRun, checks, postMerge, flaky, duplicateExports, invariants, ...(precheckBlock ? { precheckBlock } : {}), ...(Object.keys(guardCommand).length ? { guardCommand } : {}), e2e: parseE2e(s) };
|
|
72
73
|
}
|
|
73
74
|
|
|
74
75
|
const globRe = (g) => new RegExp(`^${g.split("**").map((p) => p.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, "[^/]*").replace(/\?/g, "[^/]")).join(".*")}$`);
|
package/bin/review-staging.mjs
CHANGED
|
@@ -211,6 +211,19 @@ async function headSha(io, c, pr) {
|
|
|
211
211
|
return head;
|
|
212
212
|
}
|
|
213
213
|
|
|
214
|
+
/** A gh write retried on a transient failure (FORGE_GH_RETRY_ATTEMPTS, default 6; FORGE_GH_RETRY_SLEEP seconds, default 2, doubling to 16), as gh_retry in the scripts. */
|
|
215
|
+
async function retryGh(io, args) {
|
|
216
|
+
const max = Number(io.env.FORGE_GH_RETRY_ATTEMPTS) > 0 ? Math.floor(Number(io.env.FORGE_GH_RETRY_ATTEMPTS)) : 6;
|
|
217
|
+
const base = /^\d+$/.test(io.env.FORGE_GH_RETRY_SLEEP ?? "") ? Number(io.env.FORGE_GH_RETRY_SLEEP) : 2;
|
|
218
|
+
for (let n = 1; ; n++) {
|
|
219
|
+
try { return await io.gh(args); } catch (e) {
|
|
220
|
+
if (n >= max) throw e;
|
|
221
|
+
const w = Math.min(base * 2 ** (n - 1), 16);
|
|
222
|
+
if (w > 0) await new Promise((r) => setTimeout(r, w * 1000));
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
214
227
|
async function main(argv, io) {
|
|
215
228
|
const a = parseArgs(argv);
|
|
216
229
|
if (a.mode === "finalize") return finalize(a, io);
|
|
@@ -241,7 +254,7 @@ async function main(argv, io) {
|
|
|
241
254
|
} catch { /* unreadable: treated as a first pass */ }
|
|
242
255
|
const priorOpen = priorRoutes > 0 ? await priorFindings(io, c, pr, "open") : [];
|
|
243
256
|
const priorResolved = priorRoutes > 0 ? await priorFindings(io, c, pr, "closed") : [];
|
|
244
|
-
await io
|
|
257
|
+
await retryGh(io, ["pr", "comment", String(pr), "-R", c.ghRepo, "--body", `<!-- FORGE:REVIEW_ROUTE mode=staging-deploy spec=review-pr-staging.md sha=${sha7} -->`]);
|
|
245
258
|
io.writeFile(join(sd, `staging_pr_number.${runKey(io.env)}`), `${pr}\n`);
|
|
246
259
|
|
|
247
260
|
// 0A: bundle PR set, then the open review-finding gate
|
|
@@ -365,6 +378,10 @@ async function finalize(a, io) {
|
|
|
365
378
|
const { c, sd, env, pr, head } = await postContext(a, io);
|
|
366
379
|
const common = ["--repo", c.ghRepo, "--pr", String(pr), "--sd", sd, "--head", head];
|
|
367
380
|
|
|
381
|
+
// The panel reviewed the head recorded at dispatch: a moved head is a refusal to --delta, never a degraded panel (no label, no gate failure).
|
|
382
|
+
const reviewed = readJson(io, join(sd, `review-ctx-${pr}.json`))?.pr?.review_sha;
|
|
383
|
+
if (SHA_RE.test(reviewed ?? "") && reviewed !== head) throw new Stop(1, [`REVIEW_RESULT: status: BLOCKED, blocker: head moved since the review (${reviewed.slice(0, 7)} -> ${head.slice(0, 7)}): run forgedock review-staging ${pr} --delta ${reviewed}`, BLOCK_DEPLOY]);
|
|
384
|
+
|
|
368
385
|
// Incomplete-panel guard + 6.75 delivery
|
|
369
386
|
const d = await bash(io, "review-staging-post.sh", ["delivery", ...common], env);
|
|
370
387
|
relay(io, d);
|
|
@@ -424,7 +441,7 @@ async function finalize(a, io) {
|
|
|
424
441
|
|
|
425
442
|
const findings = readJson(io, join(outDir, "findings.json"))?.findings ?? [];
|
|
426
443
|
// A finding whose own filed issue is already closed (file-prepare: skip-resolved, delta pass) is resolved: it never blocks (#4569).
|
|
427
|
-
const resolvedBy = new Map(worklist.items.filter((i) => i.action === "skip-resolved").map((i) => [i.id, i.dedup_of]));
|
|
444
|
+
const resolvedBy = new Map(worklist.items.filter((i) => i.action === "skip-resolved" || i.action === "skip-fixed-in-delta").map((i) => [i.id, i.dedup_of]));
|
|
428
445
|
const resolved = findings.filter((f) => resolvedBy.has(f.id) && upper(f.confidence) === "CONFIRMED" && ["CRITICAL", "HIGH"].includes(upper(f.severity)))
|
|
429
446
|
.map((f) => ({ id: f.id, severity: upper(f.severity), title: f.title ?? "", issue: resolvedBy.get(f.id) }));
|
|
430
447
|
const confirmed = (sev) => findings.filter((f) => !resolvedBy.has(f.id) && upper(f.confidence) === "CONFIRMED" && upper(f.severity) === sev).length;
|
|
@@ -534,7 +551,7 @@ async function summary(a, io) {
|
|
|
534
551
|
`- findings_kept: ${n.findings_kept ?? 0}; dropped_stale: ${n.findings_dropped_stale ?? 0}; pre-existing: ${n.findings_preexisting ?? 0}; notes_demoted_no_scenario: ${n.notes_demoted_no_scenario ?? 0}`,
|
|
535
552
|
`- findings_filed: ${n.findings_filed ?? 0}${filed.length ? ` (${filed.join(", ")})` : ""}; out_of_scope: ${n.out_of_scope ?? 0}`,
|
|
536
553
|
`- notes_fixed: 0; notes_listed: ${n.notes_listed ?? 0}; notes_dropped: ${n.notes_dropped ?? 0}; notes_capped: ${n.notes_capped ?? 0}; notes_reflag: ${n.notes_reflag ?? 0}`,
|
|
537
|
-
`- resolved (excluded from the verdict): ${(st.resolved ?? []).length ? st.resolved.map((r) => `${r.severity} ${r.id} ${r.title} (closed #${r.issue})`.replace(/\s+/g, " ")).join("; ") : "(none)"}`,
|
|
554
|
+
`- resolved (excluded from the verdict): ${(st.resolved ?? []).length ? st.resolved.map((r) => `${r.severity} ${r.id} ${r.title} (${r.issue ? `closed #${r.issue}` : "fixed in delta"})`.replace(/\s+/g, " ")).join("; ") : "(none)"}`,
|
|
538
555
|
`- deduped: ${deduped.length ? deduped.join("; ") : "(none)"}`,
|
|
539
556
|
...(st.delta?.from ? [`- delta pass: ${String(st.delta.from).slice(0, 7)}..${head.slice(0, 7)} (${(st.delta.files ?? []).length} file(s)); earlier pass at ${st.delta.from} credited (its findings re-read by extract)`] : []),
|
|
540
557
|
"",
|
package/commands/deploy-info.md
CHANGED
|
@@ -57,13 +57,13 @@ else
|
|
|
57
57
|
OPEN_FINDINGS_SUMMARY=""
|
|
58
58
|
for pr_num in $ALL_PR_NUMBERS; do
|
|
59
59
|
# Anchored post-filter (same as review-pr-staging.md): the search is a coarse candidate fetch only
|
|
60
|
-
FINDING_PR_FILTER='[.[] | select((.title | endswith("PR #" + $n + ")")) or ((.body // "") | test("(^|\\n)\\*\\*Source\\*\\*: PR #" + $n + "([^0-9]|$)")))] | .[] | " - #\(.number): \(.title)"' # FINDING_PR_FILTER
|
|
60
|
+
FINDING_PR_FILTER='[.[] | select(([.labels[]?.name] | (index("blocking") != null) or (index("priority:P0") != null) or (index("priority:P1") != null) or (map(select(startswith("priority:"))) | length == 0))) | select((.title | endswith("PR #" + $n + ")")) or ((.body // "") | test("(^|\\n)\\*\\*Source\\*\\*: PR #" + $n + "([^0-9]|$)")))] | .[] | " - #\(.number): \(.title)"' # FINDING_PR_FILTER
|
|
61
61
|
OPEN_FINDINGS=$(gh issue list -R {GH_REPO} \
|
|
62
62
|
--label "review-finding" \
|
|
63
63
|
--state open \
|
|
64
64
|
--search "PR #${pr_num}" \
|
|
65
65
|
--limit 20 \
|
|
66
|
-
--json number,title,body 2>/dev/null \
|
|
66
|
+
--json number,title,body,labels 2>/dev/null \
|
|
67
67
|
| jq -r --arg n "${pr_num}" "$FINDING_PR_FILTER" 2>/dev/null) # uses FINDING_PR_FILTER
|
|
68
68
|
|
|
69
69
|
if [ -n "$OPEN_FINDINGS" ]; then
|
|
@@ -307,13 +307,13 @@ if [ -z "$TRAIN_PR_NUMBER" ]; then
|
|
|
307
307
|
else
|
|
308
308
|
# Step 2: Check whether the train is held (has blocking findings)
|
|
309
309
|
# Anchored post-filter (same as review-pr-staging.md): the search is a coarse candidate fetch only
|
|
310
|
-
FINDING_PR_FILTER='[.[] | select((.title | endswith("PR #" + $n + ")")) or ((.body // "") | test("(^|\\n)\\*\\*Source\\*\\*: PR #" + $n + "([^0-9]|$)")))] | .[] | " - #\(.number): \(.title)"' # FINDING_PR_FILTER
|
|
310
|
+
FINDING_PR_FILTER='[.[] | select(([.labels[]?.name] | (index("blocking") != null) or (index("priority:P0") != null) or (index("priority:P1") != null) or (map(select(startswith("priority:"))) | length == 0))) | select((.title | endswith("PR #" + $n + ")")) or ((.body // "") | test("(^|\\n)\\*\\*Source\\*\\*: PR #" + $n + "([^0-9]|$)")))] | .[] | " - #\(.number): \(.title)"' # FINDING_PR_FILTER
|
|
311
311
|
HELD_BY=$(gh issue list {GH_FLAG} \
|
|
312
312
|
--label "review-finding" \
|
|
313
313
|
--state open \
|
|
314
314
|
--search "PR #${TRAIN_PR_NUMBER}" \
|
|
315
315
|
--limit 20 \
|
|
316
|
-
--json number,title,body 2>/dev/null \
|
|
316
|
+
--json number,title,body,labels 2>/dev/null \
|
|
317
317
|
| jq -r --arg n "${TRAIN_PR_NUMBER}" "$FINDING_PR_FILTER" 2>/dev/null | sed 's/^ - //' || true) # uses FINDING_PR_FILTER
|
|
318
318
|
|
|
319
319
|
if [ -n "$HELD_BY" ]; then
|
package/commands/issue.md
CHANGED
|
@@ -81,7 +81,9 @@ The body MUST contain exactly one marker `<!-- FORGE:BODY-INTEGRITY:<entity>_<ro
|
|
|
81
81
|
|
|
82
82
|
Read the actual code before drafting: the investigator inherits your file references. Map the domain (`forge.yaml → review.context`, the project's `CLAUDE.md`), read files from `origin/{STAGING_BRANCH}` via `git show`, and list every file the change touches: the primary file, its tests, config layers (`.env.example`, every compose override), migrations for schema changes, importers of a changed function.
|
|
83
83
|
|
|
84
|
-
Duplicates: the script's token-overlap dedup is authoritative
|
|
84
|
+
Duplicates: the script's token-overlap dedup is authoritative; also run a semantic search (`gh issue list --state open --search "{key_terms}"`, and `--state closed` for regressions). An open duplicate: tell the user and do not create. A closed one: create, and write "Regression of #N." in the body. If the user explicitly overrides a dedup STOP, re-run with `--exclude <that number>`; agents never do this without the user's authorization.
|
|
85
|
+
|
|
86
|
+
**Prior attempts (MANDATORY for bug/fix)**: `node "$(forge_resolve prior-attempts.mjs)" --repo {GH_REPO} --title "{TITLE}" --body-file "{DRAFT_BODY_FILE}"` searches by subject, not file. Paste its table as `## Prior Attempts`; fill the `?`s.
|
|
85
87
|
|
|
86
88
|
### 3. Draft
|
|
87
89
|
|
|
@@ -108,6 +110,9 @@ Duplicates: the script's token-overlap dedup is authoritative. Before drafting,
|
|
|
108
110
|
## Acceptance Criteria
|
|
109
111
|
- [ ] {specific, testable criterion} [type:api|unit|e2e|manual]
|
|
110
112
|
|
|
113
|
+
## Prior Attempts
|
|
114
|
+
{prior-attempts.mjs table; bug/fix only}
|
|
115
|
+
|
|
111
116
|
## Dependencies
|
|
112
117
|
Depends on #{N} — {reason} (omit when none)
|
|
113
118
|
|
|
@@ -35,6 +35,7 @@ If no frontend context is configured above, derive conventions from the changed
|
|
|
35
35
|
8. **Accessibility**: Interactive elements have proper labels, keyboard navigation works
|
|
36
36
|
9. **Performance**: Large bundles, unnecessary client-side rendering, missing dynamic imports
|
|
37
37
|
10. **Hook provider scope**: For any new `useX()` hook call added to a component, verify the hook is safe to call outside its provider. If `useX` internally calls `useContext(XContext)` and throws when context is undefined (i.e., the hook contains `if (!ctx) throw new Error(...)`), check ALL mount sites of the component across the codebase. If the component is used in both authenticated routes (e.g., `app/dashboard/`) AND public routes (e.g., `app/(public)/`, `app/playground/`) that do not wrap children in the provider, the hook call will crash the public route with a React error boundary cascade. Flag as CONFIRMED HIGH if a public mount site exists without the provider. <!-- Added: forge#381 -->
|
|
38
|
+
11. **Dynamic API in static/ISR tree**: Flag `headers()`, `cookies()`, `connection()` or `searchParams` reads in layouts, `not-found`/`error` boundaries and any module they import when the route tree is static or ISR (`revalidate`, `generateStaticParams`, `dynamic = 'force-static'`). They opt the route out of static rendering or throw at render, turning pages into HTTP 500. Flag as CONFIRMED HIGH when the call is reachable from such a tree. <!-- Added: forge#5077 -->
|
|
38
39
|
|
|
39
40
|
## Post Findings
|
|
40
41
|
```bash
|
|
@@ -25,6 +25,8 @@ You are auditing PR #[PR_NUMBER] for infrastructure and deployment safety in [PR
|
|
|
25
25
|
If no infra context is configured above, derive the deployment model from the changed files: read docker-compose files, GitHub Actions workflows, and entrypoint scripts to understand the deployment pipeline.
|
|
26
26
|
|
|
27
27
|
## What to Check
|
|
28
|
+
|
|
29
|
+
**New HTTP route handler (forge#5087)**: when the diff adds a route handler (Next.js `app/**/route.*`, `pages/api/**`, a FastAPI/Express router), confirm the production ingress delivers this path to this service: read the repo's ingress config (Traefik, nginx, gateway) and cite the rule. An app-level rewrite or fallback is not evidence; a path an ingress prefix rule sends to another service is a HIGH finding.
|
|
28
30
|
1. **Downtime risk**: Will this change cause container restarts during deploy?
|
|
29
31
|
1b. **Database container restart risk** (when `docker-compose*.yml` changes touch the `postgres` or `redis` service — `command:`, `image:`, `volumes:`, `environment:`):
|
|
30
32
|
Changing any of these fields forces container recreation on `docker-compose up`. For stateful services this is **HIGH risk by default** — regardless of how simple the config change appears.
|
|
@@ -379,6 +379,8 @@ Invoke `/test-gate` with the manifest's `BUNDLE_PRS` and the default branch. Sav
|
|
|
379
379
|
>>> INVOKE: Skill(skill="{FORGE_SKILL_PREFIX}test-gate", args="--prs \"<BUNDLE_PRS>\" --base <default branch>")
|
|
380
380
|
```
|
|
381
381
|
|
|
382
|
+
**Route reachability (forge#5087, blocking when declared)**: when `forge.yaml` declares `topology.route_reachability`, list the route paths the bundle adds (`git diff --name-only --diff-filter=A <default>...HEAD`, Next.js `app/**/route.*` / `pages/api/**` mapped to URL paths, route groups removed) and run the command with them as arguments. A non-zero exit is a BLOCK finding naming the path; unset = skip.
|
|
383
|
+
|
|
382
384
|
Do not judge the result yourself. Step 7A reads the output's last `FORGE:TEST_GATE:RESULT=` marker through `scripts/review-staging-post.sh test-gate`. A missing or invalid marker (gate result missing/invalid) is BLOCK, never SKIP (fail closed). The step then applies the override and the posture. It posts `FORGE:GATE_PASS` (PASS) or `FORGE:GATE_FAILURE` (blocking BLOCK, with `RESULT: BLOCK DEPLOY`), and persists the verdict for Phase 8.
|
|
383
385
|
|
|
384
386
|
**Incomplete-panel guard (MANDATORY):** Every reviewer selected in Phases 2–6 is appended to the launched-agent file before it is launched. If any dispatch fails, follow step 4 of Sub-Agent Dispatch Tool Resolution (hard stop). Before triage, step 7A counts unique trusted, current-head `FORGE:REVIEW-AGENT` markers. A marker counts only when all of these hold: the author is trusted per `scripts/trusted-comments.sh`, the body starts with the marker, the `Reviewed-SHA:` line matches the full head SHA, and the findings block is present. All comment pages are read. An unresolvable script or unreadable comments count as 0. If the completed count (`ACTUAL_AGENT_COUNT`) is lower than the selected count (`SELECTED_AGENT_COUNT`), the step labels the PR `review-degraded` and `needs-human`. It posts `FORGE:GATE_FAILURE:TYPE=review-panel-integrity` and stops. Do not create a deploy verdict from partial reviewer output.
|
|
@@ -696,6 +696,8 @@ the index is absent or returns no results for a given file.
|
|
|
696
696
|
```bash
|
|
697
697
|
# Resolve recall CLI path relative to repository root
|
|
698
698
|
RECALL_PATH="${REPO_PATH:-$(git rev-parse --show-toplevel 2>/dev/null)}/bin/recall.mjs"
|
|
699
|
+
# #5085: recall.mjs ships with ForgeDock, not the target repo: also probe the install root
|
|
700
|
+
[ -f "$RECALL_PATH" ] || RECALL_PATH="$(forge_root 2>/dev/null)/bin/recall.mjs"
|
|
699
701
|
LEDGER_AVAILABLE=0
|
|
700
702
|
|
|
701
703
|
if [ -f "$RECALL_PATH" ]; then
|
|
@@ -735,7 +737,11 @@ Query closed issues with `review-finding` label, searching by filename:
|
|
|
735
737
|
|
|
736
738
|
```bash
|
|
737
739
|
if [ "$LEDGER_AVAILABLE" -eq 0 ] || [ -z "$LEDGER_FINDINGS" ]; then
|
|
738
|
-
echo "[context:C1] Forge Ledger
|
|
740
|
+
echo "WARNING: [context:C1] Forge Ledger index absent or empty — falling back to live gh + subject search"
|
|
741
|
+
# #5085: prior attempts by subject terms (codes, symbols, title words), not only by file
|
|
742
|
+
PA_BODY="${TMPDIR:-/tmp}/pa-{NUMBER}.md"; gh issue view {NUMBER} -R {GH_REPO} --json body --jq .body > "$PA_BODY" 2>/dev/null
|
|
743
|
+
node "$(forge_resolve prior-attempts.mjs)" --repo {GH_REPO} --issue {NUMBER} --body-file "$PA_BODY" \
|
|
744
|
+
--title "$(gh issue view {NUMBER} -R {GH_REPO} --json title --jq .title)" || true # → **Prior Attempts** in output
|
|
739
745
|
IFS=' ' read -ra AFFECTED_FILES_ARR <<< "{AFFECTED_FILES}"
|
|
740
746
|
for file in "${AFFECTED_FILES_ARR[@]}"; do
|
|
741
747
|
basename=$(basename "$file" .py)
|
|
@@ -175,7 +175,7 @@ Print these blocks to stdout before Phase 1A begins. During Phase 1B (step 3 —
|
|
|
175
175
|
|
|
176
176
|
**Goal**: Query the Forge Ledger knowledge index for prior cards matching this issue's title terms, affected files, and symbols — **before reading any code**. Inject above-threshold results with explicit delta-verification framing so the investigator builds on prior knowledge rather than re-deriving it.
|
|
177
177
|
|
|
178
|
-
**This phase is non-blocking** — if the index is absent
|
|
178
|
+
**This phase is non-blocking** — if the index is absent or empty, print a WARNING and use the live subject search (Step 1); if it returns no match above threshold, log a single line and proceed to Phase 1A. Never stall the pipeline for recall.
|
|
179
179
|
|
|
180
180
|
**Relationship to Phase 0.5**: Phase 0.5 retrieves structured priors from the Gist-based FORGE:MEMORY_INDEX (coarse keyword scoring over MEMORY_ENTRY lines). Phase 0.6 queries the Forge Ledger index (BM25 over structured knowledge cards, indexed by `build-knowledge-index.mjs`). Both are complementary and both run — neither replaces the other.
|
|
181
181
|
|
|
@@ -183,6 +183,8 @@ Print these blocks to stdout before Phase 1A begins. During Phase 1B (step 3 —
|
|
|
183
183
|
|
|
184
184
|
```bash
|
|
185
185
|
RECALL_PATH="${REPO_PATH:-$(git rev-parse --show-toplevel 2>/dev/null)}/bin/recall.mjs"
|
|
186
|
+
# #5085: recall.mjs ships with ForgeDock, not the target repo: also probe the install root
|
|
187
|
+
[ -f "$RECALL_PATH" ] || RECALL_PATH="${FORGE_ROOT:-$(forge_root 2>/dev/null)}/bin/recall.mjs"
|
|
186
188
|
LEDGER_AVAILABLE=0
|
|
187
189
|
|
|
188
190
|
if [ -f "$RECALL_PATH" ]; then
|
|
@@ -192,8 +194,14 @@ if [ -f "$RECALL_PATH" ]; then
|
|
|
192
194
|
fi
|
|
193
195
|
|
|
194
196
|
if [ "$LEDGER_AVAILABLE" -eq 0 ]; then
|
|
195
|
-
|
|
196
|
-
|
|
197
|
+
WHICH=$([ -f "$RECALL_PATH" ] && echo "index ~/.forge/index empty or missing" || echo "bin/recall.mjs missing")
|
|
198
|
+
echo "WARNING: [recall] Forge Ledger index absent or empty (${WHICH}) — falling back to live subject search"
|
|
199
|
+
# #5085: prior attempts by subject terms (codes, symbols, title words) + repo-wide git log --grep, not only by file
|
|
200
|
+
PA_BODY="${TMPDIR:-/tmp}/pa-{NUMBER}.md"; gh issue view {NUMBER} {GH_FLAG} --json body --jq .body > "$PA_BODY" 2>/dev/null
|
|
201
|
+
PA=$(node "$(forge_resolve prior-attempts.mjs)" --repo {GH_REPO} --issue {NUMBER} --body-file "$PA_BODY" \
|
|
202
|
+
--title "$(gh issue view {NUMBER} {GH_FLAG} --json title --jq .title)" || true)
|
|
203
|
+
case "$PA" in *"| #"*) RECALL_RESULTS="$PA" ;; esac
|
|
204
|
+
# → Skip Steps 2-3; inject RECALL_RESULTS (the ## Prior Attempts table) via Step 4, then Phase 1A
|
|
197
205
|
fi
|
|
198
206
|
```
|
|
199
207
|
|
|
@@ -462,6 +470,10 @@ fi
|
|
|
462
470
|
|
|
463
471
|
**Mission**: Validate whether the issue is real. Assume description is wrong until proven otherwise.
|
|
464
472
|
|
|
473
|
+
### Route-adding fixes: trace the production ingress (forge#5087)
|
|
474
|
+
|
|
475
|
+
When the fix adds or relies on an HTTP route, trace the request from the production ingress inward, using the repo's declared topology (`forge.yaml topology`, ingress/proxy config such as Traefik or nginx), and cite the ingress rule that delivers each path to the service that implements it. "The app config rewrites it" (Next.js rewrites, filesystem-route precedence) is not evidence of production routing: flag it as insufficient. Existing handlers cited as a "working pattern" need the same check.
|
|
476
|
+
|
|
465
477
|
### Resolve target repo and branch
|
|
466
478
|
|
|
467
479
|
The target repo is `{GH_REPO}` (resolved from `forge.yaml → project`). The working directory is `{REPO_PATH}` (resolved from `forge.yaml → paths.root`).
|
package/docs/CONFIG.md
CHANGED
|
@@ -472,7 +472,7 @@ orchestration:
|
|
|
472
472
|
|
|
473
473
|
## `decisions` (OPTIONAL)
|
|
474
474
|
|
|
475
|
-
Decision engine (#4766, see [DECISIONS.md](DECISIONS.md)).
|
|
475
|
+
Decision engine (#4766, see [DECISIONS.md](DECISIONS.md)). The issue-watch stage (#4793) calls `settle` for the open `needs-human` issues a watch selects: it runs when `mode: live` (a repo setting: every watch of the repo runs the stage), or when the watch sets `sweep_needs_human: true`; `policy` and `human_only` are also read by prodwatch (`loadPolicy`). The parser has one nesting level and no arrays, so lists are comma-separated.
|
|
476
476
|
|
|
477
477
|
| Key | Type | Description |
|
|
478
478
|
|---|---|---|
|
|
@@ -718,6 +718,8 @@ issue_watch:
|
|
|
718
718
|
| `parallel` | integer | No | K of the batch in `batch` mode. Default 4. |
|
|
719
719
|
| `include_followups` | string | No | `none` (default) or `defer-only`. |
|
|
720
720
|
| `postdeploy_rollup` | issue number | No | Release issue that collects post-deploy-only follow-ups as checklist lines. |
|
|
721
|
+
| `sweep_needs_human` | boolean | No | Also settle `needs-human` escalations that predate the watch (the backlog). Default false; `forgedock issue-watch run --dry-run` classifies them first and writes nothing. |
|
|
722
|
+
| `max_launches` | integer | No | Total lane launches (claims plus requeues by the decision engine); at N the watch drains. Default: none. Decision-stage issues also count against `max_issues`. |
|
|
721
723
|
| `threshold` | map | release | `{ min_issues, min_score, max_age_days }`; any one fires a release. |
|
|
722
724
|
| `immediate` | list | release | `label:<name>` or `priority:P0..P3` entries that cut a release now with only their theme. |
|
|
723
725
|
| `max_release_issues` | integer | release | Cap on a release set, closure included. Default 40. |
|
|
@@ -728,11 +730,21 @@ issue_watch:
|
|
|
728
730
|
|
|
729
731
|
## `followups` (OPTIONAL)
|
|
730
732
|
|
|
731
|
-
Deferred follow-ups (`scripts/file-deferred-followups.sh`) are
|
|
733
|
+
Deferred follow-ups (`scripts/file-deferred-followups.sh`) are routed by kind (#5078):
|
|
734
|
+
|
|
735
|
+
| Kind | Where it goes |
|
|
736
|
+
|---|---|
|
|
737
|
+
| Post-promotion, production-verify, rollout-promote (`post-deploy`, `owner:orchestrator`, `owner:promote:<flag>`, or an owner why such as "after release", "rollout", "purge") | A checklist line in the repo's ONE open post-deploy rollup (`FORGE:POSTDEPLOY_ROLLUP`, else the lowest open issue labelled `followups.rollup_issue_label`). Never a new issue when one is open. |
|
|
738
|
+
| Read-only production data or capture (bucket `host`, or an owner why naming obs-psql, a `*-monitor` or a read-only prod query) | Class `host`: added to the parent's engineering follow-up of the same run, else one per-parent follow-up. Labels `followup:host` and `host-task`, never `needs-human`. |
|
|
739
|
+
| Truly external (credentials, spend, an account, a third party, a customer) | ONE issue per batch (`FORGE:EXTERNAL_BATCH: <batch id>`) holding every item under a `### #<parent>` heading, labelled `followup:external`, `external-prerequisite` and `needs-human`. |
|
|
740
|
+
| Engineering | The parent's open follow-up when one exists, else one issue per parent with all its items together. `ops` and `verify-now` also keep one issue per parent. |
|
|
741
|
+
|
|
742
|
+
Each issue carries the label `followup:<class>` and inherits the parent's milestone. A requirement may set an optional `class` in the contract. Otherwise `post-deploy` items are `post-deploy` and the rest `engineering`. A parent milestone that has shipped (closed) is replaced by the next open milestone by due date, else `followups.default_milestone`. The batch report lists `follow-ups appended: #N (<class>: k item(s) from #a,#b)` per issue, not "issues filed".
|
|
732
743
|
|
|
733
744
|
```yaml
|
|
734
745
|
followups:
|
|
735
|
-
default_milestone: "Backlog"
|
|
746
|
+
default_milestone: "Backlog" # optional: milestone title used when the parent's milestone has shipped and no later one is open
|
|
747
|
+
rollup_issue_label: post-deploy # optional: a repo's own open issue with this label is the post-deploy rollup when no marked one is open
|
|
736
748
|
```
|
|
737
749
|
|
|
738
750
|
## `rollouts` (OPTIONAL)
|
|
@@ -886,6 +898,7 @@ verify:
|
|
|
886
898
|
guard_command: # optional; per-extension command for the guard gate's base test files
|
|
887
899
|
".py": "scripts/pytest {path}"
|
|
888
900
|
e2e: auto # optional e2e merge gate: auto (default) | always | never
|
|
901
|
+
precheck_block: false # optional (#5079); true: a declared pre-PR check still failing after the fix round (or a round with no commit) ends the lane needs-human ci.red (recoverable) instead of opening the PR with an anomaly
|
|
889
902
|
e2e_label: full-ci # PR label that makes the repo's CI run e2e (label path)
|
|
890
903
|
e2e_checks: "e2e" # check names of the e2e jobs the label path waits on
|
|
891
904
|
# e2e_workflow: e2e.yml # or: a workflow_dispatch workflow that runs e2e (wins over the label)
|
|
@@ -1016,6 +1029,7 @@ execution:
|
|
|
1016
1029
|
| `budget_minutes`, `budget_usd` | number | No | The `execution.budget` per batch: once the batch's sandbox minutes / estimated USD (settled lanes plus the running ones' elapsed time) reach the cap, no new remote lane is admitted. Missing, non-numeric or negative means no cap. |
|
|
1017
1030
|
| `day_budget_minutes`, `day_budget_usd` | number | No | The same cap per host UTC day, summed over every batch on the host from the host-day store (`execution-budget.json` in `~/.forge/sched/`, or `FORGEDOCK_EXECUTION_BUDGET_FILE`). A corrupt or unreadable store refuses remote lanes while a day cap is set; remove the file to reset it. |
|
|
1018
1031
|
| `fallback_local` | boolean | No | `true`: a remote-placed lane runs on the host while the provider reports no agent online (`GET {endpoint}/status -> {"online": N}`, the built-in router answers it with `online`, `total` and per-agent `ok`/`paused`), so a batch completes locally with every pool agent offline or asleep (forge#4547). Default `false`: such lanes defer (`sandbox.capacity`). A missing or failing `/status` is unknown and never triggers it. |
|
|
1032
|
+
| `account_rotator` | string | No | `tokenbaton`: the `claude` on PATH is the tokenbaton shim, which runs each call on the logged-in account with the most room (`CLAUDE_CONFIG_DIR=~/.claude-<n>`) (forge#5094). forgedock reads `~/.tokenbaton/current` (the account number) and `~/.tokenbaton/live.<n>` (`<5h used %> <5h reset s> <weekly used %> <weekly reset s> <updated s>`), re-read on every check and never written. An account has headroom when its 5-hour use is under 95% and its weekly use under 100% (a window whose reset has passed counts as 0). While any account has headroom no host-wide usage hold is recorded and admissions continue; they stop only when every account is exhausted and resume when the first one resets. A lane whose call hits a usage limit retries that same call (same session) on the next account at once, at most once per account, before it pauses or defers. `forgedock limits show` lists each account's 5-hour use. Any other value, or unreadable files: off (per-account holds as before). |
|
|
1019
1033
|
| `warm_sandboxes` | integer | No | Warm sandboxes per repo the pool agent keeps ready (checkout at the base ref, dependency cache primed). When `N > 0` the launch spec carries `warm: N` and the lane first calls `POST {endpoint}/sandboxes/claim`; a ready warm sandbox takes the lane (refreshed to the lane's base ref with an incremental fetch before work starts), and when none is ready, the claim fails or the endpoint lacks the route the lane starts cold. Default `0` (off): no claim call and the launch spec is unchanged (forge#4912). |
|
|
1020
1034
|
| `unreachable_window_min` | number | No | Remote providers: how long a lane keeps polling (with backoff, up to 30s) after the provider's transport fails (connection refused/reset, `fetch failed`, timeout, 502/503/504) before it ends `deferred` with `sandbox.unreachable` (forge#4672). Inside the window the lane never ends and the sandbox is never torn down; after it the sandbox is kept and adopted on the next attempt or `--resume`; the engine-error breaker does not count it. `adopt` applies the same window. Default: 5 |
|
|
1021
1035
|
| `on_budget` | string | No | `stop` (default): running lanes finish and the rest end `held` (`execution budget: ...`) for a later `--resume`. `local`: the rest run as detached host lanes. |
|
|
@@ -1341,6 +1355,9 @@ verification:
|
|
|
1341
1355
|
test_runners: # path prefix -> runner command prefix (block-style map)
|
|
1342
1356
|
services/worker: "poetry -C services/worker run"
|
|
1343
1357
|
web: "pnpm --filter web exec"
|
|
1358
|
+
|
|
1359
|
+
topology:
|
|
1360
|
+
route_reachability: "bash scripts/check-ingress.sh" # optional; blocking when declared
|
|
1344
1361
|
```
|
|
1345
1362
|
|
|
1346
1363
|
| Field | Type | Required | Description |
|
|
@@ -1358,7 +1375,9 @@ verification:
|
|
|
1358
1375
|
| `consumer_sweep.mode` | string | No | `advisory` (default): the validate consumer sweep (`scripts/consumer-sweep.sh`, V4.6) records unaddressed referencing sites and never fails. `blocking`: unaddressed sites fail validate with `consumer_unaddressed`, build runs one repair round, and sites still unaddressed are recorded as `CONSUMERS: unresolved` and passed to review as known risk (never `needs-human`). Flip to `blocking` only after `bash scripts/consumer-sweep.sh --eval-history 20` shows a mean of at most 2 flagged sites per merge and none above 10 on your repo |
|
|
1359
1376
|
| `consumer_sweep.budget_seconds` | integer | No | Wall-clock budget for the sweep (default `60`). When it runs out the sweep stops with `BUDGET_EXCEEDED`, reports the partial result, and never fails, in either mode |
|
|
1360
1377
|
| `test_runners` | map (path prefix → runner) | No | Default unset (no runner enforced). For repos whose validate commands need a managed runner (uv, poetry, pnpm workspace). The lean engine checks it at contract freeze (`testRunnerProblems`, `bin/lean/preflight.mjs`): a validate command that touches a configured path prefix without containing that prefix's runner string is reported as a contract problem, instead of failing later as `contract.invalid_command` / `env.tool_missing`. Paths in the command are relative to the runner's cwd. Write it as a block-style map: `parseForgeYaml` does not read inline `{ ... }` maps. |
|
|
1378
|
+
| `coupled_files` | map (glob → glob list) | No | Default unset (#5079). "If file A changes, file B must change": a block map `{when glob: required glob[, glob]}`, e.g. `"infra/guard/bootstrap.sh": "infra/guard/MANIFEST.sha256"`. A key prefixed `added:` fires only for files the lane adds (`"added:scripts/tests/test_*.py": ".github/workflows/ci.yml"`). A lane that changes a `when` file without a matching required file fails precheck with `coupled_file_missing` (`conformance.unimplemented`, builder resumed once). Quote keys that contain `*`, and list alternatives as separate keys: brace globs (`*.{ts,tsx}`) are not expanded. |
|
|
1361
1379
|
| `validate.recipes` | map (regex → replacement) | No | Default unset. A top-level `validate:` block with a block-style `recipes:` map, e.g. `"poetry run pytest": "uv run --with-requirements r.txt pytest"`. Each key is a regex source; the first recipe that matches a frozen validate or `repro_test` command rewrites it at contract freeze (`applyValidateRecipes`), recorded as a decision. Use it for commands whose environment is not installed in lane sandboxes. |
|
|
1380
|
+
| `topology.route_reachability` | string (command) | No | Default unset (no check, no behaviour change). A repo-supplied command that takes the route paths a diff adds (Next.js `app/**/route.*`, `pages/api/**`; URL form, route groups removed) as arguments and exits non-zero when the production ingress does not deliver them to the service that implements them. Runs in the lean lane after validate and in `review-pr-staging`; a non-zero exit blocks (`conformance.unimplemented`, kind `route_unreachable`). Read by `readEnvConfig` (`bin/engine-cli.mjs`), run by `bin/lean/routecheck.mjs`. |
|
|
1362
1381
|
|
|
1363
1382
|
**Commands that use this section**: `quality-gate`, `validate` (V2 runs `verification.commands`; V4.8 runs the local CI gate; health checks and `consumer_sweep`), `work-on`
|
|
1364
1383
|
|
package/docs/DECISIONS.md
CHANGED
|
@@ -1,8 +1,6 @@
|
|
|
1
1
|
# Decision engine (#4766)
|
|
2
2
|
|
|
3
|
-
> **Status:
|
|
4
|
-
> (including `live`), `veto_hours` and `reversal_max` have no effect and no `decision_engine` telemetry is emitted. Only the policy
|
|
5
|
-
> and authority-matrix reads (`loadPolicy`) are used today, by prodwatch and issue-watch. The flow below describes the intended behaviour.
|
|
3
|
+
> **Status: wired (#4793).** The issue-watch stage runs `settle` each round for the open `needs-human` issues that match the watch's selectors.
|
|
6
4
|
|
|
7
5
|
Settles a lane's decision-only `needs-human` escalation against the target repo's own north star instead of parking it.
|
|
8
6
|
Only external actions stay with a human. Code: `bin/lean/decision-engine.mjs`. Config: [`decisions:`](CONFIG.md).
|
|
@@ -30,7 +28,20 @@ Only external actions stay with a human. Code: `bin/lean/decision-engine.mjs`. C
|
|
|
30
28
|
`decision_engine` telemetry ([TELEMETRY.md](TELEMETRY.md)): decided, escalated, vetoed, reversed, needs-human before/after. A reversal rate above
|
|
31
29
|
`reversal_max` turns the engine back to shadow. The batch report lists every decision under `decided`.
|
|
32
30
|
|
|
33
|
-
##
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
31
|
+
## The issue-watch stage
|
|
32
|
+
|
|
33
|
+
`bin/lean/issuewatch-decisions.mjs` runs inside `forgedock issue-watch` (every mode), not as a separate entry point. It is active when `decisions.mode: live`
|
|
34
|
+
or the watch sets `sweep_needs_human: true`. `decisions.mode: live` is a repo setting, so it activates the stage in every watch of the repo (also watches that never opted in); set it only when each watch's selectors are meant to settle `needs-human` issues. Each round it lists open `needs-human` issues of trusted authors that match the watch selectors, takes the newest
|
|
35
|
+
`FORGE:NEEDS_HUMAN` comment (its comment id is the escalation id `settle` is idempotent on) and the typed `terminalCode` from `FORGE:STATE`, and calls `settle`.
|
|
36
|
+
|
|
37
|
+
- **Backlog.** An escalation older than the watch start is only touched by a watch with `sweep_needs_human: true`. `forgedock issue-watch run --dry-run` prints the `classify` result for every escalation (backlog marked) and writes nothing.
|
|
38
|
+
- **Caps.** Each handled issue counts against `max_issues`; each requeue (a live decision or an evidence hand-off) against `max_launches`, which also stops the watch claiming past it.
|
|
39
|
+
- **Status.** `issue-watch status` gains `needs-human decisions: decided N evidence-gathered N folded N external-kept N`.
|
|
40
|
+
- **Evidence.** A block of "missing read-only production data" on a repo whose forge.yaml declares `prod_watch.sources` clears `needs-human` and goes back to a lane with a comment naming the prod monitor capability (placement `prod_monitor`); without a declared source it keeps the label.
|
|
41
|
+
- **Post-deploy only.** Folded into the watch's `postdeploy_rollup` checklist, `needs-human` cleared and the issue closed; without a rollup it is kept.
|
|
42
|
+
- **Shadow.** Only live mode acts: in shadow (the default) a post-deploy-only or evidence escalation posts a `FORGE:DECISION (shadow)` comment and changes nothing (no fold, close, label change or launch).
|
|
43
|
+
- **Relaunch.** A live decision or evidence hand-off clears `needs-human` and launches the issue through the watcher's own lane launch (per-issue watches); batch and release watches re-select it on the next round.
|
|
44
|
+
- **Trust.** Only `FORGE:NEEDS_HUMAN` and `FORGE:STATE` comments from OWNER/MEMBER/COLLABORATOR or the engine's own logins (`ownLogins`; the ForgeDock App posts as CONTRIBUTOR) count, never other CONTRIBUTORs.
|
|
45
|
+
- **Failures.** A judge or gh failure is not recorded as handled: the escalation is retried for up to three rounds. The judge call has a 2 minute timeout, and a round makes at most 20 judge calls even when `max_issues` is unset; the rest wait for the next round.
|
|
46
|
+
- **Veto.** A `forge:decision-veto` label or a `veto` reply inside `veto_hours` reverts a live decision (`revertVeto`) and counts toward the reversal rate that forces shadow.
|
|
47
|
+
- The judge is the only LLM call (`claude --print` in the real io).
|