forgedock 1.29.0 → 1.29.1

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.
@@ -637,7 +637,7 @@ export function lastLocalRun(dir) {
637
637
  */
638
638
  export async function runFromCli(argv, deps = {}) {
639
639
  const issue = parseInt(argv[0], 10);
640
- if (!Number.isInteger(issue)) throw new Error("usage: forgedock run-issue <issue-number> [--lane <lane>] [--lane-override] [--path core|full] [--recover <code> [--recover-attempt n]] [--retry] [--wait-lease]");
640
+ if (!Number.isInteger(issue)) throw new Error("usage: forgedock run-issue <issue-number> [--lane <lane>] [--lane-override] [--path core|full] [--recover <code> [--recover-attempt n] [--recover-nonfinal]] [--retry] [--wait-lease]");
641
641
  assertOpenCodeNativeRuntime({ operation: "forgedock run-issue" });
642
642
  const lane = flag(argv, "--lane");
643
643
  if (argv.includes("--lane") && (!lane || lane.startsWith("--"))) throw new Error("--lane requires a value: staging or milestone/<slug>");
@@ -667,10 +667,11 @@ export async function runFromCli(argv, deps = {}) {
667
667
  const placementWhere = flag(argv, "--placement");
668
668
  const placement = placementWhere === "local" || placementWhere === "remote"
669
669
  ? { where: placementWhere, reason: String(flag(argv, "--placement-reason") ?? "").slice(0, 300), mode: readExecution()?.mode ?? "local" } : null;
670
- // #4267: a batch recovery lane for a mechanical needs-human (`--recover <code> [--recover-attempt n]`).
670
+ // #4267: a batch recovery lane for a mechanical needs-human (`--recover <code> [--recover-attempt n]`); #4302: `--recover-nonfinal`
671
+ // marks an attempt the runner may follow with another, so the lane leaves the needs-human relabel to the runner.
671
672
  const recoverCode = flag(argv, "--recover");
672
673
  const recoverAttempt = Number(flag(argv, "--recover-attempt"));
673
- const recover = recoverCode && !recoverCode.startsWith("--") ? { code: recoverCode, attempt: Number.isInteger(recoverAttempt) && recoverAttempt > 0 ? recoverAttempt : 1 } : null;
674
+ const recover = recoverCode && !recoverCode.startsWith("--") ? { code: recoverCode, attempt: Number.isInteger(recoverAttempt) && recoverAttempt > 0 ? recoverAttempt : 1, final: !argv.includes("--recover-nonfinal") } : null;
674
675
  const report = (r) => { if (resultFile) (deps.writeAtomic ?? writeResultAtomic)(resultFile, JSON.stringify(r) + "\n"); return r; };
675
676
  try {
676
677
  await assertRepoMatchesCwd(io, repo, { sleep: deps.sleep });
@@ -1098,7 +1099,7 @@ export async function runBatch({ argv, cwd: callerCwd = process.cwd(), env: call
1098
1099
  // #4267: a recovery lane (ctx.recover) reaches it as --recover/--recover-attempt.
1099
1100
  const placementOf = new Map(), recoverOf = new Map();
1100
1101
  const laneArgs = (n, ln) => { const pl = placementOf.get(n), rc = recoverOf.get(n); return ["--repo", repo, ...(ln ? ["--lane", ln] : []), ...(laneOverride ? ["--lane-override"] : []), ...(model ? ["--model", model] : []),
1101
- ...(pl ? ["--placement", pl.where, "--placement-reason", pl.reason ?? ""] : []), ...(rc ? ["--recover", rc.code, "--recover-attempt", String(rc.attempt ?? 1)] : [])]; };
1102
+ ...(pl ? ["--placement", pl.where, "--placement-reason", pl.reason ?? ""] : []), ...(rc ? ["--recover", rc.code, "--recover-attempt", String(rc.attempt ?? 1), ...(rc.final === false ? ["--recover-nonfinal"] : [])] : [])]; };
1102
1103
  const withPlacement = (l) => ({ runOne: (n, ln, ctx = {}) => {
1103
1104
  if (ctx.placement) placementOf.set(n, ctx.placement); else placementOf.delete(n);
1104
1105
  if (ctx.recover) recoverOf.set(n, ctx.recover); else recoverOf.delete(n);
@@ -1153,6 +1154,9 @@ export async function runBatch({ argv, cwd: callerCwd = process.cwd(), env: call
1153
1154
  }
1154
1155
  }
1155
1156
  const orch = deps.orchestration ?? readOrchestration(cwd);
1157
+ // #4304: recovery lanes run on the core path only (run-issue throws on --recover with full), so a full-path repo gets none.
1158
+ const recoveryOn = Boolean(orch.recovery?.maxAttempts) && (deps.enginePath !== undefined ? deps.enginePath : readEnginePath(cwd)) !== "full";
1159
+ if (orch.recovery?.maxAttempts && !recoveryOn) sink.line("recovery: off (engine.path is full; recovery lanes run on the core path only)");
1156
1160
  // #4190: usage-limit holds live in the host store keyed by the account the next lane would use.
1157
1161
  const limitsMod = await import("./lean/limits.mjs");
1158
1162
  const limits = deps.limits ?? limitsMod.openLimits(limitsMod.limitsFile(env));
@@ -1231,7 +1235,7 @@ export async function runBatch({ argv, cwd: callerCwd = process.cwd(), env: call
1231
1235
  ...(registry ? { sched: { registry, batch: batch.id, repo, cap: orch.globalParallel ?? null }, priorityOf: async (n) => schedMod.priorityFromLabels(await labelNames(n)) } : {}),
1232
1236
  ...(remote ? { remote } : {}),
1233
1237
  // #4267: mechanical needs-human outcomes are recovered in the batch; every runner decision is a FORGE:DECISION.
1234
- ...(orch.recovery?.maxAttempts ? { recovery: orch.recovery } : {}),
1238
+ ...(recoveryOn ? { recovery: orch.recovery } : {}),
1235
1239
  decide: deps.decide ?? (async (d) => {
1236
1240
  if (d?.issue == null) { sink.line(`decision: ${d?.what} (why: ${d?.why}; alternative: ${d?.alternative})`); return; }
1237
1241
  const body = `<!-- FORGE:DECISION -->\n## Decision (batch ${batch.id})\n**What**: ${d.what}\n**Why**: ${d.why}\n**Alternative**: ${d.alternative}`;
@@ -1240,7 +1244,9 @@ export async function runBatch({ argv, cwd: callerCwd = process.cwd(), env: call
1240
1244
  // A recovery that ended for good without the lane relabelling it (engine-error, deferred): needs-human back, one comment.
1241
1245
  relabelNeedsHuman: deps.relabelNeedsHuman ?? (async ({ issue, evidence }) => {
1242
1246
  try {
1243
- await leanIo.gh(["issue", "comment", String(issue), "-R", repo, "--body", `<!-- FORGE:NEEDS_HUMAN -->\n## Needs human\n${evidence}`]);
1247
+ // #4315: the evidence carries the full lane reason; capped so it never exceeds GitHub's comment limit
1248
+ const { capComment } = await import("./lean/render.mjs");
1249
+ await leanIo.gh(["issue", "comment", String(issue), "-R", repo, "--body", capComment(`<!-- FORGE:NEEDS_HUMAN -->\n## Needs human\n${evidence}`)]);
1244
1250
  await leanIo.gh(["issue", "edit", String(issue), "-R", repo, "--add-label", "needs-human"]);
1245
1251
  } catch { /* best effort: the batch row keeps the evidence */ }
1246
1252
  }),
@@ -102,12 +102,35 @@ export function priorAttempts(body, comments = []) {
102
102
  }
103
103
 
104
104
  /**
105
- * Cache key for a compiled pack: issue + base head SHA, plus the config that shapes it and (#4298) the issue's
106
- * `updatedAt` and prior-attempt count, so an edit or a new attempt on the same base compiles a fresh pack.
105
+ * Cache key for a compiled pack: issue + base head SHA, plus the config that shapes it and (#4298, #4304) the hash of the
106
+ * issue's human scope text (`scope`) and prior-attempt count, so an edit or a new attempt on the same base compiles a
107
+ * fresh pack. Not `updatedAt`: the lane's own FORGE:STATE/CONTEXT posts and label edits bump it, so the cache never hit.
107
108
  */
108
109
  export const contextPackCacheKey = (issue, headSha, cfg = {}, stamp = {}) =>
109
110
  `context-${issue}-${String(headSha ?? "").trim().slice(0, 40) || "nohead"}-${hash12(JSON.stringify([cfg.sources ?? [], cfg.budgetBytes ?? null, cfg.days ?? null,
110
- ...(stamp?.updatedAt != null || stamp?.attempts != null ? [String(stamp.updatedAt ?? ""), Number(stamp.attempts) || 0] : [])]))}.json`;
111
+ ...(stamp?.scope != null || stamp?.attempts != null ? [String(stamp.scope ?? ""), Number(stamp.attempts) || 0] : []),
112
+ ...(stamp?.links ? [`links:${stamp.links}`] : [])]))}.json`;
113
+
114
+ /** Max referenced issues whose state is read for the cache stamp (one REST call each). */
115
+ export const LINK_STAMP_MAX = 20;
116
+ /**
117
+ * #4316: the linked-issue part of the cache stamp: a hash of `n:STATE` for each issue the body references (parent, Depends
118
+ * on, `#N` links), so a pack never shows a linked issue as open after it closed on the same base. Timeline cross-refs are
119
+ * left to the gather. An unreadable issue counts as `n:?`; never throws; '' when the body references nothing.
120
+ */
121
+ export async function linkStateStamp({ gh, repo, issue, body }) {
122
+ const parent = parentOf(body);
123
+ const nums = [...new Set([...(parent ? [parent] : []), ...parseDependsOn(stripComments(body), issue), ...bodyLinks(body, repo, issue)])]
124
+ .filter((n) => Number.isSafeInteger(n) && n > 0 && n !== Number(issue)).sort(byNum).slice(0, LINK_STAMP_MAX);
125
+ if (!nums.length) return "";
126
+ const states = [];
127
+ for (const n of nums) {
128
+ let st = "?";
129
+ try { st = String(await gh(["api", `repos/${repo}/issues/${n}`, "--jq", ".state"])).trim().toUpperCase() || "?"; } catch { /* unreadable: n:? */ }
130
+ states.push(`${n}:${st}`);
131
+ }
132
+ return hash12(states.join(","));
133
+ }
111
134
 
112
135
  // ---- gather (I/O) ----
113
136
 
@@ -298,7 +321,7 @@ export function renderContextRecord(pack, { mode, laneType: lt }) {
298
321
  }
299
322
 
300
323
  /**
301
- * The pack for one run: read from the cache (`cacheDir`, keyed by issue + base head SHA + config + `stamp`) or gathered
324
+ * The pack for one run: read from the cache (`cacheDir`, keyed by issue + base head SHA + config + `stamp` {scope, attempts}) or gathered
302
325
  * and compiled, then cached. `gather` returns the facts; `fs` is {read, write, rename?, list?, rm?}. A gather that
303
326
  * reported source errors is not cached (#4298), the write goes through a temp file and a rename, and the issue's other
304
327
  * cached packs are pruned. A cache miss or any write/rename/prune failure is not an error.
@@ -2,7 +2,7 @@
2
2
  // SPDX-License-Identifier: AGPL-3.0-or-later
3
3
  import { describe, it } from "node:test";
4
4
  import assert from "node:assert/strict";
5
- import { gatherContextFacts, compileContextPack, contextPackFor, contextPackCacheKey, parseContextPackConfig, laneType, priorAttempts, issueSymbols, bodyLinks, parentOf, renderContextRecord, DEFAULT_CONTEXT_BYTES, noComments } from "./context.mjs";
5
+ import { gatherContextFacts, compileContextPack, contextPackFor, contextPackCacheKey, linkStateStamp, parseContextPackConfig, laneType, priorAttempts, issueSymbols, bodyLinks, parentOf, renderContextRecord, DEFAULT_CONTEXT_BYTES, noComments } from "./context.mjs";
6
6
 
7
7
  const REPO = "o/r";
8
8
  const BOT = { author_association: "NONE", user: { type: "Bot", login: "forge[bot]" } };
@@ -212,26 +212,54 @@ describe("#4298 context-pack cache and shared names", () => {
212
212
  list: async () => [...store.keys()].map((k) => k.slice("/runs/".length)), rm: async (p) => { ops.push(["rm", p]); store.delete(p); } } };
213
213
  };
214
214
 
215
- it("the key changes with the issue's updatedAt and attempt count; no stamp keeps the old shape", () => {
215
+ it("#4304: the key changes with the scope hash and attempt count, not updatedAt; no stamp keeps the old shape", () => {
216
216
  const k = (s) => contextPackCacheKey(50, "abc", cfg, s);
217
217
  assert.equal(k(), contextPackCacheKey(50, "abc", cfg));
218
- assert.equal(k({ updatedAt: "2026-10-20T10:00:00Z", attempts: 1 }), k({ updatedAt: "2026-10-20T10:00:00Z", attempts: 1 }));
219
- assert.notEqual(k({ updatedAt: "2026-10-20T10:00:00Z", attempts: 1 }), k({ updatedAt: "2026-10-20T11:00:00Z", attempts: 1 }));
220
- assert.notEqual(k({ updatedAt: "2026-10-20T10:00:00Z", attempts: 1 }), k({ updatedAt: "2026-10-20T10:00:00Z", attempts: 2 }));
218
+ assert.equal(k({ scope: "aaa111", attempts: 1 }), k({ scope: "aaa111", attempts: 1 }));
219
+ assert.equal(k({ scope: "aaa111", attempts: 1, updatedAt: "2026-10-20T10:00:00Z" }), k({ scope: "aaa111", attempts: 1, updatedAt: "2026-10-20T11:00:00Z" }), "the lane's own posts do not miss the cache");
220
+ assert.notEqual(k({ scope: "aaa111", attempts: 1 }), k({ scope: "bbb222", attempts: 1 }));
221
+ assert.notEqual(k({ scope: "aaa111", attempts: 1 }), k({ scope: "aaa111", attempts: 2 }));
222
+ });
223
+
224
+ it("#4316: linked-issue states are in the key; a state change recompiles; no links keeps the old key", async () => {
225
+ const states = { 7: "open", 8: "open", 9: "closed" };
226
+ const calls = [];
227
+ const gh = async (args) => { calls.push(args); const n = Number(/issues\/(\d+)$/.exec(args[1])?.[1]); if (n === 9 && states.fail9) throw new Error("HTTP 502"); return `${states[n]}\n`; };
228
+ const body = "**Parent**: #7\nDepends on #8\nSee #9 and #50 (self).";
229
+ const s1 = await linkStateStamp({ gh, repo: "o/r", issue: 50, body });
230
+ assert.match(s1, /^[0-9a-f]{12}$/);
231
+ assert.deepEqual(calls.map((a) => a[1]), ["repos/o/r/issues/7", "repos/o/r/issues/8", "repos/o/r/issues/9"], "parent, depends-on and links once each, never self");
232
+ assert.equal(await linkStateStamp({ gh, repo: "o/r", issue: 50, body }), s1, "stable");
233
+ states[8] = "closed";
234
+ const s2 = await linkStateStamp({ gh, repo: "o/r", issue: 50, body });
235
+ assert.notEqual(s2, s1, "a linked issue closing changes the stamp");
236
+ states.fail9 = true;
237
+ assert.match(await linkStateStamp({ gh, repo: "o/r", issue: 50, body }), /^[0-9a-f]{12}$/, "an unreadable link never throws");
238
+ assert.equal(await linkStateStamp({ gh: async () => { throw new Error("x"); }, repo: "o/r", issue: 50, body: "no links here" }), "");
239
+ const k = (s) => contextPackCacheKey(50, "abc", cfg, s);
240
+ assert.equal(k({ scope: "s", attempts: 0, links: "" }), k({ scope: "s", attempts: 0 }), "no links: the pre-#4316 key");
241
+ assert.notEqual(k({ scope: "s", attempts: 0, links: s1 }), k({ scope: "s", attempts: 0, links: s2 }));
242
+ const { fs } = memFs();
243
+ let n = 0;
244
+ const run = (links) => contextPackFor({ cfg, issue: 50, headSha: "abc", cacheDir: "/runs", fs, stamp: { scope: "s", attempts: 0, links }, gather: async () => { n++; return facts(); } });
245
+ await run(s1);
246
+ assert.equal((await run(s1)).cached, true);
247
+ assert.equal((await run(s2)).cached, false, "the pack recompiles after a linked issue closed");
248
+ assert.equal(n, 2);
221
249
  });
222
250
 
223
251
  it("an issue edit recompiles; a gather with source errors is not cached", async () => {
224
252
  const { fs, store } = memFs();
225
253
  let n = 0;
226
254
  const run = (stamp, errors) => contextPackFor({ cfg, issue: 50, headSha: "abc", cacheDir: "/runs", fs, stamp, gather: async () => { n++; return facts(errors); } });
227
- await run({ updatedAt: "t1", attempts: 0 });
228
- assert.equal((await run({ updatedAt: "t1", attempts: 0 })).cached, true);
229
- await run({ updatedAt: "t2", attempts: 0 });
255
+ await run({ scope: "s1", attempts: 0 });
256
+ assert.equal((await run({ scope: "s1", attempts: 0, updatedAt: "later" })).cached, true);
257
+ await run({ scope: "s2", attempts: 0 });
230
258
  assert.equal(n, 2, "the edit recompiled");
231
259
  const before = store.size;
232
- const r = await run({ updatedAt: "t3", attempts: 0 }, ["graph: timeline HTTP 502"]);
260
+ const r = await run({ scope: "s3", attempts: 0 }, ["graph: timeline HTTP 502"]);
233
261
  assert.deepEqual([r.cached, r.errors, store.size], [false, ["graph: timeline HTTP 502"], before]);
234
- assert.equal((await run({ updatedAt: "t3", attempts: 0 }, ["graph: timeline HTTP 502"])).cached, false, "errors are retried, not served from cache");
262
+ assert.equal((await run({ scope: "s3", attempts: 0 }, ["graph: timeline HTTP 502"])).cached, false, "errors are retried, not served from cache");
235
263
  });
236
264
 
237
265
  it("writes through a temp file and a rename, then prunes the issue's other packs only", async () => {
@@ -239,8 +267,8 @@ describe("#4298 context-pack cache and shared names", () => {
239
267
  store.set("/runs/context-50-old-aaaaaaaaaaaa.json", "{}");
240
268
  store.set("/runs/context-500-x-bbbbbbbbbbbb.json", "{}");
241
269
  store.set("/runs/50-run.jsonl", "");
242
- await contextPackFor({ cfg, issue: 50, headSha: "abc", cacheDir: "/runs", fs, stamp: { updatedAt: "t", attempts: 0 }, gather: async () => facts() });
243
- const file = `/runs/${contextPackCacheKey(50, "abc", cfg, { updatedAt: "t", attempts: 0 })}`;
270
+ await contextPackFor({ cfg, issue: 50, headSha: "abc", cacheDir: "/runs", fs, stamp: { scope: "t", attempts: 0 }, gather: async () => facts() });
271
+ const file = `/runs/${contextPackCacheKey(50, "abc", cfg, { scope: "t", attempts: 0 })}`;
244
272
  assert.equal(ops[0][0], "write"); assert.match(ops[0][1], /\.tmp$/);
245
273
  assert.deepEqual(ops[1], ["rename", ops[0][1], file]);
246
274
  assert.deepEqual([...store.keys()].sort(), ["/runs/50-run.jsonl", "/runs/context-500-x-bbbbbbbbbbbb.json", file].sort());
package/bin/lean/lean.mjs CHANGED
@@ -19,9 +19,9 @@ import { resolveLane, isDefaultLane } from "./lane.mjs";
19
19
  import { dockerLabels, LEDGER_TTL_MS } from "./ledger.mjs";
20
20
  import { repoKey } from "../runner.mjs";
21
21
  import { prepareToolchain } from "./toolchain.mjs";
22
- import { readTestMap, readTestMapAtRef, selectCommands, factsPath, TEST_MAP_FILE } from "./testmap.mjs";
22
+ import { readTestMap, readTestMapAtRef, selectCommands, mergeChecks, factsPath, TEST_MAP_FILE } from "./testmap.mjs";
23
23
  import { retrying, PUSH_TRANSIENT_RE, PUSH_PERMANENT_RE, GH_TRANSIENT_RE, MERGE_TRANSIENT_RE, MERGE_CONFLICT_RE } from "./retry.mjs";
24
- import { renderInvestigation, renderImplementation, renderReview, renderPrBody, renderTrajectory, renderContract, renderAmendments, renderShadowReview, parseContractComment, contractJson, capComment, sanitizeMd, MAX_COMMENT_CHARS } from "./render.mjs";
24
+ import { renderInvestigation, renderImplementation, renderReview, renderPrBody, renderTrajectory, renderContract, renderAmendments, renderShadowReview, parseContractComment, contractJson, capComment, sanitizeMd, MAX_COMMENT_CHARS, recoveryEvidence } from "./render.mjs";
25
25
  import { computeTrigger, TRIGGER_MODES } from "./review-trigger.mjs";
26
26
  import { REVIEWER_SYSTEM, LEAN_DIR, RESULT_FILE, INVESTIGATION_FILE, IMPLEMENTATION_FILE, CONTRACT_FILE, builderPlanPrompt, builderImplementPrompt, contractCorrectionPrompt, conformanceResumePrompt, reviewerPrompt, reviewerRepairPrompt, REVIEW_DIFF_MAX_BYTES, blockCorrectionPrompt, HUMAN_BLOCK_REASONS, structurePrompt, hotspotPlanNote, hotspotBuildRule, reproPlanNote, contextPackNote, docsPrompt, ciFailurePrompt, ciUnavailablePrompt, precheckFailurePrompt, syncChecks, GROUPING_SYSTEM } from "./prompts.mjs";
27
27
  import { addLaneWorktree, DEFAULT_SPARSE_MAX_KB } from "./worktree.mjs";
@@ -30,7 +30,7 @@ import { laneBackendId } from "./limits.mjs";
30
30
  import { SPLIT_LABEL } from "./refactor.mjs";
31
31
  import { writeHeavyShims, sumWaits } from "./heavy.mjs";
32
32
  import { priorityFromLabels } from "./sched.mjs";
33
- import { parseContextPackConfig, contextPackFor, renderContextRecord, laneType, priorAttempts } from "./context.mjs";
33
+ import { parseContextPackConfig, contextPackFor, renderContextRecord, laneType, priorAttempts, linkStateStamp } from "./context.mjs";
34
34
 
35
35
  /**
36
36
  * #4267: what a recovery lane does per code. Codebase-agnostic: the repo's own checks and logs only. Keys stay in step
@@ -129,8 +129,9 @@ export function isProtectedLaneBranch(b, { issue, lane = null, branches = {}, de
129
129
  * @param {number} [o.sparseMaxKb] forge.yaml env.sparse_max_kb: tracked blobs >= this many KB stay out of the lane checkout (default 1024; 0 = off, #4221)
130
130
  * @param {object} [o.placement] {where: local|remote, reason, mode}: the dispatcher's lane placement (#4256), kept in FORGE:STATE
131
131
  * @param {object} [o.contextPack] {mode: shadow|on|off, sources, bytes, days}: forge.yaml engine.context_pack* (#4275, default shadow)
132
- * @param {object} [o.recover] {code, attempt}: a batch recovery lane (#4267) for a mechanical needs-human; removes the label,
133
- * posts FORGE:DECISION and resumes the open PR on the rerun path with a recovery note
132
+ * @param {object} [o.recover] {code, attempt, final}: a batch recovery lane (#4267) for a mechanical needs-human; removes the label,
133
+ * posts FORGE:DECISION and resumes the open PR on the rerun path with a recovery note. `final`
134
+ * (default true) is false when the runner may launch another attempt: the lane then never relabels (#4302)
134
135
  */
135
136
  export async function runCore(o) {
136
137
  const { issue, repo, lane: requestedLane = null, laneOverride = false, branches = {}, cwd, dir, io, builderModel = null, reviewerModel = DEFAULT_REVIEWER_MODEL,
@@ -143,8 +144,8 @@ export async function runCore(o) {
143
144
  const riskPaths = Array.isArray(o.reviewTrigger?.riskPaths) ? o.reviewTrigger.riskPaths : [];
144
145
  const sleep = io.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
145
146
  let lane = null, base = null, defaults = [], milestone = null; // set by lane resolution, before the lease
146
- // #4267: a batch recovery lane for a mechanical needs-human ({code, attempt}).
147
- const recover = o.recover && typeof o.recover.code === "string" ? { code: o.recover.code, attempt: Number.isInteger(o.recover.attempt) ? o.recover.attempt : 1 } : null;
147
+ // #4267: a batch recovery lane for a mechanical needs-human ({code, attempt, final}); #4302: only a final attempt relabels.
148
+ const recover = o.recover && typeof o.recover.code === "string" ? { code: o.recover.code, attempt: Number.isInteger(o.recover.attempt) ? o.recover.attempt : 1, final: o.recover.final !== false } : null;
148
149
  const branch = `feat/issue-${issue}-lean`;
149
150
  const wt = join(o.worktreeRoot ?? join(cwd, ".claude", "worktrees"), `lean-${issue}`);
150
151
  const t0 = now();
@@ -322,10 +323,11 @@ export async function runCore(o) {
322
323
  await emit(term);
323
324
  // #4297: only a final failure the runner never requeues puts needs-human back (needsHuman() relabels on its own path). invalid,
324
325
  // decomposed and dependency-* are legitimate non-failure endings; deferred/engine-error may be requeued, so the batch runner
325
- // (orchestrate settle, relabelNeedsHuman) relabels those once they are final.
326
- if (recoverUnlabelled && reason === "ci-pending") {
326
+ // (orchestrate settle, relabelNeedsHuman) relabels those once they are final. #4302: a non-final attempt never relabels; the runner
327
+ // either launches the next attempt or relabels in settle.
328
+ if (recoverUnlabelled && recover.final && reason === "ci-pending") {
327
329
  try {
328
- await post("FORGE:NEEDS_HUMAN", `## Needs human\n${recoveryEvidence(reason, code, detail)}\n\n_${usageLine()}_`);
330
+ await post("FORGE:NEEDS_HUMAN", `## Needs human\n${laneRecoveryEvidence(reason, code, detail)}\n\n_${usageLine()}_`);
329
331
  await ghc(["issue", "edit", String(issue), "-R", repo, "--add-label", "needs-human"]);
330
332
  } catch { /* best effort */ }
331
333
  }
@@ -344,11 +346,11 @@ export async function runCore(o) {
344
346
  ...(Object.keys(waits).length ? { waits: { ...waits } } : {}), ...(heavyQueueMax ? { heavyQueueMax } : {}), ...extra };
345
347
  return done;
346
348
  }
347
- /** #4297: what a recovery lane that did not merge tried and how it ended; the issue stays needs-human with this. */
348
- const recoveryEvidence = (reason, code, detail) => `Recovery pass for \`${recover.code}\` (attempt ${recover.attempt}) did not merge: it ended \`${reason}\`${code ? ` (\`${code}\`)` : ""}${String(detail ?? "").trim() ? `: ${String(detail).trim().split("\n")[0].slice(0, 300)}` : ""}. The issue stays \`needs-human\`.`;
349
+ /** #4297/#4316: a recovery lane that ended unmerged keeps needs-human with this evidence (shared text in render.mjs). */
350
+ const laneRecoveryEvidence = (reason, code, detail) => recoveryEvidence({ code: recover.code, attempt: recover.attempt, outcome: reason, resultCode: code, detail });
349
351
  async function needsHuman(why, code = OUTCOME_CODES.ENGINE_ERROR, extra = {}) {
350
- try {
351
- await post("FORGE:NEEDS_HUMAN", `## Needs human\n${why}${recoverUnlabelled ? `\n\n${recoveryEvidence("needs-human", code, "")}` : ""}\n\n_${usageLine()}_`);
352
+ if (!recover || recover.final) try {
353
+ await post("FORGE:NEEDS_HUMAN", `## Needs human\n${why}${recoverUnlabelled ? `\n\n${laneRecoveryEvidence("needs-human", code, "")}` : ""}\n\n_${usageLine()}_`);
352
354
  await ghc(["issue", "edit", String(issue), "-R", repo, "--add-label", "needs-human"]);
353
355
  } catch { /* best effort */ }
354
356
  return finish("needs-human", why, { code, ...extra, ...(recover ? { recovery: true } : {}) }, code);
@@ -894,7 +896,9 @@ export async function runCore(o) {
894
896
  if (ctxCfg.mode === "off" || !io.contextFacts) return null;
895
897
  try {
896
898
  const headSha = String(await git(["rev-parse", base])).trim();
897
- const r = await contextPackFor({ cfg: ctxCfg, issue, headSha, cacheDir: dir, fs: io.fs, stamp: { updatedAt: iss.updatedAt ?? null, attempts: priorAttempts(iss.body, trustedComments) },
899
+ // #4316: linked-issue states are part of the key; a failed lookup only drops them from it
900
+ const links = await linkStateStamp({ gh: ghc, repo, issue, body: iss.body }).catch(() => "");
901
+ const r = await contextPackFor({ cfg: ctxCfg, issue, headSha, cacheDir: dir, fs: io.fs, stamp: { scope: scopeHash, attempts: priorAttempts(iss.body, trustedComments), links },
898
902
  gather: () => io.contextFacts({ repo, issue, iss, comments: trustedComments, cwd: wt, base, sources: ctxCfg.sources, days: ctxCfg.days, now: now() }) });
899
903
  const p = r.pack;
900
904
  const rec = { event: "CONTEXT_PACK", mode: ctxCfg.mode, lane_type: ctxLane, sources: p.sources, found: p.found, bytes: p.bytes, items: p.items, truncated: p.truncated,
@@ -1117,11 +1121,12 @@ export async function runCore(o) {
1117
1121
  const learnedCommands = (Array.isArray(o.precheck?.commands) ? o.precheck.commands : []).map((c) => String(c ?? "").trim()).filter(Boolean);
1118
1122
  // #4297: its commands run, so the map comes from the base ref (else the operator's checkout), never the lane worktree
1119
1123
  const testMap = io.runCommand ? (await readTestMapAtRef(git, base)) ?? (await readTestMap([cwd], io.fs?.read)) : null;
1120
- const pickChecks = () => { const m = testMap ? selectCommands(testMap, files) : []; return m.length ? { commands: m, source: "test-map" } : { commands: learnedCommands, source: "learned" }; };
1124
+ // #4315: the map's selection is merged into the learned commands (deduped): a partial map never skips a learned suite
1125
+ const pickChecks = () => { const m = testMap ? selectCommands(testMap, files) : []; return { ...mergeChecks(m, learnedCommands), selected: m.length }; };
1121
1126
  let picked = pickChecks();
1122
1127
  // #4270: the last result of each deterministic gate, as computed below; only reportVerify (verify.check_run) reads it
1123
1128
  const verifyFacts = { commands: null, structure: null, undeclaredEdges: [], docs: null, precheck: null, amendments: [], hotspots: false };
1124
- if (picked.source === "test-map") T.decisions.push(`Pre-PR checks selected from ${TEST_MAP_FILE}: ${picked.commands.length} of ${testMap.entries.length} entries`);
1129
+ if (picked.source !== "learned") T.decisions.push(`Pre-PR checks selected from ${TEST_MAP_FILE}: ${picked.selected} of ${testMap.entries.length} entries${learnedCommands.length ? `, plus ${learnedCommands.length} learned` : ""}`);
1125
1130
  if (picked.commands.length && io.runCommand) {
1126
1131
  for (let round = 0; ; round++) {
1127
1132
  await renew();
@@ -1133,7 +1138,7 @@ export async function runCore(o) {
1133
1138
  try { r = await heavyStep("precheck", () => io.runCommand({ command, cwd: wt, timeoutMs: vcfg.precheckTimeoutSeconds * 1000 })); } catch (e) { r = { rc: 127, output: String(e?.message ?? e) }; }
1134
1139
  results.push({ command, rc: Number.isInteger(r?.rc) ? r.rc : 1, output: String(r?.output ?? "") });
1135
1140
  }
1136
- ev({ event: "PRECHECK_COMMANDS", round, results: results.map(({ command, rc }) => ({ command, rc })), ...(picked.source === "test-map" ? { source: "test-map" } : {}) });
1141
+ ev({ event: "PRECHECK_COMMANDS", round, results: results.map(({ command, rc }) => ({ command, rc })), ...(picked.source !== "learned" ? { source: picked.source } : {}) });
1137
1142
  verifyFacts.commands = results.map(({ command, rc }) => ({ command, rc }));
1138
1143
  const failed = results.filter((r) => r.rc !== 0);
1139
1144
  if (!failed.length) { checkCommandsFailing = false; if (round) T.decisions.push(`Declared check commands pass after ${round} builder fix round(s)`); break; }
@@ -787,6 +787,24 @@ describe("in-batch recovery of mechanical needs-human (#4267)", () => {
787
787
  assert.equal((await run(plain, ends["ci-pending"][1])).terminalReason, "ci-pending");
788
788
  assert.equal(add(plain), 0, "no recovery: a ci-pending run is not labelled");
789
789
  });
790
+
791
+ it("#4302: a non-final recovery attempt (recover.final false) never relabels needs-human; final true or absent relabels once", async () => {
792
+ const add = (w) => w.edits.filter((e) => e.join(" ") === "issue edit 7 -R o/r --add-label needs-human").length;
793
+ const nh = (w) => w.comments.filter((c) => c.startsWith("<!-- FORGE:NEEDS_HUMAN -->")).length;
794
+ const ends = {
795
+ "ci-pending": [{ waits: Array(500).fill({ rc: 2, text: "" }) }, { ciMaxMinutes: 10, mergeWait: "all" }],
796
+ "needs-human": [{ waits: [{ rc: 1, text: "lint failed" }] }, { mergeWait: "all" }],
797
+ };
798
+ for (const [reason, [wo, ro]] of Object.entries(ends)) {
799
+ for (const [final, want] of [[false, 0], [true, 1], [undefined, 1]]) {
800
+ const w = world(wo);
801
+ const r = await run(w, { recover: { code: "ci.red", attempt: 1, ...(final === undefined ? {} : { final }) }, ...ro });
802
+ assert.equal(r.terminalReason, reason);
803
+ assert.equal(add(w), want, `${reason} final=${final}: label`); assert.equal(nh(w), want, `${reason} final=${final}: comment`);
804
+ if (reason === "needs-human") assert.equal(r.recovery, true);
805
+ }
806
+ }
807
+ });
790
808
  });
791
809
 
792
810
  describe("devdocs freshness gate (the repo's own scripts/check-devdocs-freshness.sh)", () => {
@@ -3547,10 +3565,20 @@ describe("test map and repo facts caches (#4272)", () => {
3547
3565
  { globs: ["**/*.py"], command: "uv run pytest", cwd: ".", source: "pyproject.toml" }, { globs: ["test/**"], command: "npm test", cwd: "pkg", source: "pkg/package.json" }]);
3548
3566
  const dir = tmpDir("core-");
3549
3567
  assert.equal((await run(w, { dir, precheck: { commands: ["make lint"] } })).terminalReason, "merged");
3550
- assert.deepEqual(ran, ["npm run lint", "cd pkg && npm test"]);
3568
+ // #4315: the map adds to learned.test_commands, never replaces them
3569
+ assert.deepEqual(ran, ["make lint", "npm run lint", "cd pkg && npm test"]);
3551
3570
  const pre = readLog(dir, 7).filter((e) => e.event === "PRECHECK_COMMANDS");
3552
- assert.deepEqual(pre.map((e) => [e.source, e.results.map((r) => r.command)]), [["test-map", ["npm run lint", "cd pkg && npm test"]]]);
3553
- assert.match(traj(w)[0], /Pre-PR checks selected from \.forge\/test-map\.json: 2 of 3 entries/);
3571
+ assert.deepEqual(pre.map((e) => [e.source, e.results.map((r) => r.command)]), [["test-map+learned", ["make lint", "npm run lint", "cd pkg && npm test"]]]);
3572
+ assert.match(traj(w)[0], /Pre-PR checks selected from \.forge\/test-map\.json: 2 of 3 entries, plus 1 learned/);
3573
+ });
3574
+
3575
+ it("#4315 a partial test map still runs the learned commands it does not cover, each once", async () => {
3576
+ const w = world(); const ran = recorder(w);
3577
+ w.baseFiles = { [MAPF]: map([{ globs: ["src/**"], command: "npm test", cwd: "." }, { globs: ["**/*.go"], command: "go test ./...", cwd: "." }]) };
3578
+ const dir = tmpDir("core-");
3579
+ assert.equal((await run(w, { dir, precheck: { commands: ["npm test", "make lint"] } })).terminalReason, "merged");
3580
+ assert.deepEqual(ran, ["npm test", "make lint"]);
3581
+ assert.deepEqual(readLog(dir, 7).filter((e) => e.event === "PRECHECK_COMMANDS").map((e) => Object.keys(e).includes("source")), [false]);
3554
3582
  });
3555
3583
 
3556
3584
  it("the main checkout's test map is used when the base ref has none; a map alone enables the step", async () => {
@@ -51,17 +51,22 @@
51
51
  * and instead of stopping when the budget refuses. The final `{where, reason}` is kept on the run record and the row as
52
52
  * `placement` and passed to `runOne` as `ctx.placement` (the lane records it in its FORGE:STATE).
53
53
  *
54
- * In-batch recovery (#4267), off unless `recovery: {maxAttempts}` is passed: a row `classifyNeedsHuman` calls
54
+ * In-batch recovery (#4267), on by default: this module recovers only when `recovery: {maxAttempts}` is passed, and the
55
+ * CLI always passes it (forge.yaml `orchestration.recovery.max_attempts`, default 1; 0 disables): a row `classifyNeedsHuman` calls
55
56
  * recoverable (needs-human `ci.red` / `base.conflict` / `builder.block_untyped`, `contract.invalid` after a mid-run issue
56
57
  * edit, `ci-pending` whose PR `conflicting(issue, pr)` says conflicts) is not final while its persisted `recoveries` stay
57
- * under `maxAttempts`: it is requeued as a recovery lane (`ctx.recover = {code, attempt}`) under the same admission as any
58
- * lane, so its dependents stay queued. A merged recovery row carries `recovered: <code>`; any other end is final with
59
- * `recovery` evidence. Human codes (`builder.blocked`) are never recovered. Every operational decision the runner takes
58
+ * under `maxAttempts`: it is requeued as a recovery lane (`ctx.recover = {code, attempt, final}`, `final` when attempt ==
59
+ * `maxAttempts`) under the same admission as any lane, so its dependents stay queued. Only a final lane relabels needs-human
60
+ * itself; a non-final attempt that ends needs-human/ci-pending without a successor is relabelled here, once (#4302). A recovery
61
+ * still queued when the batch exits (breaker, budget, hold, or a resume that does not relaunch it) is relabelled once with the
62
+ * full persisted lane reason (#4315).
63
+ * A merged recovery row carries `recovered: <code>`; any other end is final with `recovery` evidence. Human codes (`builder.blocked`) are never recovered. Every operational decision the runner takes
60
64
  * (recovery, engine-error relaunch, dependency requeue, sub-issues added) goes to `decide({issue, what, why, alternative})`,
61
65
  * fire-and-forget: the runner never waits on it.
62
66
  */
63
67
  import { parseDependsOn } from "../engine/phases.mjs";
64
68
  import { classifyNeedsHuman, recoveryCodeOf } from "../engine/outcome-codes.mjs";
69
+ import { recoveryEvidence } from "./render.mjs";
65
70
 
66
71
  // A dependency that finished like this no longer blocks its dependents.
67
72
  const SATISFIED = new Set(["merged", "invalid"]);
@@ -206,7 +211,8 @@ export async function orchestrate({ issues, parallel = 3, runOne, readBody, pref
206
211
  let budgetStop = null, budgetHit = null; // this pass's refusal under on_budget stop; the last refusal reason
207
212
  const recoveryMax = Number.isInteger(recovery?.maxAttempts) && recovery.maxAttempts > 0 ? recovery.maxAttempts : 0;
208
213
  const recoveries = toMap(saved?.recoveries); // issue -> recovery lanes launched (#4267)
209
- const recovering = toMap(saved?.recovering); // issue -> {code, attempt} of the recovery lane queued or running
214
+ const recovering = toMap(saved?.recovering); // issue -> {code, attempt, detail, outcome, ...} of the recovery lane queued or running
215
+ const relabelled = new Set(saved?.relabelled ?? []); // #4315: queued recoveries relabelled needs-human when a batch stopped
210
216
  // #4267: decisions are recorded, never awaited: a failing or slow recorder cannot block the batch.
211
217
  const decided = (d) => { try { Promise.resolve(decide?.(d)).catch(() => {}); } catch { /* best effort */ } };
212
218
  const firstLine = (x) => String(x ?? "").split("\n")[0].slice(0, 300);
@@ -217,7 +223,8 @@ export async function orchestrate({ issues, parallel = 3, runOne, readBody, pref
217
223
  pending, notBefore: toObj(notBefore), requeues: toObj(requeues), deferrals: toObj(deferrals), engineErrors: toObj(engineErrors),
218
224
  prefilter: toObj(checked), errorTimes, breaker: tripped, ...(budget ? { classes: toObj(classes), dispatch: { ...dispatch } } : {}),
219
225
  ...(reg ? { priorities: toObj(priorities) } : {}), ...(sandboxUse.size ? { sandbox: toObj(sandboxUse) } : {}),
220
- ...(recoveries.size || recovering.size ? { recoveries: toObj(recoveries), recovering: toObj(recovering) } : {}) });
226
+ ...(recoveries.size || recovering.size ? { recoveries: toObj(recoveries), recovering: toObj(recovering) } : {}),
227
+ ...(relabelled.size ? { relabelled: [...relabelled] } : {}) });
221
228
  };
222
229
  const update = (row) => { store?.log?.(row); onUpdate(row); };
223
230
  const classifyOne = async (n) => {
@@ -338,7 +345,9 @@ export async function orchestrate({ issues, parallel = 3, runOne, readBody, pref
338
345
 
339
346
  // Apply one lane's result: requeue it (deferred, dependency-open, engine-error) or record its final row.
340
347
  const settle = async (n, r, rec, pf) => {
341
- const row = { issue: n, outcome: r?.terminalReason ?? "engine-error", pr: r?.pr ?? null, cost_usd: (r?.cost_usd ?? 0) + (pf?.cost_usd ?? 0), minutes: Math.round((now() - rec.startedAt) / 6000) / 10, detail: r?.detail, ...(r?.code ? { code: r.code } : {}), ...(pf ? { prefilter: pf } : {}),
348
+ // #4304: the pre-filter runs once per issue; a recovery pass's row adds the earlier attempt's cost, which already holds it.
349
+ const pfCost = recovering.has(n) ? 0 : (pf?.cost_usd ?? 0);
350
+ const row = { issue: n, outcome: r?.terminalReason ?? "engine-error", pr: r?.pr ?? null, cost_usd: (r?.cost_usd ?? 0) + pfCost, minutes: Math.round((now() - rec.startedAt) / 6000) / 10, detail: r?.detail, ...(r?.code ? { code: r.code } : {}), ...(pf ? { prefilter: pf } : {}),
342
351
  ...(r?.waits && typeof r.waits === "object" ? { waits: r.waits } : {}), ...(Number.isInteger(r?.heavyQueueMax) ? { heavyQueueMax: r.heavyQueueMax } : {}),
343
352
  ...(rec?.placement ? { placement: rec.placement } : {}) };
344
353
  const depList = (Array.isArray(r?.dependencies) ? r.dependencies : []).filter((d) => Number.isSafeInteger(d) && d > 0 && d !== n);
@@ -399,6 +408,7 @@ export async function orchestrate({ issues, parallel = 3, runOne, readBody, pref
399
408
  }
400
409
  // #4267: a recovery pass ends here; a recoverable row under the attempt cap is requeued as a recovery lane.
401
410
  const prior = recovering.get(n) ?? null;
411
+ const evidence = () => recoveryEvidence({ code: prior.code, attempt: prior.attempt, outcome: row.outcome, resultCode: row.code, detail: row.detail });
402
412
  if (prior) {
403
413
  recovering.delete(n);
404
414
  // #4298: the row reports the whole issue: earlier attempts' cost and minutes are summed into the recovery's.
@@ -409,7 +419,7 @@ export async function orchestrate({ issues, parallel = 3, runOne, readBody, pref
409
419
  // A recovery that failed for good keeps needs-human. The lane relabels needs-human/ci-pending itself; engine-error (after its
410
420
  // relaunch) and deferred (no longer requeued) end here, so the runner does it, once. invalid/decomposed/dependency-*/merged never.
411
421
  if (["engine-error", "deferred"].includes(row.outcome) && typeof relabelNeedsHuman === "function") {
412
- try { await relabelNeedsHuman({ issue: n, evidence: `Recovery pass for \`${prior.code}\` (attempt ${prior.attempt}) did not merge: it ended \`${row.outcome}\`${row.code ? ` (\`${row.code}\`)` : ""}${firstLine(row.detail) ? `: ${firstLine(row.detail)}` : ""}. The issue stays \`needs-human\`.` }); } catch { /* best effort */ }
422
+ try { await relabelNeedsHuman({ issue: n, evidence: evidence() }); } catch { /* best effort */ }
413
423
  }
414
424
  }
415
425
  if (recoveryMax && (row.outcome === "needs-human" || row.outcome === "ci-pending")) {
@@ -421,7 +431,9 @@ export async function orchestrate({ issues, parallel = 3, runOne, readBody, pref
421
431
  const used = recoveries.get(n) ?? 0;
422
432
  if (classifyNeedsHuman(facts) === "recoverable" && used < recoveryMax) {
423
433
  const code = recoveryCodeOf(facts), attempt = used + 1;
424
- recoveries.set(n, attempt); recovering.set(n, { code, attempt, cost_usd: Number(row.cost_usd) || 0, minutes: Number(row.minutes) || 0 });
434
+ // #4315: the full lane detail is kept (never firstLine) for the relabel when the batch stops before this attempt
435
+ recoveries.set(n, attempt); recovering.set(n, { code, attempt, cost_usd: Number(row.cost_usd) || 0, minutes: Number(row.minutes) || 0,
436
+ detail: String(row.detail ?? ""), outcome: row.outcome, ...(row.code ? { result_code: row.code } : {}), prev_attempt: prior?.attempt ?? 0 });
425
437
  requeue(n, 0); done(); save();
426
438
  decided({ issue: n, what: `recovery lane for ${code} (attempt ${attempt}/${recoveryMax}); dependents stay queued`, why: firstLine(row.detail) || code,
427
439
  alternative: "leave needs-human for the operator and skip the dependents" });
@@ -429,6 +441,10 @@ export async function orchestrate({ issues, parallel = 3, runOne, readBody, pref
429
441
  return;
430
442
  }
431
443
  }
444
+ // #4302: a non-final recovery attempt left needs-human/ci-pending unlabelled for its successor; none is launched, so relabel once.
445
+ if (prior && prior.attempt < recoveryMax && ["needs-human", "ci-pending"].includes(row.outcome) && typeof relabelNeedsHuman === "function") {
446
+ try { await relabelNeedsHuman({ issue: n, evidence: evidence() }); } catch { /* best effort */ }
447
+ }
432
448
  results.set(n, row);
433
449
  if (row.outcome === "merged" && drift?.onMerged && r?.structure && String(rec?.lane ?? r?.lane ?? "").startsWith("milestone/")) {
434
450
  save(); update(row);
@@ -449,6 +465,7 @@ export async function orchestrate({ issues, parallel = 3, runOne, readBody, pref
449
465
  return;
450
466
  }
451
467
  const lane = ln?.lane;
468
+ if (relabelled.delete(n)) save(); // #4315: the recovery lane launches after all; it removes needs-human itself
452
469
  const pl = placed.get(n);
453
470
  placed.delete(n);
454
471
  const rec = { startedAt: now(), ...(lane ? { lane } : {}), ...(remoteRun.has(n) ? { remote: true } : {}), ...(pl ? { placement: pl } : {}) };
@@ -461,7 +478,7 @@ export async function orchestrate({ issues, parallel = 3, runOne, readBody, pref
461
478
  if (pf?.closed) r = { terminalReason: "invalid", cost_usd: 0, detail: `pre-filter: ${pf.detail}` };
462
479
  else {
463
480
  try {
464
- r = await runOne(n, lane, { remote: !!rec.remote, ...(rec.placement ? { placement: rec.placement } : {}), ...(recovering.has(n) ? { recover: { code: recovering.get(n).code, attempt: recovering.get(n).attempt } } : {}), onStart: (info) => {
481
+ r = await runOne(n, lane, { remote: !!rec.remote, ...(rec.placement ? { placement: rec.placement } : {}), ...(recovering.has(n) ? { recover: { code: recovering.get(n).code, attempt: recovering.get(n).attempt, final: recovering.get(n).attempt >= recoveryMax } } : {}), onStart: (info) => {
465
482
  Object.assign(rec, info ?? {}); save();
466
483
  if (reg && slotted.has(n) && info?.pid) reg.setLanePid(keyOf(n), info.pid);
467
484
  } });
@@ -572,8 +589,21 @@ export async function orchestrate({ issues, parallel = 3, runOne, readBody, pref
572
589
  }
573
590
  }
574
591
  if (reg) withdrawExcept(new Set());
592
+ const heldDetail = (n) => tripped?.detail ?? (driftHeld.has(n) ? `drift: ${driftHeld.get(n)} flagged` : budgetStop ? `execution budget: ${budgetStop}` : budgetHeld ? "maintenance share" : "not run");
593
+ // #4315: a recovery queued but never launched (the batch stopped: breaker, budget, hold; or a resume that did not relaunch a
594
+ // lost run) left the issue without needs-human, since a non-final attempt does not relabel. Relabel it once, with the full reason.
595
+ for (const [n, q] of recovering) {
596
+ if (running.has(n) || results.has(n) || relabelled.has(n) || typeof relabelNeedsHuman !== "function") continue;
597
+ const cause = heldDetail(n) === "not run" ? "batch stopped" : heldDetail(n);
598
+ const evidence = recoveryEvidence({ code: q.code, attempt: q.prev_attempt, outcome: q.outcome, resultCode: q.result_code, detail: q.detail, full: true,
599
+ queued: q.attempt, cause, recoveryMax });
600
+ try { await relabelNeedsHuman({ issue: n, evidence }); } catch { /* best effort */ }
601
+ relabelled.add(n);
602
+ }
575
603
  save();
576
- return order.map((n) => results.get(n) ?? { issue: n, outcome: "held", pr: null, cost_usd: 0, minutes: 0, detail: tripped?.detail ?? (driftHeld.has(n) ? `drift: ${driftHeld.get(n)} flagged` : budgetStop ? `execution budget: ${budgetStop}` : budgetHeld ? "maintenance share" : "not run") });
604
+ return order.map((n) => results.get(n) ?? { issue: n, outcome: "held", pr: null, cost_usd: 0, minutes: 0, detail: heldDetail(n),
605
+ ...(recovering.has(n) ? { recovery: { code: recovering.get(n).code, attempts: recoveries.get(n) ?? recovering.get(n).attempt, outcome: recovering.get(n).outcome ?? "needs-human",
606
+ ...(recovering.get(n).result_code ? { result_code: recovering.get(n).result_code } : {}), detail: firstLine(recovering.get(n).detail ?? recovering.get(n).code) } } : {}) });
577
607
  }
578
608
 
579
609
  /** Max chars of one row's detail in the batch-report outcome lists (#4298). */