forgedock 1.46.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.
Files changed (65) hide show
  1. package/bin/engine/outcome-codes.mjs +2 -1
  2. package/bin/engine-cli.mjs +22 -8
  3. package/bin/lean/conformance.e2e.test.mjs +20 -0
  4. package/bin/lean/conformance.mjs +99 -9
  5. package/bin/lean/conformance.test.mjs +121 -4
  6. package/bin/lean/context.mjs +108 -6
  7. package/bin/lean/context.test.mjs +84 -2
  8. package/bin/lean/coupled.mjs +38 -0
  9. package/bin/lean/coupled.test.mjs +57 -0
  10. package/bin/lean/decision-engine.mjs +5 -1
  11. package/bin/lean/decision-engine.test.mjs +21 -0
  12. package/bin/lean/drift.mjs +3 -2
  13. package/bin/lean/drift.test.mjs +23 -1
  14. package/bin/lean/effects.mjs +3 -2
  15. package/bin/lean/execution.mjs +41 -8
  16. package/bin/lean/execution.test.mjs +72 -2
  17. package/bin/lean/integration.mjs +2 -2
  18. package/bin/lean/integration.test.mjs +22 -1
  19. package/bin/lean/lane.mjs +13 -0
  20. package/bin/lean/lane.test.mjs +28 -1
  21. package/bin/lean/lean.mjs +60 -12
  22. package/bin/lean/lean.test.mjs +80 -0
  23. package/bin/lean/limits.mjs +6 -2
  24. package/bin/lean/limits.test.mjs +86 -0
  25. package/bin/lean/milestonesync.mjs +2 -2
  26. package/bin/lean/milestonesync.test.mjs +22 -0
  27. package/bin/lean/orchestrate.mjs +79 -9
  28. package/bin/lean/probe.mjs +4 -2
  29. package/bin/lean/probe.test.mjs +4 -0
  30. package/bin/lean/prompts.mjs +18 -11
  31. package/bin/lean/rotator.mjs +96 -0
  32. package/bin/lean/rotator.test.mjs +143 -0
  33. package/bin/lean/routecheck.mjs +48 -0
  34. package/bin/lean/routecheck.test.mjs +69 -0
  35. package/bin/lean/sandbox-provider.contract.test.mjs +9 -0
  36. package/bin/lean/sandbox-remote.mjs +4 -2
  37. package/bin/lean/verify.mjs +2 -1
  38. package/bin/review-staging.mjs +20 -3
  39. package/commands/deploy-info.md +4 -4
  40. package/commands/issue.md +6 -1
  41. package/commands/review-pr-agents/frontend.md +1 -0
  42. package/commands/review-pr-agents/infra.md +2 -0
  43. package/commands/review-pr-staging.md +2 -0
  44. package/commands/work-on/build/context.md +7 -1
  45. package/commands/work-on/investigate.md +15 -3
  46. package/docs/CONFIG.md +19 -2
  47. package/docs/OUTCOME-CODES.md +2 -1
  48. package/package.json +1 -1
  49. package/scripts/file-deferred-followups.sh +125 -28
  50. package/scripts/file-deferred-followups.test.sh +49 -0
  51. package/scripts/issue-create.sh +7 -0
  52. package/scripts/issue-create.test.sh +13 -0
  53. package/scripts/issue-dedup.sh +5 -4
  54. package/scripts/issue-dedup.test.sh +15 -0
  55. package/scripts/owner-route-parity.test.sh +32 -0
  56. package/scripts/prior-attempts.mjs +72 -0
  57. package/scripts/prior-attempts.test.mjs +52 -0
  58. package/scripts/review-finalize.sh +35 -4
  59. package/scripts/review-finalize.test.sh +10 -0
  60. package/scripts/review-prepare.sh +16 -4
  61. package/scripts/review-prepare.test.sh +8 -0
  62. package/scripts/review-roster.sh +3 -1
  63. package/scripts/review-roster.test.sh +30 -2
  64. package/scripts/review-staging-pre.sh +8 -6
  65. package/scripts/review-staging-pre.test.sh +11 -1
