forgedock 1.50.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.
@@ -3,7 +3,7 @@
3
3
  import { readFileSync } from "node:fs";
4
4
  import { describe, it } from "node:test";
5
5
  import assert from "node:assert/strict";
6
- import { removedNamedPaths, removedPathsOf, shellSyntaxError, unrunnableValidate, noopCommand, substituteVerdict, describesUnrunnableCommand, vitestQuietFlag, normalizeRunnerCommand, cliUsageError, validateContract, applyAmendments, amendmentKindProblems, precheck, hotspotGrowth, reproFailures, isBugIssue, parseReview, checkCompleteness, decide, closePlan, isInvestigation, formatShadowSummary, followupItems, inIssue, isTestPath, normalizeEvidencePath, mergeReviewRows, reviewSkeleton, missingIds, resolveEvidencePath, movingBaseDiff, scopeText, evidencePathLine, evidencePathOnly, explainFailures, resolveForgedockPaths, missingTool, repoTracks, tmpdirInputs, isScopeCheck, isPureScopeCheck, dropScopeValidates, tagOutOfBand, outOfBandRequirements, isOutOfBand, followupMap, renderPendingFollowups, VERIFY_MODES, OUT_OF_BAND_RE, FOLLOWUP_CLASSES, followupClass, BUCKETS, bucketKind, bucketProblems, bucketTally, isE2eCommand, e2eVerified, impliedDocDeliverables, applyValidateRecipes, deferChecklessPostDeploy, substituteShellProblem, reproEnvMiss, ORCHESTRATOR_BUCKET, ownerRoute, weakenedE2eDecisions } from "./conformance.mjs";
6
+ import { removedNamedPaths, removedPathsOf, shellSyntaxError, unrunnableValidate, noopCommand, substituteVerdict, describesUnrunnableCommand, vitestQuietFlag, normalizeRunnerCommand, cliUsageError, validateContract, applyAmendments, amendmentKindProblems, precheck, hotspotGrowth, reproFailures, isBugIssue, parseReview, checkCompleteness, decide, closePlan, isInvestigation, formatShadowSummary, followupItems, inIssue, isTestPath, normalizeEvidencePath, mergeReviewRows, reviewSkeleton, missingIds, resolveEvidencePath, movingBaseDiff, scopeText, evidencePathLine, evidencePathOnly, reaskable, matchCriterion, criterionLines, fillEngineRows, explainFailures, resolveForgedockPaths, missingTool, repoTracks, tmpdirInputs, isScopeCheck, isPureScopeCheck, dropScopeValidates, tagOutOfBand, outOfBandRequirements, isOutOfBand, followupMap, renderPendingFollowups, VERIFY_MODES, OUT_OF_BAND_RE, FOLLOWUP_CLASSES, followupClass, BUCKETS, bucketKind, bucketProblems, bucketTally, isE2eCommand, e2eVerified, impliedDocDeliverables, applyValidateRecipes, deferChecklessPostDeploy, substituteShellProblem, reproEnvMiss, ORCHESTRATOR_BUCKET, ownerRoute, weakenedE2eDecisions } from "./conformance.mjs";
7
7
 
8
8
  const ISSUE = "## Requirements\n1. The scrape route must release the spend reservation on 429.\n2. The search route must release the spend reservation on 429.\n\n- [ ] Billing pricing is unchanged.";
9
9
  const Q1 = "The scrape route must release the spend reservation on 429.";
