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.
- package/bin/engine-cli.mjs +6 -3
- package/bin/lean/conformance.mjs +52 -2
- package/bin/lean/conformance.test.mjs +57 -3
- package/bin/lean/decision-engine.mjs +12 -1
- package/bin/lean/execution.mjs +7 -4
- package/bin/lean/execution.test.mjs +21 -0
- package/bin/lean/issuewatch-decisions.mjs +175 -0
- package/bin/lean/issuewatch-decisions.test.mjs +353 -0
- package/bin/lean/issuewatch-release.test.mjs +3 -1
- package/bin/lean/issuewatch.mjs +67 -10
- package/bin/lean/issuewatch.test.mjs +18 -0
- package/bin/lean/lean.mjs +32 -16
- package/bin/lean/lean.test.mjs +50 -0
- package/bin/lean/orchestrate-blocked.test.mjs +23 -0
- package/bin/lean/orchestrate.mjs +4 -3
- package/bin/lean/postdeploy-check.test.mjs +3 -1
- package/bin/lean/preflight.mjs +8 -3
- package/bin/lean/preflight.test.mjs +13 -1
- package/bin/lean/prompts.mjs +1 -1
- package/bin/lean/salvage-resume.mjs +26 -0
- package/bin/lean/salvage-resume.test.mjs +51 -0
- package/bin/lean/sandbox-provider.contract.test.mjs +15 -4
- package/bin/lean/sandbox-remote.mjs +19 -7
- package/bin/lean/signoff.mjs +11 -0
- package/bin/lean/signoff.test.mjs +31 -1
- package/bin/pool/agent.mjs +3 -3
- package/bin/pool/router.mjs +17 -6
- package/docs/CONFIG.md +4 -1
- package/docs/DECISIONS.md +18 -7
- package/docs/RELEASE-NOTES.md +1 -0
- package/package.json +1 -1
package/bin/engine-cli.mjs
CHANGED
|
@@ -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
|
};
|
package/bin/lean/conformance.mjs
CHANGED
|
@@ -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" :
|
|
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 ?? ""))) {
|
|
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
|
|
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.
|
|
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
|
-
|
|
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" };
|
package/bin/lean/execution.mjs
CHANGED
|
@@ -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
|
-
|
|
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) => (
|
|
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";
|