forgedock 1.44.0 → 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.
@@ -525,10 +525,12 @@ export function readEnvConfig(cwd = process.cwd()) {
525
525
  const e = y?.env ?? {};
526
526
  const tr = y?.verification?.test_runners; // #4857: {path prefix: runner command prefix} for validate commands
527
527
  const runners = tr && typeof tr === "object" && !Array.isArray(tr) ? Object.fromEntries(Object.entries(tr).filter(([k, v]) => k && typeof v === "string" && v.trim())) : {};
528
+ const rm = readYamlMap(cwd, "validate", "recipes"); // #5020: y?.validate?.recipes, a block map {match regex: replacement} applied to frozen validate and repro commands
529
+ const recipes = Object.entries(rm ?? {}).map(([match, replace]) => ({ match, replace }));
528
530
  const s = (v) => (typeof v === "string" && v.trim() ? v.trim() : undefined);
529
531
  const kb = typeof e.sparse_max_kb === "string" && /^\d+$/.test(e.sparse_max_kb.trim()) ? Number(e.sparse_max_kb) : e.sparse_max_kb;
530
532
  return { ...(s(e.toolchain) ? { toolchainOverride: s(e.toolchain) } : {}), ...(s(e.setup) ? { envSetup: s(e.setup) } : {}),
531
- ...(Number.isInteger(kb) && kb >= 0 ? { sparseMaxKb: kb } : {}), ...(Object.keys(runners).length ? { testRunners: runners } : {}) };
533
+ ...(Number.isInteger(kb) && kb >= 0 ? { sparseMaxKb: kb } : {}), ...(Object.keys(runners).length ? { testRunners: runners } : {}), ...(recipes.length ? { validateRecipes: recipes } : {}) };
532
534
  } catch { return {}; }
533
535
  }
534
536
  export const ENGINE_PATHS = ["core", "full", "lean", "auto"];
@@ -1762,12 +1764,13 @@ export async function runBatch({ argv, cwd: callerCwd = process.cwd(), env: call
1762
1764
  return (n) => execMod.placeLane({ exec, verifyMode, labels: labelsOf.get(n) ?? [], weight, headroom: headroom(), needs: needsOf.get(n) ?? [], relays });
1763
1765
  })();
1764
1766
  // #4540: provider free slots, refreshed in the background (admission is sync); unknown (null) never blocks.
1765
- let capSample = null, onlineSample = null, capAt = 0, capLast = -Infinity; // #4547: online = agents alive, null unknown
1767
+ let capSample = null, onlineSample = null, capAt = 0, capLast = -Infinity, capFailed = false; // #5048: capFailed = last probe rejected // #4547: online = agents alive, null unknown
1766
1768
  const capacity = () => {
1769
+ if (capFailed) throw new Error("capacity probe failed"); // #5048: until the next successful sample, so every caller sees it (release review CONC-1)
1767
1770
  const t = deps.now ? deps.now() : Date.now();
1768
1771
  if (typeof provider.capacity === "function" && t - capLast >= (deps.pollMs ?? 5000)) {
1769
1772
  capLast = t;
1770
- Promise.resolve().then(() => provider.capacity()).then((c) => { capSample = c && Number.isInteger(c.free) ? c.free : null; onlineSample = c && Number.isInteger(c.online) ? c.online : null; capAt++; }, () => { capSample = null; onlineSample = null; capAt++; });
1773
+ Promise.resolve().then(() => provider.capacity()).then((c) => { capFailed = false; capSample = c && Number.isInteger(c.free) ? c.free : null; onlineSample = c && Number.isInteger(c.online) ? c.online : null; capAt++; }, () => { capSample = null; onlineSample = null; capFailed = true; capAt++; });
1771
1774
  }
1772
1775
  return capSample === null && onlineSample === null ? null : { free: capSample, online: onlineSample, at: capAt };
1773
1776
  };