@@ -494,6 +494,38 @@ describe("conformance review: the engine computes the verdict from complete rows
494
494
  it("incomplete: a coverage quote paraphrased instead of verbatim", () => {
495
495
  assert.equal(decide({ ...good, issue_coverage: [{ quote: "scrape should release money on rate limits", status: "covered" }] }, ctx).state, "review.incomplete");
496
496
  });
497
+ it("#5164 a near-verbatim coverage misquote is requoted to the criterion line and the review completes", () => {
498
+ const issue = "## Acceptance Criteria\n- [ ] Every manifest step kind from #38826 compiles to engine nodes, with a test per kind.\n- [ ] The scrape route must release the spend reservation on 429.";
499
+ const k = { ...C, requirements: [{ id: "R1", quote: "The scrape route must release the spend reservation on 429.", disposition: "deliver" }, { id: "R2", quote: Q2, disposition: "deliver" }] };
500
+ const quote = "Every manifest step kind from #38829 compiles to engine node";
501
+ const rv = { ...good, issue_coverage: [{ quote, contract_ids: ["R1"], status: "covered" }, { quote: "The scrape route must release the spend reservation on 429.", status: "covered" }] };
502
+ const d = decide(rv, { ...ctx, contract: k, issueText: `${issue}\n${ISSUE}` });
503
+ assert.equal(d.state, "conforms");
504
+ assert.equal(d.review.issue_coverage[0].quote, "Every manifest step kind from #38826 compiles to engine nodes, with a test per kind.");
505
+ assert.equal(d.review.issue_coverage[0].requoted, quote, "the substitution is recorded in the review");
506
+ });
507
+ it("#5164 matchCriterion: loose prefix or a unique token match; a paraphrase, an ambiguous or a short quote matches nothing", () => {
508
+ const t = "- [ ] Every manifest step kind from #38826 compiles to engine nodes, with a test per kind.\n- The scrape route must release the spend reservation on 429.\n- The search route must release the spend reservation on 429.";
509
+ assert.deepEqual(criterionLines(t)[0], "Every manifest step kind from #38826 compiles to engine nodes, with a test per kind.");
510
+ assert.equal(matchCriterion("every manifest step kind from #1 compiles to engine node with a test per kinds", t), criterionLines(t)[0], "prefix tier");
511
+ assert.equal(matchCriterion("The scrape route must release the spend reservations on 429", t), "The scrape route must release the spend reservation on 429.");
512
+ assert.equal(matchCriterion("route must release the spend reservation", t), null, "two lines tie");
513
+ assert.equal(matchCriterion("scrape should release money on rate limits", t), null);
514
+ assert.equal(matchCriterion("engine node", t), null);
515
+ });
516
+ it("#5164 a quote that matches no criterion still fails as incomplete, naming the nearest criterion line; it is re-askable, a real gap is not", () => {
517
+ const d = decide({ ...good, issue_coverage: [{ quote: "scrape should release money on rate limits", status: "covered" }] }, ctx);
518
+ assert.equal(d.state, "review.incomplete");
519
+ assert.equal(d.review.issue_coverage[0].requoted, undefined);
520
+ const problems = d.failures.map((f) => f.detail);
521
+ assert.match(problems[0], /nearest criterion line: "The scrape route must release/);
522
+ assert.ok(reaskable(problems));
523
+ assert.ok(reaskable(["R1: evidence path /layout.tsx does not exist"]), "#4327 evidence paths stay re-askable");
524
+ assert.ok(!reaskable([...problems, "requirement R1 needs exactly one row (has 0)"]));
525
+ assert.ok(!reaskable(["issue_coverage is missing or empty"]));
526
+ assert.ok(!reaskable([]));
527
+ assert.deepEqual(fillEngineRows(good, ctx).issue_coverage, good.issue_coverage, "verbatim rows are untouched");
528
+ });
497
529
  it("omission: an issue requirement missing from or narrowed by the contract", () => {
498
530
  const d = decide({ ...good, issue_coverage: [...good.issue_coverage.slice(0, 2), { quote: "Billing pricing is unchanged.", status: "missing" }] }, ctx);
499
531
  assert.deepEqual(codes(d), ["conformance.omission"]);
@@ -1367,6 +1399,12 @@ describe("#5087 a plan decision may not weaken an e2e/integration criterion to m
1367
1399
  assert.deepEqual(weakenedE2eDecisions({ ...C, decisions: [{ id: "K1", choice, source: "doc" }] }), [], choice);
1368
1400
  }
1369
1401
  });
1402
+ it("#5157 negated verbs and bare negators before a preposition still weaken e2e", () => {
1403
+ for (const choice of ["We do not need to run e2e; mocked route tests cover it", "will not rely on e2e, mock the API", "not with e2e: unit tests used", "not for e2e, unit tests used", "no need to run e2e; mocks cover it", "no need for e2e, mocks cover it"]) {
1404
+ assert.deepEqual(weakenedE2eDecisions({ ...C, decisions: [{ id: "K1", choice, source: "doc" }] }), ["K1"], choice);
1405
+ }
1406
+ assert.deepEqual(weakenedE2eDecisions({ ...C, decisions: [{ id: "K1", choice: "Keep playwright e2e coverage; add unit tests (no new fixtures needed for e2e)", source: "doc" }] }), []);
1407
+ });
1370
1408
  it("leaves unrelated decisions and contracts without decisions alone", () => {
1371
1409
  assert.deepEqual(weakenedE2eDecisions(C), []);
1372
1410
  assert.deepEqual(weakenedE2eDecisions({ ...C, decisions: [{ id: "K1", choice: "Run the Playwright e2e suite against the live stack", source: "doc" }, { id: "K3", choice: "Use unit tests with mocks for the parser", source: "spec" }] }), []);
@@ -212,9 +212,15 @@ const failuresOf = (st) => (Array.isArray(st?.failureClasses) ? [...new Set(st.f
212
212
  const recordLine = (body) => oneLine(noComments(body).split("\n").map((l) => l.trim()).find((l) => l && !/^#+\s/.test(l)) ?? "");
213
213
  const markerOf = (body) => /^\s*<!--\s*FORGE:([A-Z_]+)\s*-->/.exec(String(body ?? ""))?.[1] ?? null;
214
214
 
215
+ /**
216
+ * #5152: a closed record's result for the builder context. Delivered (`workflow:merged`) or moved (`workflow:decomposed`)
217
+ * scope reads `completed` whatever its `stateReason`, so a legacy NOT_PLANNED close of delivered work never reads as dropped.
218
+ */
219
+ export const closedResult = (rec) => (["workflow:merged", "workflow:decomposed"].some((l) => (rec?.labels ?? []).includes(l)) ? "completed" : rec?.stateReason ?? "closed");
215
220
  async function readIssue(gh, repo, n) {
216
221
  const j = JSON.parse(String(await gh(["api", `repos/${repo}/issues/${n}`])));
217
- return { number: Number(j.number ?? n), title: oneLine(j.title, 120), state: String(j.state ?? "").toUpperCase() || null, stateReason: j.state_reason ?? null, body: String(j.body ?? ""), pr: Boolean(j.pull_request) };
222
+ return { number: Number(j.number ?? n), title: oneLine(j.title, 120), state: String(j.state ?? "").toUpperCase() || null, stateReason: j.state_reason ?? null, body: String(j.body ?? ""), pr: Boolean(j.pull_request),
223
+ labels: (Array.isArray(j.labels) ? j.labels : []).map((l) => String(l?.name ?? l)) };
218
224
  }
219
225
  async function readComments(gh, repo, n) {
220
226
  return parseLines(await gh(["api", "--paginate", `repos/${repo}/issues/${n}/comments`, "--jq", ".[] | {id, body, author_association, user: {type: .user.type, login: .user.login}} | @json"]));
@@ -324,7 +330,7 @@ export async function gatherContextFacts({ io, repo, issue, iss, comments = [],
324
330
  const st = stateOf(rec.body);
325
331
  const reason = codeOf(st?.terminalReason), fails = failuresOf(st);
326
332
  facts.links.push({ number: n, rel: r, kind: rec.pr ? "pr" : "issue", title: rec.title, state: rec.state, code: outcomeOf(st),
327
- result: reason ?? (rec.state === "CLOSED" ? rec.stateReason ?? "closed" : null), ...(fails.length ? { failures: fails } : {}), ...(r === "parent" || r === "depends-on" ? { body: stripComments(rec.body) } : {}) });
333
+ result: reason ?? (rec.state === "CLOSED" ? closedResult(rec) : null), ...(fails.length ? { failures: fails } : {}), ...(r === "parent" || r === "depends-on" ? { body: stripComments(rec.body) } : {}) });
328
334
  }
329
335
  });
330
336
 
@@ -3,7 +3,7 @@
3
3
  import { describe, it } from "node:test";
4
4
  import assert from "node:assert/strict";
5
5
  import { normaliseFacts, renderFactsRecord, FACTS_MARKER } from "./facts.mjs";
6
- import { gatherContextFacts, compileContextPack, contextPackFor, contextArm, contextPackCacheKey, linkStateStamp, LINK_STAMP_MAX, parseContextPackConfig, laneType, priorAttempts, issueSymbols, bodyLinks, parentOf, renderContextRecord, DEFAULT_CONTEXT_BYTES, noComments, subjectTerms, subjectHistory, MAX_SUBJECT_ITEMS, MAX_SUBJECT_QUERIES } from "./context.mjs";
6
+ import { closedResult, gatherContextFacts, compileContextPack, contextPackFor, contextArm, contextPackCacheKey, linkStateStamp, LINK_STAMP_MAX, parseContextPackConfig, laneType, priorAttempts, issueSymbols, bodyLinks, parentOf, renderContextRecord, DEFAULT_CONTEXT_BYTES, noComments, subjectTerms, subjectHistory, MAX_SUBJECT_ITEMS, MAX_SUBJECT_QUERIES } from "./context.mjs";
7
7
 
8
8
  const REPO = "o/r";
9
9
  const BOT = { author_association: "NONE", user: { type: "Bot", login: "forge[bot]" } };
@@ -570,3 +570,13 @@ describe("#5085: subject history and recall status", () => {
570
570
  assert.equal(compileContextPack({ issue: 1, links: [], attempts: [], ledger: [], facts: [], recall: absent.recall }).recall.present, false, "the empty pack carries it too");
571
571
  });
572
572
  });
573
+
574
+ describe("#5152 builder context: delivered or moved scope never reads as dropped", () => {
575
+ it("a closed workflow:merged or workflow:decomposed dependency is completed whatever its stateReason (legacy not_planned too)", () => {
576
+ assert.equal(closedResult({ stateReason: "not_planned", labels: ["workflow:merged"] }), "completed");
577
+ assert.equal(closedResult({ stateReason: "not_planned", labels: ["bug", "workflow:decomposed"] }), "completed");
578
+ assert.equal(closedResult({ stateReason: "not_planned", labels: ["workflow:invalid"] }), "not_planned", "dropped scope keeps its reason");
579
+ assert.equal(closedResult({ stateReason: "completed", labels: [] }), "completed");
580
+ assert.equal(closedResult({ stateReason: null }), "closed");
581
+ });
582
+ });
@@ -27,3 +27,21 @@ export function resolveCredential(name, { env = process.env, keychain = defaultK
27
27
  if (v !== undefined && v !== null && String(v) !== "") return String(v);
28
28
  try { const k = keychain(String(name), platform); return k === undefined || k === null || String(k) === "" ? null : String(k); } catch { return null; }
29
29
  }
30
+
31
+ /**
32
+ * #5159: a keychain lookup memoised per name for `ttlMs` (a miss or a failure is cached as null too), so a resolver run
33
+ * on every request (#5144) spawns `secret-tool`/`security` at most once per TTL instead of on every HTTP call.
34
+ */
35
+ export function memoKeychain({ keychain = defaultKeychain, ttlMs = 60000, now = Date.now } = {}) {
36
+ const cache = new Map();
37
+ return (name, platform) => {
38
+ const key = `${platform ?? ""}\u0000${name}`;
39
+ const hit = cache.get(key);
40
+ const t = now();
41
+ if (hit && t - hit.at < ttlMs) return hit.value;
42
+ let value = null;
43
+ try { const k = keychain(name, platform); value = k === undefined || k === null || String(k) === "" ? null : String(k); } catch { value = null; }
44
+ cache.set(key, { at: t, value });
45
+ return value;
46
+ };
47
+ }
@@ -58,7 +58,7 @@ import { isLaneAlive, launchLane, writeAtomic } from "./batch.mjs";
58
58
  import { schedDir } from "./sched.mjs";
59
59
  import { dockerLabels, safeSegment } from "./ledger.mjs";
60
60
  import { OUTCOME_CODES } from "../engine/outcome-codes.mjs";
61
- import { resolveCredential } from "./credentials.mjs";
61
+ import { resolveCredential, memoKeychain, defaultKeychain } from "./credentials.mjs";
62
62
  import { httpTransport, isActionsEndpoint, remoteProvider } from "./sandbox-remote.mjs";
63
63
  import { ADMISSION_DEFAULTS } from "./admission.mjs";
64
64
  import { classifyStep, heavyConfig, memAvailableMb } from "./heavy.mjs";
@@ -515,10 +515,11 @@ export function poolCredential(exec = {}, { env = process.env, router = null, ke
515
515
  /**
516
516
  * The remote transport for forge.yaml `execution` settings: `opts.transport` when injected, else `httpTransport` on
517
517
  * `exec.endpoint` with the credential `exec.tokenEnv` from `credentials`, or by default poolCredential (#5131: env,
518
- * keychain, `token_file`, the matching local router's token). `fetchFn` is a test seam. Throws `execution.provider.misconfigured`
518
+ * keychain, `token_file`, the matching local router's token; #5159: the keychain lookup is memoised per transport, `keychain`
519
+ * is its seam). `fetchFn` is a test seam. Throws `execution.provider.misconfigured`
519
520
  * without an endpoint, `execution.provider.actions_refused` for a GitHub Actions endpoint without `allow_actions`.
520
521
  */
521
- export function remoteTransportFor(exec = {}, { transport = null, env = process.env, credentials = null, router, fetchFn } = {}) {
522
+ export function remoteTransportFor(exec = {}, { transport = null, env = process.env, credentials = null, router, fetchFn, keychain } = {}) {
522
523
  if (transport) return transport;
523
524
  if (!exec?.endpoint) throw typedError("execution.provider.misconfigured", "execution.provider remote needs execution.endpoint");
524
525
  if (isActionsEndpoint(exec.endpoint) && exec.allowActions !== true) {
@@ -526,8 +527,10 @@ export function remoteTransportFor(exec = {}, { transport = null, env = process.
526
527
  }
527
528
  // #5144: a getter, so the bearer is resolved on every request (a token fixed or rotated mid-batch is picked up), not once here.
528
529
  const rt = credentials ? null : (router !== undefined ? router : poolRouterConfig({ env }));
530
+ // #5159: the env and token files are re-read per request; the sync keychain lookup is memoised (misses too) for its TTL.
531
+ const kc = credentials ? null : memoKeychain({ keychain: keychain ?? defaultKeychain });
529
532
  const token = credentials ? () => (exec.tokenEnv ? credentials(exec.tokenEnv) : null)
530
- : () => poolCredential({ provider: "remote", ...exec }, { env, router: rt }).token;
533
+ : () => poolCredential({ provider: "remote", ...exec }, { env, router: rt, keychain: kc }).token;
531
534
  return httpTransport({ endpoint: exec.endpoint, token, ...(fetchFn ? { fetchFn } : {}) });
532
535
  }
533
536
 
@@ -565,13 +568,15 @@ export function providerByName(name, opts) {
565
568
  const c = poolRouterConfig({ env, ...(opts?.poolConfigPath ? { path: opts.poolConfigPath } : {}) });
566
569
  return c ? { endpoint: router?.endpoint ?? c.endpoint, token: c.token } : router;
567
570
  };
568
- const resolve = () => poolCredential({ ...exec, provider: name, ...(endpoint ? { endpoint } : {}) }, { env, router: routerNow() });
571
+ // #5159: one memoised keychain per provider, so a `token_env` absent from the env does not spawn the keychain per request.
572
+ const keychain = memoKeychain({ keychain: opts?.keychain ?? defaultKeychain });
573
+ const resolve = () => poolCredential({ ...exec, provider: name, ...(endpoint ? { endpoint } : {}) }, { env, router: routerNow(), keychain });
569
574
  // A token read from a file is resolved under a fixed name so remoteProvider redacts it like any `token_env` credential.
570
575
  // #5144: set even when no token exists yet, so one that appears later is still resolved per call (and redacted).
571
576
  const fixed = pool ? "FORGEDOCK_POOL_TOKEN" : "FORGEDOCK_REMOTE_TOKEN";
572
577
  const tokenEnv = exec.tokenEnv ?? fixed;
573
578
  // #4685/#5131: an empty env/keychain lookup of `token_env` falls back to `token_file`, then the (matching) router's token file.
574
- const credentials = (n) => (n === tokenEnv ? resolve().token : resolveCredential(n, { env })); // per call, like resolveCredential
579
+ const credentials = (n) => (n === tokenEnv ? resolve().token : resolveCredential(n, { env, keychain })); // per call, keychain memoised (#5159)
575
580
  const settings = { ...exec, ...(endpoint ? { endpoint } : {}), ...(tokenEnv ? { tokenEnv } : {}), ...(forgeYaml ? { forgeYaml } : {}),
576
581
  ...(typeof opts?.prefetch === "function" ? { prefetch: opts.prefetch } : {}) }; // #4778: dispatch-time relay pre-fetch
577
582
  return remoteProvider({ batch: opts.batch, repo: opts.repo, settings, ...(pool ? { name: "pool" } : {}), env, credentials,
@@ -1025,6 +1025,34 @@ describe("remote/pool credential resolver (#5131)", () => {
1025
1025
  assert.equal(hit.seen.at(-1).auth, "Bearer env-late-2");
1026
1026
  });
1027
1027
 
1028
+ it("5159: a token_env absent from the env runs the keychain once, not on every request; the token_file bearer and a later env token still apply", async () => {
1029
+ const dir = tmpDir("kc-5159-");
1030
+ const file = join(dir, "explicit.token");
1031
+ writeFileSync(file, "file-tok\n");
1032
+ const hit = recorder();
1033
+ let calls = 0;
1034
+ const keychain = () => { calls++; return null; };
1035
+ const env = {};
1036
+ const p = providerByName("remote", { batch: { id: "b5159" }, repo: "o/r", exec: parseExecution({ mode: "remote", max_remote: "1", provider: "remote", endpoint: "https://runner.example.com", token_env: "COMPUTESHARE_TOKEN", token_file: file }),
1037
+ env, poolConfigPath: join(dir, "none.json"), fetchFn: hit.fetchFn, keychain });
1038
+ for (let i = 0; i < 4; i++) await p.capacity();
1039
+ assert.ok(hit.seen.length >= 4);
1040
+ assert.ok(hit.seen.every((x) => x.auth === "Bearer file-tok"), "every request carries the token_file bearer");
1041
+ assert.ok(calls <= 1, `keychain ran ${calls} times`);
1042
+ env.COMPUTESHARE_TOKEN = "env-late";
1043
+ await p.capacity();
1044
+ assert.equal(hit.seen.at(-1).auth, "Bearer env-late");
1045
+ let tcalls = 0;
1046
+ const tenv = {};
1047
+ const t = remoteTransportFor({ endpoint: "https://runner.example.com", tokenEnv: "COMPUTESHARE_TOKEN", tokenFile: file }, { env: tenv, router: null, fetchFn: hit.fetchFn, keychain: () => { tcalls++; return null; } });
1048
+ for (let i = 0; i < 4; i++) await t.capacity();
1049
+ assert.equal(hit.seen.at(-1).auth, "Bearer file-tok");
1050
+ assert.equal(tcalls, 1, "remoteTransportFor: one keychain lookup across requests");
1051
+ tenv.COMPUTESHARE_TOKEN = "env-late-2";
1052
+ await t.capacity();
1053
+ assert.equal(hit.seen.at(-1).auth, "Bearer env-late-2");
1054
+ });
1055
+
1028
1056
  it("5131: remoteTransportFor's default credentials (gc) fall back to the matching router token", async () => {
1029
1057
  const hit = recorder();
1030
1058
  const t = remoteTransportFor({ endpoint: "http://127.0.0.1:7710" }, { env: {}, router: { endpoint: "http://127.0.0.1:7710", token: "router-tok" }, fetchFn: hit.fetchFn });
@@ -0,0 +1,52 @@
1
+ // SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
2
+ // SPDX-License-Identifier: AGPL-3.0-or-later
3
+ /**
4
+ * `forgedock fix-close-reasons <owner/repo> [--milestone <m>] [--apply]` (#5177, deferred from #5152). No LLM.
5
+ * Migration helper: an issue closed `not planned` that carries `workflow:merged` or `workflow:decomposed` was delivered
6
+ * (or moved to sub-issues), not dropped. It is re-closed as `completed` with a comment. One PATCH changes the reason in place
7
+ * (no reopen window). A `workflow:invalid` issue is a real drop and is never touched. Dry-run unless `--apply`.
8
+ */
9
+ const DELIVERED = ["workflow:merged", "workflow:decomposed"];
10
+ const isNotPlanned = (r) => String(r ?? "").toUpperCase().replace(/\s/g, "_") === "NOT_PLANNED";
11
+
12
+ const commentFor = (label) => label === "workflow:decomposed"
13
+ ? "Close reason corrected to `completed`: this issue was decomposed and its scope moved to its sub-issues, it was not dropped (`workflow:decomposed`). Re-closed by `forgedock fix-close-reasons` (#5177)."
14
+ : "Close reason corrected to `completed`: the work was delivered and merged, it was not dropped (`workflow:merged`). Re-closed by `forgedock fix-close-reasons` (#5177).";
15
+
16
+ /** Closed `not planned` issues that were delivered: [{number, label}], by issue number, once each. */
17
+ export async function findMisclosed({ gh, repo, milestone }) {
18
+ const found = new Map();
19
+ for (const label of DELIVERED) {
20
+ const out = await gh(["issue", "list", "-R", repo, "--state", "closed", "--label", label, ...(milestone ? ["--milestone", milestone] : []), "--limit", "1000", "--json", "number,stateReason,labels"]);
21
+ for (const i of JSON.parse(String(out || "[]"))) {
22
+ const names = (i.labels ?? []).map((l) => (typeof l === "string" ? l : l.name));
23
+ if (!isNotPlanned(i.stateReason) || names.includes("workflow:invalid") || found.has(i.number)) continue;
24
+ found.set(i.number, { number: i.number, label });
25
+ }
26
+ }
27
+ return [...found.values()].sort((a, b) => a.number - b.number);
28
+ }
29
+
30
+ export async function runFixCloseReasonsCli(argv, deps = {}) {
31
+ const val = (n) => { const i = argv.indexOf(n); return i >= 0 ? argv[i + 1] : null; };
32
+ const repo = argv[0] && !argv[0].startsWith("--") ? argv[0] : (val("--repo") ?? deps.repo ?? null);
33
+ if (!repo || !/^[^/\s]+\/[^/\s]+$/.test(repo)) throw new Error("usage: forgedock fix-close-reasons <owner/repo> [--milestone <title|number>] [--apply]");
34
+ const milestone = val("--milestone");
35
+ if (argv.includes("--milestone") && (!milestone || milestone.startsWith("--"))) throw new Error("--milestone requires a milestone title or number");
36
+ const apply = argv.includes("--apply");
37
+ const gh = deps.gh ?? (await import("./effects.mjs")).makeLeanIo({ repo }).gh;
38
+ const print = deps.print ?? ((t) => console.log(t));
39
+ const items = await findMisclosed({ gh, repo, milestone });
40
+ let changed = 0, failed = 0;
41
+ for (const { number, label } of items) {
42
+ if (!apply) { print(`would re-close #${number} as completed (${label})`); continue; }
43
+ try {
44
+ await gh(["api", "-X", "PATCH", `repos/${repo}/issues/${number}`, "-f", "state=closed", "-f", "state_reason=completed"]);
45
+ await gh(["issue", "comment", String(number), "-R", repo, "--body", commentFor(label)]);
46
+ changed++;
47
+ print(`re-closed #${number} as completed (${label})`);
48
+ } catch (e) { failed++; print(`#${number}: failed: ${String(e?.message ?? e).slice(0, 160)}`); }
49
+ }
50
+ print(`fix-close-reasons: ${items.length} found, ${changed} changed, ${failed} failed${apply ? "" : " (dry run, pass --apply to write)"}`);
51
+ return failed ? 1 : 0;
52
+ }
@@ -0,0 +1,64 @@
1
+ // SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
2
+ // SPDX-License-Identifier: AGPL-3.0-or-later
3
+ import assert from "node:assert/strict";
4
+ import { describe, it } from "node:test";
5
+ import { runFixCloseReasonsCli } from "./fixclosereasons.mjs";
6
+
7
+ const L = (...n) => n.map((name) => ({ name }));
8
+ function fake({ lists, failOn } = {}) {
9
+ const calls = [], out = [];
10
+ const gh = async (a) => {
11
+ calls.push(a);
12
+ if (a[0] === "issue" && a[1] === "list") return JSON.stringify(lists[a[a.indexOf("--label") + 1]] ?? []);
13
+ if (a[0] === "api" && failOn === a[3].split("/").pop()) throw new Error("boom");
14
+ return "";
15
+ };
16
+ return { gh, calls, out, print: (t) => out.push(t) };
17
+ }
18
+ const lists = {
19
+ "workflow:merged": [
20
+ { number: 1, stateReason: "NOT_PLANNED", labels: L("workflow:merged") },
21
+ { number: 2, stateReason: "COMPLETED", labels: L("workflow:merged") },
22
+ { number: 3, stateReason: "NOT_PLANNED", labels: L("workflow:merged", "workflow:invalid") },
23
+ ],
24
+ "workflow:decomposed": [
25
+ { number: 4, stateReason: "NOT_PLANNED", labels: L("workflow:decomposed") },
26
+ { number: 1, stateReason: "NOT_PLANNED", labels: L("workflow:merged", "workflow:decomposed") },
27
+ ],
28
+ };
29
+
30
+ describe("#5177 fix-close-reasons", () => {
31
+ it("re-closes NOT_PLANNED merged and decomposed issues as completed with a comment; leaves the rest", async () => {
32
+ const f = fake({ lists });
33
+ assert.equal(await runFixCloseReasonsCli(["o/r", "--apply"], f), 0);
34
+ const patches = f.calls.filter((a) => a[0] === "api");
35
+ assert.deepEqual(patches.map((a) => a[3]), ["repos/o/r/issues/1", "repos/o/r/issues/4"]);
36
+ for (const p of patches) assert.deepEqual(p.slice(0, 3).concat(p.slice(4)), ["api", "-X", "PATCH", "-f", "state=closed", "-f", "state_reason=completed"]);
37
+ const comments = f.calls.filter((a) => a[1] === "comment");
38
+ assert.deepEqual(comments.map((a) => a[2]), ["1", "4"]);
39
+ assert.match(comments[0][comments[0].indexOf("--body") + 1], /delivered and merged/);
40
+ assert.match(comments[1][comments[1].indexOf("--body") + 1], /decomposed/);
41
+ assert.match(f.out.at(-1), /2 found, 2 changed, 0 failed/);
42
+ });
43
+ it("is a dry run without --apply: no write", async () => {
44
+ const f = fake({ lists });
45
+ assert.equal(await runFixCloseReasonsCli(["o/r"], f), 0);
46
+ assert.equal(f.calls.filter((a) => a[0] !== "issue" || a[1] !== "list").length, 0);
47
+ assert.match(f.out.join("\n"), /would re-close #1/);
48
+ });
49
+ it("--milestone narrows the list query", async () => {
50
+ const f = fake({ lists: {} });
51
+ await runFixCloseReasonsCli(["o/r", "--milestone", "M1"], f);
52
+ for (const c of f.calls) assert.equal(c[c.indexOf("--milestone") + 1], "M1");
53
+ assert.equal(f.calls.length, 2);
54
+ });
55
+ it("counts a failed write and exits 1, continuing with the next issue", async () => {
56
+ const f = fake({ lists, failOn: "1" });
57
+ assert.equal(await runFixCloseReasonsCli(["o/r", "--apply"], f), 1);
58
+ assert.match(f.out.at(-1), /2 found, 1 changed, 1 failed/);
59
+ });
60
+ it("throws usage without a repo", async () => {
61
+ await assert.rejects(runFixCloseReasonsCli([], fake()), /usage: forgedock fix-close-reasons/);
62
+ await assert.rejects(runFixCloseReasonsCli(["--apply"], fake()), /usage/);
63
+ });
64
+ });
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, renderPendingFollowups, 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";
@@ -488,7 +490,7 @@ export async function runCore(o) {
488
490
  }
489
491
  if (["merged", "invalid", "needs-human", "budget-exceeded"].includes(reason)) {
490
492
  if (reason === "budget-exceeded") T.close = ["left open", "recoverable budget.exceeded"]; // #4505: not a human escalation
491
- 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
492
494
  else if (reason === "invalid" && extra.close === "already-fixed") T.close = ["issue closed (completed)", "already fixed on the base"]; // #5080
493
495
  else if (reason === "invalid") T.close = ["issue closed (not planned)", "label workflow:invalid"];
494
496
  else { T.close = ["left open", "label needs-human"]; T.anomalies.push(`needs-human: ${String(detail).split("\n")[0].slice(0, 300)}`); }
@@ -636,6 +638,9 @@ export async function runCore(o) {
636
638
  const par = verifyParallelism();
637
639
  return { ...deps, ...(runTmp ? { TMPDIR: runTmp } : {}), ...(par ? { FORGE_HOST_PARALLELISM: par } : {}), ...(toolchain?.env ?? {}) };
638
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)}` });
639
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). */
640
645
  async function callRunner(args) {
641
646
  let pauses = 0, rotations = 0;
@@ -649,6 +654,7 @@ export async function runCore(o) {
649
654
  try { return await io.runner({ ...args, system, env: { ...laneEnv(), FORGE_DOCKER_LABELS: labels, ...(runTmp ? { CLAUDE_CODE_TMPDIR: runTmp } : {}), ...(toolchain?.env ?? {}), ...heavy, ...profEnv } }); }
650
655
  catch (e) {
651
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
652
658
  if (!(e?.usageLimit === true || epoch !== null)) throw e;
653
659
  if (rotatorRetry(rotator, rotations)) { rotations++; ev({ event: "PHASE_ACCOUNT_ROTATED", phase: args.role }); continue; } // #5094: same args (resume too), next account
654
660
  const waitMs = epoch !== null && epoch > now() ? epoch - now() : DEFAULT_SESSION_LIMIT_WAIT_MS;
@@ -1002,9 +1008,10 @@ export async function runCore(o) {
1002
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.
1003
1009
  if (await label("decomposed") && marked && closeParent) {
1004
1010
  try {
1005
- 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",
1006
1013
  `Decomposed into ${refs(filed)}; this tracker is closed and its dependents wait on the sub-issues. A trusted \`FORGE:DECOMPOSED:REVOKED\` reopens it.`]);
1007
- 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)}`);
1008
1015
  } catch { /* best effort: the parent stays open, as before #4337 */ }
1009
1016
  }
1010
1017
  return await finish("decomposed", `split into ${refs(filed)}`, { code: OUTCOME_CODES.DECOMPOSED, subissues: filed, decomposition: b.rationale });
@@ -1086,7 +1093,7 @@ export async function runCore(o) {
1086
1093
  try {
1087
1094
  iss = JSON.parse(await ghc(["issue", "view", String(issue), "-R", repo, "--json", "number,title,body,state,comments,labels,updatedAt"]));
1088
1095
  } catch (e) {
1089
- 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
1090
1097
  return finish("engine-error", `cannot read issue: ${e.message}`);
1091
1098
  }
1092
1099
  const priorState = stateOf(iss.body); // #4400: the last run's end (the lease write keeps its terminal fields), read before this run ends
@@ -1721,9 +1728,10 @@ export async function runCore(o) {
1721
1728
  if (closes) {
1722
1729
  T.plan = "skipped"; T.implement = "skipped"; T.review = "skipped"; T.pr = "none"; T.merge = "n/a";
1723
1730
  T.decisions.push(`Builder verdict: ${result.status}`);
1724
- 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"]);
1725
1733
  await label("invalid");
1726
- return await finish("invalid", result.summary ?? result.status);
1734
+ return await finish("invalid", result.summary ?? result.status, result.status === "already-fixed" ? { close: "already-fixed" } : {});
1727
1735
  }
1728
1736
  const blocked = await endIfBlocked();
1729
1737
  if (blocked) return blocked;
@@ -2236,16 +2244,16 @@ export async function runCore(o) {
2236
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 } };
2237
2245
  let d = null, first = null;
2238
2246
  for (let t = 0; t < 3; t++) {
2239
- // #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).
2240
2248
  if (t === 2) {
2241
- if (d.state !== "review.incomplete" || !evidencePathOnly(d.failures.map((f) => f.detail))) break;
2242
- 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`);
2243
2251
  }
2244
2252
  // #4160: a reply with rows missing is re-asked for those ids only; an unparseable one gets the full prompt again.
2245
2253
  const repair = first && (d.state === "review.incomplete" || d.state === "review.inconsistent");
2246
2254
  await renew();
2247
2255
  const rr = await callRunner({ role: "reviewer", issue, cwd: wt, model: reviewerModel, resume: null, system: REVIEWER_SYSTEM, readOnly: true,
2248
- 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) : [] })
2249
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)) }) });
2250
2258
  await renew();
2251
2259
  await attempt("review", ++reviewN, rr, null, "review");
@@ -2289,7 +2297,7 @@ export async function runCore(o) {
2289
2297
  run.findings = parseVerifierFindings(rr?.text, { pathExists: (x) => listed.has(x) });
2290
2298
  run.cost_usd = num(rr?.costUsd) ?? 0;
2291
2299
  } catch (e) {
2292
- 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
2293
2301
  run.error = String(e?.code || e?.message || e).split("\n")[0].slice(0, 200);
2294
2302
  T.anomalies.push(`Independent verifier failed at ${head.slice(0, 12)}: ${run.error}`);
2295
2303
  }
@@ -3062,9 +3070,12 @@ export async function runCore(o) {
3062
3070
  }
3063
3071
  }
3064
3072
  const partial = closing.reason === "partial";
3065
- if (!keepOpen) await ghw(["issue", "close", String(issue), "-R", repo, "--reason", partial ? "not planned" : "completed"]).catch(() => {});
3066
- if (partial) {
3067
- 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 */ }
3068
3079
  } else if (closing.tracked.length) {
3069
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 */ }
3070
3081
  }