@@ -0,0 +1,96 @@
1
+ // SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
2
+ // SPDX-License-Identifier: AGPL-3.0-or-later
3
+ /**
4
+ * Account rotators (#5094). With `execution.account_rotator: tokenbaton`, the `claude` on PATH is a shim that runs every
5
+ * call on the logged-in account with the most room (`CLAUDE_CONFIG_DIR=~/.claude-<n>`), so an exhausted account is not an
6
+ * exhausted host. This adapter reads tokenbaton's per-account quota state: `~/.tokenbaton/current` (the account number)
7
+ * and `~/.tokenbaton/live.<n>` (`"<5h used %> <5h reset epoch s> <weekly used %> <weekly reset epoch s> <updated s>"`).
8
+ * Every method re-reads the files (no cache), never writes, and fails open: unreadable state behaves as no rotator.
9
+ */
10
+ import { readFileSync, readdirSync } from "node:fs";
11
+ import { homedir } from "node:os";
12
+ import { join } from "node:path";
13
+ import { backendIdentity } from "../engine.mjs";
14
+
15
+ export const ROTATOR_KINDS = ["tokenbaton"];
16
+ /** An account has headroom below this 5-hour use (%), like the computeshare quota-aware picker. */
17
+ export const HEADROOM_PCT = 95;
18
+
19
+ /** One `live.N` line as numbers, or null when malformed. */
20
+ export function parseLive(text) {
21
+ const f = String(text ?? "").trim().split(/\s+/).map(Number);
22
+ if (f.length < 4 || !f.slice(0, 4).every(Number.isFinite)) return null;
23
+ return { fiveHourPct: f[0], fiveHourResetMs: f[1] * 1000, weeklyPct: f[2], weeklyResetMs: f[3] * 1000 };
24
+ }
25
+
26
+ /**
27
+ * Handle on an account rotator, or null (unknown kind, or no readable state).
28
+ * @returns {null | {kind, accounts, current(), headroom(), resumeAt(), accountIdentity(n), currentIdentity(), isAccount(id)}}
29
+ */
30
+ export function openRotator({ kind, home = homedir(), env = process.env, now = () => Date.now(), readFile = (p) => readFileSync(p, "utf8"), listDir = (d) => readdirSync(d) } = {}) {
31
+ if (!ROTATOR_KINDS.includes(kind)) return null;
32
+ const dir = join(home, ".tokenbaton");
33
+ const current = () => { try { const n = Number(String(readFile(join(dir, "current"))).trim()); return Number.isInteger(n) && n > 0 ? n : null; } catch { return null; } };
34
+ const accounts = () => {
35
+ let names = [];
36
+ try { names = listDir(dir); } catch { return []; }
37
+ const out = [];
38
+ for (const name of names) {
39
+ const m = /^live\.(\d+)$/.exec(name);
40
+ if (!m) continue;
41
+ let live = null;
42
+ try { live = parseLive(readFile(join(dir, name))); } catch { live = null; }
43
+ if (live) out.push({ n: Number(m[1]), ...live });
44
+ }
45
+ return out.sort((a, b) => a.n - b.n);
46
+ };
47
+ if (current() === null || !accounts().length) return null;
48
+ const used = (pct, resetMs) => (resetMs <= now() ? 0 : pct);
49
+ const hasRoom = (a) => used(a.fiveHourPct, a.fiveHourResetMs) < HEADROOM_PCT && used(a.weeklyPct, a.weeklyResetMs) < 100;
50
+ const accountIdentity = (n) => backendIdentity({ backend: "cli", env: { ...env, CLAUDE_CONFIG_DIR: join(home, `.claude-${n}`) } });
51
+ return {
52
+ kind,
53
+ /** The accounts with readable `live.N` state, re-read on every access. */
54
+ get accounts() { return accounts(); },
55
+ current,
56
+ /** True when any account has headroom. */
57
+ headroom: () => accounts().some(hasRoom),
58
+ /** Epoch ms at which the first account regains headroom (now when one has it), or null when unknown. */
59
+ resumeAt: () => {
60
+ const t = now(), at = accounts().map((a) => {
61
+ if (hasRoom(a)) return t;
62
+ const r = [];
63
+ if (used(a.fiveHourPct, a.fiveHourResetMs) >= HEADROOM_PCT) r.push(a.fiveHourResetMs);
64
+ if (used(a.weeklyPct, a.weeklyResetMs) >= 100) r.push(a.weeklyResetMs);
65
+ return Math.max(...r);
66
+ }).filter((x) => Number.isFinite(x) && x > 0);
67
+ return at.length ? Math.min(...at) : null;
68
+ },
69
+ accountIdentity,
70
+ /** Identity of the current account; null when `current` is unreadable. */
71
+ currentIdentity: () => { const n = current(); return n === null ? null : accountIdentity(n); },
72
+ /** True when `id` is the identity of one of the rotator's accounts. */
73
+ isAccount: (id) => typeof id === "string" && accounts().some((a) => accountIdentity(a.n) === id),
74
+ };
75
+ }
76
+
77
+ /** True while a usage-limited call should be retried at once on the next account: headroom, and fewer tries than accounts. */
78
+ export function rotatorRetry(rotator, tries) {
79
+ try { return Boolean(rotator) && tries < rotator.accounts.length && rotator.headroom(); } catch { return false; }
80
+ }
81
+
82
+ /**
83
+ * How orchestrate requeues a `rate_limited` lane under a rotator (#5094), or null when the host hold applies (no rotator,
84
+ * no account with headroom, unreadable state). A lane that ran on one of the rotator's accounts (or reported none) is rerun
85
+ * at once, at most once per account; any other credential (a remote sandbox's) waits for its own reset. Neither sets a host hold.
86
+ * @returns {null | {delayMs:number, rotated:boolean}}
87
+ */
88
+ export function quotaRequeue({ rotator, r, prior = null, delayMs }) {
89
+ if (!rotator) return null;
90
+ try {
91
+ if (!rotator.headroom()) return null;
92
+ const id = r?.backendId ?? null, rotations = Number(prior?.rotations) || 0;
93
+ if ((!id || rotator.isAccount(id)) && rotations < rotator.accounts.length) return { delayMs: 0, rotated: true };
94
+ return { delayMs, rotated: false };
95
+ } catch { return null; }
96
+ }
@@ -0,0 +1,143 @@
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 { mkdirSync, writeFileSync } from "node:fs";
6
+ import { join } from "node:path";
7
+ import { tmpDir } from "../tests/_tmp.mjs";
8
+ import { parseLive, openRotator, rotatorRetry, quotaRequeue, ROTATOR_KINDS, HEADROOM_PCT } from "./rotator.mjs";
9
+ import { laneBackendId, openLimits } from "./limits.mjs";
10
+ import { orchestrate, formatTable } from "./orchestrate.mjs";
11
+ import { parseExecution } from "./execution.mjs";
12
+
13
+ const T0 = 1_800_000_000_000; // epoch ms
14
+ const fakeClock = () => { const c = { t: T0 }; return { c, now: () => c.t, sleep: async (ms) => { c.t += ms; } }; };
15
+ /** A tokenbaton home: `accounts` maps n -> [5h %, 5h reset ms, weekly %, weekly reset ms]. */
16
+ const baton = (current, accounts) => {
17
+ const home = tmpDir("baton-"), dir = join(home, ".tokenbaton");
18
+ mkdirSync(dir, { recursive: true });
19
+ writeFileSync(join(dir, "current"), `${current}\n`);
20
+ const write = (n, [p5, r5, pw, rw]) => writeFileSync(join(dir, `live.${n}`), `${p5} ${Math.round(r5 / 1000)} ${pw} ${Math.round(rw / 1000)} ${Math.round(T0 / 1000)}\n`);
21
+ for (const [n, v] of Object.entries(accounts)) write(n, v);
22
+ return { home, write, setCurrent: (n) => writeFileSync(join(dir, "current"), `${n}\n`) };
23
+ };
24
+ const H = 3_600_000;
25
+
26
+ describe("account rotator adapter (#5094)", () => {
27
+ it("parseLive reads the five fields and rejects malformed lines", () => {
28
+ assert.deepEqual(parseLive("42 1800000100 7 1800500000 1800000000"), { fiveHourPct: 42, fiveHourResetMs: 1_800_000_100_000, weeklyPct: 7, weeklyResetMs: 1_800_500_000_000 });
29
+ assert.equal(parseLive(""), null);
30
+ assert.equal(parseLive("12 abc 3 4"), null);
31
+ assert.equal(parseLive("1 2 3"), null);
32
+ });
33
+
34
+ it("openRotator is null for an unknown kind or unreadable state; parseExecution keeps only tokenbaton", () => {
35
+ assert.deepEqual(ROTATOR_KINDS, ["tokenbaton"]);
36
+ assert.equal(openRotator({ kind: undefined }), null);
37
+ assert.equal(openRotator({ kind: "other", home: baton(1, { 1: [0, T0 + H, 0, T0 + H] }).home }), null);
38
+ assert.equal(openRotator({ kind: "tokenbaton", home: tmpDir("empty-") }), null);
39
+ assert.equal(openRotator({ kind: "tokenbaton", home: baton(1, {}).home }), null, "no live.N");
40
+ assert.equal(parseExecution({ account_rotator: "tokenbaton" }).accountRotator, "tokenbaton");
41
+ assert.equal("accountRotator" in parseExecution({ account_rotator: "nope" }), false);
42
+ assert.equal("accountRotator" in parseExecution({}), false);
43
+ });
44
+
45
+ it("headroom: 5h under 95% and weekly under 100%; a passed reset counts as 0; resumeAt is the earliest regain; state is re-read", () => {
46
+ const { c, now } = fakeClock();
47
+ const b = baton(1, { 1: [HEADROOM_PCT, T0 + 2 * H, 10, T0 + 100 * H], 2: [99, T0 + 4 * H, 100, T0 + 3 * H] });
48
+ const rot = openRotator({ kind: "tokenbaton", home: b.home, now });
49
+ assert.equal(rot.headroom(), false);
50
+ assert.equal(rot.resumeAt(), T0 + 2 * H, "account 1 regains at its 5h reset; account 2 needs both resets (4h)");
51
+ assert.deepEqual(rot.accounts.map((a) => a.n), [1, 2]);
52
+ c.t = T0 + 2 * H; // account 1's 5h window reset: counts as 0
53
+ assert.equal(rot.headroom(), true);
54
+ assert.equal(rot.resumeAt(), c.t);
55
+ c.t = T0;
56
+ b.write(2, [10, T0 + H, 10, T0 + 100 * H]); // tokenbaton refreshed live.2
57
+ assert.equal(rot.headroom(), true, "no cache: the new file is read");
58
+ assert.equal(rot.current(), 1);
59
+ b.setCurrent(2);
60
+ assert.equal(rot.current(), 2);
61
+ });
62
+
63
+ it("account identity is the lane identity on ~/.claude-<n>; isAccount; laneBackendId follows the rotator's current account", () => {
64
+ const b = baton(1, { 1: [0, T0 + H, 0, T0 + H], 2: [0, T0 + H, 0, T0 + H] });
65
+ const rot = openRotator({ kind: "tokenbaton", home: b.home, env: {}, now: () => T0 });
66
+ assert.equal(rot.accountIdentity(2), laneBackendId({ CLAUDE_CONFIG_DIR: join(b.home, ".claude-2") }));
67
+ assert.equal(rot.isAccount(rot.accountIdentity(1)), true);
68
+ assert.equal(rot.isAccount("remote-credential"), false);
69
+ assert.equal(laneBackendId({}, rot), rot.accountIdentity(1));
70
+ b.setCurrent(2);
71
+ assert.equal(laneBackendId({}, rot), rot.accountIdentity(2), "#R13: the identity changes when tokenbaton rotates");
72
+ assert.equal(laneBackendId({ CLAUDE_CONFIG_DIR: "/x" }, null), laneBackendId({ CLAUDE_CONFIG_DIR: "/x" }), "no rotator: unchanged");
73
+ assert.equal(laneBackendId({ CLAUDE_CONFIG_DIR: "/x" }, { currentIdentity: () => { throw new Error("x"); } }), laneBackendId({ CLAUDE_CONFIG_DIR: "/x" }), "fail-open");
74
+ });
75
+
76
+ it("rotatorRetry and quotaRequeue: rotation is capped at the account count; a remote credential waits for its own reset", () => {
77
+ const rot = { accounts: [{ n: 1 }, { n: 2 }], headroom: () => true, isAccount: (id) => id === "acct-1" };
78
+ assert.equal(rotatorRetry(rot, 0), true);
79
+ assert.equal(rotatorRetry(rot, 2), false);
80
+ assert.equal(rotatorRetry(null, 0), false);
81
+ assert.equal(rotatorRetry({ ...rot, headroom: () => false }, 0), false);
82
+ assert.deepEqual(quotaRequeue({ rotator: rot, r: { backendId: "acct-1" }, delayMs: 600_000 }), { delayMs: 0, rotated: true });
83
+ assert.deepEqual(quotaRequeue({ rotator: rot, r: {}, delayMs: 600_000 }), { delayMs: 0, rotated: true });
84
+ assert.deepEqual(quotaRequeue({ rotator: rot, r: { backendId: "acct-1" }, prior: { rotations: 2 }, delayMs: 600_000 }), { delayMs: 600_000, rotated: false });
85
+ assert.deepEqual(quotaRequeue({ rotator: rot, r: { backendId: "sandbox-cred" }, delayMs: 600_000 }), { delayMs: 600_000, rotated: false }, "R6: remote credential");
86
+ assert.equal(quotaRequeue({ rotator: { ...rot, headroom: () => false }, r: {}, delayMs: 1 }), null);
87
+ assert.equal(quotaRequeue({ rotator: null, r: {}, delayMs: 1 }), null);
88
+ });
89
+ });
90
+
91
+ describe("orchestrate with an account rotator (#5094)", () => {
92
+ const limited = (id, until, cost = 0.5, minutes = 3) => ({ terminalReason: "deferred", code: "rate_limited", resetAtEpochMs: until, backendId: id, cost_usd: cost, minutes });
93
+
94
+ it("every account exhausted: admissions stop, and resume by themselves when the first account resets", async () => {
95
+ const { now, sleep } = fakeClock();
96
+ const rot = { accounts: [{ n: 1 }, { n: 2 }], headroom: () => now() >= T0 + 2 * H, resumeAt: () => T0 + 2 * H, isAccount: () => true, currentIdentity: () => "acct-1" };
97
+ const starts = [];
98
+ const rows = await orchestrate({ issues: [1], now, sleep, readBody: async () => "", rotator: rot, limits: openLimits(join(tmpDir("lim-"), "l.json"), { now }), identity: () => "acct-1",
99
+ runOne: async () => { starts.push(now()); return { terminalReason: "merged" }; } });
100
+ assert.deepEqual(rows.map((r) => r.outcome), ["merged"]);
101
+ assert.equal(starts[0], T0 + 2 * H, "held until the rotator's resumeAt, then admitted without operator action");
102
+ });
103
+
104
+ it("report: the requeue row is 'paused (quota)' with its resume time, never 'deferred', and the paused cost is counted once", async () => {
105
+ const { now, sleep } = fakeClock();
106
+ const updates = [];
107
+ let runs = 0;
108
+ const rows = await orchestrate({ issues: [1], now, sleep, readBody: async () => "", limits: openLimits(join(tmpDir("lim-"), "l.json"), { now }), identity: () => "A",
109
+ onUpdate: (u) => updates.push(u), runOne: async () => (++runs === 1 ? limited("A", T0 + H) : { terminalReason: "merged", cost_usd: 1.25, minutes: 4 }) });
110
+ const paused = updates.find((u) => u.issue === 1 && u.outcome === "paused");
111
+ assert.ok(paused, `paused row: ${JSON.stringify(updates.map((u) => u.outcome))}`);
112
+ assert.equal(updates.some((u) => u.outcome === "deferred"), false);
113
+ assert.equal(paused.quota.resumeAt, new Date(T0 + H).toISOString());
114
+ assert.match(paused.detail, /^paused \(quota\) until /);
115
+ assert.equal(rows[0].outcome, "merged");
116
+ assert.equal(rows[0].cost_usd, 1.75, "paused attempt + rerun, once");
117
+ assert.match(formatTable([{ ...paused }]), /paused \(quota\) until \d{4}-/);
118
+ });
119
+
120
+ it("a batch that ends while quota-paused leaves a resumable 'held' row with the quota resume time and the paused cost", async () => {
121
+ const { now, sleep } = fakeClock();
122
+ // #2's engine-errors trip the breaker while #1 waits for its quota: the batch ends with #1 pending.
123
+ const rows = await orchestrate({ issues: [2, 1], parallel: 1, now, sleep, readBody: async () => "", breaker: { errors: 2, window_min: 600 },
124
+ limits: openLimits(join(tmpDir("lim-"), "l.json"), { now }), identity: () => "A", runOne: async (n) => { if (n === 2) throw new Error("boom"); return limited("A", T0 + H); } });
125
+ const row = rows.find((r) => r.issue === 1);
126
+ assert.equal(row.outcome, "held", "P4: held stays the resumable marker");
127
+ assert.equal(row.quota?.resumeAt, new Date(T0 + H).toISOString());
128
+ assert.equal(row.cost_usd, 0.5);
129
+ assert.equal(row.detail, `paused (quota) until ${new Date(T0 + H).toISOString()}`);
130
+ assert.match(formatTable(rows), /paused \(quota\) until /);
131
+ });
132
+
133
+ it("a remote lane limited on its own credential waits for its own reset and sets no host hold while the rotator has headroom", async () => {
134
+ const { now, sleep } = fakeClock();
135
+ const limits = openLimits(join(tmpDir("lim-"), "l.json"), { now });
136
+ const rot = { accounts: [{ n: 1 }], headroom: () => true, resumeAt: () => now(), isAccount: (id) => id === "acct-1", currentIdentity: () => "acct-1" };
137
+ const starts = [];
138
+ await orchestrate({ issues: [1], now, sleep, readBody: async () => "", rotator: rot, limits, identity: () => "acct-1",
139
+ runOne: async () => { starts.push(now()); return starts.length === 1 ? limited("sandbox-cred", T0 + H) : { terminalReason: "merged" }; } });
140
+ assert.deepEqual(limits.list(), []);
141
+ assert.equal(starts[1], T0 + H);
142
+ });
143
+ });
@@ -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
+ });
@@ -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) { e.retryable = true; const n = Number(r.retryAfter); if (Number.isFinite(n) && n > 0) e.retryInSeconds = n; }
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)}`;
@@ -68,7 +68,8 @@ export function parseVerify(sec = {}) {
68
68
  if (ext.length > 1 && cmd) guardCommand[ext] = cmd;
69
69
  }
70
70
  }
71
- return { mode, workflow, pathsInput, skipPaths, maxRounds, precheckTimeoutSeconds, precheckTotalSeconds, checkRun, checks, postMerge, flaky, duplicateExports, invariants, ...(Object.keys(guardCommand).length ? { guardCommand } : {}), e2e: parseE2e(s) };
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(".*")}$`);
@@ -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.gh(["pr", "comment", String(pr), "-R", c.ghRepo, "--body", `<!-- FORGE:REVIEW_ROUTE mode=staging-deploy spec=review-pr-staging.md sha=${sha7} -->`]);
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
  "",
@@ -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. Before drafting, 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.
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 unavailable or empty — falling back to live gh search"
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, empty, or returns no match above threshold, log a single line and proceed to Phase 1A. Never stall the pipeline for recall.
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
- echo "[recall] Forge Ledger index absent or empty — skipping Phase 0.6, proceeding to Phase 1A"
196
- # → Continue to Phase 1A
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`).