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/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,8 @@ 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 {
|
|
67
|
+
import { pickSalvage } from "./salvage-resume.mjs";
|
|
68
|
+
import { ensureSignedOff, salvageBranch, dropSalvageTracking } from "./signoff.mjs";
|
|
68
69
|
import { createLedger, flattenPhases } from "./phaseledger.mjs";
|
|
69
70
|
import { runLightStep, runLightTests, lightBuildPrompt, upgradeNote, renderLiteReceipt } from "./lightlane.mjs";
|
|
70
71
|
|
|
@@ -225,7 +226,7 @@ export function isProtectedLaneBranch(b, { issue, lane = null, branches = {}, de
|
|
|
225
226
|
*/
|
|
226
227
|
export async function runCore(o) {
|
|
227
228
|
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;
|
|
229
|
+
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
230
|
const now = io.now ?? (() => Date.now());
|
|
230
231
|
const vcfg = { ...parseVerify({}), ...(o.verify ?? {}) };
|
|
231
232
|
const learnedCommands = (Array.isArray(o.precheck?.commands) ? o.precheck.commands : []).map((c) => String(c ?? "").trim()).filter(Boolean);
|
|
@@ -657,8 +658,8 @@ export async function runCore(o) {
|
|
|
657
658
|
const gitPush = async (args) => {
|
|
658
659
|
if (!args.includes("--delete")) { await untrackToolState({ git, base, ev, keep: await keepPaths() });
|
|
659
660
|
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(["
|
|
661
|
+
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>)
|
|
662
|
+
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
663
|
throw e;
|
|
663
664
|
}
|
|
664
665
|
} // #4590: never push .forge-lean/ or tool caches this branch added
|
|
@@ -679,14 +680,14 @@ export async function runCore(o) {
|
|
|
679
680
|
if (ahead) { laneHead = { branch, sha: String(await git(["rev-parse", "HEAD"])).trim(), pushedTo: null }; await gitPush(["push", "-u", "origin", branch]); laneHead.pushedTo = branch; }
|
|
680
681
|
ev({ event: "CHECKPOINT", reason, dirty: Boolean(dirty), pushed: Boolean(ahead) });
|
|
681
682
|
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; }
|
|
683
|
+
} 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
684
|
}
|
|
684
685
|
/** #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
686
|
async function rescuePush(reason, rejected) {
|
|
686
687
|
// #5014: gitPush untracks tool state and rewrites unpushed commits for DCO before it pushes, so HEAD may have moved since the capture
|
|
687
688
|
try { const now = String(await git(["rev-parse", "HEAD"])).trim(); if (now) laneHead.sha = now; } catch { /* keep the captured sha */ }
|
|
688
689
|
// 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]),
|
|
690
|
+
for (const ref of [...(rejected ? [] : [branch]), salvageBranch(issue, laneHead.sha)]) {
|
|
690
691
|
try {
|
|
691
692
|
await git(["push", "origin", `${laneHead.sha}:refs/heads/${ref}`]);
|
|
692
693
|
laneHead.pushedTo = ref;
|
|
@@ -1089,15 +1090,13 @@ export async function runCore(o) {
|
|
|
1089
1090
|
|
|
1090
1091
|
// ---- 2. worktree (resume an open PR / remote branch) ----
|
|
1091
1092
|
try { await io.git(["fetch", "origin", lane], { cwd }); } catch { /* offline: use the local ref */ }
|
|
1092
|
-
let resumed = null;
|
|
1093
|
+
let resumed = null, fromSalvage = false;
|
|
1093
1094
|
try {
|
|
1094
1095
|
const prs = JSON.parse(String(await ghc(["pr", "list", "-R", repo, "--head", branch, "--state", "open", "--json", "number,headRefOid,baseRefName"])) || "[]");
|
|
1095
1096
|
const remote = String(await io.git(["ls-remote", "--heads", "origin", branch], { cwd })).trim();
|
|
1096
1097
|
if (prs.length || remote) resumed = { pr: prs[0]?.number ?? null, base: prs[0]?.baseRefName ?? null };
|
|
1097
|
-
|
|
1098
|
-
|
|
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 }); }
|
|
1100
|
-
}
|
|
1098
|
+
const salv = (await pickSalvage({ io, cwd, issue, branch, hasBranch: Boolean(remote) }).catch((e) => { ev({ event: "SALVAGE_PICK_FAILED", error: String(e?.message ?? e).slice(0, 200) }); return null; }))?.ref; // release #5068 delta SPEC-2: never silent // #4958, #5063: the newest salvage ref that is not behind the lane branch
|
|
1099
|
+
if (salv) { await io.git(["fetch", "origin", `${salv}:refs/remotes/origin/${branch}`], { cwd }); resumed = { pr: prs[0]?.number ?? null, base: prs[0]?.baseRefName ?? null }; fromSalvage = true; ev({ event: "RESUME_FROM_SALVAGE", ref: salv }); }
|
|
1101
1100
|
} catch { /* no resume info: start fresh */ }
|
|
1102
1101
|
// An open PR on another base (a hand-retargeted PR, or one opened before lane resolution) is never merged (#4125).
|
|
1103
1102
|
// #4593: a shipped milestone lane (fully merged into staging) cannot take new work; follow the open PR's live base.
|
|
@@ -1148,7 +1147,7 @@ export async function runCore(o) {
|
|
|
1148
1147
|
}
|
|
1149
1148
|
}
|
|
1150
1149
|
};
|
|
1151
|
-
if (resumed) { try { await io.git(["fetch", "origin", branch], { cwd }); } catch { /* remote branch may not exist (PR-less resume) */ } }
|
|
1150
|
+
if (resumed && !fromSalvage) { try { await io.git(["fetch", "origin", branch], { cwd }); } catch { /* remote branch may not exist (PR-less resume) */ } }
|
|
1152
1151
|
// #4786: an open-PR-less remote branch whose only changes vs the base are tool state (.forge-lean/ scratch) holds no work: plan fresh, never resume its stale contract.
|
|
1153
1152
|
const toolOnly = async (ref) => { const n = String(await io.git(["diff", "--name-only", `${base}...${ref}`], { cwd })).split("\n").map((x) => x.trim()).filter(Boolean); return n.every((f) => isToolState(f)); };
|
|
1154
1153
|
let scratchOnly = false;
|
|
@@ -1183,6 +1182,7 @@ export async function runCore(o) {
|
|
|
1183
1182
|
wtInfo = await addWorktree(["worktree", "add", "-B", branch, wt, base], base);
|
|
1184
1183
|
}
|
|
1185
1184
|
wtCreated = true;
|
|
1185
|
+
if (fromSalvage) await dropSalvageTracking({ git, branch }); // #5047: the salvaged commits are unpublished: the sign-off range must include them
|
|
1186
1186
|
if (wtInfo?.sparse) {
|
|
1187
1187
|
sparse = { excluded: wtInfo.excluded, maxKb: sparseMaxKb };
|
|
1188
1188
|
ev({ event: "WORKTREE_SPARSE", excluded: wtInfo.excluded, excluded_bytes: wtInfo.bytes, max_kb: sparseMaxKb });
|
|
@@ -1286,7 +1286,13 @@ export async function runCore(o) {
|
|
|
1286
1286
|
return null;
|
|
1287
1287
|
}
|
|
1288
1288
|
const rg = await heavyStep("precheck", () => io.redGreen({ cwd: wt, base, command: String(t.command), env: laneEnv() })).catch(() => null);
|
|
1289
|
-
|
|
1289
|
+
let words = rg?.base && rg?.head ? rg : { base: "UNRUNNABLE", head: "UNRUNNABLE" };
|
|
1290
|
+
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
|
|
1291
|
+
const hr = await io.runCommand({ command: String(t.command), cwd: wt, timeoutMs: 120000, env: laneEnv() }).then((x) => x?.r ?? x).catch(() => null);
|
|
1292
|
+
// 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)
|
|
1293
|
+
const repoHas = repoTracks(String(await git(["ls-files"]).catch(() => "")).split("\n").map((x) => x.trim()).filter(Boolean));
|
|
1294
|
+
if (reproEnvMiss(hr, { command: String(t.command), repoHas })) words = { ...words, head: "UNRUNNABLE" };
|
|
1295
|
+
}
|
|
1290
1296
|
ev({ event: "REPRO_RED_GREEN", ...words });
|
|
1291
1297
|
T.decisions.push(`repro_test ${t.path ?? ""}${t.id ? ` ${t.id}` : ""}: BASE=${words.base} HEAD=${words.head}`);
|
|
1292
1298
|
return words;
|
|
@@ -1391,6 +1397,14 @@ export async function runCore(o) {
|
|
|
1391
1397
|
* #4328: probe the host for the tools and modules a contract's validate commands need; #4393: then flag `$NAME`s the lane
|
|
1392
1398
|
* env leaves empty and run each remaining command once on the base worktree (io.preflightRun). The misses; [] without either hook.
|
|
1393
1399
|
*/
|
|
1400
|
+
/** #5020: forge.yaml validate.recipes rewrite frozen commands; a check-less post-deploy requirement becomes a defer to the orchestrator. */
|
|
1401
|
+
function freezeRewrites(c) {
|
|
1402
|
+
const r = applyValidateRecipes(c, validateRecipes);
|
|
1403
|
+
for (const x of r.applied) T.decisions.push(`validate recipe rewrote ${x.id}: \`${x.from}\` -> \`${x.to}\``);
|
|
1404
|
+
const d = deferChecklessPostDeploy(r.contract);
|
|
1405
|
+
if (d.converted.length) T.decisions.push(`Post-deploy with no registered check deferred to the orchestrator: ${d.converted.join(", ")}`);
|
|
1406
|
+
return d.contract;
|
|
1407
|
+
}
|
|
1394
1408
|
async function preflightValidates(contract) {
|
|
1395
1409
|
if ((!io.probeCommand && !io.preflightRun) || !contract || typeof contract !== "object") return [];
|
|
1396
1410
|
const files = String(await git(["ls-files"]).catch(() => "")).split("\n").map((x) => x.trim()).filter(Boolean);
|
|
@@ -1667,6 +1681,7 @@ export async function runCore(o) {
|
|
|
1667
1681
|
return o.contract;
|
|
1668
1682
|
};
|
|
1669
1683
|
let contract = dropScope(await readContract());
|
|
1684
|
+
contract = freezeRewrites(contract);
|
|
1670
1685
|
await refreshIssue("contract validation");
|
|
1671
1686
|
let vc = validateContract(contract, issueText, { repro: reproOn() });
|
|
1672
1687
|
const missed = await preflightValidates(contract); // #4328: validate commands the host cannot run share the one correction
|
|
@@ -1675,7 +1690,7 @@ export async function runCore(o) {
|
|
|
1675
1690
|
const problems = [...vc.problems, ...preflightProblems(missed), ...testRunnerProblems(contract, testRunners)];
|
|
1676
1691
|
T.decisions.push(`Contract rejected (${problems[0]}); resumed once`);
|
|
1677
1692
|
await builder(++attemptN, contractCorrectionPrompt(problems), sessionId, false, planPrompt, { trigger: "contract_correction" });
|
|
1678
|
-
contract = dropScope(await readContract());
|
|
1693
|
+
contract = freezeRewrites(dropScope(await readContract()));
|
|
1679
1694
|
await refreshIssue("contract validation");
|
|
1680
1695
|
vc = validateContract(contract, issueText, { repro: reproOn() });
|
|
1681
1696
|
if (missed.length) misses = await preflightValidates(contract);
|
|
@@ -2140,7 +2155,8 @@ export async function runCore(o) {
|
|
|
2140
2155
|
const sig = JSON.stringify(a.applied);
|
|
2141
2156
|
if (a.applied.length && sig !== lastAmend) {
|
|
2142
2157
|
lastAmend = sig;
|
|
2143
|
-
await post("FORGE:AMENDMENT", renderAmendments(a.applied, {}, { contract: hash, amendments: all }));
|
|
2158
|
+
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
|
|
2159
|
+
catch (e) { T.anomalies.push(`FORGE:AMENDMENT comment not posted: ${String(e?.message ?? e).slice(0, 160)}`); }
|
|
2144
2160
|
T.decisions.push(`Contract amended: ${a.applied.map((x) => `${x.kind} ${x.target}`).join(", ")}`);
|
|
2145
2161
|
}
|
|
2146
2162
|
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] });
|
|
@@ -3915,6 +3943,21 @@ describe("batch robustness (#4112-#4115)", () => {
|
|
|
3915
3943
|
assert.ok(w.calls.some((c) => c[0] === "git" && c[1] === "worktree" && c[2] === "add" && c.includes("origin/feat/issue-7-lean")), "the worktree is built from the salvaged head");
|
|
3916
3944
|
});
|
|
3917
3945
|
|
|
3946
|
+
it("#5063 a rerun with an existing lane branch resumes from a newer salvage ref", async () => {
|
|
3947
|
+
const w = world({ prList: [], remote: "abc\trefs/heads/feat/issue-7-lean\n" });
|
|
3948
|
+
const g = w.io.git;
|
|
3949
|
+
w.io.git = async (a, o2) => {
|
|
3950
|
+
if (a[0] === "ls-remote" && a.at(-1) === "refs/heads/forge-salvage/7-*") return "abc\trefs/heads/forge-salvage/7-1234567890\n";
|
|
3951
|
+
if (a[0] === "merge-base" && a[2] === "origin/feat/issue-7-lean") { w.calls.push(["git", ...a]); return ""; } // the lane branch is an ancestor of the salvage head
|
|
3952
|
+
return g(a, o2);
|
|
3953
|
+
};
|
|
3954
|
+
await run(w);
|
|
3955
|
+
assert.ok(w.calls.some((c) => c[0] === "git" && c[1] === "fetch" && c.includes("refs/heads/forge-salvage/7-1234567890:refs/remotes/origin/feat/issue-7-lean")), "origin/<branch> is moved to the salvage head");
|
|
3956
|
+
const at = (f) => w.calls.findIndex(f);
|
|
3957
|
+
const add = at((c) => c[1] === "worktree" && c[2] === "add");
|
|
3958
|
+
assert.ok(add >= 0 && !w.calls.slice(0, add).some((c) => c[1] === "fetch" && c.length === 4 && c[3] === "feat/issue-7-lean"), "no refetch of the lane branch moves origin/<branch> off the salvage head before the worktree is built");
|
|
3959
|
+
});
|
|
3960
|
+
|
|
3918
3961
|
it("#4118 GitHub's transient '[remote rejected] ... (failed)' and pack write errors are retried, then merge", async () => {
|
|
3919
3962
|
for (const msg of ["To github.com:o/r.git\n ! [remote rejected] feat/issue-7-lean -> feat/issue-7-lean (failed)",
|
|
3920
3963
|
"remote: error: unable to write file ./objects/pack/pack-1.pack: No such file or directory\n ! [remote rejected] x -> x (failed)"]) {
|
|
@@ -4048,6 +4091,13 @@ describe("batch robustness (#4112-#4115)", () => {
|
|
|
4048
4091
|
assert.equal(n, 1);
|
|
4049
4092
|
});
|
|
4050
4093
|
|
|
4094
|
+
it("#5020 forge.yaml validate.recipes rewrite the frozen validate command and the freeze records it", async () => {
|
|
4095
|
+
const w = world({});
|
|
4096
|
+
const r = await run(w, { validateRecipes: [{ match: "^node --test ", replace: "node --test --test-reporter=dot " }] });
|
|
4097
|
+
assert.equal(r.terminalReason, "merged");
|
|
4098
|
+
assert.ok(w.comments.some((c) => c.includes("FORGE:CONTRACT") && c.includes("node --test --test-reporter=dot test/foo.test.js")));
|
|
4099
|
+
});
|
|
4100
|
+
|
|
4051
4101
|
// ---- #4112 ----
|
|
4052
4102
|
const fakeCli = async (name, body) => {
|
|
4053
4103
|
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) {
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
|
|
2
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
3
|
+
/**
|
|
4
|
+
* #5063: pick the salvage ref a rerun resumes from. The DCO and exit rescues push to `forge-salvage/<issue>-<sha10>`
|
|
5
|
+
* (#4958, #5047). The suffix is a sha, so the newest ref is chosen by commit time, not by name. When the lane branch
|
|
6
|
+
* already exists on origin, only a ref strictly ahead of it (descending from origin/<branch>) can be resumed without
|
|
7
|
+
* losing either side; a behind or diverged ref is ignored.
|
|
8
|
+
*/
|
|
9
|
+
const ok = async (git, args, cwd) => { try { await git(args, { cwd }); return true; } catch { return false; } };
|
|
10
|
+
|
|
11
|
+
/** @returns {Promise<{ref: string, sha: string} | null>} */
|
|
12
|
+
export async function pickSalvage({ io, cwd, issue, branch, hasBranch }) {
|
|
13
|
+
const out = String(await io.git(["ls-remote", "--heads", "origin", `refs/heads/forge-salvage/${issue}-*`], { cwd }));
|
|
14
|
+
const refs = out.split("\n").map((l) => l.trim().split(/\s+/)).filter((p) => p[1]).map(([sha, ref]) => ({ sha, ref }));
|
|
15
|
+
if (!refs.length) return null;
|
|
16
|
+
if (refs.length > 1 || hasBranch) await io.git(["fetch", "origin", `+refs/heads/forge-salvage/${issue}-*:refs/remotes/origin/forge-salvage/${issue}-*`], { cwd });
|
|
17
|
+
// release #5068 BUG-2/SPEC-1: compare against the live lane branch; a missing or stale local origin/<branch> would ignore a usable salvage or resume one that is behind the remote
|
|
18
|
+
if (hasBranch) await io.git(["fetch", "origin", `+refs/heads/${branch}:refs/remotes/origin/${branch}`], { cwd });
|
|
19
|
+
for (const r of refs) r.ct = Number(String(await io.git(["log", "-1", "--format=%ct", r.sha], { cwd }).catch(() => "")).trim()) || 0;
|
|
20
|
+
refs.sort((a, b) => b.ct - a.ct || (a.ref < b.ref ? 1 : -1));
|
|
21
|
+
for (const r of refs) {
|
|
22
|
+
if (!hasBranch) return r;
|
|
23
|
+
if (await ok(io.git, ["merge-base", "--is-ancestor", `origin/${branch}`, r.sha], cwd) && !(await ok(io.git, ["merge-base", "--is-ancestor", r.sha, `origin/${branch}`], cwd))) return r;
|
|
24
|
+
}
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
|
|
2
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
3
|
+
import { describe, it } from "node:test";
|
|
4
|
+
import assert from "node:assert/strict";
|
|
5
|
+
import { pickSalvage } from "./salvage-resume.mjs";
|
|
6
|
+
|
|
7
|
+
/** refs: [{ sha, name, ct }]; ahead: shas strictly ahead of origin/<branch>; behind: shas that are ancestors of it. */
|
|
8
|
+
const world = ({ refs = [], ahead = [], behind = [] } = {}) => {
|
|
9
|
+
const calls = [];
|
|
10
|
+
const git = async (a) => {
|
|
11
|
+
calls.push(a);
|
|
12
|
+
if (a[0] === "ls-remote") return refs.map((r) => `${r.sha}\trefs/heads/forge-salvage/7-${r.name}`).join("\n");
|
|
13
|
+
if (a[0] === "log") return String(refs.find((r) => r.sha === a[3])?.ct ?? "");
|
|
14
|
+
if (a[0] === "merge-base") {
|
|
15
|
+
if (a[2].startsWith("origin/") ? ahead.includes(a[3]) : behind.includes(a[2])) return "";
|
|
16
|
+
throw new Error("not an ancestor");
|
|
17
|
+
}
|
|
18
|
+
return "";
|
|
19
|
+
};
|
|
20
|
+
return { io: { git }, calls };
|
|
21
|
+
};
|
|
22
|
+
const pick = (w, hasBranch) => pickSalvage({ io: w.io, cwd: ".", issue: 7, branch: "b", hasBranch });
|
|
23
|
+
|
|
24
|
+
describe("pickSalvage (#5063)", () => {
|
|
25
|
+
it("no salvage refs: null", async () => assert.equal(await pick(world(), false), null));
|
|
26
|
+
it("newest by commit time, not by sha suffix", async () => {
|
|
27
|
+
const w = world({ refs: [{ sha: "f", name: "ffff", ct: 10 }, { sha: "a", name: "aaaa", ct: 20 }] });
|
|
28
|
+
assert.equal((await pick(w, false)).ref, "refs/heads/forge-salvage/7-aaaa");
|
|
29
|
+
});
|
|
30
|
+
it("branch exists: a salvage ref strictly ahead of it is picked", async () => {
|
|
31
|
+
const w = world({ refs: [{ sha: "a", name: "aaaa", ct: 20 }], ahead: ["a"] });
|
|
32
|
+
assert.equal((await pick(w, true)).ref, "refs/heads/forge-salvage/7-aaaa");
|
|
33
|
+
});
|
|
34
|
+
it("branch exists: behind or diverged refs are ignored, an older usable one is kept", async () => {
|
|
35
|
+
const w = world({ refs: [{ sha: "n", name: "new", ct: 30 }, { sha: "b", name: "behind", ct: 20 }, { sha: "o", name: "old", ct: 10 }], ahead: ["o"], behind: ["b"] });
|
|
36
|
+
assert.equal((await pick(w, true)).ref, "refs/heads/forge-salvage/7-old");
|
|
37
|
+
assert.equal(await pick(world({ refs: [{ sha: "d", name: "div", ct: 5 }] }), true), null);
|
|
38
|
+
});
|
|
39
|
+
it("branch exists: the lane branch is fetched before the ancestry check (release #5068 BUG-2)", async () => {
|
|
40
|
+
const w = world({ refs: [{ sha: "a", name: "aaaa", ct: 20 }], ahead: ["a"] });
|
|
41
|
+
await pick(w, true);
|
|
42
|
+
const fetchAt = w.calls.findIndex((a) => a[0] === "fetch" && a.includes("+refs/heads/b:refs/remotes/origin/b"));
|
|
43
|
+
const baseAt = w.calls.findIndex((a) => a[0] === "merge-base");
|
|
44
|
+
assert.ok(fetchAt >= 0 && fetchAt < baseAt, JSON.stringify(w.calls));
|
|
45
|
+
});
|
|
46
|
+
it("no lane branch: the lane branch is not fetched", async () => {
|
|
47
|
+
const w = world({ refs: [{ sha: "a", name: "aaaa", ct: 20 }] });
|
|
48
|
+
await pick(w, false);
|
|
49
|
+
assert.ok(!w.calls.some((a) => a[0] === "fetch" && a.includes("+refs/heads/b:refs/remotes/origin/b")));
|
|
50
|
+
});
|
|
51
|
+
});
|
|
@@ -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;
|