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.
- 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/execution.mjs +7 -4
- package/bin/lean/execution.test.mjs +21 -0
- package/bin/lean/lean.mjs +29 -12
- package/bin/lean/lean.test.mjs +35 -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/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 +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
|
+
});
|
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 });
|
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
|
|
661
|
-
if (!e?.noIdentity) { try { await git(["
|
|
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]),
|
|
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
|
-
|
|
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
|
package/bin/lean/lean.test.mjs
CHANGED
|
@@ -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
|
+
});
|
package/bin/lean/orchestrate.mjs
CHANGED
|
@@ -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 {
|
|
521
|
-
|
|
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
|
-
|
|
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"]) });
|
package/bin/lean/preflight.mjs
CHANGED
|
@@ -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
|
|
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 = (
|
|
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
|
+
});
|
package/bin/lean/prompts.mjs
CHANGED
|
@@ -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
|
|
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}' }
|
|
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,
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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) {
|
package/bin/lean/signoff.mjs
CHANGED
|
@@ -11,6 +11,17 @@ const SEP = "\x1f", END = "\x1e";
|
|
|
11
11
|
const STASH_REF = "refs/forgedock/dco-stash";
|
|
12
12
|
const PLACEHOLDER_RE = /@localhost$/i;
|
|
13
13
|
|
|
14
|
+
/**
|
|
15
|
+
* #5047: where a lane's commits go when the sign-off rewrite failed. Never the lane branch: ensureSignedOff excludes everything on
|
|
16
|
+
* origin/<branch>, so unsigned commits published there could never be rewritten. A salvage branch is outside that range.
|
|
17
|
+
*/
|
|
18
|
+
export const salvageBranch = (issue, sha) => `forge-salvage/${issue}-${String(sha).slice(0, 10)}`;
|
|
19
|
+
|
|
20
|
+
/** #5047: a lane resumed from a salvage branch has no published lane branch: its tracking ref must not exclude the commits from the sign-off range. */
|
|
21
|
+
export async function dropSalvageTracking({ git, branch }) {
|
|
22
|
+
try { await git(["update-ref", "-d", `refs/remotes/origin/${branch}`]); return true; } catch { return false; }
|
|
23
|
+
}
|
|
24
|
+
|
|
14
25
|
/** True when the commit must be rewritten: placeholder author or committer, or no sign-off naming its author. */
|
|
15
26
|
export function needsSignoff({ authorEmail, committerEmail, signedOff }) {
|
|
16
27
|
if (PLACEHOLDER_RE.test(authorEmail ?? "") || PLACEHOLDER_RE.test(committerEmail ?? "")) return true;
|
|
@@ -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
|
});
|
package/bin/pool/agent.mjs
CHANGED
|
@@ -240,7 +240,7 @@ export function createAgent(cfg, { docker, now = () => Date.now(), log = console
|
|
|
240
240
|
async function sandboxCred(id, kind, req) {
|
|
241
241
|
const r = own(id);
|
|
242
242
|
const m = /^Bearer\s+(\S.*)$/i.exec(req.headers.authorization ?? "");
|
|
243
|
-
if (!r || r.state === "exited" || !m || !sameSecret(m[1].trim(), r.credToken)) return { status: 401, body: { error: "unauthorized" } };
|
|
243
|
+
if (!r || r.state === "exited" || !m || !sameSecret(m[1].trim(), r.credToken)) return { status: 401, body: { error: "unauthorized", hop: "agent", reason: "agent auth: bad sandbox credential" } };
|
|
244
244
|
if (!CRED_TTL[kind]) return { status: 404, body: { error: "unknown credential" } };
|
|
245
245
|
const c = credCache[kind];
|
|
246
246
|
if (!c || now() - c.at > CRED_TTL[kind]) {
|
|
@@ -256,7 +256,7 @@ export function createAgent(cfg, { docker, now = () => Date.now(), log = console
|
|
|
256
256
|
async function sandboxRelay(id, rid, req) {
|
|
257
257
|
const r = own(id);
|
|
258
258
|
const m = /^Bearer\s+(\S.*)$/i.exec(req.headers.authorization ?? "");
|
|
259
|
-
if (!r || r.state === "exited" || !m || !sameSecret(m[1].trim(), r.credToken)) return { status: 401, body: { error: "unauthorized" } };
|
|
259
|
+
if (!r || r.state === "exited" || !m || !sameSecret(m[1].trim(), r.credToken)) return { status: 401, body: { error: "unauthorized", hop: "agent", reason: "agent auth: bad sandbox credential" } };
|
|
260
260
|
const lane = r.alias ?? r.id; // #4935: a claimed warm sandbox posts under its warm id, but the router queue and the dispatcher use the lane id
|
|
261
261
|
const path = `/agent/relay/${encodeURIComponent(lane)}${rid ? `/${encodeURIComponent(rid)}` : ""}`;
|
|
262
262
|
const x = rid ? await call(cfg.router_url, "GET", path, { token: cfg.token, timeoutMs: 20000 })
|
|
@@ -400,7 +400,7 @@ export function createAgent(cfg, { docker, now = () => Date.now(), log = console
|
|
|
400
400
|
if (cred) { const r = await sandboxCred(decodeURIComponent(cred[1]), cred[2], req); return send(res, r.status, r.body); }
|
|
401
401
|
const relay = (req.method === "POST" && /^\/sandboxes\/([^/]+)\/relay$/.exec(url.pathname)) || (req.method === "GET" && /^\/sandboxes\/([^/]+)\/relay\/([^/]+)$/.exec(url.pathname));
|
|
402
402
|
if (relay) { const r = await sandboxRelay(decodeURIComponent(relay[1]), relay[2] ? decodeURIComponent(relay[2]) : null, req); return send(res, r.status, r.body ?? {}); }
|
|
403
|
-
if (url.pathname !== "/health" && !bearerOk(req, cfg.token)) return send(res, 401, { error: "unauthorized" });
|
|
403
|
+
if (url.pathname !== "/health" && !bearerOk(req, cfg.token)) return send(res, 401, { error: "unauthorized", hop: "agent", reason: "agent auth: router token rejected by agent" });
|
|
404
404
|
for (const [match, fn] of routes) {
|
|
405
405
|
const p = match(req, url);
|
|
406
406
|
if (!p) continue;
|
package/bin/pool/router.mjs
CHANGED
|
@@ -147,13 +147,15 @@ export function createRouter(cfg, { fetchFn = globalThis.fetch, now = () => Date
|
|
|
147
147
|
catch (e) { log.warn?.(`forge.yaml for ${spec.repo.slug} unreadable: ${e.message}`); return spec; }
|
|
148
148
|
}
|
|
149
149
|
|
|
150
|
+
/** #5053: an agent's 401 reaches the dispatcher through this router; tag it so it is not mistaken for a router auth failure. */
|
|
151
|
+
const agentBody = (r, name) => (r.status === 401 ? { ...(r.data && typeof r.data === "object" ? r.data : {}), error: "unauthorized", hop: "agent", agent: name, reason: typeof r.data?.reason === "string" ? r.data.reason : "agent auth: router token rejected by agent" } : r.data ?? {});
|
|
150
152
|
async function launch(spec) {
|
|
151
153
|
if (typeof spec?.id !== "string" || !/^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/.test(spec.id)) return { status: 400, body: { error: "spec.id required ([A-Za-z0-9._-], max 128)" } };
|
|
152
154
|
const known = own(sbx(), spec.id);
|
|
153
155
|
if (known && own(agents(), known.agent)) {
|
|
154
156
|
known.touched = now(); store.save();
|
|
155
157
|
const r = await agentCall(own(agents(), known.agent), "POST", "/sandboxes", { spec: withForgeYaml(spec), ...sizeFor(spec) });
|
|
156
|
-
return { status: r.status, body: r.
|
|
158
|
+
return { status: r.status, body: agentBody(r, known.agent), retryAfter: r.retryAfter };
|
|
157
159
|
}
|
|
158
160
|
const size = sizeFor(spec);
|
|
159
161
|
const fits = (await capacities()).filter((c) => c.ok && !c.paused && c.cpus_free >= size.cpus && c.mem_free_gb >= size.mem_gb)
|
|
@@ -162,7 +164,7 @@ export function createRouter(cfg, { fetchFn = globalThis.fetch, now = () => Date
|
|
|
162
164
|
const r = await agentCall(own(agents(), c.name), "POST", "/sandboxes", { spec: withForgeYaml(spec), ...size }, 120000);
|
|
163
165
|
if (r.status === 503) continue; // raced with another launch; try the next host
|
|
164
166
|
if (r.ok) { sbx()[spec.id] = { agent: c.name, issue: spec.issue ?? null, batch: spec.batch ?? null, createdAt: now(), touched: now() }; store.save(); }
|
|
165
|
-
return { status: r.status, body: { ...(r.
|
|
167
|
+
return { status: r.status, body: { ...agentBody(r, c.name), agent: c.name } };
|
|
166
168
|
}
|
|
167
169
|
return { status: 503, body: { error: "no host has a free reservation", lane: size }, retryAfter: cfg.retry_after_s ?? 60 };
|
|
168
170
|
}
|
|
@@ -190,7 +192,7 @@ export function createRouter(cfg, { fetchFn = globalThis.fetch, now = () => Date
|
|
|
190
192
|
try {
|
|
191
193
|
const r = await agentCall(a, method, path);
|
|
192
194
|
if (method === "DELETE" && (r.ok || r.status === 404)) { delete sbx()[id]; relayQ.delete(id); store.save(); }
|
|
193
|
-
return { status: r.status, body: r.
|
|
195
|
+
return { status: r.status, body: agentBody(r, s.agent) };
|
|
194
196
|
} catch (e) { return { status: 502, body: { error: `agent ${s.agent} unreachable: ${e.message}` } }; }
|
|
195
197
|
}
|
|
196
198
|
|
|
@@ -214,7 +216,7 @@ export function createRouter(cfg, { fetchFn = globalThis.fetch, now = () => Date
|
|
|
214
216
|
}
|
|
215
217
|
|
|
216
218
|
function enroll(body) {
|
|
217
|
-
if (!enrollOk(body?.token)) return { status: 401, body: { error: "bad enrollment token" } };
|
|
219
|
+
if (!enrollOk(body?.token)) return { status: 401, body: { error: "bad enrollment token", hop: "router", reason: "router auth: bad enrollment token" } };
|
|
218
220
|
const name = String(body.name ?? "").toLowerCase().replace(/[^a-z0-9-]/g, "");
|
|
219
221
|
let url;
|
|
220
222
|
try { url = new URL(String(body.url)); } catch { return { status: 400, body: { error: "url required" } }; }
|
|
@@ -321,14 +323,23 @@ export function createRouter(cfg, { fetchFn = globalThis.fetch, now = () => Date
|
|
|
321
323
|
async function handle(req, res) {
|
|
322
324
|
const url = new URL(req.url, "http://router");
|
|
323
325
|
if (!url.pathname.startsWith("/health")) log.info?.(`${req.socket.remoteAddress} ${req.method} ${url.pathname}`); // query (tokens) never logged
|
|
326
|
+
// #5053: every rejected request is logged with its status; the sandbox id comes from the path, the repo slug from a launch body (never tokens or the query).
|
|
327
|
+
const meta = { id: /^\/sandboxes\/([^/]+)/.exec(url.pathname)?.[1] };
|
|
328
|
+
const writeHead = res.writeHead.bind(res);
|
|
329
|
+
res.writeHead = (status, ...a) => {
|
|
330
|
+
if (status >= 400 && url.pathname !== "/health") log.warn?.(`rejected ${req.method} ${url.pathname} -> ${status}${meta.id && meta.id !== "claim" ? ` sandbox=${meta.id.slice(0, 128)}` : ""}${meta.repo ? ` repo=${meta.repo}` : ""}`);
|
|
331
|
+
return writeHead(status, ...a);
|
|
332
|
+
};
|
|
324
333
|
try {
|
|
325
334
|
for (const [match, r] of routes) {
|
|
326
335
|
const p = match(req, url);
|
|
327
336
|
if (!p) continue;
|
|
328
|
-
if (r.auth === "forgedock" && !bearerOk(req, cfg.forgedock_token)) return send(res, 401, { error: "unauthorized" });
|
|
329
|
-
if (r.auth === "agent" && !agentFor(req)) return send(res, 401, { error: "unauthorized" });
|
|
337
|
+
if (r.auth === "forgedock" && !bearerOk(req, cfg.forgedock_token)) return send(res, 401, { error: "unauthorized", hop: "router", reason: "router auth: bad or missing token" });
|
|
338
|
+
if (r.auth === "agent" && !agentFor(req)) return send(res, 401, { error: "unauthorized", hop: "router", reason: "router auth: bad or missing token" });
|
|
330
339
|
if (r.auth === "enroll" && !enrollOk(url.searchParams.get("token"))) return send(res, 401, "unauthorized\n");
|
|
331
340
|
const body = req.method === "POST" ? await readJson(req) : null;
|
|
341
|
+
if (typeof body?.repo?.slug === "string") meta.repo = body.repo.slug.slice(0, 200);
|
|
342
|
+
if (typeof body?.id === "string") meta.id = body.id;
|
|
332
343
|
const out = await r.fn(p, body, url, req);
|
|
333
344
|
if (out.bundle) return sendBundle(res);
|
|
334
345
|
if (out.image) return sendImage(res, out.image);
|
package/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.
|
|
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"
|