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/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 { ensureSignedOff } from "./signoff.mjs";
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 the branch as-is before reporting
661
- if (!e?.noIdentity) { try { await git(["push", "-u", "origin", branch]); ev({ event: "DCO_RESCUE_PUSH", branch }); } catch (pe) { ev({ event: "DCO_RESCUE_PUSH_FAILED", error: String(pe?.message ?? pe).slice(0, 200) }); } }
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]), `forge-salvage/${issue}-${laneHead.sha.slice(0, 10)}`]) {
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
- if (!prs.length && !remote) { // #4958: commits the exit rescue could only push to a salvage branch resume from there
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 }); }
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
- const words = rg?.base && rg?.head ? rg : { base: "UNRUNNABLE", head: "UNRUNNABLE" };
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
@@ -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
+ });
@@ -516,9 +516,10 @@ export async function orchestrate({ issues, parallel = 3, runOne, readBody, pref
516
516
  const adm = admitRemote();
517
517
  if (adm.ok) {
518
518
  // #4540: the provider's free slots (`remote.capacity() -> {free, at}`, sync, null = unknown) cap admissions per sample `at`; the issue queues here instead of a 503.
519
- let cap = null;
520
- try { cap = remote.capacity?.() ?? null; } catch { cap = null; }
521
- if (typeof remote.capacity === "function") notePool(cap === null, "capacity probe failed");
519
+ let cap = null, probeFailed = false;
520
+ try { cap = remote.capacity?.() ?? null; } catch { probeFailed = true; }
521
+ // #5048: only a failed probe (a throw) marks the pool down; null is unknown (first pass, no capacity()) and leaves the state; a sample clears it.
522
+ if (probeFailed) notePool(true, "capacity probe failed"); else if (cap !== null) notePool(false);
522
523
  // #4788: zero live agents for longer than `remote.poolOfflineMin` (default 10): one loud batch event, none while online > 0.
523
524
  if (cap && cap.online === 0) {
524
525
  poolOfflineSince ??= now();
@@ -96,7 +96,9 @@ describe("post-deploy named checks (#4626)", () => {
96
96
  assert.ok(validateContract(c, Q).problems.some((p) => /needs a check id/.test(p)));
97
97
  assert.ok(!validateContract({ ...c, requirements: [{ ...c.requirements[0], check: "smoke" }] }, Q).problems.some((p) => /check id/.test(p)));
98
98
  const frozen = { ...c, requirements: [{ id: "R1", quote: Q, disposition: "deliver" }] };
99
- assert.ok(applyAmendments(frozen, [{ kind: "post-deploy", requirement: "R1", why: "w" }], Q).problems.some((p) => /check id/.test(p)));
99
+ const deferred = applyAmendments(frozen, [{ kind: "post-deploy", requirement: "R1", why: "w" }], Q); // #5020: no check id defers to the orchestrator
100
+ assert.deepEqual(deferred.problems, []);
101
+ assert.equal(deferred.contract.requirements[0].disposition, "defer");
100
102
  const ok = applyAmendments(frozen, [{ kind: "post-deploy", requirement: "R1", why: "w", check: "smoke" }], Q);
101
103
  assert.equal(ok.contract.requirements[0].check, "smoke");
102
104
  const pc = precheck({ contract: { ...c, requirements: [{ ...c.requirements[0], check: "ghost" }] }, result: { requirements: [] }, changedFiles: [], checkIds: new Set(["smoke"]) });
@@ -101,6 +101,12 @@ function moduleNeeds(exe, args) {
101
101
  * probe (tool) a module probe depends on: skip the module probe when its interpreter is missing. A name `repoHas(name)` holds
102
102
  * or a deliverable's change text names (the change adds it) is not probed; paths and `$...` words are never probed.
103
103
  */
104
+ /** #5020: the contract's runnable commands: each validate entry plus the repro_test, which fails the same ways. */
105
+ export function contractCommands(contract) {
106
+ const out = (Array.isArray(contract?.validate) ? contract.validate : []).filter((v) => v?.id && typeof v.command === "string");
107
+ const t = contract?.repro_test;
108
+ return t && typeof t === "object" && typeof t.command === "string" && t.command.trim() ? [...out, { id: "repro_test", command: t.command }] : out;
109
+ }
104
110
  export function preflightProbes(contract, { repoHas = () => false, cap = PREFLIGHT_CAP } = {}) {
105
111
  const changes = (Array.isArray(contract?.deliverables) ? contract.deliverables : []).map((d) => String(d?.change ?? "")).join("\n");
106
112
  const named = (name) => new RegExp(`(^|[^\\w@/.-])${name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}(?![\\w/-])`).test(changes);
@@ -115,8 +121,7 @@ export function preflightProbes(contract, { repoHas = () => false, cap = PREFLIG
115
121
  : `node -e ${sq(NODE_PROBE)} ${sq(tool)} >/dev/null 2>&1`;
116
122
  byKey.set(key, { ids: [id], tool, kind, interp, needs: kind === "command" ? null : interp, probe });
117
123
  };
118
- for (const v of Array.isArray(contract?.validate) ? contract.validate : []) {
119
- if (!v?.id || typeof v.command !== "string") continue;
124
+ for (const v of contractCommands(contract)) {
120
125
  for (const seg of shellSegments(v.command)) {
121
126
  let i = 0;
122
127
  while (i < seg.length && (/^[A-Za-z_]\w*=/.test(seg[i]) || ["!", "time", "exec", "command", "env"].includes(seg[i]))) i++;
@@ -238,7 +243,7 @@ const porcelain = (out) => new Map(String(out ?? "").split("\n").filter((l) => l
238
243
  */
239
244
  export async function runPreflight({ contract, run, git, env = {}, repoHas = () => false, knownPath = () => false, skipIds = [], now = () => Date.now(), perCommandMs = RUN_PROBE_MS, budgetMs = RUN_BUDGET_MS }) {
240
245
  const misses = [], skipped = [], reverted = [];
241
- const todo = (Array.isArray(contract?.validate) ? contract.validate : []).filter((v) => v?.id && typeof v.command === "string" && !skipIds.includes(v.id));
246
+ const todo = contractCommands(contract).filter((v) => !skipIds.includes(v.id));
242
247
  if (!todo.length) return { misses, skipped, reverted };
243
248
  const status = async () => porcelain(await git(["status", "--porcelain", "--untracked-files=all"]).catch(() => ""));
244
249
  const before = await status(), deadline = now() + budgetMs;
@@ -4,7 +4,7 @@ import { describe, it } from "node:test";
4
4
  import assert from "node:assert/strict";
5
5
  import { mkdirSync, writeFileSync } from "node:fs";
6
6
  import { join } from "node:path";
7
- import { testRunnerProblems, shellSegments, pythonImports, nodeImports, preflightProbes, preflightProblems, runProbeMiss, unsetEnvVars, runPreflight, knownToContract } from "./preflight.mjs";
7
+ import { testRunnerProblems, shellSegments, pythonImports, nodeImports, preflightProbes, preflightProblems, runProbeMiss, unsetEnvVars, runPreflight, knownToContract, contractCommands } from "./preflight.mjs";
8
8
  import { runCheckCommand } from "./effects.mjs";
9
9
  import { repoTracks } from "./conformance.mjs";
10
10
  import { tmpDir } from "../tests/_tmp.mjs";
@@ -120,3 +120,15 @@ describe("testRunnerProblems (#4857)", () => {
120
120
  assert.deepEqual(testRunnerProblems(C("pytest services/worker/t.py"), null), []);
121
121
  });
122
122
  });
123
+
124
+ describe("#5020 repro_test is probed like a validate command", () => {
125
+ const c = { validate: [{ id: "V1", command: "node --test a.test.mjs" }], repro_test: { path: "t.py", id: "t", command: "madeuptool --run t.py" } };
126
+ it("contractCommands lists validate entries then the repro_test", () => {
127
+ assert.deepEqual(contractCommands(c).map((x) => x.id), ["V1", "repro_test"]);
128
+ assert.deepEqual(contractCommands({ repro_test: "none" }), []);
129
+ });
130
+ it("preflightProbes probes the repro command's tool", () => {
131
+ const { probes } = preflightProbes(c);
132
+ assert.ok(probes.some((p) => p.tool === "madeuptool" && p.ids.includes("repro_test")));
133
+ });
134
+ });
@@ -246,7 +246,7 @@ export function contextPackNote(pack) {
246
246
  /** #4857: validate-command rules for the plan prompt (the implement session resumes it), kept out of the shared templates (their token caps are guarded). */
247
247
  export function unrunnableValidateNote(testRunners = null) {
248
248
  const map = testRunners && typeof testRunners === "object" ? Object.entries(testRunners).map(([k, v]) => `${k}/ -> ${v}`).join("; ") : "";
249
- return `\n\nValidate and repro commands must pass bash -n (no prose notes), select tests that exist, and run from the right cwd${map ? ` (forge.yaml verification.test_runners: ${map})` : ""}. An unrunnable validate (missing tool, wrong cwd, no tests selected) is never a block or "human" product-decision: run an equivalent through the repo's toolchain and record {"kind":"substitute-validate","validate":"V#","command":"<runnable equivalent>","why":""} in ${RESULT_FILE} amendments.`;
249
+ return `\n\nValidate and repro commands must pass bash -n (no prose notes), select tests that exist, and run from the right cwd${map ? ` (forge.yaml verification.test_runners: ${map})` : ""}. An unrunnable validate (missing tool, wrong cwd, no tests selected) is never a block or "human" product-decision: run an equivalent through the repo's toolchain and record {"kind":"substitute-validate","validate":"V#","command":"<runnable equivalent>","why":""} in ${RESULT_FILE} amendments. A post-deploy requirement needs a registered check id; one that is a manual or production-credential action with no runnable check is disposition "defer" with bucket "owner:<action>" (the engine also converts a check-less post-deploy to a defer to the orchestrator). forge.yaml validate.recipes rewrite matching commands at freeze.`;
250
250
  }
251
251
 
252
252
  export function reproPlanNote(on) {
@@ -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, negative and transport errors are unknown (null)", async () => {
333
+ it("free integer is reported; 404, malformed and negative are unknown (null); an unreachable router throws (#5048)", async () => {
324
334
  assert.deepEqual(await mk({ status: 200, body: '{"free":3}' }).capacity(), { free: 3 });
325
335
  assert.deepEqual(await mk({ status: 200, body: '{"free":0}' }).capacity(), { free: 0 });
326
- for (const r of [{ status: 404 }, { status: 200, body: "nope" }, { status: 200, body: '{"free":"2"}' }, { status: 200, body: '{"free":-1}' }, new Error("down")]) assert.equal(await mk(r).capacity(), null);
336
+ for (const r of [{ status: 404 }, { status: 200, body: "nope" }, { status: 200, body: '{"free":"2"}' }, { status: 200, body: '{"free":-1}' }]) assert.equal(await mk(r).capacity(), null);
337
+ await assert.rejects(() => mk(new Error("down")).capacity(), (e) => e.unreachable === true && /pool router unreachable/.test(e.message));
327
338
  });
328
339
  it("#4788: no online on /status: derived from /agents (ok && !paused); an unreadable /agents stays unknown", async () => {
329
340
  const mkA = (status, agents) => httpTransport({ endpoint: "https://pool.example/v1", fetchFn: async (url) => {
@@ -337,12 +348,12 @@ describe("remote capacity (GET /status, #4540)", () => {
337
348
  assert.deepEqual(await mkA(200, down).capacity(), { online: 0, total: 1 });
338
349
  assert.equal(await mkA(404, "nope").capacity(), null);
339
350
  });
340
- it("remoteProvider.capacity delegates, and is null without a transport capability or on a throw", async () => {
351
+ it("remoteProvider.capacity delegates, is null without a transport capability, and passes a transport error on (#5048)", async () => {
341
352
  const batch = openBatch(join(tmpDir("cap-"), "batches"), "bt");
342
353
  const p = (transport) => remoteProvider({ batch, repo: "o/r", transport, env: {}, credentials: () => null });
343
354
  assert.deepEqual(await p({ capacity: async () => ({ free: 1 }) }).capacity(), { free: 1 });
344
355
  assert.equal(await p({}).capacity(), null);
345
- assert.equal(await p({ capacity: async () => { throw new Error("x"); } }).capacity(), null);
356
+ await assert.rejects(() => p({ capacity: async () => { throw new Error("x"); } }).capacity(), /x/);
346
357
  });
347
358
  });
348
359
 
@@ -60,8 +60,14 @@ export function httpTransport({ endpoint, token = null, fetchFn = globalThis.fet
60
60
  return { status: res.status, ok: res.ok, data, retryAfter: res.headers?.get?.("retry-after") ?? null };
61
61
  };
62
62
  const fail = (what, r) => {
63
- const e = new Error(`sandbox ${what}: HTTP ${r.status}`);
63
+ // #5053: a 401 names the hop that answered it (router or agent; the pool tags the body), never the token.
64
+ const hop = r.status === 401 && ["router", "agent"].includes(r.data?.hop) ? r.data.hop : null;
65
+ const who = hop === "agent" && typeof r.data?.agent === "string" ? `agent ${r.data.agent.slice(0, 64)}` : hop;
66
+ const reason = hop && typeof r.data?.reason === "string" ? r.data.reason.replace(/^(router|agent) auth: /, "").replace(/[^\x20-\x7e]/g, "").slice(0, 80) : "";
67
+ const e = new Error(`sandbox ${what}: HTTP ${r.status}${who ? ` (${who} auth${reason ? `: ${reason}` : ""})` : ""}`);
64
68
  e.status = r.status;
69
+ if (hop) e.hop = hop;
70
+ if (reason) e.reason = reason;
65
71
  if ([502, 503, 504].includes(r.status)) e.transient = true; // #4672: a router restart or gateway blip, retried by the lane poll
66
72
  if (r.status === 429 || r.status === 503) { e.retryable = true; const n = Number(r.retryAfter); if (Number.isFinite(n) && n > 0) e.retryInSeconds = n; }
67
73
  return e;
@@ -74,12 +80,12 @@ export function httpTransport({ endpoint, token = null, fetchFn = globalThis.fet
74
80
  async status(id) {
75
81
  const r = await call("GET", p(id));
76
82
  if (r.status === 404) return "gone";
77
- if (!r.ok) throw fail("status", r);
83
+ if (!r.ok) throw Object.assign(fail("status", r), r.status === 401 ? { transient: true } : {}); // #5053: the sandbox launched, so a 401 is retried like an unreachable pool
78
84
  const s = String(r.data?.state ?? "");
79
85
  return s === "running" || s === "pending" || s === "starting" ? "running" : s === "gone" ? "gone" : "exited";
80
86
  },
81
87
  async logs(id, cursor = 0) { const r = await call("GET", `${p(id)}/logs?cursor=${encodeURIComponent(cursor)}`); return r.ok ? { text: String(r.data?.text ?? ""), cursor: r.data?.cursor ?? cursor } : { text: "", cursor }; },
82
- async result(id) { const r = await call("GET", `${p(id)}/result`); return r.ok && r.data && typeof r.data === "object" ? r.data : null; },
88
+ async result(id) { const r = await call("GET", `${p(id)}/result`); if (r.status === 401) throw Object.assign(fail("result", r), { transient: true }); return r.ok && r.data && typeof r.data === "object" ? r.data : null; },
83
89
  async usage(id) { const r = await call("GET", `${p(id)}/usage`); return r.ok && Number.isFinite(Number(r.data?.minutes)) ? { minutes: Number(r.data.minutes), ...(typeof r.data.agent === "string" && r.data.agent ? { agent: r.data.agent } : {}) } : null; }, // agent: #4549, optional
84
90
  async stop(id) { const r = await call("POST", `${p(id)}/stop`); if (r.ok) return true; if (r.status === 404 || r.status === 405) return false; throw fail("stop", r); },
85
91
  async destroy(id) { const r = await call("DELETE", p(id)); if (r.ok || r.status === 404) return true; throw fail("destroy", r); },
@@ -88,19 +94,23 @@ export function httpTransport({ endpoint, token = null, fetchFn = globalThis.fet
88
94
  async capacity() {
89
95
  // `free` (#4540) and agent liveness `online`/`total` (#4547) are each optional; none valid is unknown.
90
96
  const ok = (v) => Number.isInteger(v) && v >= 0;
91
- let out = {};
97
+ let out = {}, reached = false, lastErr = null;
92
98
  try {
93
99
  const r = await call("GET", "/status");
100
+ reached = true;
94
101
  if (r.ok) { const { free, online, total } = r.data ?? {}; out = { ...(ok(free) ? { free } : {}), ...(ok(online) ? { online } : {}), ...(ok(total) ? { total } : {}) }; }
95
- } catch { out = {}; }
102
+ } catch (e) { out = {}; lastErr = e; }
96
103
  if (out.online === undefined) {
97
104
  // #4788: a router without /status liveness: online = agents answering and not paused, as bin/pool/router.mjs status() does.
98
105
  try {
99
106
  const r = await call("GET", "/agents");
107
+ reached = true;
100
108
  const list = r.ok && Array.isArray(r.data?.agents) ? r.data.agents : null;
101
109
  if (list) out = { ...out, online: list.filter((a) => a?.ok === true && !a.paused).length, total: list.length };
102
- } catch { /* liveness stays unknown */ }
110
+ } catch (e) { lastErr = e; /* liveness stays unknown */ }
103
111
  }
112
+ // #5048 (release review REG-2): a router that answered neither call is unreachable: throw, so the pool is marked down
113
+ if (!reached) throw Object.assign(new Error(`pool router unreachable: ${String(lastErr?.message ?? lastErr).slice(0, 160)}`), { unreachable: true });
104
114
  return Object.keys(out).length ? out : null;
105
115
  },
106
116
  };
@@ -218,7 +228,9 @@ export function remoteProvider({ batch, repo, settings = {}, name = "remote", tr
218
228
  return r ?? null;
219
229
  },
220
230
  async capacity() {
221
- try { return (await transport.capacity?.()) ?? null; } catch { return null; }
231
+ // #5048: no capacity() is unknown (null); a transport error propagates, so the caller can report the pool down
232
+ if (typeof transport.capacity !== "function") return null;
233
+ return (await transport.capacity()) ?? null;
222
234
  },
223
235
  /** #4612: a graceful stop at the lane's checkpoint; false when unsupported or failed. Never destroys the sandbox. */
224
236
  async stop(h) {
@@ -11,6 +11,17 @@ const SEP = "\x1f", END = "\x1e";
11
11
  const STASH_REF = "refs/forgedock/dco-stash";
12
12
  const PLACEHOLDER_RE = /@localhost$/i;
13
13
 
14
+ /**
15
+ * #5047: where a lane's commits go when the sign-off rewrite failed. Never the lane branch: ensureSignedOff excludes everything on
16
+ * origin/<branch>, so unsigned commits published there could never be rewritten. A salvage branch is outside that range.
17
+ */
18
+ export const salvageBranch = (issue, sha) => `forge-salvage/${issue}-${String(sha).slice(0, 10)}`;
19
+
20
+ /** #5047: a lane resumed from a salvage branch has no published lane branch: its tracking ref must not exclude the commits from the sign-off range. */
21
+ export async function dropSalvageTracking({ git, branch }) {
22
+ try { await git(["update-ref", "-d", `refs/remotes/origin/${branch}`]); return true; } catch { return false; }
23
+ }
24
+
14
25
  /** True when the commit must be rewritten: placeholder author or committer, or no sign-off naming its author. */
15
26
  export function needsSignoff({ authorEmail, committerEmail, signedOff }) {
16
27
  if (PLACEHOLDER_RE.test(authorEmail ?? "") || PLACEHOLDER_RE.test(committerEmail ?? "")) return true;