@@ -431,7 +431,7 @@ export function applyAmendments(frozen, amendments, issueText = null, { generate
431
431
  // #4857: a runnable equivalent of an unrunnable frozen validate command; same id, same checks as a planned command
432
432
  const v = (c.validate ?? []).find((x) => x.id === a.validate);
433
433
  if (!v) { problems.push(`substitute-validate names unknown validate ${a.validate}`); continue; }
434
- const bad = blank(a.command) ? "no command" : shellSyntaxError(a.command) ? "not valid shell (bash -n)" : noopCommand(a.command) ? "runs no check (true, :, exit 0, echo)" : movingBaseDiff(a.command) ? "diffs against a moving base tip" : tmpdirInputs(a.command).length ? "reads a $TMPDIR file it did not create" : null;
434
+ const bad = blank(a.command) ? "no command" : substituteShellProblem(a.command) ? "not valid shell (bash -n)" : noopCommand(a.command) ? "runs no check (true, :, exit 0, echo)" : movingBaseDiff(a.command) ? "diffs against a moving base tip" : tmpdirInputs(a.command).length ? "reads a $TMPDIR file it did not create" : null;
435
435
  if (bad) { problems.push(`substitute-validate ${a.validate}: ${bad}`); continue; }
436
436
  // SEC-11: a second substitute of the same id keeps the frozen command as `from`, so the engine always reruns the original
437
437
  v.substituted = { from: v.substituted?.from ?? v.command, why: String(a.why).trim() }; v.command = String(a.command).trim();
@@ -480,7 +480,9 @@ export function applyAmendments(frozen, amendments, issueText = null, { generate
480
480
  if (!r) { problems.push(`amendment names unknown requirement ${a.requirement}`); continue; }
481
481
  if (!TRACKED.includes(r.disposition)) {
482
482
  if (a.bucket != null) r.bucket = a.bucket; // #4724
483
- if (a.kind === "post-deploy" && !CHECK_ID_RE.test(String(a.check ?? r.check ?? ""))) { problems.push(`amendment post-deploy ${a.requirement} needs a check id (check)`); continue; }
483
+ if (a.kind === "post-deploy" && !CHECK_ID_RE.test(String(a.check ?? r.check ?? ""))) { // #5020: no runnable check: a manual or credentialed action is deferred to the orchestrator, never a conformance failure
484
+ r.disposition = "defer"; r.why = a.why; r.amended = true; r.bucket = String(a.bucket ?? "").startsWith("owner:") ? a.bucket : ORCHESTRATOR_BUCKET; toTrack.push(r); continue;
485
+ }
484
486
  r.disposition = a.kind; r.why = a.why; r.amended = true; r.bucket ??= "post-deploy"; toTrack.push(r); // #4724: an amendment without a bucket defaults to post-deploy
485
487
  if (a.kind === "post-deploy") r.check = a.check ?? r.check; // #4626
486
488
  }
@@ -508,6 +510,7 @@ export function applyAmendments(frozen, amendments, issueText = null, { generate
508
510
 
509
511
  const PY_MISSING_RE = /ModuleNotFoundError: No module named '([^']+)'|ImportError: No module named '?([\w.]+)'?/;
510
512
  const NODE_MISSING_RE = /Cannot find (?:module|package) '([^']+)'/;
513
+ const POETRY_NOT_FOUND_RE = /^\s*Command not found: (\S+)/m;
511
514
  const CMD_NOT_FOUND_RE = /([^\s:'"`]+): command not found/, SH_NOT_FOUND_RE = /([^\s:'"`]+): not found\b/;
512
515
  /** #4857: why a command is not valid bash (`bash -n`), or null when it parses (or bash is unavailable). Catches prose left on a command. */
513
516
  export function shellSyntaxError(command) {
@@ -515,6 +518,49 @@ export function shellSyntaxError(command) {
515
518
  if (r.error || r.status === 0 || r.status == null) return null;
516
519
  return String(r.stderr || "syntax error").trim().split("\n")[0].replace(/^bash: (?:line \d+: )?/, "").slice(0, 160);
517
520
  }
521
+ /** #5020: like shellSyntaxError, but fails closed: when bash cannot run the check the command is not shown to parse. */
522
+ export function substituteShellProblem(command) {
523
+ const r = spawnSync("bash", ["-n", "-c", String(command ?? "")], { encoding: "utf8", timeout: 5000 });
524
+ if (r.error || r.status == null) return "bash -n could not run, so the command is unverified";
525
+ return r.status === 0 ? null : shellSyntaxError(command) ?? "syntax error";
526
+ }
527
+ export const ORCHESTRATOR_BUCKET = "owner:orchestrator";
528
+ /**
529
+ * #5020: a post-deploy requirement that names no check id (a manual or production-credential action) becomes a `defer` with an
530
+ * `owner:orchestrator` bucket, the legal disposition the engine files as a follow-up. Pure; returns {contract, converted[]}.
531
+ */
532
+ export function deferChecklessPostDeploy(contract) {
533
+ const converted = [];
534
+ const requirements = (Array.isArray(contract?.requirements) ? contract.requirements : []).map((r) => {
535
+ if (r?.disposition !== "post-deploy" || CHECK_ID_RE.test(String(r?.check ?? ""))) return r;
536
+ converted.push(r.id);
537
+ return { ...r, disposition: "defer", bucket: String(r.bucket ?? "").startsWith("owner:") ? r.bucket : ORCHESTRATOR_BUCKET, why: r.why ?? "post-deploy action with no registered check: deferred to the orchestrator" };
538
+ });
539
+ return { contract: converted.length ? { ...contract, requirements } : contract, converted };
540
+ }
541
+ /**
542
+ * #5020: apply forge.yaml `validate.recipes` ([{match, replace}], match a regex source) to each validate command and the
543
+ * repro_test command at freeze, first matching recipe per command. Pure; returns {contract, applied[{id, from, to}]}.
544
+ */
545
+ export function applyValidateRecipes(contract, recipes) {
546
+ const list = (Array.isArray(recipes) ? recipes : []).map((r) => { try { return typeof r?.replace === "string" && r?.match ? { re: new RegExp(String(r.match)), replace: r.replace } : null; } catch { return null; } }).filter(Boolean);
547
+ if (!list.length || !contract || typeof contract !== "object") return { contract, applied: [] };
548
+ const applied = [];
549
+ const rewrite = (id, cmd) => {
550
+ if (typeof cmd !== "string") return cmd;
551
+ for (const { re, replace } of list) if (re.test(cmd)) { const to = cmd.replace(re, replace); if (to !== cmd) applied.push({ id, from: cmd, to }); return to; }
552
+ return cmd;
553
+ };
554
+ const out = { ...contract };
555
+ if (Array.isArray(contract.validate)) out.validate = contract.validate.map((v) => (v && typeof v === "object" ? { ...v, command: rewrite(v.id, v.command) } : v));
556
+ const t = contract.repro_test;
557
+ if (t && typeof t === "object" && !Array.isArray(t)) out.repro_test = { ...t, command: rewrite("repro_test", t.command) };
558
+ return { contract: applied.length ? out : contract, applied };
559
+ }
560
+ /** #5020: the env.* verdict for a repro command that failed on head because a tool is missing (e.g. an uninstalled Poetry env); null when the failure is the test's own. */
561
+ export function reproEnvMiss(run, opts) {
562
+ return run && run.rc !== 0 && run.rc !== 124 ? missingTool(run, opts) : null;
563
+ }
518
564
  const NOOP_SEGMENT_RE = /^(?:true|false|:|exit(?:\s+\d+)?|echo(?:\s.*)?|printf(?:\s.*)?|sleep(?:\s+[\d.]+)?|test\s+-?\w+|\[\s+-?\w+\s+\]|fi|done|node\s+-e\s+['"]?[\d;\s]*['"]?|python3?\s+-c\s+['"]?(?:pass|0|None)?['"]?)$/;
519
565
  const KEYWORD_RE = /^(if|then|else|elif|do|while|until)(?:\s+|$)/;
520
566
  const CONST_FALSE_RE = /^(?:false|\[\s+0\s+=\s+1\s+\]|\[\s+1\s+=\s+0\s+\]|test\s+0\s+=\s+1|test\s+-n\s+(?:""|'')|\[\s+-n\s+(?:""|'')\s+\])$/;
@@ -645,6 +691,8 @@ export function missingTool({ rc, output } = {}, { repoHas = () => false, comman
645
691
  const tool = scoped ? spec.split("/").slice(0, 2).join("/") : spec;
646
692
  return local(tool) || (scoped && !tool.includes("/")) ? null : { tool, kind: "node-module" };
647
693
  }
694
+ const po = POETRY_NOT_FOUND_RE.exec(out)?.[1]; // #5020: `poetry run pytest` in an env that was never installed
695
+ if (po) return local(po) ? null : { tool: po, kind: "command" };
648
696
  const cmd = CMD_NOT_FOUND_RE.exec(out)?.[1] ?? (rc === 127 ? SH_NOT_FOUND_RE.exec(out)?.[1] ?? String(command).trim().split(/\s+/)[0] : null);
649
697
  if (!cmd || cmd.includes("/") || local(cmd)) return null;
650
698
  return { tool: cmd, kind: "command" };
@@ -688,6 +736,8 @@ export function precheck({ contract, result, changedFiles, ignore = [], ciVerifi
688
736
  for (const v of contract.validate ?? []) {
689
737
  // #4979 SPEC-2: a substitute counts only when the frozen command it replaced cannot run (or passes); checked first, never CI-covered
690
738
  if (v.substituted) {
739
+ const shellBad = substituteShellProblem(v.command); // #5020: re-checked before the substitute is used
740
+ if (shellBad) { failures.push({ code: C.CONTRACT_INVALID_COMMAND, kind: "validate_command_invalid", detail: `validate ${v.id} substitute \`${v.command}\` is not valid shell (bash -n: ${shellBad})` }); continue; }
691
741
  if (removedPaths === null) { failures.push({ code: C.CONFORMANCE_UNIMPLEMENTED, kind: "substitute_unverified", detail: `validate ${v.id} substituted \`${v.command}\` for the frozen \`${v.substituted.from}\`, but the engine could not read the diff's removed paths to show the frozen command's target survives` }); continue; }
692
742
  const gone = removedNamedPaths(v.substituted.from, removedPaths, baseFiles);
693
743
  if (gone.length) { failures.push({ code: C.CONFORMANCE_UNIMPLEMENTED, kind: "substitute_removed_path", detail: `validate ${v.id} substituted \`${v.command}\` for the frozen \`${v.substituted.from}\`, but the diff removes ${gone.map((x) => `\`${x}\``).join(", ")} that the frozen command names: a substitute may not replace a check by deleting its target` }); continue; }
@@ -3,7 +3,7 @@
3
3
  import { readFileSync } from "node:fs";
4
4
  import { describe, it } from "node:test";
5
5
  import assert from "node:assert/strict";
6
- import { removedNamedPaths, removedPathsOf, shellSyntaxError, unrunnableValidate, noopCommand, substituteVerdict, describesUnrunnableCommand, vitestQuietFlag, normalizeRunnerCommand, cliUsageError, validateContract, applyAmendments, precheck, hotspotGrowth, reproFailures, isBugIssue, parseReview, checkCompleteness, decide, closePlan, isInvestigation, formatShadowSummary, followupItems, inIssue, isTestPath, normalizeEvidencePath, mergeReviewRows, reviewSkeleton, missingIds, resolveEvidencePath, movingBaseDiff, scopeText, evidencePathLine, evidencePathOnly, explainFailures, resolveForgedockPaths, missingTool, repoTracks, tmpdirInputs, isScopeCheck, isPureScopeCheck, dropScopeValidates, tagOutOfBand, outOfBandRequirements, isOutOfBand, followupMap, VERIFY_MODES, OUT_OF_BAND_RE, FOLLOWUP_CLASSES, followupClass, BUCKETS, bucketKind, bucketProblems, bucketTally, isE2eCommand, e2eVerified, impliedDocDeliverables } from "./conformance.mjs";
6
+ import { removedNamedPaths, removedPathsOf, shellSyntaxError, unrunnableValidate, noopCommand, substituteVerdict, describesUnrunnableCommand, vitestQuietFlag, normalizeRunnerCommand, cliUsageError, validateContract, applyAmendments, precheck, hotspotGrowth, reproFailures, isBugIssue, parseReview, checkCompleteness, decide, closePlan, isInvestigation, formatShadowSummary, followupItems, inIssue, isTestPath, normalizeEvidencePath, mergeReviewRows, reviewSkeleton, missingIds, resolveEvidencePath, movingBaseDiff, scopeText, evidencePathLine, evidencePathOnly, explainFailures, resolveForgedockPaths, missingTool, repoTracks, tmpdirInputs, isScopeCheck, isPureScopeCheck, dropScopeValidates, tagOutOfBand, outOfBandRequirements, isOutOfBand, followupMap, VERIFY_MODES, OUT_OF_BAND_RE, FOLLOWUP_CLASSES, followupClass, BUCKETS, bucketKind, bucketProblems, bucketTally, isE2eCommand, e2eVerified, impliedDocDeliverables, applyValidateRecipes, deferChecklessPostDeploy, substituteShellProblem, reproEnvMiss, ORCHESTRATOR_BUCKET } from "./conformance.mjs";
7
7
 
8
8
  const ISSUE = "## Requirements\n1. The scrape route must release the spend reservation on 429.\n2. The search route must release the spend reservation on 429.\n\n- [ ] Billing pricing is unchanged.";
9
9
  const Q1 = "The scrape route must release the spend reservation on 429.";
@@ -910,9 +910,10 @@ describe("post-deploy check ids (#4626)", () => {
910
910
  assert.ok(validateContract(pd({ check: "Bad Id" }), ISSUE).problems.some((p) => /post-deploy needs a check id/.test(p)));
911
911
  assert.ok(!validateContract(pd({ check: "login-smoke" }), ISSUE).problems.some((p) => /check id/.test(p)));
912
912
  });
913
- it("applyAmendments: a post-deploy amendment needs a check id", () => {
913
+ it("applyAmendments: a post-deploy amendment without a check id is deferred to the orchestrator (#5020)", () => {
914
914
  const bad = applyAmendments(C, [{ kind: "post-deploy", requirement: "R1", why: "w" }], ISSUE);
915
- assert.ok(bad.problems.some((p) => /needs a check id/.test(p)));
915
+ assert.deepEqual(bad.problems, []);
916
+ assert.equal(bad.contract.requirements[0].disposition, "defer");
916
917
  const ok = applyAmendments(C, [{ kind: "post-deploy", requirement: "R1", why: "w", check: "login-smoke" }], ISSUE);
917
918
  assert.equal(ok.contract.requirements[0].check, "login-smoke");
918
919
  });
@@ -1213,3 +1214,56 @@ describe("builder amendment slips (#5036)", () => {
1213
1214
  assert.match(applyAmendments(C, [{ kind: "flaky-note", why: "w" }]).problems[0], /unknown kind 'flaky-note'; valid kinds are .*substitute-validate/);
1214
1215
  });
1215
1216
  });
1217
+
1218
+ describe("#5020 freeze-time environment defects", () => {
1219
+ const base = { requirements: [{ id: "R1", quote: "q", disposition: "deliver" }], validate: [{ id: "V1", command: "cd services/api && poetry run pytest tests/x.py" }, { id: "V2", command: "node --test a.test.mjs" }], repro_test: { path: "t.py", id: "t", command: "cd services/api && poetry run pytest t.py" } };
1220
+ const recipes = [{ match: "poetry run pytest", replace: "uv run --with-requirements r.txt pytest" }];
1221
+
1222
+ it("applyValidateRecipes rewrites matching validate and repro commands and leaves the rest", () => {
1223
+ const { contract, applied } = applyValidateRecipes(base, recipes);
1224
+ assert.equal(contract.validate[0].command, "cd services/api && uv run --with-requirements r.txt pytest tests/x.py");
1225
+ assert.equal(contract.validate[1].command, "node --test a.test.mjs");
1226
+ assert.match(contract.repro_test.command, /uv run --with-requirements r\.txt pytest t\.py/);
1227
+ assert.deepEqual(applied.map((a) => a.id), ["V1", "repro_test"]);
1228
+ assert.equal(base.validate[0].command.includes("poetry"), true, "input is not mutated");
1229
+ });
1230
+ it("applyValidateRecipes ignores no recipes and invalid regexes", () => {
1231
+ assert.equal(applyValidateRecipes(base, null).contract, base);
1232
+ assert.equal(applyValidateRecipes(base, [{ match: "(", replace: "x" }]).applied.length, 0);
1233
+ });
1234
+ it("Poetry's missing-env output is env.tool_missing material, and a repro head that dies on it is an env miss", () => {
1235
+ const run = { rc: 1, output: "Creating virtualenv api-py3.12\n\nCommand not found: pytest\n" };
1236
+ assert.deepEqual(missingTool(run), { tool: "pytest", kind: "command" });
1237
+ assert.deepEqual(reproEnvMiss(run), { tool: "pytest", kind: "command" });
1238
+ assert.equal(reproEnvMiss({ rc: 1, output: "1 failed" }), null);
1239
+ // release review BUG-1: a module the repo tracks is a real failure of the change, not an env miss
1240
+ const mod = { rc: 1, output: "ModuleNotFoundError: No module named 'mypkg'" };
1241
+ assert.deepEqual(reproEnvMiss(mod, { command: "pytest t.py" }), { tool: "mypkg", kind: "python-module" });
1242
+ assert.equal(reproEnvMiss(mod, { command: "pytest t.py", repoHas: repoTracks(["src/mypkg/__init__.py"]) }), null);
1243
+ assert.equal(reproEnvMiss({ rc: 0, output: "" }), null);
1244
+ });
1245
+ it("a post-deploy requirement with no check id is deferred to the orchestrator; one with a check is untouched", () => {
1246
+ const c = { requirements: [{ id: "R1", quote: "q", disposition: "post-deploy", why: "needs prod creds" }, { id: "R2", quote: "q", disposition: "post-deploy", check: "login-smoke", why: "w" }] };
1247
+ const { contract, converted } = deferChecklessPostDeploy(c);
1248
+ assert.deepEqual(converted, ["R1"]);
1249
+ assert.equal(contract.requirements[0].disposition, "defer");
1250
+ assert.equal(contract.requirements[0].bucket, ORCHESTRATOR_BUCKET);
1251
+ assert.equal(contract.requirements[1].disposition, "post-deploy");
1252
+ assert.deepEqual(bucketProblems(contract.requirements[0]), []);
1253
+ });
1254
+ it("a post-deploy amendment with no check becomes a tracked defer instead of a problem", () => {
1255
+ const c = { requirements: [{ id: "R1", quote: "q", disposition: "deliver" }], deliverables: [], validate: [] };
1256
+ const r = applyAmendments(c, [{ kind: "post-deploy", requirement: "R1", why: "manual prod action" }], "q");
1257
+ assert.deepEqual(r.problems, []);
1258
+ assert.equal(r.contract.requirements[0].disposition, "defer");
1259
+ assert.equal(r.contract.requirements[0].bucket, ORCHESTRATOR_BUCKET);
1260
+ });
1261
+ it("a substitute-validate that is not valid shell is rejected", () => {
1262
+ assert.match(substituteShellProblem("pytest tests/ && ("), /./);
1263
+ assert.equal(substituteShellProblem("pytest tests/"), null);
1264
+ const c = { requirements: [{ id: "R1", quote: "q", disposition: "deliver" }], deliverables: [], validate: [{ id: "V1", command: "poetry run pytest" }] };
1265
+ const r = applyAmendments(c, [{ kind: "substitute-validate", validate: "V1", command: "uv run pytest tests/ && (", why: "poetry env missing" }], "q");
1266
+ assert.ok(r.problems.some((p) => /not valid shell/.test(p)));
1267
+ assert.equal(r.contract.validate[0].command, "poetry run pytest");
1268
+ });
1269
+ });
@@ -562,15 +562,18 @@ export function providerLanes({ provider, laneArgs, ledgerFor, env = process.env
562
562
  for (;;) {
563
563
  try { const v = await fn(); st.waited = 0; st.tries = 0; return v; } catch (e) {
564
564
  if (!isTransportError(e)) throw e;
565
- if (st.waited >= unreachableWindowMs) throw Object.assign(new Error(`unreachable for ${Math.round(st.waited / 1000)}s: ${errText(e)}`), { unreachable: true });
565
+ // release review BUG-6: a 401 that outlasts the window is an auth failure (capped sandbox.auth), not an uncapped unreachable pool
566
+ if (st.waited >= unreachableWindowMs) throw Object.assign(new Error(`unreachable for ${Math.round(st.waited / 1000)}s: ${errText(e)}`), { unreachable: true, ...(e?.status === 401 ? { auth: true, hop: e.hop } : {}) });
566
567
  const d = Math.min(pollMs * 2 ** Math.min(st.tries++, 10), 30000, Math.max(1, unreachableWindowMs - st.waited));
567
568
  st.waited += d;
568
569
  await sleep(d);
569
570
  }
570
571
  }
571
572
  };
572
- const unreachable = (h, e) => ({ terminalReason: "deferred", code: OUTCOME_CODES.SANDBOX_UNREACHABLE, retryInSeconds: 60,
573
- detail: `sandbox ${h.id} ${errText(e)}; kept for adopt on resume` });
573
+ const unreachable = (h, e) => (e?.auth
574
+ ? { terminalReason: "deferred", code: OUTCOME_CODES.SANDBOX_AUTH, retryInSeconds: 300, detail: `sandbox ${h.id} kept answering 401 (${e.hop === "agent" ? "the pool agent refused the router" : "check the pool token"}): ${errText(e)}; kept for adopt on resume` }
575
+ : { terminalReason: "deferred", code: OUTCOME_CODES.SANDBOX_UNREACHABLE, retryInSeconds: 60,
576
+ detail: `sandbox ${h.id} ${errText(e)}; kept for adopt on resume` });
574
577
  const tailOf = async (h) => { try { return typeof logTail === "function" ? String(await logTail(h) ?? "") : ""; } catch { return ""; } };
575
578
  const settleClone = (r, id) => (r?.terminalReason === "engine-error" && r.code === undefined && isCloneFailure(r.detail) ? cloneDeferred(id, errLine(r.detail)) : r);
576
579
  const wait = async (h, g = guard({ waited: 0, tries: 0 })) => {
@@ -590,7 +593,7 @@ export function providerLanes({ provider, laneArgs, ledgerFor, env = process.env
590
593
  // A start failure repeats on a relaunch: typed, never engine-error (#4254). Retryable (capacity) defers with backoff.
591
594
  // #4685: a refused or missing credential defers with its own code (a fixed token is picked up on the retry), never needs-human.
592
595
  const startFailed = (id, e) => ([401, 403].includes(e?.status)
593
- ? { terminalReason: "deferred", code: OUTCOME_CODES.SANDBOX_AUTH, retryInSeconds: 300, detail: `sandbox ${id} could not start (auth): ${errText(e)}; check the pool token (COMPUTESHARE_TOKEN or ~/.config/computeshare/forgedock.token)` }
596
+ ? { terminalReason: "deferred", code: OUTCOME_CODES.SANDBOX_AUTH, retryInSeconds: 300, detail: `sandbox ${id} could not start (auth): ${errText(e)}; ${e?.hop === "agent" ? "the pool agent refused the router (re-enroll or fix the agent token)" : "check the pool token (COMPUTESHARE_TOKEN or ~/.config/computeshare/forgedock.token)"}` }
594
597
  : e?.retryable === true
595
598
  ? { terminalReason: "deferred", code: OUTCOME_CODES.SANDBOX_CAPACITY, detail: `sandbox ${id} could not start (capacity): ${errText(e)}`, ...(Number(e.retryInSeconds) > 0 ? { retryInSeconds: Number(e.retryInSeconds) } : {}) }
596
599
  : isCloneFailure(errText(e)) ? cloneDeferred(id, errText(e))
@@ -116,6 +116,27 @@ describe("providerLanes: an unreachable provider is retried, then deferred witho
116
116
  assert.deepEqual(ledgerFor(7).entries("sandbox").map((e) => e.value), ["fake-7"]);
117
117
  });
118
118
 
119
+ it("#5053 a 401 on status/result of a launched sandbox is retried, then ends sandbox.auth (capped), never engine-error", async () => {
120
+ const e401 = () => Object.assign(new Error("sandbox status: HTTP 401 (agent a1 auth)"), { status: 401, hop: "agent", transient: true });
121
+ const mk = (failPolls) => { const prov = flaky(0); let polls = 0; prov.start = async () => ({ provider: "fake", id: "fake-7", issue: 7 });
122
+ prov.status = async (h) => { if (polls++ < failPolls) throw e401(); return prov.live.has(h.issue) ? "running" : "exited"; }; return prov; };
123
+ const ok = mk(2); ok.results[7] = { terminalReason: "merged", pr: 1 }; ok.live.delete(7);
124
+ assert.equal((await lanes(ok).l.runOne(7, "staging")).terminalReason, "merged");
125
+ const down = mk(1e9);
126
+ const r = await lanes(down, { unreachableWindowMs: 5000 }).l.runOne(7, "staging");
127
+ // release review BUG-6: a persistent 401 is sandbox.auth (capped by max_deferrals), not the uncapped sandbox.unreachable
128
+ assert.deepEqual([r.terminalReason, r.code, r.retryInSeconds], ["deferred", "sandbox.auth", 300]);
129
+ assert.match(r.detail, /agent a1 auth/);
130
+ assert.match(r.detail, /pool agent refused the router/);
131
+ });
132
+
133
+ it("#5053 launch 401 from the agent hop says the agent refused, not the forgedock token", async () => {
134
+ const prov = fakeProvider(); prov.start = async () => { throw Object.assign(new Error("sandbox launch: HTTP 401 (agent a1 auth)"), { status: 401, hop: "agent" }); };
135
+ const r = await lanes(prov).l.runOne(7, "staging");
136
+ assert.equal(r.code, "sandbox.auth");
137
+ assert.match(r.detail, /agent a1 auth.*pool agent refused the router/);
138
+ });
139
+
119
140
  it("unreachable: adopt applies the same tolerance, and a non-transport error is still engine-error", async () => {
120
141
  const down = flaky(1e9);
121
142
  const { l } = lanes(down, { unreachableWindowMs: 3000 });
package/bin/lean/lean.mjs CHANGED
@@ -15,7 +15,7 @@ import { ciPollDelayMs, ENGINE_VERSION, DEFAULT_MAX_SESSION_LIMIT_PAUSES, DEFAUL
15
15
  import { openDependency, laneMergedPrs, dependencyOnLane, isTrustedComment, decomposedSubissues, isDecomposedRecord, reopenRevokedDecomposition } from "../engine/phases.mjs";
16
16
  import { createHash, randomBytes } from "node:crypto";
17
17
  import { otherLaneOwner } from "./lanelock.mjs";
18
- import { resolveForgedockPaths, describesUnrunnableCommand, removedPathsOf, TRACKED, normalizeKindModes, blockingFailures, unverifiedRequirements, mutationTargets, mutationFailures, runMutationCheck, MUTATION_SURVIVED, isBugIssue, scopeText, explainFailures, inIssue, validateContract, cliUsageError, normalizeRunnerCommand, applyAmendments, precheck, repoTracks, dropScopeValidates, tagOutOfBand, parseReview, decide, mergeReviewRows, evidencePathOnly, closePlan, followupItems, bucketTally, pendingTracking, unnamedEdges, structureNote, impliedDocDeliverables } from "./conformance.mjs";
18
+ import { applyValidateRecipes, deferChecklessPostDeploy, reproEnvMiss, resolveForgedockPaths, describesUnrunnableCommand, removedPathsOf, TRACKED, normalizeKindModes, blockingFailures, unverifiedRequirements, mutationTargets, mutationFailures, runMutationCheck, MUTATION_SURVIVED, isBugIssue, scopeText, explainFailures, inIssue, validateContract, cliUsageError, normalizeRunnerCommand, applyAmendments, precheck, repoTracks, dropScopeValidates, tagOutOfBand, parseReview, decide, mergeReviewRows, evidencePathOnly, closePlan, followupItems, bucketTally, pendingTracking, unnamedEdges, structureNote, impliedDocDeliverables } from "./conformance.mjs";
19
19
  import { checkAdmission, hostParallelism, ADMISSION_RETRY_SECONDS } from "./admission.mjs";
20
20
  import { namedPaths, isTrustedIssueComment } from "./prefilter.mjs";
21
21
  import { checksAt } from "./postdeploy-check.mjs";
@@ -64,7 +64,7 @@ import { buildResumeDigest } from "./resume.mjs";
64
64
  import { parsePredicate, evalPredicate, premiseAnnotation, registerAsk, registryPkgVersion, closedEdgeNote, onlyAsk, fileRemainder } from "./needs-human-premise.mjs";
65
65
  import { lightEligibility } from "./light.mjs";
66
66
  import { untrackToolState, unstageToolState, isToolState, clearLaneScratch } from "./toolstate.mjs";
67
- import { ensureSignedOff } from "./signoff.mjs";
67
+ import { ensureSignedOff, salvageBranch, dropSalvageTracking } from "./signoff.mjs";
68
68
  import { createLedger, flattenPhases } from "./phaseledger.mjs";
69
69
  import { runLightStep, runLightTests, lightBuildPrompt, upgradeNote, renderLiteReceipt } from "./lightlane.mjs";
70
70
 
@@ -225,7 +225,7 @@ export function isProtectedLaneBranch(b, { issue, lane = null, branches = {}, de
225
225
  */
226
226
  export async function runCore(o) {
227
227
  const { issue, repo, lane: requestedLane = null, laneOverride = false, branches = {}, cwd, dir, io, builderModel = null, reviewerModel = DEFAULT_REVIEWER_MODEL, fixModel = null,
228
- toolchainOverride = null, testRunners = null, envSetup = null, ciMaxMinutes = 360, conformance = "shadow", conformanceKinds = null, conformanceMutation = "off", reviewIncomplete = "needs-human", reproTest = "prefer", closeParent = false, mergeWait = "required", maxSessionLimitPauses = DEFAULT_MAX_SESSION_LIMIT_PAUSES, cliIdleMinutes = 15, cliMaxMinutes = 180, cliBusyMinutes = 45, contextCeilingTokens = 120000, turnBudgets = null, laneLimits = null, maxMergeRetries = 5, sessionLimitMaxWaitMs = DEFAULT_SESSION_LIMIT_WAIT_MS, onProgress = () => {}, emitTelemetry = null, random = Math.random } = o;
228
+ toolchainOverride = null, testRunners = null, validateRecipes = null, envSetup = null, ciMaxMinutes = 360, conformance = "shadow", conformanceKinds = null, conformanceMutation = "off", reviewIncomplete = "needs-human", reproTest = "prefer", closeParent = false, mergeWait = "required", maxSessionLimitPauses = DEFAULT_MAX_SESSION_LIMIT_PAUSES, cliIdleMinutes = 15, cliMaxMinutes = 180, cliBusyMinutes = 45, contextCeilingTokens = 120000, turnBudgets = null, laneLimits = null, maxMergeRetries = 5, sessionLimitMaxWaitMs = DEFAULT_SESSION_LIMIT_WAIT_MS, onProgress = () => {}, emitTelemetry = null, random = Math.random } = o;
229
229
  const now = io.now ?? (() => Date.now());
230
230
  const vcfg = { ...parseVerify({}), ...(o.verify ?? {}) };
231
231
  const learnedCommands = (Array.isArray(o.precheck?.commands) ? o.precheck.commands : []).map((c) => String(c ?? "").trim()).filter(Boolean);
@@ -657,8 +657,8 @@ export async function runCore(o) {
657
657
  const gitPush = async (args) => {
658
658
  if (!args.includes("--delete")) { await untrackToolState({ git, base, ev, keep: await keepPaths() });
659
659
  try { await signOffUnpushed(); }
660
- catch (e) { // #4905: a failed rewrite must not cost the lane's finished commits: push the branch as-is before reporting
661
- if (!e?.noIdentity) { try { await git(["push", "-u", "origin", branch]); ev({ event: "DCO_RESCUE_PUSH", branch }); } catch (pe) { ev({ event: "DCO_RESCUE_PUSH_FAILED", error: String(pe?.message ?? pe).slice(0, 200) }); } }
660
+ catch (e) { // #4905: a failed rewrite must not cost the lane's finished commits: push them as-is before reporting; #5047: to a salvage ref, never the lane branch (the sign-off range excludes origin/<branch>)
661
+ if (!e?.noIdentity) { try { const sha = String(await git(["rev-parse", "HEAD"])).trim(), ref = salvageBranch(issue, sha); await git(["push", "origin", `${sha}:refs/heads/${ref}`]); laneHead = { branch, sha, pushedTo: ref }; ev({ event: "DCO_RESCUE_PUSH", branch: ref }); } catch (pe) { ev({ event: "DCO_RESCUE_PUSH_FAILED", error: String(pe?.message ?? pe).slice(0, 200) }); } }
662
662
  throw e;
663
663
  }
664
664
  } // #4590: never push .forge-lean/ or tool caches this branch added
@@ -679,14 +679,14 @@ export async function runCore(o) {
679
679
  if (ahead) { laneHead = { branch, sha: String(await git(["rev-parse", "HEAD"])).trim(), pushedTo: null }; await gitPush(["push", "-u", "origin", branch]); laneHead.pushedTo = branch; }
680
680
  ev({ event: "CHECKPOINT", reason, dirty: Boolean(dirty), pushed: Boolean(ahead) });
681
681
  return true;
682
- } catch (e) { ev({ event: "CHECKPOINT_FAILED", reason, error: String(e?.message ?? e).slice(0, 300) }); return laneHead ? await rescuePush(reason, e?.pushRejected) : false; }
682
+ } catch (e) { ev({ event: "CHECKPOINT_FAILED", reason, error: String(e?.message ?? e).slice(0, 300) }); return laneHead ? await rescuePush(reason, e?.pushRejected || e?.engineCode === OUTCOME_CODES.ENGINE_DCO_REWRITE) : false; }
683
683
  }
684
684
  /** #4958: the normal push failed (sign-off rewrite, rejection): push HEAD as is to the lane branch, else to a salvage branch, so a torn-down remote sandbox never takes the commits with it. */
685
685
  async function rescuePush(reason, rejected) {
686
686
  // #5014: gitPush untracks tool state and rewrites unpushed commits for DCO before it pushes, so HEAD may have moved since the capture
687
687
  try { const now = String(await git(["rev-parse", "HEAD"])).trim(); if (now) laneHead.sha = now; } catch { /* keep the captured sha */ }
688
688
  // a rejected lane-branch push (protected branch, permissions) would be rejected again: go straight to the salvage branch
689
- for (const ref of [...(rejected ? [] : [branch]), `forge-salvage/${issue}-${laneHead.sha.slice(0, 10)}`]) {
689
+ for (const ref of [...(rejected ? [] : [branch]), salvageBranch(issue, laneHead.sha)]) {
690
690
  try {
691
691
  await git(["push", "origin", `${laneHead.sha}:refs/heads/${ref}`]);
692
692
  laneHead.pushedTo = ref;
@@ -1089,14 +1089,14 @@ export async function runCore(o) {
1089
1089
 
1090
1090
  // ---- 2. worktree (resume an open PR / remote branch) ----
1091
1091
  try { await io.git(["fetch", "origin", lane], { cwd }); } catch { /* offline: use the local ref */ }
1092
- let resumed = null;
1092
+ let resumed = null, fromSalvage = false;
1093
1093
  try {
1094
1094
  const prs = JSON.parse(String(await ghc(["pr", "list", "-R", repo, "--head", branch, "--state", "open", "--json", "number,headRefOid,baseRefName"])) || "[]");
1095
1095
  const remote = String(await io.git(["ls-remote", "--heads", "origin", branch], { cwd })).trim();
1096
1096
  if (prs.length || remote) resumed = { pr: prs[0]?.number ?? null, base: prs[0]?.baseRefName ?? null };
1097
1097
  if (!prs.length && !remote) { // #4958: commits the exit rescue could only push to a salvage branch resume from there
1098
1098
  const salv = String(await io.git(["ls-remote", "--heads", "origin", `refs/heads/forge-salvage/${issue}-*`], { cwd })).split("\n").map((l) => l.trim().split(/\s+/)[1]).filter(Boolean).sort().at(-1);
1099
- if (salv) { await io.git(["fetch", "origin", `${salv}:refs/remotes/origin/${branch}`], { cwd }); resumed = { pr: null, base: null }; ev({ event: "RESUME_FROM_SALVAGE", ref: salv }); }
1099
+ if (salv) { await io.git(["fetch", "origin", `${salv}:refs/remotes/origin/${branch}`], { cwd }); resumed = { pr: null, base: null }; fromSalvage = true; ev({ event: "RESUME_FROM_SALVAGE", ref: salv }); }
1100
1100
  }
1101
1101
  } catch { /* no resume info: start fresh */ }
1102
1102
  // An open PR on another base (a hand-retargeted PR, or one opened before lane resolution) is never merged (#4125).
@@ -1183,6 +1183,7 @@ export async function runCore(o) {
1183
1183
  wtInfo = await addWorktree(["worktree", "add", "-B", branch, wt, base], base);
1184
1184
  }
1185
1185
  wtCreated = true;
1186
+ if (fromSalvage) await dropSalvageTracking({ git, branch }); // #5047: the salvaged commits are unpublished: the sign-off range must include them
1186
1187
  if (wtInfo?.sparse) {
1187
1188
  sparse = { excluded: wtInfo.excluded, maxKb: sparseMaxKb };
1188
1189
  ev({ event: "WORKTREE_SPARSE", excluded: wtInfo.excluded, excluded_bytes: wtInfo.bytes, max_kb: sparseMaxKb });
@@ -1286,7 +1287,13 @@ export async function runCore(o) {
1286
1287
  return null;
1287
1288
  }
1288
1289
  const rg = await heavyStep("precheck", () => io.redGreen({ cwd: wt, base, command: String(t.command), env: laneEnv() })).catch(() => null);
1289
- const words = rg?.base && rg?.head ? rg : { base: "UNRUNNABLE", head: "UNRUNNABLE" };
1290
+ let words = rg?.base && rg?.head ? rg : { base: "UNRUNNABLE", head: "UNRUNNABLE" };
1291
+ if (words.head === "FAILED" && io.runCommand) { // #5020: a head that failed because a tool or env is missing is env.repro_unrunnable, not a red-green verdict
1292
+ const hr = await io.runCommand({ command: String(t.command), cwd: wt, timeoutMs: 120000, env: laneEnv() }).then((x) => x?.r ?? x).catch(() => null);
1293
+ // release review BUG-1: a module the repo tracks is the change's own failure, never an env miss (same repoHas as the validate rerun)
1294
+ const repoHas = repoTracks(String(await git(["ls-files"]).catch(() => "")).split("\n").map((x) => x.trim()).filter(Boolean));
1295
+ if (reproEnvMiss(hr, { command: String(t.command), repoHas })) words = { ...words, head: "UNRUNNABLE" };
1296
+ }
1290
1297
  ev({ event: "REPRO_RED_GREEN", ...words });
1291
1298
  T.decisions.push(`repro_test ${t.path ?? ""}${t.id ? ` ${t.id}` : ""}: BASE=${words.base} HEAD=${words.head}`);
1292
1299
  return words;
@@ -1391,6 +1398,14 @@ export async function runCore(o) {
1391
1398
  * #4328: probe the host for the tools and modules a contract's validate commands need; #4393: then flag `$NAME`s the lane
1392
1399
  * env leaves empty and run each remaining command once on the base worktree (io.preflightRun). The misses; [] without either hook.
1393
1400
  */
1401
+ /** #5020: forge.yaml validate.recipes rewrite frozen commands; a check-less post-deploy requirement becomes a defer to the orchestrator. */
1402
+ function freezeRewrites(c) {
1403
+ const r = applyValidateRecipes(c, validateRecipes);
1404
+ for (const x of r.applied) T.decisions.push(`validate recipe rewrote ${x.id}: \`${x.from}\` -> \`${x.to}\``);
1405
+ const d = deferChecklessPostDeploy(r.contract);
1406
+ if (d.converted.length) T.decisions.push(`Post-deploy with no registered check deferred to the orchestrator: ${d.converted.join(", ")}`);
1407
+ return d.contract;
1408
+ }
1394
1409
  async function preflightValidates(contract) {
1395
1410
  if ((!io.probeCommand && !io.preflightRun) || !contract || typeof contract !== "object") return [];
1396
1411
  const files = String(await git(["ls-files"]).catch(() => "")).split("\n").map((x) => x.trim()).filter(Boolean);
@@ -1667,6 +1682,7 @@ export async function runCore(o) {
1667
1682
  return o.contract;
1668
1683
  };
1669
1684
  let contract = dropScope(await readContract());
1685
+ contract = freezeRewrites(contract);
1670
1686
  await refreshIssue("contract validation");
1671
1687
  let vc = validateContract(contract, issueText, { repro: reproOn() });
1672
1688
  const missed = await preflightValidates(contract); // #4328: validate commands the host cannot run share the one correction
@@ -1675,7 +1691,7 @@ export async function runCore(o) {
1675
1691
  const problems = [...vc.problems, ...preflightProblems(missed), ...testRunnerProblems(contract, testRunners)];
1676
1692
  T.decisions.push(`Contract rejected (${problems[0]}); resumed once`);
1677
1693
  await builder(++attemptN, contractCorrectionPrompt(problems), sessionId, false, planPrompt, { trigger: "contract_correction" });
1678
- contract = dropScope(await readContract());
1694
+ contract = freezeRewrites(dropScope(await readContract()));
1679
1695
  await refreshIssue("contract validation");
1680
1696
  vc = validateContract(contract, issueText, { repro: reproOn() });
1681
1697
  if (missed.length) misses = await preflightValidates(contract);
@@ -2140,7 +2156,8 @@ export async function runCore(o) {
2140
2156
  const sig = JSON.stringify(a.applied);
2141
2157
  if (a.applied.length && sig !== lastAmend) {
2142
2158
  lastAmend = sig;
2143
- await post("FORGE:AMENDMENT", renderAmendments(a.applied, {}, { contract: hash, amendments: all }));
2159
+ try { await post("FORGE:AMENDMENT", renderAmendments(a.applied, {}, { contract: hash, amendments: all })); } // #5020: retried by ghw; a still-failing post is an anomaly, never an engine-error
2160
+ catch (e) { T.anomalies.push(`FORGE:AMENDMENT comment not posted: ${String(e?.message ?? e).slice(0, 160)}`); }
2144
2161
  T.decisions.push(`Contract amended: ${a.applied.map((x) => `${x.kind} ${x.target}`).join(", ")}`);
2145
2162
  }
2146
2163
  pendingDecisions = a.decisions; // #4737: persisted only once the PR merges
@@ -3898,6 +3898,34 @@ describe("batch robustness (#4112-#4115)", () => {
3898
3898
  assert.ok(traj(w).some((c) => /Lane commits pushed: branch `forge-salvage\/7-/.test(c)));
3899
3899
  });
3900
3900
 
3901
+ it("#5047 a failed DCO rewrite rescue-pushes to a salvage ref, never the lane branch", async () => {
3902
+ const w = world();
3903
+ const g = w.io.git;
3904
+ const SEP = "\x1f", END = "\x1e";
3905
+ w.io.git = async (a, o2) => {
3906
+ if (a[0] === "log" && a.some((x) => String(x).startsWith("--format=%H"))) return `deadbeef${SEP}x@localhost${SEP}x@localhost${SEP}${SEP}parent${END}`;
3907
+ if (a[0] === "config") return "Lane Bot";
3908
+ if (a[0] === "filter-branch") throw new Error("boom");
3909
+ return g(a, o2);
3910
+ };
3911
+ const r = await run(w);
3912
+ const pushes = w.calls.filter((c) => c[0] === "git" && c[1] === "push").map((c) => c.join(" "));
3913
+ assert.ok(pushes.length > 0);
3914
+ assert.ok(pushes.some((p) => /origin [0-9a-z]+:refs\/heads\/forge-salvage\/7-/.test(p)), "the commits are published under a salvage ref");
3915
+ assert.ok(!pushes.some((p) => /origin feat\/issue-7-lean$|:refs\/heads\/feat\/issue-7-lean$/.test(p)), `never the lane branch: ${pushes.join(" | ")}`);
3916
+ assert.match(String(r.laneHead?.pushedTo), /^forge-salvage\/7-/);
3917
+ });
3918
+
3919
+ it("#5047 a lane resumed from a salvage branch drops the origin/<branch> tracking ref so the sign-off range covers its commits", async () => {
3920
+ const w = world({ prList: [] });
3921
+ const g = w.io.git;
3922
+ w.io.git = async (a, o2) => (a[0] === "ls-remote" && a.at(-1) === "refs/heads/forge-salvage/7-*" ? "abc\trefs/heads/forge-salvage/7-1234567890\n" : g(a, o2));
3923
+ await run(w);
3924
+ const wtAdd = w.calls.findIndex((c) => c[1] === "worktree" && c[2] === "add");
3925
+ const drop = w.calls.findIndex((c) => c[1] === "update-ref" && c[2] === "-d" && c[3] === "refs/remotes/origin/feat/issue-7-lean");
3926
+ assert.ok(wtAdd >= 0 && drop > wtAdd, "dropped after the worktree is built from it");
3927
+ });
3928
+
3901
3929
  it("#4958 when every rescue push is rejected the head is reported as NOT pushed", async () => {
3902
3930
  const msg = "remote: error: GH006: Protected branch update failed for refs/heads/x";
3903
3931
  const w = world({ pushErrors: [msg, msg, msg, msg] });
@@ -4048,6 +4076,13 @@ describe("batch robustness (#4112-#4115)", () => {
4048
4076
  assert.equal(n, 1);
4049
4077
  });
4050
4078
 
4079
+ it("#5020 forge.yaml validate.recipes rewrite the frozen validate command and the freeze records it", async () => {
4080
+ const w = world({});
4081
+ const r = await run(w, { validateRecipes: [{ match: "^node --test ", replace: "node --test --test-reporter=dot " }] });
4082
+ assert.equal(r.terminalReason, "merged");
4083
+ assert.ok(w.comments.some((c) => c.includes("FORGE:CONTRACT") && c.includes("node --test --test-reporter=dot test/foo.test.js")));
4084
+ });
4085
+
4051
4086
  // ---- #4112 ----
4052
4087
  const fakeCli = async (name, body) => {
4053
4088
  const { writeFileSync, chmodSync } = await import("node:fs");
@@ -97,3 +97,26 @@ describe("remote lanes with a registry lacking the #4964 methods", () => {
97
97
  assert.deepEqual(calls, [], "a remote lane never takes a host slot");
98
98
  });
99
99
  });
100
+
101
+ describe("#5048: the pool is unreachable only after a failed capacity probe", () => {
102
+ const run = async (capacity) => {
103
+ const notes = [];
104
+ const registry = { tryAcquire: () => ({ ok: true }), offer() {}, withdraw() {}, release() {}, setLanePid() {}, adoptSlot() {}, holder: () => null, notePool: (b, st) => notes.push(st === null ? "clear" : "down") };
105
+ const rows = await orchestrate({ issues: [4, 5, 6], parallel: 1, pollMs: 5, trusted: () => true, readBody: async () => "", sched: { registry, batch: "B", repo: "o/r" },
106
+ remote: { max: 1, isRemote: () => true, ...(capacity ? { capacity } : {}) },
107
+ runOne: async (n, lane, ctx) => { ctx.onStart?.({ handle: { id: `s-${n}` }, remote: true }); return merged; } });
108
+ assert.ok(rows.every((r) => r.outcome === "merged"));
109
+ return notes;
110
+ };
111
+ it("unknown capacity (null) does not mark the pool unreachable", async () => {
112
+ assert.deepEqual(await run(() => null), []);
113
+ });
114
+ it("a provider without capacity() never marks the pool unreachable", async () => {
115
+ assert.deepEqual(await run(null), []);
116
+ });
117
+ it("a throwing probe marks it down and a later successful sample clears it", async () => {
118
+ let calls = 0;
119
+ const notes = await run(() => { if (++calls === 1) throw new Error("boom"); return { free: 1, online: 1, at: calls }; });
120
+ assert.deepEqual(notes, ["down", "clear"]);
121
+ });
122
+ });
@@ -516,9 +516,10 @@ export async function orchestrate({ issues, parallel = 3, runOne, readBody, pref
516
516
  const adm = admitRemote();
517
517
  if (adm.ok) {
518
518
  // #4540: the provider's free slots (`remote.capacity() -> {free, at}`, sync, null = unknown) cap admissions per sample `at`; the issue queues here instead of a 503.
519
- let cap = null;
520
- try { cap = remote.capacity?.() ?? null; } catch { cap = null; }
521
- if (typeof remote.capacity === "function") notePool(cap === null, "capacity probe failed");
519
+ let cap = null, probeFailed = false;
520
+ try { cap = remote.capacity?.() ?? null; } catch { probeFailed = true; }
521
+ // #5048: only a failed probe (a throw) marks the pool down; null is unknown (first pass, no capacity()) and leaves the state; a sample clears it.
522
+ if (probeFailed) notePool(true, "capacity probe failed"); else if (cap !== null) notePool(false);
522
523
  // #4788: zero live agents for longer than `remote.poolOfflineMin` (default 10): one loud batch event, none while online > 0.
523
524
  if (cap && cap.online === 0) {
524
525
  poolOfflineSince ??= now();
@@ -96,7 +96,9 @@ describe("post-deploy named checks (#4626)", () => {
96
96
  assert.ok(validateContract(c, Q).problems.some((p) => /needs a check id/.test(p)));
97
97
  assert.ok(!validateContract({ ...c, requirements: [{ ...c.requirements[0], check: "smoke" }] }, Q).problems.some((p) => /check id/.test(p)));
98
98
  const frozen = { ...c, requirements: [{ id: "R1", quote: Q, disposition: "deliver" }] };
99
- assert.ok(applyAmendments(frozen, [{ kind: "post-deploy", requirement: "R1", why: "w" }], Q).problems.some((p) => /check id/.test(p)));
99
+ const deferred = applyAmendments(frozen, [{ kind: "post-deploy", requirement: "R1", why: "w" }], Q); // #5020: no check id defers to the orchestrator
100
+ assert.deepEqual(deferred.problems, []);
101
+ assert.equal(deferred.contract.requirements[0].disposition, "defer");
100
102
  const ok = applyAmendments(frozen, [{ kind: "post-deploy", requirement: "R1", why: "w", check: "smoke" }], Q);
101
103
  assert.equal(ok.contract.requirements[0].check, "smoke");
102
104
  const pc = precheck({ contract: { ...c, requirements: [{ ...c.requirements[0], check: "ghost" }] }, result: { requirements: [] }, changedFiles: [], checkIds: new Set(["smoke"]) });
@@ -101,6 +101,12 @@ function moduleNeeds(exe, args) {
101
101
  * probe (tool) a module probe depends on: skip the module probe when its interpreter is missing. A name `repoHas(name)` holds
102
102
  * or a deliverable's change text names (the change adds it) is not probed; paths and `$...` words are never probed.
103
103
  */
104
+ /** #5020: the contract's runnable commands: each validate entry plus the repro_test, which fails the same ways. */
105
+ export function contractCommands(contract) {
106
+ const out = (Array.isArray(contract?.validate) ? contract.validate : []).filter((v) => v?.id && typeof v.command === "string");
107
+ const t = contract?.repro_test;
108
+ return t && typeof t === "object" && typeof t.command === "string" && t.command.trim() ? [...out, { id: "repro_test", command: t.command }] : out;
109
+ }
104
110
  export function preflightProbes(contract, { repoHas = () => false, cap = PREFLIGHT_CAP } = {}) {
105
111
  const changes = (Array.isArray(contract?.deliverables) ? contract.deliverables : []).map((d) => String(d?.change ?? "")).join("\n");
106
112
  const named = (name) => new RegExp(`(^|[^\\w@/.-])${name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}(?![\\w/-])`).test(changes);
@@ -115,8 +121,7 @@ export function preflightProbes(contract, { repoHas = () => false, cap = PREFLIG
115
121
  : `node -e ${sq(NODE_PROBE)} ${sq(tool)} >/dev/null 2>&1`;
116
122
  byKey.set(key, { ids: [id], tool, kind, interp, needs: kind === "command" ? null : interp, probe });
117
123
  };
118
- for (const v of Array.isArray(contract?.validate) ? contract.validate : []) {
119
- if (!v?.id || typeof v.command !== "string") continue;
124
+ for (const v of contractCommands(contract)) {
120
125
  for (const seg of shellSegments(v.command)) {
121
126
  let i = 0;
122
127
  while (i < seg.length && (/^[A-Za-z_]\w*=/.test(seg[i]) || ["!", "time", "exec", "command", "env"].includes(seg[i]))) i++;
@@ -238,7 +243,7 @@ const porcelain = (out) => new Map(String(out ?? "").split("\n").filter((l) => l
238
243
  */
239
244
  export async function runPreflight({ contract, run, git, env = {}, repoHas = () => false, knownPath = () => false, skipIds = [], now = () => Date.now(), perCommandMs = RUN_PROBE_MS, budgetMs = RUN_BUDGET_MS }) {
240
245
  const misses = [], skipped = [], reverted = [];
241
- const todo = (Array.isArray(contract?.validate) ? contract.validate : []).filter((v) => v?.id && typeof v.command === "string" && !skipIds.includes(v.id));
246
+ const todo = contractCommands(contract).filter((v) => !skipIds.includes(v.id));
242
247
  if (!todo.length) return { misses, skipped, reverted };
243
248
  const status = async () => porcelain(await git(["status", "--porcelain", "--untracked-files=all"]).catch(() => ""));
244
249
  const before = await status(), deadline = now() + budgetMs;
@@ -4,7 +4,7 @@ import { describe, it } from "node:test";
4
4
  import assert from "node:assert/strict";
5
5
  import { mkdirSync, writeFileSync } from "node:fs";
6
6
  import { join } from "node:path";
7
- import { testRunnerProblems, shellSegments, pythonImports, nodeImports, preflightProbes, preflightProblems, runProbeMiss, unsetEnvVars, runPreflight, knownToContract } from "./preflight.mjs";
7
+ import { testRunnerProblems, shellSegments, pythonImports, nodeImports, preflightProbes, preflightProblems, runProbeMiss, unsetEnvVars, runPreflight, knownToContract, contractCommands } from "./preflight.mjs";
8
8
  import { runCheckCommand } from "./effects.mjs";
9
9
  import { repoTracks } from "./conformance.mjs";
10
10
  import { tmpDir } from "../tests/_tmp.mjs";
@@ -120,3 +120,15 @@ describe("testRunnerProblems (#4857)", () => {
120
120
  assert.deepEqual(testRunnerProblems(C("pytest services/worker/t.py"), null), []);
121
121
  });
122
122
  });
123
+
124
+ describe("#5020 repro_test is probed like a validate command", () => {
125
+ const c = { validate: [{ id: "V1", command: "node --test a.test.mjs" }], repro_test: { path: "t.py", id: "t", command: "madeuptool --run t.py" } };
126
+ it("contractCommands lists validate entries then the repro_test", () => {
127
+ assert.deepEqual(contractCommands(c).map((x) => x.id), ["V1", "repro_test"]);
128
+ assert.deepEqual(contractCommands({ repro_test: "none" }), []);
129
+ });
130
+ it("preflightProbes probes the repro command's tool", () => {
131
+ const { probes } = preflightProbes(c);
132
+ assert.ok(probes.some((p) => p.tool === "madeuptool" && p.ids.includes("repro_test")));
133
+ });
134
+ });
@@ -246,7 +246,7 @@ export function contextPackNote(pack) {
246
246
  /** #4857: validate-command rules for the plan prompt (the implement session resumes it), kept out of the shared templates (their token caps are guarded). */
247
247
  export function unrunnableValidateNote(testRunners = null) {
248
248
  const map = testRunners && typeof testRunners === "object" ? Object.entries(testRunners).map(([k, v]) => `${k}/ -> ${v}`).join("; ") : "";
249
- return `\n\nValidate and repro commands must pass bash -n (no prose notes), select tests that exist, and run from the right cwd${map ? ` (forge.yaml verification.test_runners: ${map})` : ""}. An unrunnable validate (missing tool, wrong cwd, no tests selected) is never a block or "human" product-decision: run an equivalent through the repo's toolchain and record {"kind":"substitute-validate","validate":"V#","command":"<runnable equivalent>","why":""} in ${RESULT_FILE} amendments.`;
249
+ return `\n\nValidate and repro commands must pass bash -n (no prose notes), select tests that exist, and run from the right cwd${map ? ` (forge.yaml verification.test_runners: ${map})` : ""}. An unrunnable validate (missing tool, wrong cwd, no tests selected) is never a block or "human" product-decision: run an equivalent through the repo's toolchain and record {"kind":"substitute-validate","validate":"V#","command":"<runnable equivalent>","why":""} in ${RESULT_FILE} amendments. A post-deploy requirement needs a registered check id; one that is a manual or production-credential action with no runnable check is disposition "defer" with bucket "owner:<action>" (the engine also converts a check-less post-deploy to a defer to the orchestrator). forge.yaml validate.recipes rewrite matching commands at freeze.`;
250
250
  }
251
251
 
252
252
  export function reproPlanNote(on) {
@@ -189,6 +189,16 @@ 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("#5053 httpTransport: a 401 on status is transient and names its hop; a launch 401 is not transient", async () => {
193
+ const reply = (status, body) => ({ status, ok: status < 300, text: async () => JSON.stringify(body), headers: { get: () => null } });
194
+ 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" }) });
195
+ await assert.rejects(t.status("x"), (e) => e.transient === true && e.hop === "agent" && e.reason === "router token rejected by agent" && /agent a1 auth: router token rejected/.test(e.message) && !e.message.includes(TOKEN));
196
+ await assert.rejects(t.result("x"), (e) => e.transient === true && e.status === 401);
197
+ await assert.rejects(t.launch({ id: "x" }), (e) => e.transient !== true && e.status === 401);
198
+ const r = httpTransport({ endpoint: "https://pool.example", fetchFn: async () => reply(401, { hop: "router" }) });
199
+ await assert.rejects(r.status("x"), /router auth/);
200
+ });
201
+
192
202
  it("httpTransport: bearer token, JSON protocol, 404 = gone/destroyed, 429 retryable; the token never in errors", async () => {
193
203
  const calls = [];
194
204
  const reply = (status, body, headers = {}) => ({ status, ok: status >= 200 && status < 300, text: async () => (body === undefined ? "" : JSON.stringify(body)), headers: { get: (k) => headers[k] ?? null } });
@@ -320,10 +330,11 @@ describe("pool provider (#4546)", () => {
320
330
 
321
331
  describe("remote capacity (GET /status, #4540)", () => {
322
332
  const mk = (res) => httpTransport({ endpoint: "https://pool.example/v1", fetchFn: async (url) => { if (res instanceof Error) throw res; if (url.endsWith("/agents")) return { status: 404, ok: false, text: async () => "", headers: { get: () => null } }; assert.equal(url, "https://pool.example/v1/status"); return { status: res.status, ok: res.status < 400, text: async () => res.body ?? "", headers: { get: () => null } }; } });
323
- it("free integer is reported; 404, malformed, negative and transport errors are unknown (null)", async () => {
333
+ it("free integer is reported; 404, malformed and negative are unknown (null); an unreachable router throws (#5048)", async () => {
324
334
  assert.deepEqual(await mk({ status: 200, body: '{"free":3}' }).capacity(), { free: 3 });
325
335
  assert.deepEqual(await mk({ status: 200, body: '{"free":0}' }).capacity(), { free: 0 });
326
- for (const r of [{ status: 404 }, { status: 200, body: "nope" }, { status: 200, body: '{"free":"2"}' }, { status: 200, body: '{"free":-1}' }, new Error("down")]) assert.equal(await mk(r).capacity(), null);
336
+ for (const r of [{ status: 404 }, { status: 200, body: "nope" }, { status: 200, body: '{"free":"2"}' }, { status: 200, body: '{"free":-1}' }]) assert.equal(await mk(r).capacity(), null);
337
+ await assert.rejects(() => mk(new Error("down")).capacity(), (e) => e.unreachable === true && /pool router unreachable/.test(e.message));
327
338
  });
328
339
  it("#4788: no online on /status: derived from /agents (ok && !paused); an unreadable /agents stays unknown", async () => {
329
340
  const mkA = (status, agents) => httpTransport({ endpoint: "https://pool.example/v1", fetchFn: async (url) => {
@@ -337,12 +348,12 @@ describe("remote capacity (GET /status, #4540)", () => {
337
348
  assert.deepEqual(await mkA(200, down).capacity(), { online: 0, total: 1 });
338
349
  assert.equal(await mkA(404, "nope").capacity(), null);
339
350
  });
340
- it("remoteProvider.capacity delegates, and is null without a transport capability or on a throw", async () => {
351
+ it("remoteProvider.capacity delegates, is null without a transport capability, and passes a transport error on (#5048)", async () => {
341
352
  const batch = openBatch(join(tmpDir("cap-"), "batches"), "bt");
342
353
  const p = (transport) => remoteProvider({ batch, repo: "o/r", transport, env: {}, credentials: () => null });
343
354
  assert.deepEqual(await p({ capacity: async () => ({ free: 1 }) }).capacity(), { free: 1 });
344
355
  assert.equal(await p({}).capacity(), null);
345
- assert.equal(await p({ capacity: async () => { throw new Error("x"); } }).capacity(), null);
356
+ await assert.rejects(() => p({ capacity: async () => { throw new Error("x"); } }).capacity(), /x/);
346
357
  });
347
358
  });
348
359
 
@@ -60,8 +60,14 @@ export function httpTransport({ endpoint, token = null, fetchFn = globalThis.fet
60
60
  return { status: res.status, ok: res.ok, data, retryAfter: res.headers?.get?.("retry-after") ?? null };
61
61
  };
62
62
  const fail = (what, r) => {
63
- const e = new Error(`sandbox ${what}: HTTP ${r.status}`);
63
+ // #5053: a 401 names the hop that answered it (router or agent; the pool tags the body), never the token.
64
+ const hop = r.status === 401 && ["router", "agent"].includes(r.data?.hop) ? r.data.hop : null;
65
+ const who = hop === "agent" && typeof r.data?.agent === "string" ? `agent ${r.data.agent.slice(0, 64)}` : hop;
66
+ const reason = hop && typeof r.data?.reason === "string" ? r.data.reason.replace(/^(router|agent) auth: /, "").replace(/[^\x20-\x7e]/g, "").slice(0, 80) : "";
67
+ const e = new Error(`sandbox ${what}: HTTP ${r.status}${who ? ` (${who} auth${reason ? `: ${reason}` : ""})` : ""}`);
64
68
  e.status = r.status;
69
+ if (hop) e.hop = hop;
70
+ if (reason) e.reason = reason;
65
71
  if ([502, 503, 504].includes(r.status)) e.transient = true; // #4672: a router restart or gateway blip, retried by the lane poll
66
72
  if (r.status === 429 || r.status === 503) { e.retryable = true; const n = Number(r.retryAfter); if (Number.isFinite(n) && n > 0) e.retryInSeconds = n; }
67
73
  return e;
@@ -74,12 +80,12 @@ export function httpTransport({ endpoint, token = null, fetchFn = globalThis.fet
74
80
  async status(id) {
75
81
  const r = await call("GET", p(id));
76
82
  if (r.status === 404) return "gone";
77
- if (!r.ok) throw fail("status", r);
83
+ if (!r.ok) throw Object.assign(fail("status", r), r.status === 401 ? { transient: true } : {}); // #5053: the sandbox launched, so a 401 is retried like an unreachable pool
78
84
  const s = String(r.data?.state ?? "");
79
85
  return s === "running" || s === "pending" || s === "starting" ? "running" : s === "gone" ? "gone" : "exited";
80
86
  },
81
87
  async logs(id, cursor = 0) { const r = await call("GET", `${p(id)}/logs?cursor=${encodeURIComponent(cursor)}`); return r.ok ? { text: String(r.data?.text ?? ""), cursor: r.data?.cursor ?? cursor } : { text: "", cursor }; },
82
- async result(id) { const r = await call("GET", `${p(id)}/result`); return r.ok && r.data && typeof r.data === "object" ? r.data : null; },
88
+ async result(id) { const r = await call("GET", `${p(id)}/result`); if (r.status === 401) throw Object.assign(fail("result", r), { transient: true }); return r.ok && r.data && typeof r.data === "object" ? r.data : null; },
83
89
  async usage(id) { const r = await call("GET", `${p(id)}/usage`); return r.ok && Number.isFinite(Number(r.data?.minutes)) ? { minutes: Number(r.data.minutes), ...(typeof r.data.agent === "string" && r.data.agent ? { agent: r.data.agent } : {}) } : null; }, // agent: #4549, optional
84
90
  async stop(id) { const r = await call("POST", `${p(id)}/stop`); if (r.ok) return true; if (r.status === 404 || r.status === 405) return false; throw fail("stop", r); },
85
91
  async destroy(id) { const r = await call("DELETE", p(id)); if (r.ok || r.status === 404) return true; throw fail("destroy", r); },
@@ -88,19 +94,23 @@ export function httpTransport({ endpoint, token = null, fetchFn = globalThis.fet
88
94
  async capacity() {
89
95
  // `free` (#4540) and agent liveness `online`/`total` (#4547) are each optional; none valid is unknown.
90
96
  const ok = (v) => Number.isInteger(v) && v >= 0;
91
- let out = {};
97
+ let out = {}, reached = false, lastErr = null;
92
98
  try {
93
99
  const r = await call("GET", "/status");
100
+ reached = true;
94
101
  if (r.ok) { const { free, online, total } = r.data ?? {}; out = { ...(ok(free) ? { free } : {}), ...(ok(online) ? { online } : {}), ...(ok(total) ? { total } : {}) }; }
95
- } catch { out = {}; }
102
+ } catch (e) { out = {}; lastErr = e; }
96
103
  if (out.online === undefined) {
97
104
  // #4788: a router without /status liveness: online = agents answering and not paused, as bin/pool/router.mjs status() does.
98
105
  try {
99
106
  const r = await call("GET", "/agents");
107
+ reached = true;
100
108
  const list = r.ok && Array.isArray(r.data?.agents) ? r.data.agents : null;
101
109
  if (list) out = { ...out, online: list.filter((a) => a?.ok === true && !a.paused).length, total: list.length };
102
- } catch { /* liveness stays unknown */ }
110
+ } catch (e) { lastErr = e; /* liveness stays unknown */ }
103
111
  }
112
+ // #5048 (release review REG-2): a router that answered neither call is unreachable: throw, so the pool is marked down
113
+ if (!reached) throw Object.assign(new Error(`pool router unreachable: ${String(lastErr?.message ?? lastErr).slice(0, 160)}`), { unreachable: true });
104
114
  return Object.keys(out).length ? out : null;
105
115
  },
106
116
  };
@@ -218,7 +228,9 @@ export function remoteProvider({ batch, repo, settings = {}, name = "remote", tr
218
228
  return r ?? null;
219
229
  },
220
230
  async capacity() {
221
- try { return (await transport.capacity?.()) ?? null; } catch { return null; }
231
+ // #5048: no capacity() is unknown (null); a transport error propagates, so the caller can report the pool down
232
+ if (typeof transport.capacity !== "function") return null;
233
+ return (await transport.capacity()) ?? null;
222
234
  },
223
235
  /** #4612: a graceful stop at the lane's checkpoint; false when unsupported or failed. Never destroys the sandbox. */
224
236
  async stop(h) {
@@ -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;
@@ -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();
@@ -143,4 +143,34 @@ describe("ensureSignedOff", () => {
143
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));
144
144
  assert.equal(readFileSync(join(f.wt, "base.txt"), "utf8"), "churn\n");
145
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
+ });
146
176
  });
@@ -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;
@@ -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.data, retryAfter: r.retryAfter };
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.data ?? {}), agent: c.name } };
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.data };
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/docs/CONFIG.md CHANGED
@@ -1358,6 +1358,7 @@ verification:
1358
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 |
1359
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
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. |
1361
1362
 
1362
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`
1363
1364
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "forgedock",
3
- "version": "1.44.0",
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"