@@ -3089,13 +3100,13 @@ export async function runCore(o) {
3089
3100
  await gatePassed("merge_ci", "ci_red"); // #4375: merged means the required checks are green after a CI fix round
3090
3101
  let mergedTo = null; // #4711: FORGE:STATE records the lane and merge commit, so a dependent's check needs no PR search
3091
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 */ }
3092
- 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 } : {}) });
3093
3104
  } catch (e) {
3094
3105
  if (e?.deferUsageLimit) {
3095
3106
  const when = e.resetAt || (Number.isFinite(e.resetAtEpochMs) ? new Date(e.resetAtEpochMs).toISOString() : "unknown");
3096
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);
3097
3108
  }
3098
- 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
3099
3110
  // #4505: a turn overrun is mechanical: a non-human end the batch recovers, needs-human only once a budget recovery failed too
3100
3111
  if (e?.laneCode === OUTCOME_CODES.BUDGET_EXCEEDED && !(recover?.final && recover.code === OUTCOME_CODES.BUDGET_EXCEEDED)) {
3101
3112
  return finish("budget-exceeded", e.message, { code: OUTCOME_CODES.BUDGET_EXCEEDED, branch, ...(recover ? { recovery: true } : {}) }, OUTCOME_CODES.BUDGET_EXCEEDED);