forgedock 1.49.0 → 1.51.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.
Files changed (44) hide show
  1. package/bin/engine/outcome-codes.mjs +2 -0
  2. package/bin/engine-cli.mjs +123 -20
  3. package/bin/forgedock.mjs +31 -3
  4. package/bin/lean/batch-health.mjs +219 -0
  5. package/bin/lean/batch-health.test.mjs +156 -0
  6. package/bin/lean/batch.mjs +12 -3
  7. package/bin/lean/batch.test.mjs +304 -8
  8. package/bin/lean/conformance.mjs +79 -7
  9. package/bin/lean/conformance.test.mjs +63 -1
  10. package/bin/lean/context.mjs +8 -2
  11. package/bin/lean/context.test.mjs +11 -1
  12. package/bin/lean/credentials.mjs +18 -0
  13. package/bin/lean/execution.mjs +23 -9
  14. package/bin/lean/execution.test.mjs +93 -0
  15. package/bin/lean/fixclosereasons.mjs +52 -0
  16. package/bin/lean/fixclosereasons.test.mjs +64 -0
  17. package/bin/lean/heavy.mjs +14 -4
  18. package/bin/lean/heavy.test.mjs +55 -1
  19. package/bin/lean/lean.mjs +93 -36
  20. package/bin/lean/lean.test.mjs +143 -19
  21. package/bin/lean/live-execution.mjs +136 -0
  22. package/bin/lean/live-execution.test.mjs +83 -0
  23. package/bin/lean/orchestrate-blocked.test.mjs +47 -0
  24. package/bin/lean/orchestrate.mjs +135 -17
  25. package/bin/lean/prefilter.mjs +4 -3
  26. package/bin/lean/prefilter.test.mjs +5 -4
  27. package/bin/lean/prompts.mjs +4 -2
  28. package/bin/lean/recovery.test.mjs +5 -5
  29. package/bin/lean/sandbox-provider.contract.test.mjs +36 -1
  30. package/bin/lean/sandbox-remote.mjs +6 -2
  31. package/bin/lean/schedd.mjs +9 -0
  32. package/bin/pool/agent.mjs +3 -1
  33. package/bin/pool/router.mjs +72 -7
  34. package/bin/review-staging.mjs +2 -0
  35. package/bin/runner.mjs +24 -0
  36. package/commands/orchestrate.md +2 -1
  37. package/commands/review-pr-staging.md +2 -2
  38. package/docs/CONFIG.md +39 -1
  39. package/docs/OUTCOME-CODES.md +2 -1
  40. package/package.json +1 -1
  41. package/pool/README.md +2 -0
  42. package/pool/image/cs-cred +8 -1
  43. package/scripts/review-staging-post.sh +70 -25
  44. package/scripts/review-staging-post.test.sh +40 -8
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 { applyValidateRecipes, deferChecklessPostDeploy, reproEnvMiss, resolveForgedockPaths, describesUnrunnableCommand, removedPathsOf, TRACKED, normalizeKindModes, blockingFailures, unverifiedRequirements, mutationTargets, mutationFailures, runMutationCheck, MUTATION_SURVIVED, isBugIssue, scopeText, explainFailures, inIssue, validateContract, cliUsageError, normalizeRunnerCommand, applyAmendments, amendmentKindProblems, precheck, alreadyFixedOnBase, READONLY_PROD_RE, EXTERNAL_NEED_RE, 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, amendmentKindProblems, precheck, alreadyFixedOnBase, READONLY_PROD_RE, EXTERNAL_NEED_RE, repoTracks, dropScopeValidates, tagOutOfBand, parseReview, decide, mergeReviewRows, reaskable, criterionLines, closePlan, followupItems, renderPendingFollowups, 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";
@@ -91,6 +91,8 @@ export const RECOVER_HOW = Object.freeze({
91
91
  [OUTCOME_CODES.CONFORMANCE_UNJUSTIFIED_DEFERRAL]: "an earlier run deferred a requirement without a sufficient why after one builder resume; the branch holds its work. For each named gap either deliver the requirement (adding any needed file as an `add-deliverable` amendment) or, when the clause is optional, unquantified or outside the issue's acceptance criteria, record a `defer` amendment with a concrete why so the engine files a tracked follow-up instead of stopping",
92
92
  [OUTCOME_CODES.ENV_REPRO_UNRUNNABLE]: "the repro harness could not run the contract's repro_test on head (timeout, missing runner or dependencies); the change may already be correct. Do not edit the deliverables or stub dependencies for it and never install anything globally: run the repro command through the repo's own toolchain and installed dependencies, and when it cannot run in this checkout, fix the repro_test command (not the code) so it does",
93
93
  });
94
+ /** #5168: retry-in for a `backend.auth` deferral; the batch holds the account for it (an operator `--set-env` fix runs under a new identity). */
95
+ const AUTH_RETRY_SECONDS = 900;
94
96
  /** #4323/#4334: precheck failure codes that are environment errors (state "environment"), never a builder resume. */
95
97
  const ENV_CODES = Object.freeze([OUTCOME_CODES.ENV_TOOL_MISSING, OUTCOME_CODES.ENV_REPRO_UNRUNNABLE, ENV_VALIDATE_TIMEOUT, OUTCOME_CODES.CONTRACT_INVALID_COMMAND]);
96
98
  export const DEFAULT_REVIEWER_MODEL = "claude-sonnet-5-5";
@@ -319,7 +321,8 @@ export async function runCore(o) {
319
321
  const terminal = ["merged", "invalid", "needs-human", "engine-error"].includes(reason);
320
322
  // forge#4275: the typed outcome code and failure classes ride on FORGE:STATE, so the next run's context pack reads them typed.
321
323
  const typed = terminal ? { terminalCode: code ?? null, failureClasses: [...failureClasses].sort(), ...(reason === "merged" && mergedTo ? { mergedTo } : {}) } : {}; // #4711: where the change landed
322
- try { await writeLease(null, terminal ? { terminal: true, terminalReason: reason, ...typed } : {}); } catch { /* best effort */ }
324
+ const pending = pendingRecord().length ? { pendingFollowups: pendingRecord() } : {}; // #5141: unfiled follow-ups, filed later by the marker
325
+ try { await writeLease(null, terminal ? { terminal: true, terminalReason: reason, ...typed, ...pending } : pending); } catch { /* best effort */ }
323
326
  }
324
327
 
325
328
  // Pipeline trajectory (posted once at the end): phase rows, decisions, anomalies.
@@ -357,6 +360,8 @@ export async function runCore(o) {
357
360
  let seqLease = null; // #4386: the host sequence lease, held from the merge-gate renumber through the merge
358
361
  const waits = {};
359
362
  const followupsFiled = []; // #5078: {issue, class, count} per tracking issue this lane filed into, for the batch report
363
+ const pendingFollowups = []; // #5141: {req, why} whose filing kept failing in-lane; filed after the merge, else recorded
364
+ const pendingRecord = () => pendingFollowups.filter((p) => !p.req.tracked).map((p) => ({ id: p.req.id, quote: String(p.req.quote ?? ""), disposition: p.req.disposition, why: p.why, marker: `FORGE:DEFERRED_FROM: #${issue}` }));
360
365
  let precheckMs = 0, precheckDeferred = 0, precheckRan = false; // #4331: pre-PR check wall time and deferrals, for the batch report
361
366
  const addWait = (phase, ms, queued = 0) => {
362
367
  if (Number.isFinite(ms) && ms > 0) waits[phase] = (waits[phase] ?? 0) + ms;
@@ -485,7 +490,7 @@ export async function runCore(o) {
485
490
  }
486
491
  if (["merged", "invalid", "needs-human", "budget-exceeded"].includes(reason)) {
487
492
  if (reason === "budget-exceeded") T.close = ["left open", "recoverable budget.exceeded"]; // #4505: not a human escalation
488
- else if (reason === "merged") T.close = [extra.close === "partial" ? "issue closed (partial)" : "issue closed (completed)", "label workflow:merged"];
493
+ else if (reason === "merged") T.close = [extra.keptOpen ? "left open (follow-up pending)" : extra.close === "partial" ? "issue closed (partial)" : "issue closed (completed)", "label workflow:merged"]; // #5159
489
494
  else if (reason === "invalid" && extra.close === "already-fixed") T.close = ["issue closed (completed)", "already fixed on the base"]; // #5080
490
495
  else if (reason === "invalid") T.close = ["issue closed (not planned)", "label workflow:invalid"];
491
496
  else { T.close = ["left open", "label needs-human"]; T.anomalies.push(`needs-human: ${String(detail).split("\n")[0].slice(0, 300)}`); }
@@ -494,7 +499,7 @@ export async function runCore(o) {
494
499
  await releaseLease(reason, code, extra.mergedTo ?? null);
495
500
  done = { terminalReason: reason, detail, branch, worktree: wt, cost_usd: usage.cost_usd, tokens: { input: usage.input, output: usage.output, cache: usage.cache }, wall_ms: now() - t0, model_ms: modelMs, phases: phaseLedger.snapshot(),
496
501
  ...(laneHead && reason !== "merged" ? { laneHead: { ...laneHead } } : {}), light, ...(lightVerdict ? { light_eligibility: lightVerdict } : {}), ...(lightUpgrade ? { light_upgrade: lightUpgrade } : {}), // #4453
497
- ...(Object.keys(waits).length ? { waits: { ...waits } } : {}), ...(followupsFiled.length ? { followups: followupsFiled.map((f) => ({ ...f })) } : {}), ...(heavyQueueMax ? { heavyQueueMax } : {}), ...(precheckRan ? { precheckMs, precheckDeferred } : {}),
502
+ ...(Object.keys(waits).length ? { waits: { ...waits } } : {}), ...(followupsFiled.length ? { followups: followupsFiled.map((f) => ({ ...f })) } : {}), ...(pendingRecord().length ? { pendingFollowups: pendingRecord() } : {}), ...(heavyQueueMax ? { heavyQueueMax } : {}), ...(precheckRan ? { precheckMs, precheckDeferred } : {}),
498
503
  ...(firstPassOf(builderTriggers) != null ? { firstPass: firstPassOf(builderTriggers) } : {}), ...(tb.record() ? { turns: tb.record() } : {}), // #4392
499
504
  ...(route ? { builderRoute: { tier: route.tier, class: route.class, escalated, model: route.model ?? "default" } } : {}),
500
505
  ...(gateRan ? { conformance: { mode: conformance === "enforce" || riskEnforced ? "enforce" : "shadow", would: shadowWould, risk: Boolean(riskEnforced), ...(Object.keys(kinds).length ? { kinds: [...failureClasses] } : {}) } } : {}), ...extra };
@@ -633,6 +638,9 @@ export async function runCore(o) {
633
638
  const par = verifyParallelism();
634
639
  return { ...deps, ...(runTmp ? { TMPDIR: runTmp } : {}), ...(par ? { FORGE_HOST_PARALLELISM: par } : {}), ...(toolchain?.env ?? {}) };
635
640
  }
641
+ /** #5168: a failed claude login ends the lane `deferred` `backend.auth`, held per account by the batch (never the relaunch or the breaker). */
642
+ const authDeferral = (e) => Object.assign(e, { deferCode: OUTCOME_CODES.BACKEND_AUTH, retryInSeconds: AUTH_RETRY_SECONDS, backendId: laneBackendId(process.env, rotator),
643
+ deferDetail: `claude CLI authentication failed (login expired or invalid); held until the login is fixed: ${String(e?.message ?? e).split("\n")[0].slice(0, 300)}` });
636
644
  /** Runner call with the per-run TMPDIR and FORGE_HOST_PARALLELISM; a usage limit pauses and resumes the same step, or defers past the cap (#4101). */
637
645
  async function callRunner(args) {
638
646
  let pauses = 0, rotations = 0;
@@ -646,6 +654,7 @@ export async function runCore(o) {
646
654
  try { return await io.runner({ ...args, system, env: { ...laneEnv(), FORGE_DOCKER_LABELS: labels, ...(runTmp ? { CLAUDE_CODE_TMPDIR: runTmp } : {}), ...(toolchain?.env ?? {}), ...heavy, ...profEnv } }); }
647
655
  catch (e) {
648
656
  const epoch = Number.isFinite(e?.resetAtEpochMs) ? e.resetAtEpochMs : null;
657
+ if (e?.authFailure === true && e.usageLimit !== true && epoch === null) throw authDeferral(e); // #5168: an account condition, never engine-error
649
658
  if (!(e?.usageLimit === true || epoch !== null)) throw e;
650
659
  if (rotatorRetry(rotator, rotations)) { rotations++; ev({ event: "PHASE_ACCOUNT_ROTATED", phase: args.role }); continue; } // #5094: same args (resume too), next account
651
660
  const waitMs = epoch !== null && epoch > now() ? epoch - now() : DEFAULT_SESSION_LIMIT_WAIT_MS;
@@ -999,9 +1008,10 @@ export async function runCore(o) {
999
1008
  // #4337: with decomposition.close_parent, the tracker closes only once its trail and label exist: dependents then wait on the sub-issues, not on it.
1000
1009
  if (await label("decomposed") && marked && closeParent) {
1001
1010
  try {
1002
- await ghw(["issue", "close", String(issue), "-R", repo, "--reason", "not planned", "--comment",
1011
+ // #5152: the scope moved to the sub-issues, it was not dropped: `completed`, never `not planned`
1012
+ await ghw(["issue", "close", String(issue), "-R", repo, "--reason", "completed", "--comment",
1003
1013
  `Decomposed into ${refs(filed)}; this tracker is closed and its dependents wait on the sub-issues. A trusted \`FORGE:DECOMPOSED:REVOKED\` reopens it.`]);
1004
- T.decisions.push(`Closed the decomposed parent (not planned); tracked by ${refs(filed)}`);
1014
+ T.decisions.push(`Closed the decomposed parent (completed; scope moved to sub-issues); tracked by ${refs(filed)}`);
1005
1015
  } catch { /* best effort: the parent stays open, as before #4337 */ }
1006
1016
  }
1007
1017
  return await finish("decomposed", `split into ${refs(filed)}`, { code: OUTCOME_CODES.DECOMPOSED, subissues: filed, decomposition: b.rationale });
@@ -1083,7 +1093,7 @@ export async function runCore(o) {
1083
1093
  try {
1084
1094
  iss = JSON.parse(await ghc(["issue", "view", String(issue), "-R", repo, "--json", "number,title,body,state,comments,labels,updatedAt"]));
1085
1095
  } catch (e) {
1086
- if (e?.deferCode) return finish("deferred", e.deferDetail ?? String(e.message), { code: e.deferCode, ...(e.retryInSeconds ? { retryInSeconds: e.retryInSeconds } : {}) }, e.deferCode); // #4662
1096
+ if (e?.deferCode) return finish("deferred", e.deferDetail ?? String(e.message), { code: e.deferCode, ...(e.retryInSeconds ? { retryInSeconds: e.retryInSeconds } : {}), ...(e.backendId ? { backendId: e.backendId } : {}) }, e.deferCode); // #4662; #5168 backendId
1087
1097
  return finish("engine-error", `cannot read issue: ${e.message}`);
1088
1098
  }
1089
1099
  const priorState = stateOf(iss.body); // #4400: the last run's end (the lease write keeps its terminal fields), read before this run ends
@@ -1404,25 +1414,54 @@ export async function runCore(o) {
1404
1414
  }
1405
1415
  return block ? await endBlocked(block, result.summary) : null;
1406
1416
  }
1407
- /** File (or append to) the parent's one batched follow-up issue for these requirements, then verify each tracking issue is open. */
1408
- async function track(reqs, why) {
1409
- const fail = (detail) => Object.assign(new Error(detail), { deferCode: OUTCOME_CODES.FOLLOWUP_UNAVAILABLE, deferDetail: `${detail}; nothing merged, rerun to retry` });
1417
+ /**
1418
+ * File (or append to) the parent's one batched follow-up issue for these requirements, then verify each tracking issue is open.
1419
+ * #5141: the whole step is retried in-lane (3 attempts, 5s then 15s): the script is idempotent (claim marker /
1420
+ * FORGE:DEFERRED_FROM lookup, checklist dedup, duplicate close), so only a persistent failure defers the run.
1421
+ * A malformed TSV (DEFERRED_TSV=INVALID) is deterministic and never retried. #5141: when the retries are spent the run
1422
+ * keeps going (keep): the untracked requirements join pendingFollowups, recorded in FORGE:STATE and filed after the merge.
1423
+ */
1424
+ async function track(reqs, why, { keep = true } = {}) {
1425
+ const fail = (detail, final = true) => Object.assign(new Error(detail), { deferCode: OUTCOME_CODES.FOLLOWUP_UNAVAILABLE, deferDetail: `${detail}; nothing merged, rerun to retry`, final });
1410
1426
  if (!io.fileFollowups) throw fail("no follow-up filing effect is configured");
1411
1427
  const items = followupItems(reqs, { now: now() });
1412
- let filed;
1413
- try { filed = await io.fileFollowups({ parent: issue, repo, items, milestone, defaultMilestone: o.followupsDefaultMilestone ?? null, lane, batchId: o.batchId ?? null, rollupLabel: o.followupsRollupLabel ?? null }); } catch (e) { throw fail(`filing the follow-up issue failed: ${String(e?.message ?? e).slice(0, 200)}`); }
1414
- for (const [i, r] of reqs.entries()) {
1415
- const n = Number(filed?.[items[i].key]);
1416
- if (!Number.isSafeInteger(n) || n <= 0) throw fail(`no tracking issue was returned for ${r.id}`);
1417
- let st = null;
1418
- try { st = JSON.parse(String(await ghc(["issue", "view", String(n), "-R", repo, "--json", "number,state"]))); } catch { st = null; }
1419
- if (String(st?.state).toUpperCase() !== "OPEN") throw fail(`tracking issue #${n} for ${r.id} is not open or unreadable`);
1420
- r.tracked = `#${n}`;
1421
- const f = followupsFiled.find((x) => x.issue === n && x.class === items[i].class); // #5078: grouped per issue and class
1422
- if (f) f.count += 1; else followupsFiled.push({ issue: n, class: items[i].class, count: 1 });
1423
- }
1424
- const buckets = Object.entries(bucketTally(reqs)).map(([k, c]) => `${k}:${c}`).join(","); // #4724
1425
- T.decisions.push(`Tracked ${reqs.map((r) => `${r.id} (${r.disposition})`).join(", ")} in ${[...new Set(reqs.map((r) => r.tracked))].join(", ")} (${why}; buckets ${buckets})`);
1428
+ const attempt = async () => {
1429
+ let filed;
1430
+ try { filed = await io.fileFollowups({ parent: issue, repo, items, milestone, defaultMilestone: o.followupsDefaultMilestone ?? null, lane, batchId: o.batchId ?? null, rollupLabel: o.followupsRollupLabel ?? null }); } catch (e) {
1431
+ throw fail(`filing the follow-up issue failed: ${String(e?.message ?? e).slice(0, 200)}`, /DEFERRED_TSV=INVALID/.test(`${e?.stdout ?? ""}${e?.message ?? ""}`));
1432
+ }
1433
+ for (const [i, r] of reqs.entries()) {
1434
+ if (recorded.has(i)) continue; // tracked in an earlier attempt: keeps its r.tracked, never re-counted
1435
+ const n = Number(filed?.[items[i].key]);
1436
+ if (!Number.isSafeInteger(n) || n <= 0) throw fail(`no tracking issue was returned for ${r.id}`, false);
1437
+ let st = null;
1438
+ try { st = JSON.parse(String(await ghc(["issue", "view", String(n), "-R", repo, "--json", "number,state"]))); } catch { st = null; }
1439
+ if (String(st?.state).toUpperCase() !== "OPEN") throw fail(`tracking issue #${n} for ${r.id} is not open or unreadable`, false);
1440
+ recorded.add(i);
1441
+ r.tracked = `#${n}`;
1442
+ const f = followupsFiled.find((x) => x.issue === n && x.class === items[i].class); // #5078: grouped per issue and class
1443
+ if (f) f.count += 1; else followupsFiled.push({ issue: n, class: items[i].class, count: 1 });
1444
+ }
1445
+ };
1446
+ const recorded = new Set();
1447
+ const waits = [5000, 15000];
1448
+ for (let k = 0; ; k++) {
1449
+ try { await attempt(); break; } catch (e) {
1450
+ if (e.final || (k >= waits.length && !keep)) throw e;
1451
+ if (k >= waits.length) { // #5141: keep the merge-capable work; the close step files these after the merge
1452
+ const left = reqs.filter((_, i) => !recorded.has(i));
1453
+ for (const r of left) if (!pendingFollowups.some((p) => p.req === r)) pendingFollowups.push({ req: r, why });
1454
+ T.anomalies.push(`follow-up filing failed ${k + 1} times (${String(e.message).slice(0, 160)}): ${left.map((r) => r.id).join(", ")} pending, filed after the merge (FORGE:DEFERRED_FROM: #${issue})`);
1455
+ break;
1456
+ }
1457
+ T.decisions.push(`Follow-up filing attempt ${k + 1} failed (${String(e.message).slice(0, 160)}); retrying in-lane in ${waits[k] / 1000}s`);
1458
+ await sleep(waits[k]);
1459
+ }
1460
+ }
1461
+ const done = reqs.filter((r) => r.tracked);
1462
+ if (!done.length) return;
1463
+ const buckets = Object.entries(bucketTally(done)).map(([k, c]) => `${k}:${c}`).join(","); // #4724
1464
+ T.decisions.push(`Tracked ${done.map((r) => `${r.id} (${r.disposition})`).join(", ")} in ${[...new Set(done.map((r) => r.tracked))].join(", ")} (${why}; buckets ${buckets})`);
1426
1465
  }
1427
1466
  /**
1428
1467
  * #4328: probe the host for the tools and modules a contract's validate commands need; #4393: then flag `$NAME`s the lane
@@ -1689,9 +1728,10 @@ export async function runCore(o) {
1689
1728
  if (closes) {
1690
1729
  T.plan = "skipped"; T.implement = "skipped"; T.review = "skipped"; T.pr = "none"; T.merge = "n/a";
1691
1730
  T.decisions.push(`Builder verdict: ${result.status}`);
1692
- await ghw(["issue", "close", String(issue), "-R", repo, "--reason", "not planned"]);
1731
+ // #5152: already-fixed is delivered behaviour (`completed`); only invalid is dropped scope (`not planned`)
1732
+ await ghw(["issue", "close", String(issue), "-R", repo, "--reason", result.status === "already-fixed" ? "completed" : "not planned"]);
1693
1733
  await label("invalid");
1694
- return await finish("invalid", result.summary ?? result.status);
1734
+ return await finish("invalid", result.summary ?? result.status, result.status === "already-fixed" ? { close: "already-fixed" } : {});
1695
1735
  }
1696
1736
  const blocked = await endIfBlocked();
1697
1737
  if (blocked) return blocked;
@@ -2204,16 +2244,16 @@ export async function runCore(o) {
2204
2244
  const ctx = { contract: effective, changedFiles: files, issueText, pathExists: (x) => listed.has(x), repoFiles: listed, ignore: IGNORE, e2e: { checks: vcfg.e2e?.checks ?? [], isSpec: (x) => e2eSpecs([x], vcfg.e2e).length > 0 } };
2205
2245
  let d = null, first = null;
2206
2246
  for (let t = 0; t < 3; t++) {
2207
- // #4327: a third run only when the repaired reply is incomplete solely by evidence paths that did not resolve.
2247
+ // #4327/#5164: a third run only when the repaired reply is incomplete solely by reviewer slips (unresolved evidence paths, coverage misquotes).
2208
2248
  if (t === 2) {
2209
- if (d.state !== "review.incomplete" || !evidencePathOnly(d.failures.map((f) => f.detail))) break;
2210
- T.decisions.push(`Review incomplete only by unresolved evidence paths (${(d.missing ?? []).join(", ")}); reviewer re-asked once more`);
2249
+ if (d.state !== "review.incomplete" || !reaskable(d.failures.map((f) => f.detail))) break;
2250
+ T.decisions.push(`Review incomplete only by unresolved evidence paths or coverage misquotes (${(d.missing ?? []).join(", ")}); reviewer re-asked once more`);
2211
2251
  }
2212
2252
  // #4160: a reply with rows missing is re-asked for those ids only; an unparseable one gets the full prompt again.
2213
2253
  const repair = first && (d.state === "review.incomplete" || d.state === "review.inconsistent");
2214
2254
  await renew();
2215
2255
  const rr = await callRunner({ role: "reviewer", issue, cwd: wt, model: reviewerModel, resume: null, system: REVIEWER_SYSTEM, readOnly: true,
2216
- prompt: repair ? reviewerRepairPrompt({ missing: d.missing ?? [], problems: d.failures.map((f) => f.detail), rows: first })
2256
+ prompt: repair ? reviewerRepairPrompt({ missing: d.missing ?? [], problems: d.failures.map((f) => f.detail), rows: first, criteria: (d.missing ?? []).includes("issue_coverage") ? criterionLines(issueText) : [] })
2217
2257
  : reviewerPrompt({ issue: iss, comments: trustedComments, contract: effective, result, files, diff, stat, base, structure: structureLine, ignore: IGNORE, domains, investigationVerdict: investigationVerdict(await io.fs.read(`${wt}/.forge-lean/investigation.md`).catch(() => null)) }) });
2218
2258
  await renew();
2219
2259
  await attempt("review", ++reviewN, rr, null, "review");
@@ -2257,7 +2297,7 @@ export async function runCore(o) {
2257
2297
  run.findings = parseVerifierFindings(rr?.text, { pathExists: (x) => listed.has(x) });
2258
2298
  run.cost_usd = num(rr?.costUsd) ?? 0;
2259
2299
  } catch (e) {
2260
- if (e?.deferUsageLimit) throw e;
2300
+ if (e?.deferUsageLimit || e?.deferCode === OUTCOME_CODES.BACKEND_AUTH) throw e; // #5168: a login failure (backend.auth) is not swallowed as a verifier error
2261
2301
  run.error = String(e?.code || e?.message || e).split("\n")[0].slice(0, 200);
2262
2302
  T.anomalies.push(`Independent verifier failed at ${head.slice(0, 12)}: ${run.error}`);
2263
2303
  }
@@ -3015,10 +3055,27 @@ export async function runCore(o) {
3015
3055
  }
3016
3056
  try { await recordStructure(pr); } catch (e) { ev({ event: "STRUCTURE_DELTA_FAILED", error: String(e?.message ?? e).slice(0, 300) }); }
3017
3057
  await deleteMergedBranch();
3058
+ let keepOpen = false;
3059
+ if (pendingRecord().length) { // #5141: the close step files what the lane could not track before the PR (idempotent by the marker)
3060
+ const left = pendingFollowups.filter((p) => !p.req.tracked);
3061
+ for (const why of new Set(left.map((p) => p.why))) {
3062
+ try { await track(left.filter((p) => p.why === why).map((p) => p.req), `${why}; filed after the merge`); } catch (e) { T.anomalies.push(`follow-up filing after the merge failed: ${String(e?.message ?? e).slice(0, 160)}`); }
3063
+ }
3064
+ for (const p of left) if (p.req.tracked && !closing.tracked.includes(p.req.tracked)) closing.tracked.push(p.req.tracked);
3065
+ const still = pendingRecord();
3066
+ if (still.length) {
3067
+ keepOpen = true; // release review #5155 (#5156): nothing re-files a pending item, so the issue stays open as the durable record
3068
+ T.decisions.push(`Follow-up still unfiled after the merge: ${still.map((x) => x.id).join(", ")}; issue left open (FORGE:FOLLOWUP_PENDING, FORGE:STATE pendingFollowups) until it is tracked`);
3069
+ try { await post("FORGE:FOLLOWUP_PENDING", renderPendingFollowups(still, pr)); } catch { /* FORGE:STATE and the lane result carry them too */ }
3070
+ }
3071
+ }
3018
3072
  const partial = closing.reason === "partial";
3019
- await ghw(["issue", "close", String(issue), "-R", repo, "--reason", partial ? "not planned" : "completed"]).catch(() => {});
3020
- if (partial) {
3021
- try { await post("FORGE:TRACKED", `## Closed as partial\nThe investigation's stated question is not answered in this change (PR #${pr}); carried forward in ${closing.tracked.join(", ")}.`); } catch { /* the PR body links them too */ }
3073
+ // #5152: a merged change is `completed` even when partial: the remainder is tracked (FORGE:TRACKED), not dropped
3074
+ if (!keepOpen) await ghw(["issue", "close", String(issue), "-R", repo, "--reason", "completed"]).catch(() => {});
3075
+ if (keepOpen) { // #5159: the issue is not closed while a follow-up is pending, so the note must not say it is
3076
+ if (partial || closing.tracked.length) try { await post("FORGE:TRACKED", `## Left open: follow-up pending\nMerged by PR #${pr}; the issue stays open until the pending follow-up is filed (FORGE:FOLLOWUP_PENDING).${partial ? " The investigation's stated question is not answered in this change." : ""}${closing.tracked.length ? ` Already tracked in ${closing.tracked.join(", ")}.` : ""}`); } catch { /* the PR body links them too */ }
3077
+ } else if (partial) {
3078
+ try { await post("FORGE:TRACKED", `## Closed as partial\nClosed as completed by PR #${pr}; the investigation's stated question is not answered in this change and is carried forward in ${closing.tracked.join(", ")}.`); } catch { /* the PR body links them too */ }
3022
3079
  } else if (closing.tracked.length) {
3023
3080
  try { await post("FORGE:TRACKED", `## Remaining work tracked\nClosed as completed by PR #${pr}. The contracted deferred and post-deploy requirements are tracked in ${closing.tracked.join(", ")}.`); } catch { /* the PR body links them too */ }
3024
3081
  }
@@ -3043,13 +3100,13 @@ export async function runCore(o) {
3043
3100
  await gatePassed("merge_ci", "ci_red"); // #4375: merged means the required checks are green after a CI fix round
3044
3101
  let mergedTo = null; // #4711: FORGE:STATE records the lane and merge commit, so a dependent's check needs no PR search
3045
3102
  if (lane) try { const sha = JSON.parse(String(await ghc(["pr", "view", String(pr), "-R", repo, "--json", "mergeCommit"])) || "{}").mergeCommit?.oid; if (typeof sha === "string" && sha) mergedTo = { lane, pr, sha }; } catch { /* the PR-ancestry rule still applies */ }
3046
- return await finish("merged", `PR #${pr}`, { pr, ...(mergedTo ? { mergedTo } : {}), ...(partial ? { close: "partial" } : {}), structure: T.structure ?? null, ...(milestone ? { milestone } : {}), ...(lane ? { lane } : {}) });
3103
+ return await finish("merged", `PR #${pr}`, { pr, ...(mergedTo ? { mergedTo } : {}), ...(partial ? { close: "partial" } : {}), ...(keepOpen ? { keptOpen: true } : {}), structure: T.structure ?? null, ...(milestone ? { milestone } : {}), ...(lane ? { lane } : {}) });
3047
3104
  } catch (e) {
3048
3105
  if (e?.deferUsageLimit) {
3049
3106
  const when = e.resetAt || (Number.isFinite(e.resetAtEpochMs) ? new Date(e.resetAtEpochMs).toISOString() : "unknown");
3050
3107
  return finish("deferred", `usage limit hit; resets at ${when}`, { code: OUTCOME_CODES.RATE_LIMITED, resetAt: when, resetAtEpochMs: Number.isFinite(e.resetAtEpochMs) ? e.resetAtEpochMs : null, backendId: laneBackendId(process.env, rotator) }, OUTCOME_CODES.RATE_LIMITED);
3051
3108
  }
3052
- if (e?.deferCode) return finish("deferred", e.deferDetail ?? String(e.message), { code: e.deferCode, ...(e.retryInSeconds ? { retryInSeconds: e.retryInSeconds } : {}) }, e.deferCode);
3109
+ if (e?.deferCode) return finish("deferred", e.deferDetail ?? String(e.message), { code: e.deferCode, ...(e.retryInSeconds ? { retryInSeconds: e.retryInSeconds } : {}), ...(e.backendId ? { backendId: e.backendId } : {}) }, e.deferCode); // #5168: backendId keys the account hold
3053
3110
  // #4505: a turn overrun is mechanical: a non-human end the batch recovers, needs-human only once a budget recovery failed too
3054
3111
  if (e?.laneCode === OUTCOME_CODES.BUDGET_EXCEEDED && !(recover?.final && recover.code === OUTCOME_CODES.BUDGET_EXCEEDED)) {
3055
3112
  return finish("budget-exceeded", e.message, { code: OUTCOME_CODES.BUDGET_EXCEEDED, branch, ...(recover ? { recovery: true } : {}) }, OUTCOME_CODES.BUDGET_EXCEEDED);
@@ -186,7 +186,9 @@ function world(o = {}) {
186
186
  transitionLabel: async (n, s) => { w.labels.push(s); w.seq.push(`label:${s}`); },
187
187
  ...(o.procs ? { proc: { inDir: async () => o.procs, alive: (pid) => w.alive.has(pid), kill: (pid, sig) => { w.kills.push([pid, sig]); w.seq.push(`kill-${sig}`); if (sig === "SIGKILL" || !o.stubborn) w.alive.delete(pid); } } } : {}),
188
188
  fs: { ...(o.wtExists ? { exists: async (p) => p.startsWith("/wt/lean-7") } : {}), stat: async (p) => (p in w.files ? { mtimeMs: w.mtimes[p] ?? 1 } : null), read: async (p) => { if (!(p in w.files)) throw new Error("ENOENT"); return w.files[p]; }, append: async (p, t) => { w.files[p] = (w.files[p] ?? "") + t; }, write: async (p, t) => { w.files[p] = t; }},
189
- fileFollowups: async ({ parent, items }) => { w.followups.push({ parent, items }); if (o.followupError) throw new Error(o.followupError); return Object.fromEntries(items.map((i) => [i.key, o.followupIssue ?? 900])); },
189
+ fileFollowups: async ({ parent, items }) => { w.followups.push({ parent, items }); if (o.followupError) throw Object.assign(new Error(o.followupError), { stdout: o.followupStdout ?? "" });
190
+ if (o.followupRet) return o.followupRet(w.followups.length, items);
191
+ if (w.followups.length <= (o.followupFailTimes ?? 0)) throw new Error("DEFERRED_LOOKUP=FAILED"); if (w.followups.length <= (o.followupMissingTimes ?? 0)) return {}; return Object.fromEntries(items.filter((i) => !(o.followupSkip ?? []).some((q) => JSON.stringify(i).includes(q))).map((i) => [i.key, o.followupIssue ?? 900])); },
190
192
  };
191
193
  return w;
192
194
  }
@@ -279,13 +281,16 @@ describe("core work-on", () => {
279
281
  const t = traj(w)[0];
280
282
  for (const needle of ["| Validate | INVALID |", "| Plan | skipped |", "| Implement | skipped |", "| Review | skipped |", "| PR | none |", "| Close | issue closed (not planned) |"]) assert.ok(t.includes(needle), needle);
281
283
  assert.equal(w.closed.length, 1);
284
+ assert.equal(w.closed[0][w.closed[0].indexOf("--reason") + 1], "not planned", "#5152: invalid keeps not planned");
282
285
  assert.equal(w.prs.length, 0);
283
286
  assert.equal(reviewers(w).length, 0);
284
287
  });
285
288
 
286
- it("already-fixed with evidence also closes", async () => {
289
+ it("already-fixed with evidence also closes, as completed (#5152)", async () => {
287
290
  const w = world({ changed: [], result: { status: "already-fixed", summary: "done upstream", evidence: ["a.js:1"], tests: [] } });
288
291
  assert.equal((await run(w)).terminalReason, "invalid");
292
+ assert.deepEqual(w.closed.map((a) => a[a.indexOf("--reason") + 1]), ["completed"], "#5152: already-fixed is delivered, never not planned");
293
+ assert.ok(traj(w)[0].includes("| Close | issue closed (completed) |"));
289
294
  });
290
295
 
291
296
  it("a failed needs-human comment is recorded as an anomaly and the lane still ends needs-human (#4905)", async () => {
@@ -381,6 +386,21 @@ describe("core work-on", () => {
381
386
  assert.equal(r2.code, "review.incomplete"); assert.equal(reviewers(w2).length, 3, "at most three reviewer runs"); assert.equal(w2.merges.length, 0);
382
387
  });
383
388
 
389
+ it("#5164 a coverage misquote gets one more targeted re-ask with the issue's criterion lines; a near-verbatim one is requoted without a re-ask", async () => {
390
+ const mis = () => conf({ issue_coverage: [{ quote: "foo copes when nothing at all is passed", contract_ids: ["R1"], status: "covered" }] });
391
+ const w = world({ reviews: [mis(), mis(), ok()] });
392
+ assert.equal((await run(w)).terminalReason, "merged");
393
+ const rv = reviewers(w);
394
+ assert.equal(rv.length, 3); assert.equal(builders(w).length, 2, "never charged to the builder");
395
+ for (const i of [1, 2]) { assert.match(rv[i][4], /<issue_criteria>/); assert.match(rv[i][4], /- foo must handle a null argument without throwing\./); }
396
+ assert.match(rv[1][4], /answer ONLY for these ids or sections: issue_coverage/);
397
+ const w2 = world({ reviews: [mis(), mis(), mis(), ok()] });
398
+ const r2 = await run(w2);
399
+ assert.equal(r2.code, "review.incomplete"); assert.equal(reviewers(w2).length, 3, "at most three reviewer runs"); assert.equal(w2.merges.length, 0);
400
+ const w3 = world({ reviews: [conf({ issue_coverage: [{ quote: "foo must handle null arguments without throwing", contract_ids: ["R1"], status: "covered" }] })] });
401
+ assert.equal((await run(w3)).terminalReason, "merged"); assert.equal(reviewers(w3).length, 1);
402
+ });
403
+
384
404
  it("a CONFORMS verdict whose rows fail is not conforming (the engine computes the verdict)", async () => {
385
405
  const w = world({ reviews: [bad("half done", "CONFORMS"), ok()] });
386
406
  assert.equal((await run(w)).terminalReason, "merged");
@@ -687,13 +707,13 @@ describe("core work-on", () => {
687
707
  assert.ok(w.labels.includes("decomposed"));
688
708
  });
689
709
 
690
- it("#4337 with decomposition.close_parent a split closes the parent not planned after its FORGE:DECOMPOSED trail and label, listing every sub-issue", async () => {
710
+ it("#4337 #5152 with decomposition.close_parent a split closes the parent completed after its FORGE:DECOMPOSED trail and label, listing every sub-issue", async () => {
691
711
  const w = depWorld({ changed: [], result: splitBlock }, {});
692
712
  const r = await run(w, { closeParent: true });
693
713
  assert.equal(r.terminalReason, "decomposed");
694
714
  assert.equal(w.closed.length, 1);
695
715
  const c = w.closed[0];
696
- assert.equal(c[c.indexOf("--reason") + 1], "not planned");
716
+ assert.equal(c[c.indexOf("--reason") + 1], "completed", "#5152: the scope moved to the sub-issues, it was not dropped");
697
717
  const note = c[c.indexOf("--comment") + 1];
698
718
  assert.match(note, /#201/); assert.match(note, /#202/);
699
719
  assert.ok(w.labels.includes("decomposed"));
@@ -982,21 +1002,100 @@ describe("conformance gate (frozen contract, precheck, structured review)", () =
982
1002
  });
983
1003
  }
984
1004
 
985
- it("a follow-up that cannot be filed defers the run (followup.unavailable): no PR, merge or close; never needs-human", async () => {
1005
+ it("#5141 a follow-up that cannot be filed keeps the work: PR and merge proceed, the issue stays open with the unfiled item recorded, never needs-human (#5156)", async () => {
986
1006
  const k = two("defer", { why: "w" });
987
1007
  const w = world({ contract: k, claims: [{ id: "R1", status: "implemented" }], reviews: [conf({}, k)], followupError: "DEFERRED_LOOKUP=FAILED" });
1008
+ w.leaseWrites = []; w.io.projector = { readState: async () => null, writeState: async (_i, st) => { w.leaseWrites.push(st); } };
988
1009
  const r = await run(w);
989
- assert.equal(r.terminalReason, "deferred"); assert.equal(r.code, "followup.unavailable");
990
- assert.ok(!w.comments.some((c) => c.includes("FORGE:REVIEW")), "no verdict is published for a run that cannot link its tracking");
991
- assert.equal(w.prs.length, 0); assert.equal(w.merges.length, 0); assert.equal(w.closed.length, 0);
1010
+ assert.equal(r.terminalReason, "merged"); assert.notEqual(r.code, "followup.unavailable");
1011
+ assert.equal(w.prs.length, 1); assert.equal(w.merges.length, 1);
1012
+ assert.equal(w.closed.length, 0, "release review #5155 (#5156): nothing re-files a pending item, so the issue is not closed over it");
1013
+ assert.equal(w.followups.length, 6, "3 in-lane attempts before the PR, 3 more in the close step");
1014
+ assert.deepEqual(r.pendingFollowups, [{ id: "R2", quote: "Existing callers of foo keep working.", disposition: "defer", why: "accepted by the conformance review", marker: "FORGE:DEFERRED_FROM: #7" }]);
1015
+ assert.deepEqual(w.leaseWrites.at(-1).pendingFollowups, r.pendingFollowups, "the issue's FORGE:STATE carries the unfiled item");
1016
+ const note = w.comments.find((c) => c.includes("FORGE:FOLLOWUP_PENDING"));
1017
+ assert.match(note, /R2 \(defer\): Existing callers of foo keep working\..*`FORGE:DEFERRED_FROM: #7`/);
992
1018
  assert.ok(!w.edits.some((e) => e.includes("--add-label") && e.includes("needs-human")));
1019
+ assert.ok(!w.comments.some((c) => /Closed as/.test(c)), "#5159: no note says the kept-open issue was closed");
1020
+ assert.match(traj(w).join("\n"), /left open \(follow-up pending\)/, "#5159: the trajectory Close row says left open");
1021
+ assert.doesNotMatch(traj(w).join("\n"), /issue closed \(/);
1022
+ });
1023
+
1024
+ it("#5141 a follow-up that keeps failing before the PR is filed by the close step after the merge", async () => {
1025
+ const k = two("defer", { why: "w" });
1026
+ const w = world({ contract: k, claims: [{ id: "R1", status: "implemented" }], reviews: [conf({}, k)], followupMissingTimes: 3 });
1027
+ const r = await run(w);
1028
+ assert.equal(r.terminalReason, "merged"); assert.equal(w.followups.length, 4); assert.equal(r.pendingFollowups, undefined);
1029
+ assert.ok(w.comments.some((c) => c.includes("FORGE:TRACKED") && c.includes("#900")));
1030
+ assert.ok(!w.comments.some((c) => c.includes("FORGE:FOLLOWUP_PENDING")));
1031
+ assert.match(traj(w).join("\n"), /Tracked R2 \(defer\) in #900 \(accepted by the conformance review; filed after the merge/);
1032
+ });
1033
+
1034
+ it("#5141 a follow-up filing that fails once is retried in-lane, not requeued: the run proceeds and tracks once", async () => {
1035
+ const k = two("defer", { why: "w" });
1036
+ const w = world({ contract: k, claims: [{ id: "R1", status: "implemented" }], reviews: [conf({}, k)], followupFailTimes: 1 });
1037
+ const r = await run(w);
1038
+ assert.notEqual(r.code, "followup.unavailable");
1039
+ assert.equal(w.followups.length, 2, "one retry of the whole filing step");
1040
+ assert.equal(w.prs.length, 1);
1041
+ });
1042
+
1043
+ it("#5141 a follow-up filing that returns no tracking issue is retried in-lane before deferring", async () => {
1044
+ const k = two("defer", { why: "w" });
1045
+ const w = world({ contract: k, claims: [{ id: "R1", status: "implemented" }], reviews: [conf({}, k)], followupMissingTimes: 2 });
1046
+ const r = await run(w);
1047
+ assert.notEqual(r.code, "followup.unavailable");
1048
+ assert.equal(w.followups.length, 3);
1049
+ });
1050
+
1051
+ it("#5141 a requirement tracked in an earlier attempt keeps its tracking issue; the retry tracks only the rest, once each", async () => {
1052
+ const base = two("defer", { why: "w" });
1053
+ const k = { ...base, requirements: [...base.requirements, { id: "R3", quote: "Old callers log a warning.", disposition: "defer", bucket: "post-deploy", why: "w" }] };
1054
+ let views900 = 0;
1055
+ const w = world({ contract: k, body: `${ISSUE_BODY}\n- Old callers log a warning.`, issueStates: { get 900() { views900 += 1; return "OPEN"; } }, claims: [{ id: "R1", status: "implemented" }], reviews: [conf({}, k)],
1056
+ followupRet: (call, items) => (call === 1 ? { [items[0].key]: 900 } : { [items[0].key]: 900, [items[1].key]: 901 }) });
1057
+ const r = await run(w);
1058
+ assert.notEqual(r.code, "followup.unavailable");
1059
+ assert.equal(w.followups.length, 2);
1060
+ const t = traj(w).join("\n");
1061
+ assert.match(t, /Follow-up filing attempt 1 failed \(no tracking issue was returned for R3\); retrying in-lane in 5s/);
1062
+ assert.match(t, /Tracked R2 \(defer\), R3 \(defer\) in #900, #901/);
1063
+ assert.equal(views900, 1, "R2, verified open in attempt 1, is not re-verified or re-counted by the retry");
1064
+ });
1065
+
1066
+ it("#5141 a persistent follow-up failure is retried 3 times in-lane, then the run keeps going; a malformed TSV is not retried and defers", async () => {
1067
+ const k = two("defer", { why: "w" });
1068
+ const w = world({ contract: k, claims: [{ id: "R1", status: "implemented" }], reviews: [conf({}, k)], followupMissingTimes: 99 });
1069
+ const r = await run(w);
1070
+ assert.equal(r.terminalReason, "merged"); assert.equal(w.followups.length, 6); assert.equal(w.prs.length, 1);
1071
+ assert.equal(r.pendingFollowups[0].id, "R2");
1072
+ const w2 = world({ contract: k, claims: [{ id: "R1", status: "implemented" }], reviews: [conf({}, k)], followupError: "Command failed", followupStdout: "DEFERRED_TSV=INVALID\n" });
1073
+ const r2 = await run(w2);
1074
+ assert.equal(r2.code, "followup.unavailable"); assert.equal(w2.followups.length, 1); assert.equal(w2.prs.length, 0);
993
1075
  });
994
1076
 
995
- it("a tracking issue that is not open is not a tracking issue (followup.unavailable)", async () => {
1077
+ it("#5159 one follow-up tracked, another still pending: FORGE:TRACKED says left open, never 'Closed as', and the trajectory says left open", async () => {
1078
+ const k0 = two("defer", { why: "w" });
1079
+ const k = { ...k0, requirements: [...k0.requirements, { id: "R3", quote: "foo crashes on null input.", disposition: "defer", bucket: "post-deploy", why: "w" }] };
1080
+ const w = world({ contract: k, claims: [{ id: "R1", status: "implemented" }], reviews: [conf({}, k)], followupSkip: ["foo crashes on null input."] });
1081
+ const r = await run(w);
1082
+ assert.equal(r.terminalReason, "merged");
1083
+ assert.deepEqual((r.pendingFollowups ?? []).map((p) => p.id), ["R3"]);
1084
+ assert.equal(w.closed.length, 0);
1085
+ const tracked = w.comments.find((c) => c.includes("FORGE:TRACKED"));
1086
+ assert.ok(tracked, "the tracked item is still recorded");
1087
+ assert.match(tracked, /Left open: follow-up pending/); assert.match(tracked, /#900/);
1088
+ assert.ok(!w.comments.some((c) => /Closed as/.test(c)));
1089
+ assert.match(traj(w).join("\n"), /left open \(follow-up pending\)/);
1090
+ });
1091
+
1092
+ it("a tracking issue that is not open is not a tracking issue: the item stays pending and is recorded", async () => {
996
1093
  const k = two("defer", { why: "w" });
997
1094
  const w = world({ contract: k, claims: [{ id: "R1", status: "implemented" }], reviews: [conf({}, k)], issueStates: { 900: "CLOSED" } });
998
1095
  const r = await run(w);
999
- assert.equal(r.code, "followup.unavailable"); assert.equal(w.merges.length, 0);
1096
+ assert.equal(r.terminalReason, "merged"); assert.equal(r.pendingFollowups[0].id, "R2");
1097
+ assert.ok(w.comments.some((c) => c.includes("FORGE:FOLLOWUP_PENDING")));
1098
+ assert.equal(w.closed.length, 0, "left open while R2 is untracked (#5156)");
1000
1099
  });
1001
1100
 
1002
1101
  it("an existing PR gets the tracking links in its body before merge", async () => {
@@ -1649,13 +1748,13 @@ describe("conformance shadow mode (the default)", () => {
1649
1748
  assert.deepEqual(rs.conformance, { mode: "shadow", would: "nonconforming", risk: false });
1650
1749
  });
1651
1750
 
1652
- it("#4398 an investigation whose question is deferred closes partial (not planned), carried forward in the tracking issue", async () => {
1751
+ it("#4398 #5152 an investigation whose question is deferred closes partial (completed), carried forward in the tracking issue", async () => {
1653
1752
  const k = { ...CONTRACT, preserve: [], requirements: [CONTRACT.requirements[0], { id: "R2", quote: "Existing callers of foo keep working.", disposition: "defer", why: "w", bucket: "new-issue" }] };
1654
1753
  const w = world({ title: "investigate(stealth): which signal is rejected", contract: k, claims: [{ id: "R1", status: "implemented" }], reviews: [conf({}, k)] });
1655
1754
  const r = await runShadow(w);
1656
1755
  assert.equal(r.terminalReason, "merged"); assert.equal(r.close, "partial");
1657
- assert.deepEqual(w.closed.map((a) => a[a.indexOf("--reason") + 1]), ["not planned"]);
1658
- assert.ok(w.comments.some((c) => c.includes("<!-- FORGE:TRACKED -->") && /Closed as partial[\s\S]*carried forward in #900/.test(c)));
1756
+ assert.deepEqual(w.closed.map((a) => a[a.indexOf("--reason") + 1]), ["completed"], "#5152: merged with a tracked remainder is completed");
1757
+ assert.ok(w.comments.some((c) => c.includes("<!-- FORGE:TRACKED -->") && /Closed as partial[\s\S]*Closed as completed by PR #[\s\S]*carried forward in #900/.test(c)));
1659
1758
  assert.ok(traj(w)[0].includes("| Close | issue closed (partial) |"));
1660
1759
  });
1661
1760
 
@@ -1684,7 +1783,7 @@ describe("conformance shadow mode (the default)", () => {
1684
1783
  assert.equal(r.terminalReason, "merged"); assert.equal(r.close, "partial");
1685
1784
  assert.equal(w.followups.length, 1); assert.match(w.followups[0].items[0].key, /^R1: /); assert.match(w.followups[0].items[0].why, /investigation question not answered/);
1686
1785
  assert.match(w.prs[0].join(" "), /#900/);
1687
- assert.deepEqual(w.closed.map((a) => a[a.indexOf("--reason") + 1]), ["not planned"]);
1786
+ assert.deepEqual(w.closed.map((a) => a[a.indexOf("--reason") + 1]), ["completed"]);
1688
1787
  });
1689
1788
 
1690
1789
  it("#4398 an answered investigation and a non-investigation with a deferral both close completed", async () => {
@@ -2586,10 +2685,10 @@ describe("orchestrate", () => {
2586
2685
  assert.ok(h.log.ends.indexOf(1) < h.log.starts.indexOf(2), JSON.stringify(h.log));
2587
2686
  });
2588
2687
 
2589
- it("a dependent is skipped, without running, when its dependency ends needs-human (transitively)", async () => {
2688
+ it("a dependent is held, without running, when its dependency ends needs-human (transitively)", async () => {
2590
2689
  const h = harness({ 2: "**Depends on**: #1", 3: "**Depends on**: #2" }, { 1: "needs-human" });
2591
2690
  const rows = await orchestrate({ issues: [1, 2, 3, 4], parallel: 3, ...h });
2592
- assert.deepEqual(rows.map((r) => r.outcome), ["needs-human", "skipped", "skipped", "merged"]);
2691
+ assert.deepEqual(rows.map((r) => r.outcome), ["needs-human", "held", "held", "merged"]);
2593
2692
  assert.ok(!h.log.starts.includes(2) && !h.log.starts.includes(3));
2594
2693
  assert.match(rows[1].detail, /#1/);
2595
2694
  });
@@ -2670,7 +2769,7 @@ describe("orchestrate", () => {
2670
2769
  const rows = await orchestrate({ issues: [9, 1], parallel: 1, readBody: async (n) => (n === 1 ? "**Depends on**: #9" : ""), ...(dispatchCheck ? { dispatchCheck } : {}),
2671
2770
  runOne: async (n) => { log.push(n); return { terminalReason: "needs-human", detail: "ci.red" }; } });
2672
2771
  assert.deepEqual(log, [9]);
2673
- assert.deepEqual(rows.map((r) => [r.issue, r.outcome]), [[9, "needs-human"], [1, "skipped"]]);
2772
+ assert.deepEqual(rows.map((r) => [r.issue, r.outcome]), [[9, "needs-human"], [1, "held"]]);
2674
2773
  }
2675
2774
  });
2676
2775
 
@@ -2681,7 +2780,7 @@ describe("orchestrate", () => {
2681
2780
  onUpdate: (r) => rowsSeen.push(r),
2682
2781
  dispatchCheck: async (n) => (n === 9 && merged ? { outcome: "merged", pr: 90, detail: "PR #90 already merged" } : null),
2683
2782
  runOne: async (n) => { if (n === 9) { merged = true; return { terminalReason: "needs-human", detail: "ci.red" }; } return { terminalReason: "merged", pr: 101 }; } });
2684
- const skipRow = rowsSeen.find((r) => r.issue === 1 && r.outcome === "skipped");
2783
+ const skipRow = rowsSeen.find((r) => r.issue === 1 && r.outcome === "held");
2685
2784
  assert.equal(skipRow.waiting, true);
2686
2785
  assert.equal(skipRow.trigger, 9);
2687
2786
  assert.ok(rowsSeen.some((r) => r.issue === 1 && r.outcome === "readmitted" && r.trigger === 9));
@@ -2693,7 +2792,7 @@ describe("orchestrate", () => {
2693
2792
  const rows = await orchestrate({ issues: [9, 1], parallel: 1, readBody: async (n) => (n === 1 ? "**Depends on**: #9" : ""),
2694
2793
  dispatchCheck: async (n) => { if (n === 9 && ran) { checks++; return { outcome: "invalid", detail: "issue closed (not planned)", final: true }; } return null; },
2695
2794
  runOne: async () => { ran = true; return { terminalReason: "needs-human", detail: "ci.red" }; } });
2696
- assert.deepEqual(rows.map((r) => [r.issue, r.outcome, r.waiting]), [[9, "needs-human", undefined], [1, "skipped", undefined]]);
2795
+ assert.deepEqual(rows.map((r) => [r.issue, r.outcome, r.waiting]), [[9, "needs-human", undefined], [1, "held", undefined]]);
2697
2796
  assert.equal(checks, 1, "a final blocker is not re-checked");
2698
2797
  });
2699
2798
 
@@ -3347,6 +3446,31 @@ describe("usage limit, lease takeover, worktree setup, run tmp (#4101-#4104)", (
3347
3446
  assert.equal(w.merges.length, 0);
3348
3447
  });
3349
3448
 
3449
+ it("#5168 a claude login failure ends the lane deferred/backend.auth with the account, not engine-error", async () => {
3450
+ const w = world(); projector(w, null); const sleeps = [];
3451
+ const s0 = w.io.sleep; w.io.sleep = async (ms) => { sleeps.push(ms); return s0(ms); };
3452
+ const authErr = Object.assign(new Error("claude CLI exited with status 1. Failed to authenticate. API Error: 401"), { code: "CLI_BACKEND_FAILED", authFailure: true });
3453
+ let tries = 0; const orig = w.io.runner; w.io.runner = async (c) => { if (c.role === "builder") { tries++; throw authErr; } return orig(c); };
3454
+ const r = await run(w);
3455
+ assert.equal(r.terminalReason, "deferred");
3456
+ assert.equal(r.code, "backend.auth");
3457
+ assert.equal(r.retryInSeconds, 900);
3458
+ assert.equal(r.backendId, laneBackendId(process.env), "the hold names the account whose login failed");
3459
+ assert.match(r.detail, /claude CLI authentication failed/);
3460
+ assert.equal(tries, 1, "no in-lane retry or pause on a dead login");
3461
+ assert.equal(sleeps.length, 0);
3462
+ assert.equal(w.edits.some((e) => e.includes("needs-human")), false);
3463
+ assert.equal(w.leaseWrites.at(-1).lease, null, "lease released");
3464
+ });
3465
+
3466
+ it("#5168 an error that is both a usage limit and auth-like stays rate_limited", async () => {
3467
+ const w = world();
3468
+ failFirstBuilder(w, limitErr({ authFailure: true, resetAtEpochMs: 3 * 3600 * 1000, resetAt: "8:30pm" }));
3469
+ const r = await run(w);
3470
+ assert.equal(r.terminalReason, "deferred");
3471
+ assert.equal(r.code, "rate_limited");
3472
+ });
3473
+
3350
3474
  it("#4101 unknown reset pauses a bounded number of times, then defers", async () => {
3351
3475
  const w = world(); let sleeps = 0;
3352
3476
  const s0 = w.io.sleep; w.io.sleep = async (ms) => { sleeps++; return s0(ms); };