forgedock 1.44.0 → 1.46.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
+ });
@@ -160,7 +160,18 @@ export function classify(item, policy, opts = {}) {
160
160
  export async function settle(item, policy, deps = {}, opts = {}) {
161
161
  const row = { issue: item?.issue ?? null, escalationId: item?.escalationId ?? null, kind: null, action: "skipped" };
162
162
  try {
163
- if (item.escalationId && (item.comments ?? []).some((c) => /FORGE:DECISION/.test(String(c?.body ?? "")) && String(c.body).includes(`Escalation: ${item.escalationId}`))) return { ...row, reason: "already handled" };
163
+ const prior = item.escalationId ? (item.comments ?? []).filter((c) => /FORGE:DECISION/.test(String(c?.body ?? "")) && String(c.body).includes(`Escalation: ${item.escalationId}`)) : [];
164
+ if (prior.length) {
165
+ // release #5068 BUG-1: a full live decision whose label clear or requeue failed is finished, not skipped (the caller
166
+ // only lists issues still labelled needs-human); a vetoed, shadow, escalated or mixed one stays handled
167
+ const body = (c) => String(c?.body ?? "");
168
+ const torn = prior.some((c) => /^<!-- FORGE:DECISION -->\s*\n## Decision\n/.test(body(c)) && !/External part stays needs-human/.test(body(c)))
169
+ && !prior.some((c) => /## Decision vetoed/.test(body(c)));
170
+ if (!torn) return { ...row, reason: "already handled" };
171
+ await deps.setLabels({ issue: item.issue, remove: [NEEDS_HUMAN_LABEL] });
172
+ await deps.requeue(item.issue);
173
+ return { ...row, action: "decided", resumed: true, reason: "finished an interrupted live decision" };
174
+ }
164
175
  const s = splitEscalation(item, policy.matrix, opts);
165
176
  row.kind = s.kind;
166
177
  if (s.kind === "recovery") return { ...row, reason: "recovery owns this code" };
@@ -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 });
@@ -0,0 +1,175 @@
1
+ // SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
2
+ // SPDX-License-Identifier: AGPL-3.0-or-later
3
+ /**
4
+ * #4793: the north-star decision engine (#4766) as an issue-watch stage. Each round the watcher lists the open `needs-human`
5
+ * issues that match its selectors, reads the newest trusted `FORGE:NEEDS_HUMAN` comment of each (its comment id is the escalation
6
+ * id the engine is idempotent on) and the typed `terminalCode` from a trusted `FORGE:STATE`, and hands it to `settle`. Active when
7
+ * `decisions.mode: live`, or when the watch sets `sweep_needs_human: true`; escalations that predate the watch are a backlog and
8
+ * only a `sweep_needs_human` watch touches them. `--dry-run` classifies and writes nothing. Handled issues count against the
9
+ * watch's `max_issues`, issues sent back to a lane against `max_launches` (`claimsUsed`, shared with the watcher's own caps).
10
+ * Only live mode acts: a post-deploy-only escalation is folded into `postdeploy_rollup` and closed, an evidence escalation on a repo
11
+ * that declares `prod_watch` sources goes back to a lane with the prod monitor capability; in shadow mode each only posts.
12
+ * Gh, the clock, the judge, the lane launch and the rollup `fold` are injected. The judge is the only LLM call.
13
+ */
14
+ import { settle, classify, tally, isVetoed, revertVeto, effectiveMode, NEEDS_HUMAN_LABEL } from "./decision-engine.mjs";
15
+ import { codeOf } from "./context.mjs";
16
+ import { noComments } from "./render.mjs";
17
+ import { listAssociations } from "./gh-assoc.mjs";
18
+ import { parseProdWatch } from "./prodwatch.mjs";
19
+ import { resolveOwnLogins } from "./prefilter.mjs";
20
+
21
+ const ROWS_MAX = 200;
22
+ const LIST_LIMIT = "200";
23
+ const MAX_ATTEMPTS = 3; // a judge/gh failure is retried this many rounds, then the escalation is left to a human
24
+ const JUDGE_ROUND_MAX = 20; // judge calls per round, whether or not the watch sets max_issues
25
+ const MS_PER_HOUR = 3600_000;
26
+ const commentId = (c) => /#issuecomment-(\d+)/.exec(String(c?.url ?? ""))?.[1] ?? c?.id ?? null;
27
+ const marked = (body, m) => new RegExp(`^\\s*<!--\\s*FORGE:${m}\\b`).test(String(body ?? ""));
28
+
29
+ /** True when this watch runs the stage: `decisions.mode: live` (policy from loadPolicy) or `sweep_needs_human: true`. */
30
+ export const decisionsActive = (watch, policy) => policy?.mode === "live" || watch.sweep_needs_human === true;
31
+
32
+ /** What the watch has used up: `issues` (claimed or handled by the stage, for max_issues) and `launches` (claimed or sent back to a lane, for max_launches). */
33
+ export function claimsUsed(state) {
34
+ const d = state.decisions;
35
+ return { issues: new Set([...(state.claimed ?? []), ...(d?.issues ?? [])]).size, launches: new Set([...(state.claimed ?? []), ...(d?.launched ?? [])]).size };
36
+ }
37
+
38
+ /** `{policy, hasSource}`: the repo's decisions policy and whether forge.yaml declares read-only data access (`prod_watch` sources). */
39
+ export function decisionContext({ loadPolicy, readFile, cwd = "." }) {
40
+ const policy = loadPolicy({ io: { readFile }, cwd });
41
+ let hasSource = false;
42
+ try { hasSource = Object.keys(parseProdWatch(readFile(`${cwd}/forge.yaml`)).sources ?? {}).length > 0; } catch { /* no forge.yaml or no prod_watch block */ }
43
+ return { policy, hasSource };
44
+ }
45
+
46
+ /** The typed terminal code of the newest FORGE:STATE record (trusted comments, then the issue body, whose author the stage already vetted). */
47
+ export function terminalCodeOf(body, comments, trusted) {
48
+ for (const t of [...(comments ?? []).filter(trusted).map((c) => c.body).reverse(), body]) {
49
+ const m = /FORGE:STATE\s*(\{[^\n]*\})/.exec(String(t ?? ""));
50
+ if (!m) continue;
51
+ try { const st = JSON.parse(m[1]); return codeOf(st.terminalCode) ?? codeOf(st.terminalReason); } catch { /* next record */ }
52
+ }
53
+ return null;
54
+ }
55
+
56
+ /** An issue's open escalation: `{id, text, at}` from its newest trusted FORGE:NEEDS_HUMAN comment, or null. */
57
+ export function escalationOf(comments, trusted) {
58
+ const c = [...(comments ?? [])].reverse().find((x) => marked(x.body, "NEEDS_HUMAN") && trusted(x));
59
+ return c && commentId(c) ? { id: String(commentId(c)), text: noComments(c.body).replace(/^\s*## Needs human\s*/i, "").trim(), at: Date.parse(c.createdAt) || 0 } : null;
60
+ }
61
+
62
+ const dec = (state) => (state.decisions ??= { rows: [], handled: {}, attempts: {}, issues: [], launched: [], live: [], stats: { decided: 0, reversed: 0 } });
63
+
64
+ /** Counts for the watcher status: decided, evidence-gathered, folded, external-kept (decision-engine `tally`). */
65
+ export const decisionCounts = (state) => tally(state.decisions?.rows ?? []);
66
+
67
+ const ghJson = async (io, args) => JSON.parse(String(await io.gh(args)));
68
+ const poster = (io, repo) => async (_marker, body, issue) => { await io.gh(["issue", "comment", String(issue), "-R", repo, "--body", `<!-- FORGE:DECISION -->\n${body}`]); };
69
+ const labeller = (io, repo) => async ({ issue, add = [], remove = [] }) => {
70
+ if (!add.length && !remove.length) return;
71
+ await io.gh(["issue", "edit", String(issue), "-R", repo, ...add.flatMap((l) => ["--add-label", l]), ...remove.flatMap((l) => ["--remove-label", l])]);
72
+ };
73
+
74
+ /**
75
+ * One round. `ctx`: `{policy, hasSource}`; `shared`: issuewatch's `{matches(issue, selectors), fold({issue, watch, repo, io, state}), labelNames, isBot, TRUSTED}`;
76
+ * `launch(issue)→bool` sends the issue back to a lane (the watcher's per-issue launch; false leaves it to normal selection).
77
+ * `io.judge(q)→answer` is the judgment call (none: every decision part escalates). Returns `{rows}`; with `dryRun` the rows are
78
+ * classifications and nothing is written to GitHub or `state`. Never throws on one issue: a failure is retried, then left to a human.
79
+ */
80
+ export async function decisionStep({ watch, repo, io, state, ctx, shared, launch = () => false, dryRun = false }) {
81
+ const d = dec(state);
82
+ const startedAt = state.started ?? io.now();
83
+ if (!dryRun) state.started ??= startedAt;
84
+ let own = [];
85
+ try { own = (await resolveOwnLogins(io.gh)).logins; } catch { /* only the associations count */ }
86
+ const bare = (l) => String(l ?? "").trim().toLowerCase().replace(/^app\//, "").replace(/\[bot\]$/, "");
87
+ const trusted = (c) => shared.TRUSTED.has(c?.authorAssociation) || (bare(c?.author?.login) !== "" && own.includes(bare(c.author.login))); // never another CONTRIBUTOR
88
+ const round = { judged: 0 };
89
+ const rio = { ...io, judge: io.judge && ((q) => { if (round.judged >= JUDGE_ROUND_MAX) throw new Error("judge cap for this round reached"); round.judged++; return io.judge(q); }) };
90
+ const rows = [];
91
+ const issues = await ghJson(io, ["issue", "list", "-R", repo, "--state", "open", "--label", NEEDS_HUMAN_LABEL, "--limit", LIST_LIMIT, "--json", "number,title,body,labels,milestone,author,createdAt"]);
92
+ const assoc = await listAssociations(io.gh, repo);
93
+ const sel = { ...watch, labels: (watch.labels ?? []).filter((l) => l !== NEEDS_HUMAN_LABEL), exclude_labels: (watch.exclude_labels ?? []).filter((l) => l !== NEEDS_HUMAN_LABEL) };
94
+ for (const raw of issues) {
95
+ const i = { ...raw, authorAssociation: assoc.get(raw.number) };
96
+ if (!shared.TRUSTED.has(i.authorAssociation) || !shared.matches(i, sel)) continue;
97
+ let view; // release #5068 MAT-2: one unreadable issue is retried next round, never ends the round for the rest
98
+ try { view = await ghJson(io, ["issue", "view", String(i.number), "-R", repo, "--json", "comments,body,labels"]); } catch (e) { rows.push({ issue: i.number, action: "error", reason: String(e?.message ?? e).slice(0, 200) }); continue; }
99
+ const esc = escalationOf(view.comments, trusted);
100
+ if (!esc || d.handled[esc.id]) continue;
101
+ const backlog = esc.at < startedAt;
102
+ if (backlog && !watch.sweep_needs_human && !dryRun) continue;
103
+ const item = { issue: i.number, repo, escalationId: esc.id, text: esc.text, title: i.title, terminalCode: terminalCodeOf(view.body, view.comments, trusted), comments: (view.comments ?? []).filter(trusted) }; // release #5068 SEC-1: a forged FORGE:DECISION can neither finish nor suppress a decision
104
+ if (dryRun) { rows.push({ ...classify(item, ctx.policy), backlog }); continue; }
105
+ if (round.judged >= JUDGE_ROUND_MAX) break;
106
+ const used = claimsUsed(state);
107
+ if (watch.max_issues && used.issues >= watch.max_issues) break;
108
+ if (watch.max_launches && used.launches >= watch.max_launches) break;
109
+ const row = await handle({ item, issue: i, watch, repo, io: rio, state, ctx, shared, launch, d });
110
+ if (row.action === "error") { // not handled: retried next round, bounded
111
+ d.attempts[esc.id] = (d.attempts[esc.id] ?? 0) + 1;
112
+ if (d.attempts[esc.id] >= MAX_ATTEMPTS) d.handled[esc.id] = "error";
113
+ rows.push(row);
114
+ continue;
115
+ }
116
+ d.handled[esc.id] = row.action;
117
+ if (row.action === "skipped") continue;
118
+ d.rows = [...d.rows, { issue: row.issue, escalationId: esc.id, action: row.action, kept: row.kept ?? false }].slice(-ROWS_MAX);
119
+ if (!d.issues.includes(i.number)) d.issues.push(i.number);
120
+ rows.push(row);
121
+ }
122
+ if (!dryRun) await vetoes({ repo, io, ctx, d, shared });
123
+ return { rows };
124
+ }
125
+
126
+ async function handle({ item, issue, watch, repo, io, state, ctx, shared, launch, d }) {
127
+ const n = item.issue;
128
+ try {
129
+ const post = poster(io, repo), setLabels = labeller(io, repo);
130
+ const back = async () => { if ((await launch(issue)) && !d.launched.includes(n)) d.launched.push(n); }; // label cleared: a lane takes it again; release #5068 MAT-4: counted only when it does
131
+ const deps = { judge: (q) => (io.judge ? io.judge(q) : null), post, setLabels, requeue: back, records: (await io.records?.()) ?? [], stats: d.stats, emit: io.emit };
132
+ const row = await settle(item, ctx.policy, deps);
133
+ if (row.action === "error") return row;
134
+ if (row.action === "decided" && !row.resumed) { d.stats.decided++; if (!row.kept) d.live.push({ issue: n, escalationId: item.escalationId, at: io.now() }); }
135
+ const key = `Escalation: ${item.escalationId}`;
136
+ if (row.action !== "post-deploy-only" && row.action !== "evidence-needed") return row;
137
+ if (effectiveMode(ctx.policy, d.stats).mode !== "live") { // shadow: say what would happen, change nothing
138
+ await post(null, `## Decision (shadow)\n${key}\nWould ${row.action === "post-deploy-only" ? "fold into the release checklist and close" : "send back to a prod-monitor lane"}; nothing changed.`, n);
139
+ return { ...row, action: "shadow" };
140
+ }
141
+ if (row.action === "post-deploy-only") {
142
+ if (!watch.postdeploy_rollup) return { ...row, action: "post-deploy-kept", reason: "no postdeploy_rollup to fold into" };
143
+ await shared.fold({ issue: { number: n, title: item.title ?? `#${n}` }, watch, repo, io, state });
144
+ await post(null, `## Post-deploy only\n${key}\nEvery remaining item is post-deploy: folded into #${watch.postdeploy_rollup} (the release checklist) and closed.`, n);
145
+ await setLabels({ issue: n, remove: [NEEDS_HUMAN_LABEL] });
146
+ await io.gh(["issue", "close", String(n), "-R", repo, "--reason", "completed"]);
147
+ return { ...row, folded: true };
148
+ }
149
+ if (!ctx.hasSource) return { ...row, action: "evidence-kept", reason: "no read-only data access declared in forge.yaml (prod_watch.sources)" };
150
+ await post(null, `## Evidence needed\n${key}\nThe block is missing read-only production data and forge.yaml declares a read-only source (prod_watch.sources): back to a lane that has the prod monitor capability (placement prod_monitor), needs-human cleared.`, n);
151
+ await setLabels({ issue: n, remove: [NEEDS_HUMAN_LABEL] });
152
+ await back();
153
+ return row;
154
+ } catch (e) {
155
+ return { issue: n, escalationId: item.escalationId, action: "error", reason: String(e?.message ?? e).slice(0, 200) };
156
+ }
157
+ }
158
+
159
+ /** A live decision vetoed inside its window (label or reply) is reverted once; a window that ended retires the record. */
160
+ async function vetoes({ repo, io, ctx, d, shared }) {
161
+ const keep = [];
162
+ for (const r of d.live) {
163
+ try {
164
+ const v = await ghJson(io, ["issue", "view", String(r.issue), "-R", repo, "--json", "comments,labels"]);
165
+ if (isVetoed({ labels: shared.labelNames(v), comments: v.comments, decidedAt: r.at, vetoHours: ctx.policy.vetoHours, now: io.now() })) {
166
+ await revertVeto({ issue: r.issue, escalationId: r.escalationId }, { setLabels: labeller(io, repo), post: poster(io, repo), emit: io.emit });
167
+ d.stats.reversed++;
168
+ } else if (io.now() < r.at + ctx.policy.vetoHours * MS_PER_HOUR) keep.push(r);
169
+ } catch { keep.push(r); }
170
+ }
171
+ d.live = keep;
172
+ }
173
+
174
+ /** Dry-run text: one line per escalation with its classification. */
175
+ export const formatDecisionDryRun = (rows) => rows.length ? rows.map((r) => `#${r.issue} ${r.kind}${r.backlog ? " (backlog)" : ""}: ${r.decisions} decision part(s)${r.external.length ? `, external ${r.external.join(",")}` : ""}`).join("\n") : "no needs-human escalations to classify";