forgedock 1.43.1 → 1.45.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 -2
- package/bin/engine/telemetry-seam.mjs +1 -1
- package/bin/engine-cli.mjs +50 -11
- package/bin/forgedock.mjs +50 -24
- package/bin/gc.mjs +3 -1
- package/bin/hooks/pre-tool-use.mjs +54 -3
- package/bin/lean/autochildren.mjs +13 -5
- package/bin/lean/autochildren.test.mjs +36 -18
- package/bin/lean/batch.test.mjs +46 -0
- package/bin/lean/conformance.mjs +107 -16
- package/bin/lean/conformance.test.mjs +126 -13
- package/bin/lean/decision-engine.mjs +1 -1
- package/bin/lean/decision-engine.test.mjs +11 -0
- package/bin/lean/execution.mjs +7 -4
- package/bin/lean/execution.test.mjs +21 -0
- package/bin/lean/gatewatch.test.mjs +5 -5
- package/bin/lean/lane.mjs +22 -4
- package/bin/lean/lane.test.mjs +33 -1
- package/bin/lean/lean.mjs +49 -16
- package/bin/lean/lean.test.mjs +66 -0
- package/bin/lean/orchestrate-blocked.test.mjs +35 -0
- package/bin/lean/orchestrate-children.test.mjs +16 -4
- package/bin/lean/orchestrate.mjs +61 -13
- package/bin/lean/pmap.mjs +17 -0
- package/bin/lean/pmap.test.mjs +27 -0
- package/bin/lean/postdeploy-check.test.mjs +3 -1
- package/bin/lean/preflight.mjs +8 -3
- package/bin/lean/preflight.test.mjs +13 -1
- package/bin/lean/prodwatch-triage.mjs +6 -0
- package/bin/lean/prodwatch-triage.test.mjs +14 -1
- package/bin/lean/prodwatch.mjs +1 -3
- package/bin/lean/prompts.mjs +1 -1
- package/bin/lean/recovery.test.mjs +21 -4
- package/bin/lean/retry.mjs +11 -0
- package/bin/lean/retry.test.mjs +16 -0
- package/bin/lean/sandbox-provider.contract.test.mjs +15 -4
- package/bin/lean/sandbox-remote.mjs +19 -7
- package/bin/lean/sched.mjs +25 -2
- package/bin/lean/sched.test.mjs +58 -1
- package/bin/lean/selftest-live.mjs +56 -31
- package/bin/lean/selftest-live.test.mjs +48 -6
- package/bin/lean/selftest-nightly.mjs +7 -3
- package/bin/lean/selftest-nightly.test.mjs +24 -2
- package/bin/lean/selftest-static.mjs +18 -11
- package/bin/lean/selftest-static.test.mjs +8 -0
- package/bin/lean/signoff.mjs +16 -3
- package/bin/lean/signoff.test.mjs +52 -1
- package/bin/pool/agent.mjs +3 -3
- package/bin/pool/cli.mjs +29 -1
- package/bin/pool/router.mjs +17 -6
- package/commands/orchestrate.md +5 -2
- package/docs/CONFIG.md +10 -4
- package/docs/DECISIONS.md +5 -1
- package/docs/OUTCOME-CODES.md +2 -2
- package/docs/TELEMETRY.md +1 -1
- package/package.json +1 -1
- package/scripts/plan-validate-live.mjs +15 -8
package/bin/lean/signoff.mjs
CHANGED
|
@@ -11,6 +11,17 @@ const SEP = "\x1f", END = "\x1e";
|
|
|
11
11
|
const STASH_REF = "refs/forgedock/dco-stash";
|
|
12
12
|
const PLACEHOLDER_RE = /@localhost$/i;
|
|
13
13
|
|
|
14
|
+
/**
|
|
15
|
+
* #5047: where a lane's commits go when the sign-off rewrite failed. Never the lane branch: ensureSignedOff excludes everything on
|
|
16
|
+
* origin/<branch>, so unsigned commits published there could never be rewritten. A salvage branch is outside that range.
|
|
17
|
+
*/
|
|
18
|
+
export const salvageBranch = (issue, sha) => `forge-salvage/${issue}-${String(sha).slice(0, 10)}`;
|
|
19
|
+
|
|
20
|
+
/** #5047: a lane resumed from a salvage branch has no published lane branch: its tracking ref must not exclude the commits from the sign-off range. */
|
|
21
|
+
export async function dropSalvageTracking({ git, branch }) {
|
|
22
|
+
try { await git(["update-ref", "-d", `refs/remotes/origin/${branch}`]); return true; } catch { return false; }
|
|
23
|
+
}
|
|
24
|
+
|
|
14
25
|
/** True when the commit must be rewritten: placeholder author or committer, or no sign-off naming its author. */
|
|
15
26
|
export function needsSignoff({ authorEmail, committerEmail, signedOff }) {
|
|
16
27
|
if (PLACEHOLDER_RE.test(authorEmail ?? "") || PLACEHOLDER_RE.test(committerEmail ?? "")) return true;
|
|
@@ -61,16 +72,18 @@ export async function ensureSignedOff({ git, lane, branch, env = process.env })
|
|
|
61
72
|
}
|
|
62
73
|
const fenv = { FD_BAD: bad.map((c) => c.sha).join(" "), FD_NAME: name, FD_EMAIL: email, FILTER_BRANCH_SQUELCH_WARNING: "1" };
|
|
63
74
|
// filter-branch refuses a dirty tree (lockfile churn, build output): park tracked changes on a private ref, never the shared stash stack
|
|
64
|
-
const
|
|
75
|
+
const porcelain = await ok(["status", "--porcelain", "--untracked-files=no"]);
|
|
76
|
+
const dirty = porcelain ? porcelain.split("\n").map((l) => l.replace(/^\s*\S+\s+/, "")).slice(0, 20) : []; // #4905: the dirty paths are reported so the source of the dirt (validate/format output) is visible
|
|
77
|
+
const saved = porcelain ? await ok(["stash", "create"]) : "";
|
|
65
78
|
if (saved) { await git(["update-ref", STASH_REF, saved]); await git(["reset", "-q", "--hard", "HEAD"]); }
|
|
66
79
|
let failure = null;
|
|
67
80
|
try { await git(["filter-branch", "-f", "--env-filter", ENV_FILTER, "--msg-filter", MSG_FILTER, "--", "HEAD", ...exclude], { env: fenv }); }
|
|
68
|
-
catch (e) { failure = `could not rewrite ${bad.length} unpushed commit(s) for DCO: ${String(e?.stderr ?? e?.message ?? e).slice(0, 300)}`; }
|
|
81
|
+
catch (e) { failure = `could not rewrite ${bad.length} unpushed commit(s) for DCO: ${String(e?.stderr ?? e?.message ?? e).slice(0, 300)}${dirty.length ? ` (dirty tracked files: ${dirty.join(", ")})` : ""}`; }
|
|
69
82
|
if (saved) {
|
|
70
83
|
try { await git(["stash", "apply", saved]); await ok(["update-ref", "-d", STASH_REF]); }
|
|
71
84
|
catch { failure = `${failure ? `${failure}; ` : ""}could not restore the lane's uncommitted changes after the DCO rewrite; they are kept as ${saved} (${STASH_REF})`; }
|
|
72
85
|
}
|
|
73
86
|
if (failure) throw Object.assign(new Error(failure), { noIdentity: false, engineCode: "engine.dco_rewrite" });
|
|
74
87
|
await ok(["for-each-ref", "--format=delete %(refname)", "refs/original/"]).then(async (l) => { for (const r of l.split("\n").map((x) => x.replace(/^delete /, "")).filter(Boolean)) await ok(["update-ref", "-d", r]); });
|
|
75
|
-
return { rewritten: bad.length };
|
|
88
|
+
return { rewritten: bad.length, ...(dirty.length ? { dirty } : {}) };
|
|
76
89
|
}
|
|
@@ -7,7 +7,7 @@ import { execFileSync } from "node:child_process";
|
|
|
7
7
|
import { readFileSync, writeFileSync } from "node:fs";
|
|
8
8
|
import { join } from "node:path";
|
|
9
9
|
import { tmpDir } from "../tests/_tmp.mjs";
|
|
10
|
-
import { ensureSignedOff, needsSignoff, offendersOf } from "./signoff.mjs";
|
|
10
|
+
import { ensureSignedOff, needsSignoff, offendersOf, salvageBranch, dropSalvageTracking } from "./signoff.mjs";
|
|
11
11
|
|
|
12
12
|
const CLEAN = Object.fromEntries(Object.entries(process.env).filter(([k]) => !/^GIT_(AUTHOR|COMMITTER)_/.test(k)));
|
|
13
13
|
const sh = (cwd, ...a) => execFileSync("git", a, { cwd, encoding: "utf8", env: { ...CLEAN, GIT_CONFIG_GLOBAL: "/dev/null", GIT_CONFIG_SYSTEM: "/dev/null" } }).trim();
|
|
@@ -99,6 +99,27 @@ describe("ensureSignedOff", () => {
|
|
|
99
99
|
await assert.rejects(ensureSignedOff({ git: f.git, lane: "staging", branch: "feat/x", env: {} }), (e) => e.noIdentity === true);
|
|
100
100
|
assert.equal(f.sh("rev-parse", "HEAD"), bad);
|
|
101
101
|
});
|
|
102
|
+
it("rewrites on a dirty worktree and keeps the dirty tracked and untracked files (#4905)", async () => {
|
|
103
|
+
const f = fixture();
|
|
104
|
+
f.commit("b.txt", "unpushed placeholder", "placeholder");
|
|
105
|
+
writeFileSync(join(f.wt, "base.txt"), "modified\n");
|
|
106
|
+
writeFileSync(join(f.wt, "new.txt"), "untracked\n");
|
|
107
|
+
const r = await ensureSignedOff({ git: f.git, lane: "staging", branch: "feat/x", env: {} });
|
|
108
|
+
assert.equal(r.rewritten, 1);
|
|
109
|
+
assert.deepEqual(r.dirty, ["base.txt"]); // the source of the dirt is reported
|
|
110
|
+
assert.equal(readFileSync(join(f.wt, "base.txt"), "utf8"), "modified\n");
|
|
111
|
+
assert.equal(readFileSync(join(f.wt, "new.txt"), "utf8"), "untracked\n");
|
|
112
|
+
assert.match(f.sh("status", "--porcelain"), /M base\.txt/);
|
|
113
|
+
assert.equal(f.sh("log", "-1", "--format=%ae"), "lane@example.com");
|
|
114
|
+
});
|
|
115
|
+
it("restores the dirty file when the rewrite itself fails (#4905)", async () => {
|
|
116
|
+
const f = fixture();
|
|
117
|
+
f.commit("b.txt", "unpushed placeholder", "placeholder");
|
|
118
|
+
writeFileSync(join(f.wt, "base.txt"), "modified\n");
|
|
119
|
+
const git = async (a, o) => { if (a[0] === "filter-branch") throw Object.assign(new Error("boom"), { stderr: "boom" }); return f.git(a, o); };
|
|
120
|
+
await assert.rejects(ensureSignedOff({ git, lane: "staging", branch: "feat/x", env: {} }), /could not rewrite 1 unpushed.*dirty tracked files: base\.txt/s);
|
|
121
|
+
assert.equal(readFileSync(join(f.wt, "base.txt"), "utf8"), "modified\n");
|
|
122
|
+
});
|
|
102
123
|
|
|
103
124
|
it("rewrites on a dirty tree (#4997): unstaged and staged tracked changes survive, no stash entry is left", async () => {
|
|
104
125
|
const f = fixture();
|
|
@@ -122,4 +143,34 @@ describe("ensureSignedOff", () => {
|
|
|
122
143
|
await assert.rejects(ensureSignedOff({ git, lane: "staging", branch: "feat/x", env: {} }), (e) => e.engineCode === "engine.dco_rewrite" && e.noIdentity === false && /boom/.test(e.message));
|
|
123
144
|
assert.equal(readFileSync(join(f.wt, "base.txt"), "utf8"), "churn\n");
|
|
124
145
|
});
|
|
146
|
+
it("#5047 unsigned commits on a salvage ref are rewritten once the lane is resumed from it, and the signed rewrite pushes as the lane branch", async () => {
|
|
147
|
+
const f = fixture();
|
|
148
|
+
const bad = f.commit("a.txt", "unsigned placeholder", "placeholder");
|
|
149
|
+
const ref = salvageBranch(7, bad);
|
|
150
|
+
assert.equal(ref, `forge-salvage/7-${bad.slice(0, 10)}`);
|
|
151
|
+
f.sh("push", "-q", "origin", `${bad}:refs/heads/${ref}`);
|
|
152
|
+
// the recovery lane fetches the salvage branch as origin/<branch>, builds the worktree from it, then drops that tracking ref
|
|
153
|
+
f.sh("fetch", "-q", "origin", `${ref}:refs/remotes/origin/feat/x`);
|
|
154
|
+
assert.equal((await ensureSignedOff({ git: f.git, lane: "staging", branch: "feat/x", env: {} })).rewritten, 0, "with the tracking ref the commits are excluded (the defect)");
|
|
155
|
+
assert.equal(await dropSalvageTracking({ git: f.git, branch: "feat/x" }), true);
|
|
156
|
+
const r = await ensureSignedOff({ git: f.git, lane: "staging", branch: "feat/x", env: {} });
|
|
157
|
+
assert.equal(r.rewritten, 1);
|
|
158
|
+
assert.match(f.sh("log", "-1", "--format=%B"), /Signed-off-by: Lane Bot <lane@example.com>/);
|
|
159
|
+
f.sh("push", "-q", "-u", "origin", "feat/x");
|
|
160
|
+
assert.notEqual(f.sh("rev-parse", "origin/feat/x"), bad);
|
|
161
|
+
});
|
|
162
|
+
it("#5047 an unsigned commit pushed to a salvage ref is not on the lane branch's exclusion range", async () => {
|
|
163
|
+
const f = fixture();
|
|
164
|
+
const bad = f.commit("a.txt", "unsigned placeholder", "placeholder");
|
|
165
|
+
f.sh("push", "-q", "origin", `${bad}:refs/heads/${salvageBranch(7, bad)}`);
|
|
166
|
+
assert.equal((await ensureSignedOff({ git: f.git, lane: "staging", branch: "feat/x", env: {} })).rewritten, 1);
|
|
167
|
+
});
|
|
168
|
+
it("#5047 signed history already pushed on origin/<branch> is never rewritten", async () => {
|
|
169
|
+
const f = fixture();
|
|
170
|
+
f.commit("s.txt", "signed", "--signoff");
|
|
171
|
+
f.sh("push", "-q", "-u", "origin", "feat/x");
|
|
172
|
+
const head = f.sh("rev-parse", "HEAD");
|
|
173
|
+
assert.equal((await ensureSignedOff({ git: f.git, lane: "staging", branch: "feat/x", env: {} })).rewritten, 0);
|
|
174
|
+
assert.equal(f.sh("rev-parse", "HEAD"), head);
|
|
175
|
+
});
|
|
125
176
|
});
|
package/bin/pool/agent.mjs
CHANGED
|
@@ -240,7 +240,7 @@ export function createAgent(cfg, { docker, now = () => Date.now(), log = console
|
|
|
240
240
|
async function sandboxCred(id, kind, req) {
|
|
241
241
|
const r = own(id);
|
|
242
242
|
const m = /^Bearer\s+(\S.*)$/i.exec(req.headers.authorization ?? "");
|
|
243
|
-
if (!r || r.state === "exited" || !m || !sameSecret(m[1].trim(), r.credToken)) return { status: 401, body: { error: "unauthorized" } };
|
|
243
|
+
if (!r || r.state === "exited" || !m || !sameSecret(m[1].trim(), r.credToken)) return { status: 401, body: { error: "unauthorized", hop: "agent", reason: "agent auth: bad sandbox credential" } };
|
|
244
244
|
if (!CRED_TTL[kind]) return { status: 404, body: { error: "unknown credential" } };
|
|
245
245
|
const c = credCache[kind];
|
|
246
246
|
if (!c || now() - c.at > CRED_TTL[kind]) {
|
|
@@ -256,7 +256,7 @@ export function createAgent(cfg, { docker, now = () => Date.now(), log = console
|
|
|
256
256
|
async function sandboxRelay(id, rid, req) {
|
|
257
257
|
const r = own(id);
|
|
258
258
|
const m = /^Bearer\s+(\S.*)$/i.exec(req.headers.authorization ?? "");
|
|
259
|
-
if (!r || r.state === "exited" || !m || !sameSecret(m[1].trim(), r.credToken)) return { status: 401, body: { error: "unauthorized" } };
|
|
259
|
+
if (!r || r.state === "exited" || !m || !sameSecret(m[1].trim(), r.credToken)) return { status: 401, body: { error: "unauthorized", hop: "agent", reason: "agent auth: bad sandbox credential" } };
|
|
260
260
|
const lane = r.alias ?? r.id; // #4935: a claimed warm sandbox posts under its warm id, but the router queue and the dispatcher use the lane id
|
|
261
261
|
const path = `/agent/relay/${encodeURIComponent(lane)}${rid ? `/${encodeURIComponent(rid)}` : ""}`;
|
|
262
262
|
const x = rid ? await call(cfg.router_url, "GET", path, { token: cfg.token, timeoutMs: 20000 })
|
|
@@ -400,7 +400,7 @@ export function createAgent(cfg, { docker, now = () => Date.now(), log = console
|
|
|
400
400
|
if (cred) { const r = await sandboxCred(decodeURIComponent(cred[1]), cred[2], req); return send(res, r.status, r.body); }
|
|
401
401
|
const relay = (req.method === "POST" && /^\/sandboxes\/([^/]+)\/relay$/.exec(url.pathname)) || (req.method === "GET" && /^\/sandboxes\/([^/]+)\/relay\/([^/]+)$/.exec(url.pathname));
|
|
402
402
|
if (relay) { const r = await sandboxRelay(decodeURIComponent(relay[1]), relay[2] ? decodeURIComponent(relay[2]) : null, req); return send(res, r.status, r.body ?? {}); }
|
|
403
|
-
if (url.pathname !== "/health" && !bearerOk(req, cfg.token)) return send(res, 401, { error: "unauthorized" });
|
|
403
|
+
if (url.pathname !== "/health" && !bearerOk(req, cfg.token)) return send(res, 401, { error: "unauthorized", hop: "agent", reason: "agent auth: router token rejected by agent" });
|
|
404
404
|
for (const [match, fn] of routes) {
|
|
405
405
|
const p = match(req, url);
|
|
406
406
|
if (!p) continue;
|
package/bin/pool/cli.mjs
CHANGED
|
@@ -15,10 +15,38 @@ import { call as httpCall } from "./http.mjs";
|
|
|
15
15
|
const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..", "..");
|
|
16
16
|
const loadJson = (p) => JSON.parse(readFileSync(p, "utf8"));
|
|
17
17
|
|
|
18
|
+
const USAGE = "usage: forgedock pool router [install] | enroll [--cpus N --mem GB] | status | upgrade [agent] | remove <agent> [--config path]";
|
|
19
|
+
const HELP_FLAGS = ["--help", "-h"];
|
|
20
|
+
// Flags each verb accepts: `value` flags consume the next token, `bool` flags stand alone. Anything else dash-prefixed is rejected (#5008).
|
|
21
|
+
const VERB_FLAGS = {
|
|
22
|
+
router: { value: ["--config"], bool: [] },
|
|
23
|
+
agent: { value: ["--config"], bool: [] },
|
|
24
|
+
status: { value: ["--config"], bool: [] },
|
|
25
|
+
upgrade: { value: ["--config"], bool: [] },
|
|
26
|
+
remove: { value: ["--config"], bool: [] },
|
|
27
|
+
enroll: { value: ["--config", "--cpus", "--mem"], bool: [] },
|
|
28
|
+
"install-command": { value: ["--config", "--cpus", "--mem"], bool: [] },
|
|
29
|
+
resize: { value: ["--config", "--cpus", "--mem", "--vm-cpus", "--vm-mem", "--vm-swap"], bool: ["--drain"] },
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
function rejectUnknownFlags(cmd, rest) {
|
|
33
|
+
const spec = VERB_FLAGS[cmd];
|
|
34
|
+
if (!spec) return;
|
|
35
|
+
for (let i = 0; i < rest.length; i++) {
|
|
36
|
+
const t = rest[i];
|
|
37
|
+
if (spec.value.includes(t)) { i++; continue; }
|
|
38
|
+
if (spec.bool.includes(t)) continue;
|
|
39
|
+
if (t.startsWith("-") && !/^-\d/.test(t)) throw new Error(`usage: unknown flag ${t} for 'forgedock pool ${cmd}'. ${USAGE.slice("usage: ".length)}`);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
18
43
|
/** The `forgedock pool` verbs (and the `computeshare` script). Throws on usage or router errors; `deps` injects the HTTP client and config. */
|
|
19
44
|
export async function runPoolCli(argv, deps = {}) {
|
|
20
45
|
const [cmd, ...rest] = argv;
|
|
21
46
|
const call = deps.call ?? httpCall;
|
|
47
|
+
// Help never reaches the router or starts a service (#5008).
|
|
48
|
+
if (HELP_FLAGS.includes(cmd) || cmd === "help" || rest.some((x) => HELP_FLAGS.includes(x))) { console.log(USAGE); return; }
|
|
49
|
+
rejectUnknownFlags(cmd, rest);
|
|
22
50
|
const flag = (name, dflt) => { const i = rest.indexOf(`--${name}`); return i >= 0 ? rest[i + 1] : dflt; };
|
|
23
51
|
|
|
24
52
|
function routerConfig() {
|
|
@@ -103,7 +131,7 @@ export async function runPoolCli(argv, deps = {}) {
|
|
|
103
131
|
break;
|
|
104
132
|
}
|
|
105
133
|
default:
|
|
106
|
-
throw new Error(
|
|
134
|
+
throw new Error(USAGE);
|
|
107
135
|
}
|
|
108
136
|
}
|
|
109
137
|
|
package/bin/pool/router.mjs
CHANGED
|
@@ -147,13 +147,15 @@ export function createRouter(cfg, { fetchFn = globalThis.fetch, now = () => Date
|
|
|
147
147
|
catch (e) { log.warn?.(`forge.yaml for ${spec.repo.slug} unreadable: ${e.message}`); return spec; }
|
|
148
148
|
}
|
|
149
149
|
|
|
150
|
+
/** #5053: an agent's 401 reaches the dispatcher through this router; tag it so it is not mistaken for a router auth failure. */
|
|
151
|
+
const agentBody = (r, name) => (r.status === 401 ? { ...(r.data && typeof r.data === "object" ? r.data : {}), error: "unauthorized", hop: "agent", agent: name, reason: typeof r.data?.reason === "string" ? r.data.reason : "agent auth: router token rejected by agent" } : r.data ?? {});
|
|
150
152
|
async function launch(spec) {
|
|
151
153
|
if (typeof spec?.id !== "string" || !/^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/.test(spec.id)) return { status: 400, body: { error: "spec.id required ([A-Za-z0-9._-], max 128)" } };
|
|
152
154
|
const known = own(sbx(), spec.id);
|
|
153
155
|
if (known && own(agents(), known.agent)) {
|
|
154
156
|
known.touched = now(); store.save();
|
|
155
157
|
const r = await agentCall(own(agents(), known.agent), "POST", "/sandboxes", { spec: withForgeYaml(spec), ...sizeFor(spec) });
|
|
156
|
-
return { status: r.status, body: r.
|
|
158
|
+
return { status: r.status, body: agentBody(r, known.agent), retryAfter: r.retryAfter };
|
|
157
159
|
}
|
|
158
160
|
const size = sizeFor(spec);
|
|
159
161
|
const fits = (await capacities()).filter((c) => c.ok && !c.paused && c.cpus_free >= size.cpus && c.mem_free_gb >= size.mem_gb)
|
|
@@ -162,7 +164,7 @@ export function createRouter(cfg, { fetchFn = globalThis.fetch, now = () => Date
|
|
|
162
164
|
const r = await agentCall(own(agents(), c.name), "POST", "/sandboxes", { spec: withForgeYaml(spec), ...size }, 120000);
|
|
163
165
|
if (r.status === 503) continue; // raced with another launch; try the next host
|
|
164
166
|
if (r.ok) { sbx()[spec.id] = { agent: c.name, issue: spec.issue ?? null, batch: spec.batch ?? null, createdAt: now(), touched: now() }; store.save(); }
|
|
165
|
-
return { status: r.status, body: { ...(r.
|
|
167
|
+
return { status: r.status, body: { ...agentBody(r, c.name), agent: c.name } };
|
|
166
168
|
}
|
|
167
169
|
return { status: 503, body: { error: "no host has a free reservation", lane: size }, retryAfter: cfg.retry_after_s ?? 60 };
|
|
168
170
|
}
|
|
@@ -190,7 +192,7 @@ export function createRouter(cfg, { fetchFn = globalThis.fetch, now = () => Date
|
|
|
190
192
|
try {
|
|
191
193
|
const r = await agentCall(a, method, path);
|
|
192
194
|
if (method === "DELETE" && (r.ok || r.status === 404)) { delete sbx()[id]; relayQ.delete(id); store.save(); }
|
|
193
|
-
return { status: r.status, body: r.
|
|
195
|
+
return { status: r.status, body: agentBody(r, s.agent) };
|
|
194
196
|
} catch (e) { return { status: 502, body: { error: `agent ${s.agent} unreachable: ${e.message}` } }; }
|
|
195
197
|
}
|
|
196
198
|
|
|
@@ -214,7 +216,7 @@ export function createRouter(cfg, { fetchFn = globalThis.fetch, now = () => Date
|
|
|
214
216
|
}
|
|
215
217
|
|
|
216
218
|
function enroll(body) {
|
|
217
|
-
if (!enrollOk(body?.token)) return { status: 401, body: { error: "bad enrollment token" } };
|
|
219
|
+
if (!enrollOk(body?.token)) return { status: 401, body: { error: "bad enrollment token", hop: "router", reason: "router auth: bad enrollment token" } };
|
|
218
220
|
const name = String(body.name ?? "").toLowerCase().replace(/[^a-z0-9-]/g, "");
|
|
219
221
|
let url;
|
|
220
222
|
try { url = new URL(String(body.url)); } catch { return { status: 400, body: { error: "url required" } }; }
|
|
@@ -321,14 +323,23 @@ export function createRouter(cfg, { fetchFn = globalThis.fetch, now = () => Date
|
|
|
321
323
|
async function handle(req, res) {
|
|
322
324
|
const url = new URL(req.url, "http://router");
|
|
323
325
|
if (!url.pathname.startsWith("/health")) log.info?.(`${req.socket.remoteAddress} ${req.method} ${url.pathname}`); // query (tokens) never logged
|
|
326
|
+
// #5053: every rejected request is logged with its status; the sandbox id comes from the path, the repo slug from a launch body (never tokens or the query).
|
|
327
|
+
const meta = { id: /^\/sandboxes\/([^/]+)/.exec(url.pathname)?.[1] };
|
|
328
|
+
const writeHead = res.writeHead.bind(res);
|
|
329
|
+
res.writeHead = (status, ...a) => {
|
|
330
|
+
if (status >= 400 && url.pathname !== "/health") log.warn?.(`rejected ${req.method} ${url.pathname} -> ${status}${meta.id && meta.id !== "claim" ? ` sandbox=${meta.id.slice(0, 128)}` : ""}${meta.repo ? ` repo=${meta.repo}` : ""}`);
|
|
331
|
+
return writeHead(status, ...a);
|
|
332
|
+
};
|
|
324
333
|
try {
|
|
325
334
|
for (const [match, r] of routes) {
|
|
326
335
|
const p = match(req, url);
|
|
327
336
|
if (!p) continue;
|
|
328
|
-
if (r.auth === "forgedock" && !bearerOk(req, cfg.forgedock_token)) return send(res, 401, { error: "unauthorized" });
|
|
329
|
-
if (r.auth === "agent" && !agentFor(req)) return send(res, 401, { error: "unauthorized" });
|
|
337
|
+
if (r.auth === "forgedock" && !bearerOk(req, cfg.forgedock_token)) return send(res, 401, { error: "unauthorized", hop: "router", reason: "router auth: bad or missing token" });
|
|
338
|
+
if (r.auth === "agent" && !agentFor(req)) return send(res, 401, { error: "unauthorized", hop: "router", reason: "router auth: bad or missing token" });
|
|
330
339
|
if (r.auth === "enroll" && !enrollOk(url.searchParams.get("token"))) return send(res, 401, "unauthorized\n");
|
|
331
340
|
const body = req.method === "POST" ? await readJson(req) : null;
|
|
341
|
+
if (typeof body?.repo?.slug === "string") meta.repo = body.repo.slug.slice(0, 200);
|
|
342
|
+
if (typeof body?.id === "string") meta.id = body.id;
|
|
332
343
|
const out = await r.fn(p, body, url, req);
|
|
333
344
|
if (out.bundle) return sendBundle(res);
|
|
334
345
|
if (out.image) return sendImage(res, out.image);
|
package/commands/orchestrate.md
CHANGED
|
@@ -150,7 +150,10 @@ When a lane finishes (agent lanes; the batch runner applies the same rules itsel
|
|
|
150
150
|
- `needs-human` with a **recoverable code**: `ci.red`, `base.conflict` (merge conflict), `ci-pending` on a conflicting
|
|
151
151
|
PR, `contract.invalid` caused by an issue edited mid-run, `builder.block_untyped` (an in-scope technical choice),
|
|
152
152
|
and the environment codes `env.tool_missing` (a validate command the host cannot run) and `env.repro_unrunnable`
|
|
153
|
-
(the repro harness could not run the repro test).
|
|
153
|
+
(the repro harness could not run the repro test), and the conformance gaps `conformance.unimplemented`,
|
|
154
|
+
`conformance.omission` and `conformance.unjustified_deferral` (the recovery lane delivers the gap, or defers an optional
|
|
155
|
+
clause with a why so a tracked follow-up is filed; `conformance.regression`, `guard_broken`, `scope_creep` and
|
|
156
|
+
`e2e_unverified` stay terminal).
|
|
154
157
|
These are mechanical. **Do not ask for confirmation on them.** The batch runner recovers each one itself
|
|
155
158
|
(`orchestration.recovery.max_attempts`, default 1 per issue). It launches one recovery lane under the same lease, lane
|
|
156
159
|
and admission rules as any lane, with `run-issue --recover <code> [--recover-attempt n]` (core path only:
|
|
@@ -166,7 +169,7 @@ When a lane finishes (agent lanes; the batch runner applies the same rules itsel
|
|
|
166
169
|
the issue's last comment for the reason, mark its dependents skipped, and add it to the escalation list (step 4). Only
|
|
167
170
|
explicit or logical edges skip a dependent (after you decide the block in scope, clear the label and run
|
|
168
171
|
`forgedock orchestrate --requeue <id> N... --reason "..."`, never a second batch); an issue that merely overlaps the same file keeps running.
|
|
169
|
-
- `needs-human` with a **terminal code** that is neither recoverable nor human (for example
|
|
172
|
+
- `needs-human` with a **terminal code** that is neither recoverable nor human (for example `conformance.regression`, or
|
|
170
173
|
`contract.invalid` without a mid-run issue edit): the runner does not recover it and it is not an escalation. The
|
|
171
174
|
batch report lists it as `unrecovered: #N (<code>: <detail>)`, like a recovery that did not merge. Mark its dependents
|
|
172
175
|
skipped and leave the issue for a later run once its cause is fixed.
|
package/docs/CONFIG.md
CHANGED
|
@@ -439,10 +439,10 @@ orchestration:
|
|
|
439
439
|
| `breaker.errors` / `breaker.window_min` | integer | No | `forgedock orchestrate` circuit breaker (#4121): after `errors` engine-errors within `window_min` minutes the batch stops admitting lanes, lets running lanes finish, and keeps the rest pending for `--resume`. `errors: 0` turns it off. Default: 3 in 30 |
|
|
440
440
|
| `max_deferrals` | integer | No | `forgedock orchestrate`: requeues of one issue after a non-host `deferred` (usage limit, merge/push transient, CLI idle, lease held, gh unavailable) before its row is final. `host.busy` and `sandbox.capacity` (sandbox pool full) deferrals are not capped. Default: 3 |
|
|
441
441
|
| `blocked_label` | string | No | `forgedock orchestrate` (#4773): an open issue carrying this label is admitted to the batch but not dispatched; removing the label releases it on the next scheduler pass. A `Blocked by #N` / `Depends on #N` line in the body of a trusted author (OWNER, MEMBER, COLLABORATOR or a bot) is a DAG edge to #N, and `--add` prints the edges it created. Empty turns the label off. Default: `blocked` |
|
|
442
|
-
| `auto_add_children` | string | No | `forgedock orchestrate` (#4965): which children a running batch adds by itself. `off` (no auto-adds; a decomposed lane's sub-issues stay out too), `decomposed` (a decomposed lane's sub-issues join, as before), `epic` (also the open children of every batch issue and of each `--epic N`, found by a `**Parent**: #P` line, the `parent` field or GitHub sub-issues, including children filed after the batch started) or `all`. Children labelled `needs-human` and children another live batch of the repo still holds are not added. Each pass that adds posts one batch decision and one `added` row listing the children and edges. `forgedock orchestrate --auto-children <mode>` overrides it per batch and `--resume` keeps it. Default: `decomposed` |
|
|
442
|
+
| `auto_add_children` | string | No | `forgedock orchestrate` (#4965): which children a running batch adds by itself. `off` (no auto-adds; a decomposed lane's sub-issues stay out too), `decomposed` (a decomposed lane's sub-issues join, as before), `epic` (also the open children of every batch issue and of each `--epic N`, found by a `**Parent**: #P` line, the `parent` field or GitHub sub-issues, including children filed after the batch started) or `all`. Only children whose author is trusted (an owner, member or collaborator, or a bot, the `Blocked by` rule) are auto-added; any other is skipped with a logged row. Children labelled `needs-human` and children another live batch of the repo still holds are not added. Each pass that adds posts one batch decision and one `added` row listing the children and edges. `forgedock orchestrate --auto-children <mode>` overrides it per batch and `--resume` keeps it. Default: `decomposed` |
|
|
443
443
|
| `auto_add_children_poll_s` | integer | No | Seconds between child scans for `epic`/`all` (one `gh issue list` per scan). Default: `120` |
|
|
444
444
|
| `global_parallel` | integer | No | `forgedock orchestrate` (#4191): host-wide cap on running lanes across every batch and repo on the host, enforced through the host lane registry (`~/.forge/sched/`, or `FORGEDOCK_SCHED_DIR`). A freed slot goes to the highest-priority queued issue (`priority:P<n>` / `P<n>` labels, unlabelled P2); within a priority, slots are shared across repos by weight (#4193: `forgedock sched weight <owner/repo> N`, `--clear` resets; default equal, FIFO inside a repo), and priority always dominates weights; running lanes are never preempted and `--parallel` K still bounds each batch. `forgedock sched cap N` overrides it live for the whole host (`--clear` reverts); `forgedock sched show` lists the weights, slots and the queue. Default: unset (no global cap) |
|
|
445
|
-
| `recovery.max_attempts` | integer | No | `forgedock orchestrate` in-batch recovery (#4267): how many recovery lanes the batch launches per issue for a `needs-human` with a mechanical code (`ci.red`, `base.conflict`, `ci-pending` on a conflicting PR, `contract.invalid` after a mid-run issue edit, `builder.block_untyped`). A recovery lane (`run-issue --recover <code>`) removes `needs-human`, posts a `FORGE:DECISION`, resumes the open PR and re-queues the issue with its dependents. If it ends anything but `merged`, the issue is labelled `needs-human` again with a recovery-evidence `FORGE:NEEDS_HUMAN` comment. Human codes are never recovered. Flat form: `recovery_max_attempts`. Default: 1; `0` disables in-batch recovery (a mechanical `needs-human` is final, as before). |
|
|
445
|
+
| `recovery.max_attempts` | integer | No | `forgedock orchestrate` in-batch recovery (#4267): how many recovery lanes the batch launches per issue for a `needs-human` with a mechanical code (`ci.red`, `base.conflict`, `ci-pending` on a conflicting PR, `contract.invalid` after a mid-run issue edit, `builder.block_untyped`, the `env.*` codes, `contract.invalid_command`, `budget.exceeded`, and since #5035 `conformance.unimplemented`, `conformance.omission` and `conformance.unjustified_deferral`; the full list is in docs/OUTCOME-CODES.md). A recovery lane (`run-issue --recover <code>`) removes `needs-human`, posts a `FORGE:DECISION`, resumes the open PR and re-queues the issue with its dependents. If it ends anything but `merged`, the issue is labelled `needs-human` again with a recovery-evidence `FORGE:NEEDS_HUMAN` comment. Human codes are never recovered. Flat form: `recovery_max_attempts`. Default: 1; `0` disables in-batch recovery (a mechanical `needs-human` is final, as before). |
|
|
446
446
|
| `recovery.mechanical_max_attempts` | integer | No | `forgedock orchestrate` (#4725): the separate recovery budget of mechanical `needs-human` rows (`env.*`, `base.conflict`, a conflicting PR); never below `recovery.max_attempts`. Flat form: `recovery_mechanical_max_attempts`. Default: 2, so with the default config such a row gets 2 recovery lanes, not 1. |
|
|
447
447
|
| `base_sync.enabled` | boolean | No | `forgedock orchestrate` parked-PR base sync (#4525): every 10 min while the batch runs, an open PR of a `needs-human` row is merged with its moved base in a throwaway worktree. A clean merge, or one whose only conflicts are hunks where both sides purely added lines in a `union_globs` file, is pushed as a plain merge commit (never forced). Any other conflict is aborted, the branch left alone, and its paths are posted once per base sha as a `FORGE:DECISION` on the issue. Sequence/`_journal.json` files are never resolved here. Flat form: `base_sync_enabled`. Default: `true`; `false` opts out. |
|
|
448
448
|
| `base_sync.union_globs` | string | No | Comma-separated globs (`*` within a segment, `**` across; no `/` matches the file name anywhere) of well-known append-only files (registries, changelogs) whose two-sided line additions the base sync keeps both of. Flat form: `base_sync_union_globs`. Default: none (every conflict is reported). |
|
|
@@ -472,7 +472,7 @@ orchestration:
|
|
|
472
472
|
|
|
473
473
|
## `decisions` (OPTIONAL)
|
|
474
474
|
|
|
475
|
-
Decision engine (#4766, see [DECISIONS.md](DECISIONS.md)). The parser has one nesting level and no arrays, so lists are comma-separated.
|
|
475
|
+
Decision engine (#4766, see [DECISIONS.md](DECISIONS.md)). **Not yet wired:** nothing calls `settle` in production, so `mode` (including `live`), `veto_hours` and `reversal_max` have no effect today; `policy` and `human_only` are read by prodwatch and issue-watch (`loadPolicy`) only. The parser has one nesting level and no arrays, so lists are comma-separated.
|
|
476
476
|
|
|
477
477
|
| Key | Type | Description |
|
|
478
478
|
|---|---|---|
|
|
@@ -605,7 +605,7 @@ engine:
|
|
|
605
605
|
| `cli_max_minutes` | number | No | Absolute ceiling for a core builder session (code `cli.timeout`), replacing the fixed 60-minute kill. A session that keeps making progress is never idle-killed. Default 180. On either kill, uncommitted work is committed as `wip(forge): checkpoint after <reason>` and the lane branch is pushed (with the push retry), the builder retries once from the checkpoint, and a second kill ends `deferred`; a rerun resumes from the branch. |
|
|
606
606
|
| `turn_budget_s` / `turn_budget_m` / `turn_budget_l` | number | No | Core implement turn budget per lane size (#4392). Size comes from a `size:S|M|L` issue label, else the frozen contract's distinct deliverable paths (S ≤ 2, M ≤ 5, else L). The build call and its context/timeout restarts share the budget; at the limit the session is stopped, checkpointed, and one fresh wrap-up session of max(5, budget/5) turns either finishes or writes `status: budget-exceeded` with a digest. A stop or overrun ends with the non-human `budget-exceeded` reason and code `budget.exceeded` (no `needs-human` label, forge#4505), which `forgedock orchestrate` recovers once (continue from the checkpoint or split); a batch that does not recover it labels it `needs-human` once. Lane results carry `turns: {size, budget, build, total, exceeded}`, and the batch report prints `turns by size`. Defaults 25 / 60 / 100; 0 disables that size. An L lane's budget scales with the contract: +10 turns per distinct deliverable path above 5 (capped at +2x `turn_budget_l`), and a `budget.exceeded` recovery of an L lane gets a further +50% of `turn_budget_l` (#4587). |
|
|
607
607
|
| `lane_limits` | map | No | Cap each core lane's whole process tree (#4115, #4690), under `pipeline:` or `engine:`: `tasks_max` (default `1024`; `0`/`off` disables), `memory_max` (for example `3G`) and `cpu_quota` (for example `150%`). Where `systemd-run --user --scope` works (probed once) every lane's builder CLI is spawned as `systemd-run --user --scope -q --unit=forgedock-lane-... -p TasksMax=... -p MemoryMax=... -p CPUQuota=... -- claude ...` by default. `memory_max` has no default: lanes get no `MemoryMax` unless it is set (`off` also disables; #4740). A lane that hits `TasksMax` or `MemoryMax` is stopped and ends `needs-human` with `env.lane_limit` (not an engine-error, so the breaker ignores it). Where scopes are unavailable the run logs one line saying lanes are uncapped and the limits are ignored. Write it nested or flat (`lane_limits_tasks_max`, `lane_limits_memory_max`, `lane_limits_cpu_quota`). While a builder runs the engine polls host load and memory every 15 s against the `admission` thresholds: over them it SIGSTOPs the lane's process group, under them SIGCONT, and never pauses longer than 10 minutes. |
|
|
608
|
-
| `admission` | map | No | Host-resource admission for the core work-on (#4105), checked before the lease, worktree or any GitHub call. When a check fails the run ends `deferred` (code `host.busy`, exit 75) with a last line such as `issue #N → deferred (host busy: mem 1.2G < 2G; retry in 60s)`; no lease is taken and nothing is posted. Keys: `enabled` (default `true`; `false` disables admission), `min_free_mem_mb` (default 2048; `MemAvailable` from `/proc/meminfo`, else `os.freemem()`), `min_free_tmp_mb` (default 1024; free space of the run TMPDIR filesystem, `~/.forge/tmp`, and of `/tmp`, via `statfs`), `max_load_per_core` (default 4; 1-minute load average divided by cores), `lane_cpu_reservation` (default 1) and `lane_mem_mb` (default 2048). The last two feed the live local lane cap (#4777), on by default: `min(--parallel / global_parallel, max(1, min(floor(cores / lane_cpu_reservation), floor((MemAvailable - min_free_mem_mb) / lane_mem_mb))))`, re-probed at every admission attempt, so `--parallel 12` on a 4-core / 8 GB host runs fewer concurrent local lanes. Remote sandbox lanes are exempt; `enabled: false` removes the cap. The cap reads the `forge.yaml` of the batch's repo, also when the batch runs under the schedd daemon. Write it nested (`engine:` then `admission:` with the keys indented) or flat as `admission_enabled`, `admission_min_free_mem_mb`, `admission_min_free_tmp_mb`, `admission_max_load_per_core`; the parser reads one nested level into `engine`. `/orchestrate` requeues a host-busy lane and lowers K by 1 for 5 minutes. |
|
|
608
|
+
| `admission` | map | No | Host-resource admission for the core work-on (#4105), checked before the lease, worktree or any GitHub call. When a check fails the run ends `deferred` (code `host.busy`, exit 75) with a last line such as `issue #N → deferred (host busy: mem 1.2G < 2G; retry in 60s)`; no lease is taken and nothing is posted. Keys: `enabled` (default `true`; `false` disables admission), `min_free_mem_mb` (default 2048; `MemAvailable` from `/proc/meminfo`, else `os.freemem()`), `min_free_tmp_mb` (default 1024; free space of the run TMPDIR filesystem, `~/.forge/tmp`, and of `/tmp`, via `statfs`), `max_load_per_core` (default 4; 1-minute load average divided by cores), `lane_cpu_reservation` (default 1) and `lane_mem_mb` (default 2048). The last two feed the live local lane cap (#4777), on by default: `min(--parallel / global_parallel, max(1, min(floor(cores / lane_cpu_reservation), floor((MemAvailable - min_free_mem_mb) / lane_mem_mb))))`, re-probed at every admission attempt, so `--parallel 12` on a 4-core / 8 GB host runs fewer concurrent local lanes. Remote sandbox lanes are exempt; `enabled: false` removes the cap. The cap reads the `forge.yaml` of the batch's repo, also when the batch runs under the schedd daemon. Write it nested (`engine:` then `admission:` with the keys indented) or flat as `admission_enabled`, `admission_min_free_mem_mb`, `admission_min_free_tmp_mb`, `admission_max_load_per_core`, `admission_lane_cpu_reservation`, `admission_lane_mem_mb`; the parser reads one nested level into `engine`. `/orchestrate` requeues a host-busy lane and lowers K by 1 for 5 minutes. |
|
|
609
609
|
| `merge_wait` | string | No | Core work-on merge gate. `required` (default): wait only for the status checks the base branch requires: the union of classic branch protection (`gh api repos/{o}/{r}/branches/{base}/protection/required_status_checks`, contexts and checks) and repository rulesets (`gh api repos/{o}/{r}/rules/branches/{base}`, `required_status_checks` rules); with neither (404 or empty) merge right after review PASS. A merge rejected by base branch policy (PR `MERGEABLE` but `BLOCKED`) is waited on and retried within the CI budget, then left `ci-pending`; it is never base-synced and never `base.conflict`. A failing required check is `needs-human`; a pending one is polled with backoff and never `needs-human`. `all`: wait for every check (`scripts/wait-ci-green.sh`). `none`: merge right after review. Always `--match-head-commit` at the reviewed head. The policy used is recorded in the `FORGE:WORK-ON` comment. |
|
|
610
610
|
| `context_pack` | string | No | Context pack (#4275). Before the plan call the core compiles the issue's GitHub/git neighbourhood into a small, budgeted pack: linked issues and PRs (parent, `Depends on`, body links, same-repo cross-references, siblings), prior attempts, merged PRs on the issue's files that were reverted or whose decisions/findings name a symbol from the issue, structured `FORGE:FACTS` records of earlier lanes on the issue's files (rejected approaches with why, hot spots, decisions, conventions, baselines; #4401), and repo-fact file pointers (found in the lane worktree or, for untracked caches, the main checkout; #4400). `shadow` (default): the pack is compiled, posted on the issue as a collapsed `FORGE:CONTEXT` comment and logged as a `CONTEXT_PACK` event; the builder prompt is unchanged. `on`: it is also appended to the plan prompt as hints. `off`: nothing is gathered, posted or logged. `ab` (#4444): the A/B experiment. Each issue gets a stable arm, `on` or `shadow`, from a hash of `repo#issue`, logged as that mode with `context_ab: true`; the default stays `shadow`. The pack never enters the text contract quotes are checked against. Values read from linked issues' `FORGE:STATE` are kept only when they are outcome codes. Compare with `forgedock stats --context-ab` (per lane type and mode: first-pass yield, turns and cache-read per merge, rework rate, and recovery lanes' implement attempt against the first run on the same issue). Unless `off`, a resumed or recovery lane also gets a bounded resume digest of its last run in the builder prompt (end reason, validate results per command, deliver requirements, files changed; logged as `RESUME_DIGEST`; #4400), whatever the mode. |
|
|
611
611
|
| `context_pack_sources` | string | No | Comma list of the pack sources to use: `graph`, `attempts`, `pitfalls`, `ledger`, `facts` (earlier lanes' `FORGE:FACTS` on the issue's files plus repo-fact file pointers). Default: all five. |
|
|
@@ -1337,6 +1337,10 @@ verification:
|
|
|
1337
1337
|
consumer_sweep:
|
|
1338
1338
|
mode: advisory # advisory (default) | blocking
|
|
1339
1339
|
budget_seconds: 60 # wall-clock budget for the sweep
|
|
1340
|
+
|
|
1341
|
+
test_runners: # path prefix -> runner command prefix (block-style map)
|
|
1342
|
+
services/worker: "poetry -C services/worker run"
|
|
1343
|
+
web: "pnpm --filter web exec"
|
|
1340
1344
|
```
|
|
1341
1345
|
|
|
1342
1346
|
| Field | Type | Required | Description |
|
|
@@ -1353,6 +1357,8 @@ verification:
|
|
|
1353
1357
|
| `commands.typescript.test_changed`, `commands.typescript.typecheck_changed`, `commands.python.test_changed` | string | No | Changed-file scoped steps for `/review-pr` Phase 2 (repos where the full typecheck or suite is too slow or heavy). They run in the per-PR worktree and read the PR's changed files that still exist from `$FORGE_CHANGED_FILES` (newline-separated environment variable; never spliced into the command string). Reported as Phase 2 evidence with `EXIT <lang>.<step>=<code>` like the full-repo steps; a non-zero `typecheck_changed` blocks, a failing `test_changed` follows the REAL/flaky rule of `test`. Examples: `vitest related $FORGE_CHANGED_FILES --run`; `pytest -q $(printf '%s\n' $FORGE_CHANGED_FILES \| grep 'test_.*\.py$')`. For `typecheck_changed`, filter the compiler output to the changed files so pre-existing errors elsewhere do not block. `forgedock init` suggests them when vitest or pytest is detected |
|
|
1354
1358
|
| `consumer_sweep.mode` | string | No | `advisory` (default): the validate consumer sweep (`scripts/consumer-sweep.sh`, V4.6) records unaddressed referencing sites and never fails. `blocking`: unaddressed sites fail validate with `consumer_unaddressed`, build runs one repair round, and sites still unaddressed are recorded as `CONSUMERS: unresolved` and passed to review as known risk (never `needs-human`). Flip to `blocking` only after `bash scripts/consumer-sweep.sh --eval-history 20` shows a mean of at most 2 flagged sites per merge and none above 10 on your repo |
|
|
1355
1359
|
| `consumer_sweep.budget_seconds` | integer | No | Wall-clock budget for the sweep (default `60`). When it runs out the sweep stops with `BUDGET_EXCEEDED`, reports the partial result, and never fails, in either mode |
|
|
1360
|
+
| `test_runners` | map (path prefix → runner) | No | Default unset (no runner enforced). For repos whose validate commands need a managed runner (uv, poetry, pnpm workspace). The lean engine checks it at contract freeze (`testRunnerProblems`, `bin/lean/preflight.mjs`): a validate command that touches a configured path prefix without containing that prefix's runner string is reported as a contract problem, instead of failing later as `contract.invalid_command` / `env.tool_missing`. Paths in the command are relative to the runner's cwd. Write it as a block-style map: `parseForgeYaml` does not read inline `{ ... }` maps. |
|
|
1361
|
+
| `validate.recipes` | map (regex → replacement) | No | Default unset. A top-level `validate:` block with a block-style `recipes:` map, e.g. `"poetry run pytest": "uv run --with-requirements r.txt pytest"`. Each key is a regex source; the first recipe that matches a frozen validate or `repro_test` command rewrites it at contract freeze (`applyValidateRecipes`), recorded as a decision. Use it for commands whose environment is not installed in lane sandboxes. |
|
|
1356
1362
|
|
|
1357
1363
|
**Commands that use this section**: `quality-gate`, `validate` (V2 runs `verification.commands`; V4.8 runs the local CI gate; health checks and `consumer_sweep`), `work-on`
|
|
1358
1364
|
|
package/docs/DECISIONS.md
CHANGED
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
# Decision engine (#4766)
|
|
2
2
|
|
|
3
|
+
> **Status: not yet wired.** No production code calls `settle` yet: the issue-watch stage does not run it, so `decisions.mode`
|
|
4
|
+
> (including `live`), `veto_hours` and `reversal_max` have no effect and no `decision_engine` telemetry is emitted. Only the policy
|
|
5
|
+
> and authority-matrix reads (`loadPolicy`) are used today, by prodwatch and issue-watch. The flow below describes the intended behaviour.
|
|
6
|
+
|
|
3
7
|
Settles a lane's decision-only `needs-human` escalation against the target repo's own north star instead of parking it.
|
|
4
8
|
Only external actions stay with a human. Code: `bin/lean/decision-engine.mjs`. Config: [`decisions:`](CONFIG.md).
|
|
5
9
|
|
|
@@ -29,4 +33,4 @@ Only external actions stay with a human. Code: `bin/lean/decision-engine.mjs`. C
|
|
|
29
33
|
## Not in this module
|
|
30
34
|
|
|
31
35
|
The `issue-watch` stage that lists `needs-human` issues and calls `settle` (`sweep_needs_human`, `--dry-run` via `classify`, caps, status counts)
|
|
32
|
-
|
|
36
|
+
did not land with #4765 (merged without the sweep); a follow-up issue tracks it. Running a declared monitor query for `evidence` and folding `post-deploy` items into the release checklist are follow-ups.
|
package/docs/OUTCOME-CODES.md
CHANGED
|
@@ -108,13 +108,13 @@ An absent or unknown code is recorded as `unclassified`, with the raw reason kep
|
|
|
108
108
|
`forgedock orchestrate` recovers mechanical outcomes inside the batch:
|
|
109
109
|
|
|
110
110
|
- **recoverable**:
|
|
111
|
-
- needs-human with `ci.red`, `base.conflict`, `builder.block_untyped`, `env.tool_missing`, `env.repro_unrunnable`, `env.validate_timeout`, `env.capability_remote`, `contract.invalid_command` or `
|
|
111
|
+
- needs-human with `ci.red`, `base.conflict`, `builder.block_untyped`, `env.tool_missing`, `env.repro_unrunnable`, `env.validate_timeout`, `env.capability_remote`, `contract.invalid_command`, `budget.exceeded`, `conformance.unimplemented`, `conformance.omission` or `conformance.unjustified_deferral` (#5035: the recovery lane resumes the lane branch, delivers the named gap or records a defer-with-why amendment that files a tracked follow-up);
|
|
112
112
|
- `contract.invalid` when the issue was edited mid-run (`issueEdited`);
|
|
113
113
|
- `ci-pending` on a conflicting PR, recovered as `base.conflict`.
|
|
114
114
|
|
|
115
115
|
The runner relaunches the issue once per `orchestration.recovery.max_attempts` (default 1) as a recovery lane (`run-issue --recover <code>`), under the normal admission rules. That lane removes the `needs-human` label, posts `FORGE:DECISION`, and resumes the open PR: base-sync, then CI fix rounds from the failed-check logs. Dependents stay queued. A merged recovery is reported as `recovered`. A failed one is final, keeps the recovery evidence on the row, and skips its dependents. A failed recovery stays `needs-human`: the lane re-adds the label (with a `FORGE:NEEDS_HUMAN` comment carrying the evidence) only for a final `needs-human` or `ci-pending`, and only when it is the last attempt (`attempt == max_attempts`); a non-final attempt that ends `needs-human` or `ci-pending` without another attempt following is re-labelled once by the batch runner; the batch runner re-adds it, once, for a final `engine-error` or `deferred` after the recovery pass (those may be requeued, so the lane cannot know). `invalid`, `decomposed`, `dependency-*` and `merged` never re-label. In-batch recovery is off when `engine.path: full`: the runner passes no recovery to the batch, and `run-issue --path full` rejects `--recover`.
|
|
116
116
|
- **human**: `builder.blocked`, which is only reached by a typed human block: credentials, spend, promotion, customer or product-decision. It is never recovered, it skips dependents, and it is the only class in the report's escalation list.
|
|
117
|
-
- **terminal**: every other code. No recovery runs, dependents are skipped, and the row is reported as `unrecovered`.
|
|
117
|
+
- **terminal**: every other code, including `conformance.regression`, `conformance.guard_broken` and `conformance.scope_creep`. No recovery runs, dependents are skipped, and the row is reported as `unrecovered`.
|
|
118
118
|
|
|
119
119
|
## Known limits
|
|
120
120
|
|
package/docs/TELEMETRY.md
CHANGED
|
@@ -71,7 +71,7 @@ issue or PR.
|
|
|
71
71
|
|
|
72
72
|
## `decision_engine` (#4766)
|
|
73
73
|
|
|
74
|
-
One record per escalation the north-star decision engine handled (`bin/lean/decision-engine.mjs`): `decided`, `escalated`,
|
|
74
|
+
Not emitted yet: the decision engine is not wired into issue-watch, so no record is written until it is. One record per escalation the north-star decision engine handled (`bin/lean/decision-engine.mjs`): `decided`, `escalated`,
|
|
75
75
|
`vetoed`, `reversed` (counts, 0 or 1 per issue), `mode` (`shadow` or `live`), and `needs_human_before` / `needs_human_after`
|
|
76
76
|
(the repo's open needs-human count around a sweep). A reversal rate (`reversed` over `decided`) above `decisions.reversal_max`
|
|
77
77
|
turns the engine back to shadow. See [DECISIONS.md](DECISIONS.md).
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "forgedock",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.45.0",
|
|
4
4
|
"description": "Autonomous development pipeline for coding agents. GitHub as institutional memory, with native Claude Code and OpenCode entrypoints.",
|
|
5
5
|
"bin": {
|
|
6
6
|
"forgedock": "bin/forgedock.mjs"
|
|
@@ -58,11 +58,13 @@ export async function readLive({ gh, repo }) {
|
|
|
58
58
|
|
|
59
59
|
/**
|
|
60
60
|
* Parallel-start gate: dependencies of `issues` (not themselves in the batch) that are not merged into `baseOf(issue)`.
|
|
61
|
-
* A dep is merged when it is CLOSED by a PR that is merged with that base
|
|
61
|
+
* A dep is merged when it is CLOSED by a PR that is merged with that base, or whose merge commit is an ancestor of
|
|
62
|
+
* that base (`isAncestor(sha, base)`, e.g. it arrived through a milestone sync). Unreadable issues are skipped (fail open:
|
|
62
63
|
* the engine's own openDependency still guards each lane); an unreadable dep counts as unmet.
|
|
63
|
-
*
|
|
64
|
+
* `sha`/`kind` (#5031): `kind` is "commit" when the dependency merged but its commit is not on the base yet (`sha`), else "other".
|
|
65
|
+
* @returns {Promise<Array<{ issue: number, dep: number, base: string, why: string, sha: string|null, kind: "commit"|"other" }>>}
|
|
64
66
|
*/
|
|
65
|
-
export async function unmetDependencies({ issues, baseOf, gh, repo }) {
|
|
67
|
+
export async function unmetDependencies({ issues, baseOf, gh, repo, isAncestor }) {
|
|
66
68
|
const r = repo ? ["-R", repo] : [];
|
|
67
69
|
const view = async (n, fields) => parse(await gh(["issue", "view", String(n), ...r, "--json", fields]));
|
|
68
70
|
const inBatch = new Set(issues);
|
|
@@ -74,20 +76,25 @@ export async function unmetDependencies({ issues, baseOf, gh, repo }) {
|
|
|
74
76
|
const base = await baseOf(n);
|
|
75
77
|
if (!base) continue;
|
|
76
78
|
for (const dep of parseDependsOn(rec.body, n).filter((d) => !inBatch.has(d))) {
|
|
77
|
-
let why = null;
|
|
79
|
+
let why = null, sha = null;
|
|
78
80
|
try {
|
|
79
81
|
const d = await view(dep, "state,closedByPullRequestsReferences");
|
|
80
82
|
if (String(d?.state).toUpperCase() !== "CLOSED") why = "not closed";
|
|
81
83
|
else {
|
|
82
84
|
let merged = false;
|
|
85
|
+
let missing = null;
|
|
83
86
|
for (const pr of d.closedByPullRequestsReferences ?? []) {
|
|
84
|
-
const p = parse(await gh(["pr", "view", String(pr.number), ...r, "--json", "baseRefName,mergedAt"]));
|
|
85
|
-
if (p?.mergedAt
|
|
87
|
+
const p = parse(await gh(["pr", "view", String(pr.number), ...r, "--json", "baseRefName,mergedAt,mergeCommit"]));
|
|
88
|
+
if (!p?.mergedAt) continue;
|
|
89
|
+
const sha = p.mergeCommit?.oid;
|
|
90
|
+
if (p.baseRefName === base || (sha && isAncestor && await isAncestor(sha, base))) { merged = true; break; }
|
|
91
|
+
if (sha) missing ??= sha;
|
|
86
92
|
}
|
|
87
|
-
if (!merged)
|
|
93
|
+
if (!merged) sha = missing;
|
|
94
|
+
if (!merged) why = missing ? `merge commit ${missing.slice(0, 10)} is not on ${base}` : `closed but not merged into ${base}`;
|
|
88
95
|
}
|
|
89
96
|
} catch { why = "unreadable"; }
|
|
90
|
-
if (why) unmet.push({ issue: n, dep, base, why });
|
|
97
|
+
if (why) unmet.push({ issue: n, dep, base, why, sha, kind: sha ? "commit" : "other" });
|
|
91
98
|
}
|
|
92
99
|
}
|
|
93
100
|
return unmet;
|