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.
- 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 +5 -1
- 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/lane.mjs +13 -0
- package/bin/lean/lane.test.mjs +28 -1
- package/bin/lean/lean.mjs +60 -12
- package/bin/lean/lean.test.mjs +80 -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/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 +19 -2
- package/docs/OUTCOME-CODES.md +2 -1
- 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,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)
|
|
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`).
|