forgedock 1.46.0 → 1.48.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 (69) hide show
  1. package/bin/engine/outcome-codes.mjs +2 -1
  2. package/bin/engine-cli.mjs +22 -8
  3. package/bin/lean/conformance.e2e.test.mjs +20 -0
  4. package/bin/lean/conformance.mjs +99 -9
  5. package/bin/lean/conformance.test.mjs +121 -4
  6. package/bin/lean/context.mjs +108 -6
  7. package/bin/lean/context.test.mjs +84 -2
  8. package/bin/lean/coupled.mjs +38 -0
  9. package/bin/lean/coupled.test.mjs +57 -0
  10. package/bin/lean/decision-engine.mjs +5 -1
  11. package/bin/lean/decision-engine.test.mjs +21 -0
  12. package/bin/lean/drift.mjs +3 -2
  13. package/bin/lean/drift.test.mjs +23 -1
  14. package/bin/lean/effects.mjs +3 -2
  15. package/bin/lean/execution.mjs +41 -8
  16. package/bin/lean/execution.test.mjs +72 -2
  17. package/bin/lean/integration.mjs +2 -2
  18. package/bin/lean/integration.test.mjs +22 -1
  19. package/bin/lean/issuewatch.mjs +134 -37
  20. package/bin/lean/issuewatch.test.mjs +123 -1
  21. package/bin/lean/lane.mjs +13 -0
  22. package/bin/lean/lane.test.mjs +28 -1
  23. package/bin/lean/lean.mjs +60 -12
  24. package/bin/lean/lean.test.mjs +80 -0
  25. package/bin/lean/limits.mjs +6 -2
  26. package/bin/lean/limits.test.mjs +86 -0
  27. package/bin/lean/milestonesync.mjs +2 -2
  28. package/bin/lean/milestonesync.test.mjs +22 -0
  29. package/bin/lean/orchestrate.mjs +79 -9
  30. package/bin/lean/probe.mjs +4 -2
  31. package/bin/lean/probe.test.mjs +4 -0
  32. package/bin/lean/prompts.mjs +18 -11
  33. package/bin/lean/rotator.mjs +96 -0
  34. package/bin/lean/rotator.test.mjs +143 -0
  35. package/bin/lean/routecheck.mjs +48 -0
  36. package/bin/lean/routecheck.test.mjs +69 -0
  37. package/bin/lean/sandbox-provider.contract.test.mjs +9 -0
  38. package/bin/lean/sandbox-remote.mjs +4 -2
  39. package/bin/lean/verify.mjs +2 -1
  40. package/bin/review-staging.mjs +20 -3
  41. package/commands/deploy-info.md +4 -4
  42. package/commands/issue.md +6 -1
  43. package/commands/review-pr-agents/frontend.md +1 -0
  44. package/commands/review-pr-agents/infra.md +2 -0
  45. package/commands/review-pr-staging.md +2 -0
  46. package/commands/work-on/build/context.md +7 -1
  47. package/commands/work-on/investigate.md +15 -3
  48. package/docs/CONFIG.md +21 -2
  49. package/docs/ISSUE-WATCH.md +22 -1
  50. package/docs/OUTCOME-CODES.md +2 -1
  51. package/package.json +1 -1
  52. package/scripts/file-deferred-followups.sh +125 -28
  53. package/scripts/file-deferred-followups.test.sh +49 -0
  54. package/scripts/issue-create.sh +7 -0
  55. package/scripts/issue-create.test.sh +13 -0
  56. package/scripts/issue-dedup.sh +5 -4
  57. package/scripts/issue-dedup.test.sh +15 -0
  58. package/scripts/owner-route-parity.test.sh +32 -0
  59. package/scripts/plan-validate-live.mjs +18 -6
  60. package/scripts/prior-attempts.mjs +72 -0
  61. package/scripts/prior-attempts.test.mjs +52 -0
  62. package/scripts/review-finalize.sh +35 -4
  63. package/scripts/review-finalize.test.sh +10 -0
  64. package/scripts/review-prepare.sh +16 -4
  65. package/scripts/review-prepare.test.sh +8 -0
  66. package/scripts/review-roster.sh +3 -1
  67. package/scripts/review-roster.test.sh +30 -2
  68. package/scripts/review-staging-pre.sh +8 -6
  69. package/scripts/review-staging-pre.test.sh +11 -1
@@ -5,7 +5,7 @@ import assert from "node:assert/strict";
5
5
  import { mkdirSync, writeFileSync, existsSync, readFileSync } from "node:fs";
6
6
  import { tmpDir } from "../tests/_tmp.mjs";
7
7
  import { join } from "node:path";
8
- import { unclaimedOrOwn, parseWatches, validateWatch, loadWatches, matchesSelectors, issueVersion, hasUnresolvedDefer, classifyIssue, laneFor, dagOrder, claimIssue, acquireLock, tick, runWatch, formatStatus, repoDir, watchDir, issueWatchFromCli } from "./issuewatch.mjs";
8
+ import { unclaimedOrOwn, parseWatches, validateWatch, loadWatches, matchesSelectors, issueVersion, hasUnresolvedDefer, classifyIssue, laneFor, dagOrder, claimIssue, acquireLock, tick, runWatch, formatStatus, repoDir, watchDir, issueWatchFromCli, isOwnApp, selectorMiss, planClaims, ghFailureEffect, ghRunner } from "./issuewatch.mjs";
9
9
  import { openBatch } from "./batch.mjs";
10
10
 
11
11
  const tmp = () => tmpDir("iw-");
@@ -419,3 +419,125 @@ test("#4793 formatStatus lists decided / evidence-gathered / folded / external-k
419
419
  decisions: { rows: [{ action: "decided" }, { action: "evidence-needed" }, { action: "post-deploy-only" }, { action: "external", kept: true }, { action: "external", kept: true }] } };
420
420
  assert.match(formatStatus(s, w, 0), /needs-human decisions: decided 1 evidence-gathered 1 folded 1 external-kept 2/);
421
421
  });
422
+
423
+ // ── #5088: opt-in trust for the repo's own ForgeDock App, claim dry-run, named 401/403 ──────────────────────────────
424
+ const appIss = (n, login = "rapiercraft-forgedock[bot]", o = {}) => iss(n, { title: `fix: app ${n}`, authorAssociation: "NONE", author: { login }, ...o });
425
+
426
+ test("#5088 trust_app / trust_app_labels are validated watch keys, default off", () => {
427
+ assert.equal(W().trust_app, false);
428
+ assert.deepEqual(W().trust_app_labels, []);
429
+ assert.equal(W({ trust_app: "true" }).trust_app, true);
430
+ assert.equal(W({ trust_app: "false" }).trust_app, false);
431
+ assert.deepEqual(W({ trust_app: true, trust_app_labels: ["operator"] }).trust_app_labels, ["operator"]);
432
+ assert.throws(() => W({ trust_app: "yes" }), /trust_app must be true or false/);
433
+ assert.throws(() => W({ trust_app_labels: ["operator"] }), /trust_app_labels needs trust_app: true/);
434
+ assert.deepEqual(parseWatches("issue_watch:\n watches:\n a:\n trust_app: true\n trust_app_labels: [operator, intake]\n").a, { trust_app: "true", trust_app_labels: ["operator", "intake"] });
435
+ });
436
+
437
+ test("#5088 trust_app claims the repo's own App issues only; other bots and NONE humans stay out", () => {
438
+ const on = W({ trust_app: true });
439
+ // Release review #5123 (AUTH-1): the bare slug counts only with a bot signal; a human who registers the slug is never the App.
440
+ assert.ok(isOwnApp(appIss(1, "rapiercraft-forgedock", { author: { login: "rapiercraft-forgedock", is_bot: true } })));
441
+ assert.ok(!isOwnApp(appIss(1, "rapiercraft-forgedock", { author: { login: "rapiercraft-forgedock", is_bot: false } })), "human with the slug as login");
442
+ assert.equal(classifyIssue(appIss(1, "rapiercraft-forgedock", { author: { login: "rapiercraft-forgedock", is_bot: false } }), on), null, "human slug never claimed");
443
+ for (const l of ["rapiercraft-forgedock[bot]", "app/rapiercraft-forgedock"]) {
444
+ assert.ok(isOwnApp(appIss(1, l)), l);
445
+ assert.equal(classifyIssue(appIss(1, l), on), "claim", l);
446
+ }
447
+ for (const l of ["forge[bot]", "dependabot[bot]", "rapiercraft-forgedock-evil"]) assert.equal(classifyIssue(appIss(2, l), on), null, l);
448
+ assert.ok(!isOwnApp(appIss(2, "rapiercraft-forgedock-evil")));
449
+ assert.equal(classifyIssue(iss(3, { authorAssociation: "NONE", author: { login: "mallory" } }), on), null);
450
+ assert.equal(classifyIssue(iss(4), on), "claim"); // trusted humans unchanged
451
+ const narrow = W({ trust_app: true, trust_app_labels: ["operator"] });
452
+ assert.equal(classifyIssue(appIss(5), narrow), null);
453
+ assert.equal(classifyIssue(appIss(5, undefined, { labels: lbl("operator") }), narrow), "claim");
454
+ // App follow-ups stay governed by include_followups (post-deploy-only folds, never claims)
455
+ const defer = "- [ ] `R1: x` — defer: separate work\n", pd = "- [ ] `R2: y` — post-deploy: check later\n";
456
+ const fu = (body) => appIss(6, undefined, { title: "follow-up: 1 deferred item(s)", body });
457
+ const onFu = W({ trust_app: true, include_followups: "defer-only", postdeploy_rollup: "50" });
458
+ assert.equal(classifyIssue(fu(defer), onFu), "claim");
459
+ assert.equal(classifyIssue(fu(pd), onFu), "fold");
460
+ assert.equal(classifyIssue(fu("no items"), onFu), null);
461
+ assert.equal(classifyIssue(fu(pd), W({ trust_app: true })), null);
462
+ });
463
+
464
+ test("#5088 regression: without trust_app an App-authored issue is not claimed", async () => {
465
+ for (const l of ["rapiercraft-forgedock[bot]", "app/rapiercraft-forgedock", "rapiercraft-forgedock"]) assert.equal(classifyIssue(appIss(1, l), W()), null, l);
466
+ const io = fakeIo(tmp(), [appIss(1), iss(2)]);
467
+ const s = await tick({ watch: W(), repo: "o/r", io, state: { phase: "running", claimed: [], inflight: [], merged: [], verified: [], failed: [], folded: [], retries: {}, lanes: {}, batch: null } });
468
+ assert.deepEqual(s.claimed, [2]);
469
+ });
470
+
471
+ test("#5088 tick with trust_app claims and launches the App issue, not a NONE human or another bot", async () => {
472
+ const io = fakeIo(tmp(), [appIss(1), appIss(2, "forge[bot]"), iss(3, { authorAssociation: "NONE", author: { login: "mallory" } }), iss(4)]);
473
+ const s = await tick({ watch: W({ trust_app: true }), repo: "o/r", io, state: { phase: "running", claimed: [], inflight: [], merged: [], verified: [], failed: [], folded: [], retries: {}, lanes: {}, batch: null } });
474
+ assert.deepEqual(s.claimed, [1, 4]);
475
+ assert.deepEqual(io.launched.map(([n]) => n), [1, 4]);
476
+ });
477
+
478
+ test("#5088 selectorMiss names the failing selector", () => {
479
+ assert.equal(selectorMiss(iss(1, { labels: lbl("epic") }), W({ exclude_labels: ["epic"] })), "excluded label epic");
480
+ assert.equal(selectorMiss(iss(1), W({ labels: ["ready"] })), "missing label ready");
481
+ assert.equal(selectorMiss(iss(1), W()), null);
482
+ });
483
+
484
+ for (const mode of ["per-issue", "batch"]) {
485
+ test(`#5088 --dry-run (${mode}) lists would-claim and skipped issues with reasons and writes nothing`, async () => {
486
+ const dir = tmp(), cwd = tmp();
487
+ writeFileSync(join(cwd, "forge.yaml"), `branches:\n default: main\nissue_watch:\n watches:\n intake:\n mode: ${mode}\n trust_app: true\n exclude_labels: [epic]\n`);
488
+ const io = fakeIo(dir, [appIss(1), iss(2, { labels: lbl("epic") }), iss(3, { authorAssociation: "NONE", author: { login: "mallory" } }), iss(4), appIss(5, "dependabot[bot]")]);
489
+ io.gh = ((orig) => async (a) => (a[0] === "repo" ? (a.includes("nameWithOwner") ? JSON.stringify({ nameWithOwner: "o/r" }) : "main") : orig(a)))(io.gh);
490
+ const rdir = repoDir("o/r", io.env);
491
+ mkdirSync(rdir, { recursive: true });
492
+ writeFileSync(join(rdir, "claims.json"), JSON.stringify({ 4: "other" }) + "\n");
493
+ const out = [];
494
+ const r = await issueWatchFromCli(["run", "--name", "intake", "--dry-run"], { cwd, env: io.env, io, print: (l) => out.push(l) });
495
+ const t = out.join("\n");
496
+ assert.match(t, /would claim #1 -> staging: ForgeDock App author \(trust_app\)/);
497
+ assert.match(t, /skip #2: excluded label epic/);
498
+ assert.match(t, /skip #3: untrusted author mallory \(NONE\)/);
499
+ assert.match(t, /skip #4: already claimed by watch other/);
500
+ assert.match(t, /skip #5: bot author dependabot\[bot\]/);
501
+ assert.deepEqual(r.dryRun.claim, [{ number: 1, lane: "staging", reason: "ForgeDock App author (trust_app)" }]);
502
+ assert.ok(Array.isArray(r.dryRun.rows));
503
+ assert.deepEqual(JSON.parse(readFileSync(join(rdir, "claims.json"), "utf8")), { 4: "other" });
504
+ assert.ok(!existsSync(join(watchDir("o/r", "intake", io.env), "status.json")));
505
+ assert.deepEqual(io.launched, []);
506
+ assert.deepEqual(io.batchOps, []);
507
+ });
508
+ }
509
+
510
+ test("#5088 planClaims: own registry claim stays claimable, room caps the rest as over capacity", async () => {
511
+ const io = fakeIo(tmp(), [iss(1), iss(2), iss(3)]);
512
+ const rdir = repoDir("o/r", io.env);
513
+ mkdirSync(rdir, { recursive: true });
514
+ writeFileSync(join(rdir, "claims.json"), JSON.stringify({ 1: "w" }) + "\n");
515
+ const p = await planClaims({ watch: W(), repo: "o/r", io, state: { claimed: [3], inflight: [] }, room: 1 });
516
+ assert.deepEqual(p.claim.map((c) => c.number), [1]);
517
+ assert.deepEqual(p.skip, [{ number: 3, reason: "already claimed by this watch" }, { number: 2, reason: "over capacity (room 1)" }]);
518
+ });
519
+
520
+ test("#5088 ghRunner names the failing call; 401/403 also warn with the effect on the pass", () => {
521
+ const assocArgs = ["api", "repos/o/r/issues?state=open&per_page=100", "--paginate", "--jq", ".[]"];
522
+ assert.match(ghFailureEffect(assocArgs), /every author untrusted/);
523
+ assert.match(ghFailureEffect(["api", "user", "--jq", ".login"]), /^no effect/);
524
+ assert.match(ghFailureEffect(["api", "app", "--jq", ".slug"]), /^no effect/);
525
+ assert.match(ghFailureEffect(["api", "repos/o/r/branches/staging/protection/required_status_checks"]), /required checks unknown/);
526
+ assert.match(ghFailureEffect(["issue", "list", "-R", "o/r"]), /raised to its caller/);
527
+ const warned = [];
528
+ const fail = (stderr) => () => { throw Object.assign(new Error("Command failed"), { status: 1, stderr }); };
529
+ const gh403 = ghRunner(fail("gh: Resource not accessible by integration (HTTP 403)\n"), (l) => warned.push(l));
530
+ assert.throws(() => gh403(assocArgs), (e) => /gh api repos\/o\/r\/issues\?state=open&per_page=100 --paginate failed: gh: Resource not accessible by integration \(HTTP 403\)/.test(e.message) && e.status === 1);
531
+ assert.equal(warned.length, 1);
532
+ assert.match(warned[0], /gh api repos\/o\/r\/issues\?state=open.* -> HTTP 403: .*effect: pass degraded: .*every author untrusted/);
533
+ const gh401 = ghRunner(fail("gh: A JSON web token could not be decoded (HTTP 401)"), (l) => warned.push(l));
534
+ assert.throws(() => gh401(["api", "app", "--jq", ".slug"]), /gh api app --jq failed/);
535
+ assert.match(warned[1], /gh api app --jq -> HTTP 401: .*effect: no effect/);
536
+ const gh500 = ghRunner(fail("gh: Server Error (HTTP 500)"), (l) => warned.push(l));
537
+ assert.throws(() => gh500(["issue", "list"]), /gh issue list failed: gh: Server Error/);
538
+ assert.equal(warned.length, 2);
539
+ let seen;
540
+ assert.equal(ghRunner((cmd, args, opts) => { seen = { cmd, args, opts }; return "out"; })(["repo", "view"]), "out");
541
+ assert.equal(seen.cmd, "gh");
542
+ assert.deepEqual(seen.opts.stdio, ["ignore", "pipe", "pipe"]);
543
+ });
package/bin/lean/lane.mjs CHANGED
@@ -21,6 +21,19 @@ export function milestoneSlug(title) {
21
21
  return String(title ?? "").toLowerCase().replace(/ /g, "-").replace(/[^a-z0-9-]/g, "").replace(/-+/g, "-").replace(/^-|-$/g, "");
22
22
  }
23
23
 
24
+ /**
25
+ * Every milestone of `repo` in `state` (all|open|closed), across every page (#5084: one page of 100 hid newer milestones).
26
+ * One `gh api --paginate` call, one object per line; a whole-JSON reply (a single array) is accepted too. gh errors propagate.
27
+ */
28
+ export async function listMilestones({ gh, repo, state = "all" }) {
29
+ const text = String(await gh(["api", `repos/${repo}/milestones?state=${state}&per_page=100`, "--paginate", "--jq", ".[] | @json"]) ?? "").trim();
30
+ if (!text) return [];
31
+ let whole;
32
+ try { whole = JSON.parse(text); } catch { whole = undefined; }
33
+ if (whole !== undefined) return Array.isArray(whole) ? whole : whole && typeof whole === "object" ? [whole] : [];
34
+ return text.split("\n").map((l) => l.trim()).filter(Boolean).flatMap((l) => JSON.parse(l));
35
+ }
36
+
24
37
  /** forge.yaml `branches` from the checkout: {default, staging, featurePattern}; missing keys are null. */
25
38
  export function readBranches(cwd = process.cwd()) {
26
39
  let b = {};
@@ -4,7 +4,7 @@ import { describe, it } from "node:test";
4
4
  import assert from "node:assert/strict";
5
5
  import { writeFileSync } from "node:fs";
6
6
  import { tmpDir } from "../tests/_tmp.mjs";
7
- import { milestoneSlug, readBranches, resolveLane, makeLaneCache } from "./lane.mjs";
7
+ import { milestoneSlug, readBranches, resolveLane, makeLaneCache, listMilestones } from "./lane.mjs";
8
8
 
9
9
  /** Fake gh/git: the issue's milestone, GitHub's default branch, and the branches on origin. */
10
10
  function io({ milestone = null, ghDefault = "main", onOrigin = ["staging", "main"], ghFails = null, gitFails = false } = {}) {
@@ -133,3 +133,30 @@ describe("per-batch lane lookups (#4963)", () => {
133
133
  assert.equal((await resolveLane({ issue: 1, repo: "o/r", io: io(), cwd: "/r", cache, milestone: null })).ok, true);
134
134
  });
135
135
  });
136
+
137
+ describe("(#5084) listMilestones", () => {
138
+ it("(#5084) listMilestones pages with --paginate, path at args[1], state in the query", async () => {
139
+ const calls = [];
140
+ const gh = async (a) => { calls.push(a); return '{"number":1,"title":"A"}\n{"number":2,"title":"B"}\n'; };
141
+ assert.deepEqual(await listMilestones({ gh, repo: "o/r", state: "open" }), [{ number: 1, title: "A" }, { number: 2, title: "B" }]);
142
+ assert.equal(calls[0][1], "repos/o/r/milestones?state=open&per_page=100");
143
+ assert.ok(calls[0].includes("--paginate"));
144
+ assert.equal(calls[0][calls[0].indexOf("--jq") + 1], ".[] | @json");
145
+ await listMilestones({ gh, repo: "o/r" });
146
+ assert.match(calls[1][1], /state=all/);
147
+ });
148
+ it("(#5084) listMilestones accepts a single array, a single object and empty output", async () => {
149
+ assert.deepEqual(await listMilestones({ gh: async () => '[{"number":3}]', repo: "o/r" }), [{ number: 3 }]);
150
+ assert.deepEqual(await listMilestones({ gh: async () => '{"number":4}\n', repo: "o/r" }), [{ number: 4 }]);
151
+ assert.deepEqual(await listMilestones({ gh: async () => " \n", repo: "o/r" }), []);
152
+ assert.deepEqual(await listMilestones({ gh: async () => "", repo: "o/r" }), []);
153
+ });
154
+ it("(#5084) listMilestones keeps a description containing '][' intact", async () => {
155
+ const ms = [{ number: 1, title: "A", description: "see [a][b] and ] [" }, { number: 2, title: "B", description: "]\n[" }];
156
+ const out = ms.map((m) => JSON.stringify(m)).join("\n");
157
+ assert.deepEqual(await listMilestones({ gh: async () => out, repo: "o/r" }), ms);
158
+ });
159
+ it("(#5084) listMilestones propagates a gh error", async () => {
160
+ await assert.rejects(listMilestones({ gh: async () => { throw new Error("HTTP 502"); }, repo: "o/r" }), /HTTP 502/);
161
+ });
162
+ });
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, precheck, repoTracks, dropScopeValidates, tagOutOfBand, parseReview, decide, mergeReviewRows, evidencePathOnly, closePlan, followupItems, bucketTally, pendingTracking, unnamedEdges, structureNote, impliedDocDeliverables } from "./conformance.mjs";
18
+ import { applyValidateRecipes, deferChecklessPostDeploy, reproEnvMiss, resolveForgedockPaths, describesUnrunnableCommand, removedPathsOf, TRACKED, normalizeKindModes, blockingFailures, unverifiedRequirements, mutationTargets, mutationFailures, runMutationCheck, MUTATION_SURVIVED, isBugIssue, scopeText, explainFailures, inIssue, validateContract, cliUsageError, normalizeRunnerCommand, applyAmendments, 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";
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";
@@ -38,9 +38,11 @@ import { hostCapabilityBlock, contractHostNeeds } from "./execution.mjs";
38
38
  const MISSING_TOOL_RE = /command not found|not found in PATH|no such file or directory.*\b(?:docker|promtool)\b|\b(?:docker|promtool)\b[^\n]{0,80}(?:not (?:installed|found|available)|no such file|cannot connect|daemon)|cannot connect to the docker daemon|no docker daemon|no module named|ModuleNotFoundError|\bENOENT\b[^\n]{0,60}spawn/i;
39
39
  /** #4685: true when a builder's block summary names a missing tool or module (environment), not a credential. */
40
40
  export function missingToolBlock(summary) { return MISSING_TOOL_RE.test(String(summary ?? "")); }
41
- import { CHECKS_CLI, FORGEDOCK_CLI, REVIEWER_SYSTEM, LEAN_DIR, RESULT_FILE, INVESTIGATION_FILE, IMPLEMENTATION_FILE, CONTRACT_FILE, builderPlanPrompt, builderImplementPrompt, contractCorrectionPrompt, conformanceResumePrompt, reviewerPrompt, investigationVerdict, decisionNote, reviewerRepairPrompt, REVIEW_DIFF_MAX_BYTES, blockCorrectionPrompt, HUMAN_BLOCK_REASONS, structurePrompt, hotspotPlanNote, hotspotBuildRule, reproPlanNote, unrunnableValidateNote, contextPackNote, docsPrompt, ciFailurePrompt, ciUnavailablePrompt, precheckFailurePrompt, contextRestartPrompt, turnBudgetWrapupPrompt, syncChecks, GROUPING_SYSTEM, enumeratingPlanNote, enumeratingBuildRule, invariantsPlanNote, invariantsBuildRule, missingEntriesPrompt, sequencePrompt, sequenceSyncNote, sequenceSyncPrompt, fixSessionPrompt, postMergeFailurePrompt, duplicateExportsPrompt, migrationGeneratorPrompt, i18nCopiesPrompt } from "./prompts.mjs";
41
+ import { CHECKS_CLI, FORGEDOCK_CLI, REVIEWER_SYSTEM, LEAN_DIR, RESULT_FILE, INVESTIGATION_FILE, IMPLEMENTATION_FILE, CONTRACT_FILE, builderPlanPrompt, builderImplementPrompt, contractCorrectionPrompt, conformanceResumePrompt, amendmentCorrectionPrompt, reviewerPrompt, investigationVerdict, decisionNote, reviewerRepairPrompt, REVIEW_DIFF_MAX_BYTES, blockCorrectionPrompt, HUMAN_BLOCK_REASONS, structurePrompt, hotspotPlanNote, hotspotBuildRule, reproPlanNote, unrunnableValidateNote, contextPackNote, docsPrompt, ciFailurePrompt, ciUnavailablePrompt, precheckFailurePrompt, contextRestartPrompt, turnBudgetWrapupPrompt, syncChecks, GROUPING_SYSTEM, enumeratingPlanNote, enumeratingBuildRule, invariantsPlanNote, invariantsBuildRule, missingEntriesPrompt, sequencePrompt, sequenceSyncNote, sequenceSyncPrompt, fixSessionPrompt, postMergeFailurePrompt, duplicateExportsPrompt, migrationGeneratorPrompt, i18nCopiesPrompt } from "./prompts.mjs";
42
42
  import { postMergeCommands } from "./postmerge.mjs";
43
43
  import { guardAfterMerge, withHistoryRows } from "./guards.mjs";
44
+ import { routePathsFromFiles, routeReachabilityFailures } from "./routecheck.mjs";
45
+ import { parseCoupled, coupledFailures } from "./coupled.mjs";
44
46
  import { loadGuards, selectGuards, runGuards, supersededIds, supersessionComment } from "./guardgate.mjs";
45
47
  import { freshFix, issueSummary, firstPassOf, sessionCostDelta } from "./fixround.mjs";
46
48
  import { parseBuilderRouting, routeBuilder, shouldEscalate } from "./routing.mjs";
@@ -51,6 +53,7 @@ import { addLaneWorktree, DEFAULT_SPARSE_MAX_KB } from "./worktree.mjs";
51
53
  import { parseVerify, runCiVerify, collectFailedLogs, capLogs, VERIFY_MODES, VERIFY_CHECK_NAME, verifyReport, ciGate, rerunFailedOnce, rerunTarget, splitFlaky } from "./verify.mjs";
52
54
  import { e2eSpecs, e2eWanted, ownFailures, runE2eGate, NO_TRIGGER } from "./e2e.mjs";
53
55
  import { laneBackendId } from "./limits.mjs";
56
+ import { openRotator, rotatorRetry } from "./rotator.mjs";
54
57
  import { SPLIT_LABEL } from "./refactor.mjs";
55
58
  import { writeHeavyShims, sumWaits, isHeavyProcess } from "./heavy.mjs";
56
59
  import { priorityFromLabels } from "./sched.mjs";
@@ -194,6 +197,7 @@ export function isProtectedLaneBranch(b, { issue, lane = null, branches = {}, de
194
197
  * @param {string} [o.fixModel] model of fresh fix-round sessions (#4363, forge.yaml lean.fix_model); default the reviewer model
195
198
  * @param {object} [o.builderRouting] forge.yaml lean.builder_routing/builder_mid_model (#4394): {mode: on (default)|off, midModel}
196
199
  * @param {string} [o.followupsDefaultMilestone] forge.yaml followups.default_milestone (#4664): milestone of deferred follow-ups whose parent's milestone has shipped and has no next one
200
+ * @param {string} [o.followupsRollupLabel] forge.yaml followups.rollup_issue_label (#5078): a repo's own open issue with this label is the post-deploy rollup when no marked one is open
197
201
  * @param {string} [o.sequenceRegenerate] forge.yaml lean.sequence.regenerate (#4386): snapshot generator for a schema-changing renumbered migration
198
202
  * @param {object} [o.baseMerge] forge.yaml lean.base_merge_* (#4588): {unionGlobs, jsonGlobs, generatedGlobs, generatedCommand} for deterministic base-merge conflict resolution
199
203
  * @param {number} [o.ciMaxMinutes] remote-CI wait budget (default 360); exhaustion is NOT needs-human
@@ -226,8 +230,9 @@ export function isProtectedLaneBranch(b, { issue, lane = null, branches = {}, de
226
230
  */
227
231
  export async function runCore(o) {
228
232
  const { issue, repo, lane: requestedLane = null, laneOverride = false, branches = {}, cwd, dir, io, builderModel = null, reviewerModel = DEFAULT_REVIEWER_MODEL, fixModel = null,
229
- toolchainOverride = null, testRunners = null, validateRecipes = null, envSetup = null, ciMaxMinutes = 360, conformance = "shadow", conformanceKinds = null, conformanceMutation = "off", reviewIncomplete = "needs-human", reproTest = "prefer", closeParent = false, mergeWait = "required", maxSessionLimitPauses = DEFAULT_MAX_SESSION_LIMIT_PAUSES, cliIdleMinutes = 15, cliMaxMinutes = 180, cliBusyMinutes = 45, contextCeilingTokens = 120000, turnBudgets = null, laneLimits = null, maxMergeRetries = 5, sessionLimitMaxWaitMs = DEFAULT_SESSION_LIMIT_WAIT_MS, onProgress = () => {}, emitTelemetry = null, random = Math.random } = o;
233
+ toolchainOverride = null, testRunners = null, coupledFiles = null, validateRecipes = null, routeReachability = null, envSetup = null, ciMaxMinutes = 360, conformance = "shadow", conformanceKinds = null, conformanceMutation = "off", reviewIncomplete = "needs-human", reproTest = "prefer", closeParent = false, mergeWait = "required", maxSessionLimitPauses = DEFAULT_MAX_SESSION_LIMIT_PAUSES, cliIdleMinutes = 15, cliMaxMinutes = 180, cliBusyMinutes = 45, contextCeilingTokens = 120000, turnBudgets = null, laneLimits = null, maxMergeRetries = 5, sessionLimitMaxWaitMs = DEFAULT_SESSION_LIMIT_WAIT_MS, onProgress = () => {}, emitTelemetry = null, random = Math.random } = o;
230
234
  const now = io.now ?? (() => Date.now());
235
+ const rotator = io.rotator !== undefined ? io.rotator : openRotator({ kind: o.accountRotator }); // #5094
231
236
  const vcfg = { ...parseVerify({}), ...(o.verify ?? {}) };
232
237
  const learnedCommands = (Array.isArray(o.precheck?.commands) ? o.precheck.commands : []).map((c) => String(c ?? "").trim()).filter(Boolean);
233
238
  if (!VERIFY_MODES.includes(vcfg.mode)) vcfg.mode = "local";
@@ -348,6 +353,7 @@ export async function runCore(o) {
348
353
  let heavyEnv = null, heavyWaitsFile = null, heavyQueueMax = 0, stage = "plan", iss;
349
354
  let seqLease = null; // #4386: the host sequence lease, held from the merge-gate renumber through the merge
350
355
  const waits = {};
356
+ const followupsFiled = []; // #5078: {issue, class, count} per tracking issue this lane filed into, for the batch report
351
357
  let precheckMs = 0, precheckDeferred = 0, precheckRan = false; // #4331: pre-PR check wall time and deferrals, for the batch report
352
358
  const addWait = (phase, ms, queued = 0) => {
353
359
  if (Number.isFinite(ms) && ms > 0) waits[phase] = (waits[phase] ?? 0) + ms;
@@ -470,13 +476,14 @@ export async function runCore(o) {
470
476
  await ghc(["issue", "edit", String(issue), "-R", repo, "--add-label", "needs-human"]);
471
477
  } catch { /* best effort */ }
472
478
  }
473
- if (["needs-human", "engine-error", "dependency-open", "dependency-blocked", "dependency-unreadable", "decomposed"].includes(reason)) {
479
+ if (["needs-human", "engine-error", "dependency-open", "dependency-blocked", "dependency-unreadable", "decomposed", "host-task"].includes(reason)) {
474
480
  const wf = ["investigating", "building", "in-review"].map((x) => `workflow:${x}`).join(",");
475
481
  try { await ghc(["issue", "edit", String(issue), "-R", repo, "--remove-label", wf]); } catch { /* best effort */ }
476
482
  }
477
483
  if (["merged", "invalid", "needs-human", "budget-exceeded"].includes(reason)) {
478
484
  if (reason === "budget-exceeded") T.close = ["left open", "recoverable budget.exceeded"]; // #4505: not a human escalation
479
485
  else if (reason === "merged") T.close = [extra.close === "partial" ? "issue closed (partial)" : "issue closed (completed)", "label workflow:merged"];
486
+ else if (reason === "invalid" && extra.close === "already-fixed") T.close = ["issue closed (completed)", "already fixed on the base"]; // #5080
480
487
  else if (reason === "invalid") T.close = ["issue closed (not planned)", "label workflow:invalid"];
481
488
  else { T.close = ["left open", "label needs-human"]; T.anomalies.push(`needs-human: ${String(detail).split("\n")[0].slice(0, 300)}`); }
482
489
  try { await post(["FORGE:TRAJECTORY", REGISTER_MARKER], renderTrajectory({ ...T, usage: usageLine() })); } catch { /* best effort */ }
@@ -484,7 +491,7 @@ export async function runCore(o) {
484
491
  await releaseLease(reason, code, extra.mergedTo ?? null);
485
492
  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(),
486
493
  ...(laneHead && reason !== "merged" ? { laneHead: { ...laneHead } } : {}), light, ...(lightVerdict ? { light_eligibility: lightVerdict } : {}), ...(lightUpgrade ? { light_upgrade: lightUpgrade } : {}), // #4453
487
- ...(Object.keys(waits).length ? { waits: { ...waits } } : {}), ...(heavyQueueMax ? { heavyQueueMax } : {}), ...(precheckRan ? { precheckMs, precheckDeferred } : {}),
494
+ ...(Object.keys(waits).length ? { waits: { ...waits } } : {}), ...(followupsFiled.length ? { followups: followupsFiled.map((f) => ({ ...f })) } : {}), ...(heavyQueueMax ? { heavyQueueMax } : {}), ...(precheckRan ? { precheckMs, precheckDeferred } : {}),
488
495
  ...(firstPassOf(builderTriggers) != null ? { firstPass: firstPassOf(builderTriggers) } : {}), ...(tb.record() ? { turns: tb.record() } : {}), // #4392
489
496
  ...(route ? { builderRoute: { tier: route.tier, class: route.class, escalated, model: route.model ?? "default" } } : {}),
490
497
  ...(gateRan ? { conformance: { mode: conformance === "enforce" || riskEnforced ? "enforce" : "shadow", would: shadowWould, risk: Boolean(riskEnforced), ...(Object.keys(kinds).length ? { kinds: [...failureClasses] } : {}) } } : {}), ...extra };
@@ -625,7 +632,7 @@ export async function runCore(o) {
625
632
  }
626
633
  /** 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). */
627
634
  async function callRunner(args) {
628
- let pauses = 0;
635
+ let pauses = 0, rotations = 0;
629
636
  for (;;) {
630
637
  // #4181: every Docker resource the builder creates carries the lane's ownership labels (same batch/lane as the ledger).
631
638
  const labels = dockerLabels({ batch: o.batchId ?? "adhoc", lane: `lean-${issue}`, issue, expires: now() + LEDGER_TTL_MS }).join(" ");
@@ -637,6 +644,7 @@ export async function runCore(o) {
637
644
  catch (e) {
638
645
  const epoch = Number.isFinite(e?.resetAtEpochMs) ? e.resetAtEpochMs : null;
639
646
  if (!(e?.usageLimit === true || epoch !== null)) throw e;
647
+ if (rotatorRetry(rotator, rotations)) { rotations++; ev({ event: "PHASE_ACCOUNT_ROTATED", phase: args.role }); continue; } // #5094: same args (resume too), next account
640
648
  const waitMs = epoch !== null && epoch > now() ? epoch - now() : DEFAULT_SESSION_LIMIT_WAIT_MS;
641
649
  if (pauses >= maxSessionLimitPauses || waitMs > sessionLimitMaxWaitMs) { e.deferUsageLimit = true; throw e; }
642
650
  pauses++;
@@ -929,6 +937,17 @@ export async function runCore(o) {
929
937
  const caps = hostCapabilityBlock(s, o.hostCaps);
930
938
  if (caps.length) return await needsHuman(`Remote sandbox lacks host capability ${caps.join(", ")} (Builder is blocked (credentials)): ${s}\n\nThe dispatching host has it; the lane is recovered locally.`, OUTCOME_CODES.ENV_CAPABILITY_REMOTE);
931
939
  }
940
+ if (b.kind === "human" && b.why === "credentials" && READONLY_PROD_RE.test(s) && !EXTERNAL_NEED_RE.test(s)) {
941
+ // #5080: read-only production access with no credential/spend/account need is host work: recovered on the host, never a human ask
942
+ T.decisions.push("Builder block: read-only production access is host work (host-task), not an owner ask");
943
+ if (o.placement?.where !== "remote") { // on the host already: a host task (label host-task), never needs-human
944
+ const why = `Host-task: the lane needs read-only production access (Builder is blocked (credentials)): ${s}\n\nNot a credential or an owner ask: run it on the host with its read-only prod tools.`;
945
+ try { await post("FORGE:HOST_TASK", `## Host task\n${why}\n\n_${usageLine()}_`); } catch { /* best effort */ }
946
+ try { await ghc(["issue", "edit", String(issue), "-R", repo, "--add-label", "host-task"]); } catch { /* best effort */ }
947
+ return await finish("host-task", why, { code: OUTCOME_CODES.ENV_CAPABILITY_REMOTE }, OUTCOME_CODES.ENV_CAPABILITY_REMOTE);
948
+ }
949
+ return await needsHuman(`Host-task: the lane needs read-only production access, which the dispatching host has (Builder is blocked (credentials)): ${s}\n\nNot a credential: the lane is recovered on the host.`, OUTCOME_CODES.ENV_CAPABILITY_REMOTE);
950
+ }
932
951
  if (b.kind === "human" && b.why === "credentials" && o.placement?.where === "remote" && missingToolBlock(s)) {
933
952
  // #4685: a tool the sandbox lacks (docker, promtool, a Python module) is an environment gap the host recovers, never a credential.
934
953
  return await needsHuman(`Remote sandbox lacks a validate tool (Builder is blocked (credentials)): ${s}\n\nNot a credential: the lane is recovered where the tool exists.`, OUTCOME_CODES.ENV_TOOL_MISSING);
@@ -1265,6 +1284,13 @@ export async function runCore(o) {
1265
1284
  const differs = new Set(names(await git(["diff", "--name-only", base, "HEAD"])));
1266
1285
  return touched.filter((f) => differs.has(f));
1267
1286
  };
1287
+ /** #5079: verification.coupled_files failures for the lane diff ([] when unconfigured; added files only read when a rule needs them) */
1288
+ const coupledOf = async (files) => {
1289
+ const rules = parseCoupled(coupledFiles);
1290
+ if (!rules.length) return null;
1291
+ const addedFiles = rules.some((r) => r.addedOnly) ? names(await git(["diff", "--name-only", "--diff-filter=A", `${base}...HEAD`]).catch(() => "")) : [];
1292
+ return coupledFailures({ rules, changedFiles: files, addedFiles });
1293
+ };
1268
1294
  /** #4364: the last `forgedock check` record vs the current tree: skipped|stale|red|green; any read or git error is skipped. */
1269
1295
  const selfCheckNow = async () => {
1270
1296
  try { return selfCheckState(JSON.parse(await io.fs.read(`${wt}/${SELF_CHECK_FILE}`)), await worktreeFingerprint({ git, base })); } catch { return "skipped"; }
@@ -1332,7 +1358,7 @@ export async function runCore(o) {
1332
1358
  pitMatched = inPack.filter((x) => x.match === "files").length;
1333
1359
  // #4375: which pitfalls were in the pack (signatures only, run log only: never telemetry or a comment)
1334
1360
  const rec = { event: "CONTEXT_PACK", mode: ctxCfg.mode, ...ctxAb, lane_type: ctxLane, sources: p.sources, found: p.found, bytes: p.bytes, items: p.items, truncated: p.truncated,
1335
- hash: p.hash, cached: r.cached, ...(r.errors?.length ? { errors: r.errors } : {}) };
1361
+ hash: p.hash, cached: r.cached, ...(p.recall ? { recall: p.recall.present ? "present" : "absent" } : {}), ...(r.errors?.length ? { errors: r.errors } : {}) };
1336
1362
  ev({ ...rec, pitfalls: inPack }); await emit(rec);
1337
1363
  const seen = trustedComments.some((c) => String(c?.body ?? "").startsWith("<!-- FORGE:CONTEXT -->") && String(c.body).includes(`hash ${p.hash})`));
1338
1364
  if (p.items && !seen) { try { await post("FORGE:CONTEXT", renderContextRecord(p, { mode: ctxCfg.mode, laneType: ctxLane })); } catch { /* best effort */ } }
@@ -1381,7 +1407,7 @@ export async function runCore(o) {
1381
1407
  if (!io.fileFollowups) throw fail("no follow-up filing effect is configured");
1382
1408
  const items = followupItems(reqs, { now: now() });
1383
1409
  let filed;
1384
- try { filed = await io.fileFollowups({ parent: issue, repo, items, milestone, defaultMilestone: o.followupsDefaultMilestone ?? null, lane }); } catch (e) { throw fail(`filing the follow-up issue failed: ${String(e?.message ?? e).slice(0, 200)}`); }
1410
+ 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)}`); }
1385
1411
  for (const [i, r] of reqs.entries()) {
1386
1412
  const n = Number(filed?.[items[i].key]);
1387
1413
  if (!Number.isSafeInteger(n) || n <= 0) throw fail(`no tracking issue was returned for ${r.id}`);
@@ -1389,6 +1415,8 @@ export async function runCore(o) {
1389
1415
  try { st = JSON.parse(String(await ghc(["issue", "view", String(n), "-R", repo, "--json", "number,state"]))); } catch { st = null; }
1390
1416
  if (String(st?.state).toUpperCase() !== "OPEN") throw fail(`tracking issue #${n} for ${r.id} is not open or unreadable`);
1391
1417
  r.tracked = `#${n}`;
1418
+ const f = followupsFiled.find((x) => x.issue === n && x.class === items[i].class); // #5078: grouped per issue and class
1419
+ if (f) f.count += 1; else followupsFiled.push({ issue: n, class: items[i].class, count: 1 });
1392
1420
  }
1393
1421
  const buckets = Object.entries(bucketTally(reqs)).map(([k, c]) => `${k}:${c}`).join(","); // #4724
1394
1422
  T.decisions.push(`Tracked ${reqs.map((r) => `${r.id} (${r.disposition})`).join(", ")} in ${[...new Set(reqs.map((r) => r.tracked))].join(", ")} (${why}; buckets ${buckets})`);
@@ -1802,7 +1830,7 @@ export async function runCore(o) {
1802
1830
  const names = failed.map((f) => `\`${f.command}\` (exit ${f.rc})`).join(", ");
1803
1831
  if (isSplit) return await needsHuman(`Declared check command(s) fail on the refactor:split move (no builder edits a split); nothing merged: ${names}`, OUTCOME_CODES.CI_RED);
1804
1832
  checkCommandsFailing = true;
1805
- if (round >= vcfg.maxRounds || !sessionId) { T.anomalies.push(`Declared check command(s) still failing before the PR: ${names}`); break; }
1833
+ if (round >= vcfg.maxRounds || !sessionId) { if (vcfg.precheckBlock) return await needsHuman(`Declared check command(s) still failing before the PR (verify.precheck_block); nothing merged: ${names}`, OUTCOME_CODES.CI_RED); T.anomalies.push(`Declared check command(s) still failing before the PR: ${names}`); break; }
1806
1834
  T.decisions.push(`Declared check command(s) failed: ${names}; builder resumed (round ${round + 1}/${vcfg.maxRounds})`);
1807
1835
  const before = String(await git(["rev-parse", "HEAD"])).trim();
1808
1836
  const capped = capLogs(failed.map((f) => ({ ...f, log: f.output }))).map((f) => ({ command: f.command, rc: f.rc, output: f.log }));
@@ -1816,7 +1844,7 @@ export async function runCore(o) {
1816
1844
  await fixRound("precheck", precheckFailurePrompt(quiet, vcfg.mode), [], "precheck", capped.map((f) => `${f.command}\n${f.output ?? ""}`).join("\n"));
1817
1845
  await upsertStage("implementation");
1818
1846
  result = (await readResult()) ?? result;
1819
- if (String(await git(["rev-parse", "HEAD"])).trim() === before) { T.anomalies.push(`Declared check command(s) failing and the builder made no new commit for them: ${names}`); break; }
1847
+ if (String(await git(["rev-parse", "HEAD"])).trim() === before) { if (vcfg.precheckBlock) return await needsHuman(`Declared check command(s) failing and the builder made no new commit for them (verify.precheck_block); nothing merged: ${names}`, OUTCOME_CODES.CI_RED); T.anomalies.push(`Declared check command(s) failing and the builder made no new commit for them: ${names}`); break; }
1820
1848
  files = await changedFiles();
1821
1849
  }
1822
1850
  }
@@ -2301,6 +2329,7 @@ export async function runCore(o) {
2301
2329
  const cfg = readRollouts(wt);
2302
2330
  return cfg ? detectNewOffKeys(cfg, String(await git(["diff", "-U0", `${base}...HEAD`, "--", ...cfg.files]).catch(() => ""))) : [];
2303
2331
  }
2332
+ let amendFixed = false;
2304
2333
  async function gate() {
2305
2334
  await refreshIssue("conformance");
2306
2335
  result = (await readResult()) ?? result;
@@ -2313,6 +2342,14 @@ export async function runCore(o) {
2313
2342
  ev({ event: "CONFORMANCE_RISK_ENFORCED", paths: hits });
2314
2343
  T.decisions.push(`Conformance enforced (review.risk_paths): ${hits.slice(0, 10).join(", ")}${hits.length > 10 ? ` and ${hits.length - 10} more` : ""}`);
2315
2344
  }
2345
+ if (!amendFixed && sessionId && amendmentKindProblems(result?.amendments).length) { // #5089: one in-lane correction, outside the conformance resume budget
2346
+ amendFixed = true;
2347
+ const bad = amendmentKindProblems(result.amendments);
2348
+ T.decisions.push(`Amendment kinds invalid (${bad.length}); builder asked to correct ${RESULT_FILE} once`);
2349
+ await builder(++attemptN, amendmentCorrectionPrompt(bad), sessionId, false, undefined, { mode: "resume", trigger: "contract_correction" });
2350
+ result = (await readResult()) ?? result;
2351
+ files = await changedFiles();
2352
+ }
2316
2353
  const amendProblems = await amend();
2317
2354
  const gateHead = String(await git(["rev-parse", "HEAD"])).trim();
2318
2355
  const ciVerified = remoteVerify && ciVerifiedHead !== null && ciVerifiedHead === gateHead;
@@ -2321,6 +2358,8 @@ export async function runCore(o) {
2321
2358
  // #4693: guards run beside the validate rerun; SPEC-11: the substituted-original rerun shares the one validate phase
2322
2359
  const validatePhase = phaseLedger.during("validate", async () => ({ runs: await rerunFailedValidatesRaw(effective), subs: await rerunSubstitutedOriginals(effective) })).finally(() => { validateMs = now() - gateT0; });
2323
2360
  const [{ runs: validateRuns, subs: substitutedRuns }, guardFailures] = await Promise.all([validatePhase, guardGate(files)]);
2361
+ // #5087: forge.yaml topology.route_reachability gates the route handlers this diff adds; unset = no check
2362
+ const routeFailures = routeReachability && io.runCommand ? await routeReachabilityFailures({ command: routeReachability, paths: routePathsFromFiles(String(await git(["diff", "--name-only", "--diff-filter=A", `${base}...HEAD`]).catch(() => "")).split("\n").map((x) => x.trim()).filter(Boolean)), run: async ({ command }) => (await io.runCommand({ command, cwd: wt, timeoutMs: vcfg.precheckTimeoutSeconds * 1000, env: laneEnv() }).then((x) => x?.r ?? x)) ?? { rc: 127 } }) : [];
2324
2363
  // #4705: only the part not hidden behind the validate rerun; 0 when the gate errored or skipped
2325
2364
  const guardRunMs = guardEv && !guardEv.error ? Math.max(0, now() - gateT0 - validateMs) : 0;
2326
2365
  guardAddedMs += guardRunMs;
@@ -2330,7 +2369,7 @@ export async function runCore(o) {
2330
2369
  const checkIds = effective.requirements?.some((r) => r.disposition === "post-deploy") ? new Set(Object.keys(await checksAt(git, [base, "HEAD"], wt))) : null;
2331
2370
  const removedPaths = (effective.validate ?? []).some((v) => v.substituted) ? await removedPathsOf(git, base) : []; // #4990
2332
2371
  const subBaseFiles = (effective.validate ?? []).some((v) => v.substituted) ? String(await git(["ls-tree", "-r", "-z", "--name-only", base]).catch(() => "")).split("\0").filter(Boolean) : []; // #5003
2333
- const pc = precheck({ contract: effective, result, changedFiles: files, ignore: IGNORE, ciVerified, hotspots, repro: await reproOf(effective), validateRuns, repoHas, frozen, checkIds, guardFailures, newOffKeys: await newOffKeysOf(), substitutedRuns, removedPaths, baseFiles: subBaseFiles });
2372
+ const pc = precheck({ contract: effective, result, changedFiles: files, ignore: IGNORE, ciVerified, hotspots, repro: await reproOf(effective), validateRuns, repoHas, frozen, checkIds, guardFailures, coupledFailures: await coupledOf(files), routeFailures, newOffKeys: await newOffKeysOf(), substitutedRuns, removedPaths, baseFiles: subBaseFiles });
2334
2373
  // #4350: a scope-check validate stale only by amended deliverables is judged by the engine's scope gate instead
2335
2374
  if (pc.superseded?.length) {
2336
2375
  ev({ event: "VALIDATE_SUPERSEDED", runs: pc.superseded });
@@ -2367,6 +2406,8 @@ export async function runCore(o) {
2367
2406
  const envFails = pc.failures.filter((f) => ENV_CODES.includes(f.code));
2368
2407
  const pre = [...pc.failures.filter((f) => !ENV_CODES.includes(f.code)), ...amendProblems, ...mutBlock];
2369
2408
  if (pre.length) ev({ event: "PRECHECK", failures: pre.map((f) => f.code) });
2409
+ // #5080: the repro already passes on the base and the diff changes no source: the fix is on the base, not missing
2410
+ if (alreadyFixedOnBase(pre, files, IGNORE)) return { state: "already-fixed", by: "precheck", failures: pre };
2370
2411
  if (pre.length && !recording()) return { state: "nonconforming", by: "precheck", failures: pre };
2371
2412
  // shadow: the builder's own failed checks block before any reviewer tokens are spent (#4160)
2372
2413
  const hard = recording() ? blockingOf(pre, SHADOW_PRE_BLOCKS).block : [];
@@ -2462,6 +2503,13 @@ export async function runCore(o) {
2462
2503
  const explained = explainFailures(outcome.failures, effective); // #4265: name the unmet requirement, not only the code
2463
2504
  const list = explained.slice(0, 15).map((f) => `- ${f.code}: ${f.detail}`).join("\n");
2464
2505
  if (outcome.state === "environment") return await envNeedsHuman(outcome.failures);
2506
+ if (outcome.state === "already-fixed") { // #5080: close as delivered, citing the base commit
2507
+ const sha = String(await git(["rev-parse", base]).catch(() => "")).trim().slice(0, 12) || base;
2508
+ const note = `Already fixed on the base: the repro test passes on \`${base}\` (${sha}) without this change, and the diff changes no source file. Closing as delivered.\n\n${list}`;
2509
+ T.decisions.push(`Already fixed on base ${sha} (repro BASE=RAN HEAD=RAN)`);
2510
+ await ghw(["issue", "close", String(issue), "-R", repo, "--reason", "completed", "--comment", note]);
2511
+ return await finish("invalid", `already fixed on base ${sha}`, { close: "already-fixed" });
2512
+ }
2465
2513
  if (outcome.state === "review.unparseable") return await needsHuman(`The conformance reviewer returned no parseable JSON twice; nothing merged.`, OUTCOME_CODES.REVIEW_NO_VERDICT);
2466
2514
  if (shadow && reviewIncomplete === "degraded" && (outcome.state === "review.incomplete" || outcome.state === "review.inconsistent")) {
2467
2515
  degraded = { state: outcome.state, missing: outcome.missing ?? [] };
@@ -2982,7 +3030,7 @@ export async function runCore(o) {
2982
3030
  } catch (e) {
2983
3031
  if (e?.deferUsageLimit) {
2984
3032
  const when = e.resetAt || (Number.isFinite(e.resetAtEpochMs) ? new Date(e.resetAtEpochMs).toISOString() : "unknown");
2985
- 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) }, OUTCOME_CODES.RATE_LIMITED);
3033
+ 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);
2986
3034
  }
2987
3035
  if (e?.deferCode) return finish("deferred", e.deferDetail ?? String(e.message), { code: e.deferCode, ...(e.retryInSeconds ? { retryInSeconds: e.retryInSeconds } : {}) }, e.deferCode);
2988
3036
  // #4505: a turn overrun is mechanical: a non-human end the batch recovers, needs-human only once a budget recovery failed too
@@ -310,6 +310,22 @@ describe("core work-on", () => {
310
310
  assert.match(t, /\| Plan \| not reached \|/);
311
311
  });
312
312
 
313
+ it("an unknown amendment kind gets one in-lane correction before the gate and does not use the conformance resume (#5089)", async () => {
314
+ const w = world({ amendments: [{ kind: "deliverable_change", requirement: "R1", why: "no Linux build" }], resumeAmendments: [], reviews: [ok()] });
315
+ const r = await run(w);
316
+ assert.equal(r.terminalReason, "merged");
317
+ assert.ok(builders(w).some((c) => /amendments in .*result\.json are not valid/.test(c[4] ?? "")), "the correction prompt ran");
318
+ assert.match(traj(w)[0], /Amendment kinds invalid \(1\); builder asked to correct/);
319
+ });
320
+
321
+ it("an unknown amendment kind the builder does not fix parks only after the one correction (#5089)", async () => {
322
+ const bad = [{ kind: "deliverable_change", requirement: "R1", why: "w" }];
323
+ const w = world({ amendments: bad, resumeAmendments: bad, reviews: [ok()] });
324
+ const r = await run(w);
325
+ assert.equal(r.terminalReason, "needs-human");
326
+ assert.equal(builders(w).filter((c) => /amendments in .*result\.json are not valid/.test(c[4] ?? "")).length, 1);
327
+ });
328
+
313
329
  it("nonconforming review -> builder resumed in its session with the failed rows -> re-review conforms -> merge; the note is re-posted", async () => {
314
330
  const w = world({ reviews: [bad("src/foo.js:9 still wrong"), ok()] });
315
331
  const r = await run(w);
@@ -443,6 +459,19 @@ describe("core work-on", () => {
443
459
  assert.equal((await run(world({ changed: [] }))).terminalReason, "needs-human");
444
460
  });
445
461
 
462
+ it("#5080 a credentials block that is only read-only prod access ends host-task locally, env.capability_remote remotely, never builder.blocked; real credentials stay builder.blocked", async () => {
463
+ const prod = { result: { status: "blocked", summary: "gated on read-only production telemetry: a host-only prod_monitor capability", block: { kind: "human", why: "credentials" } } };
464
+ const lw = world(prod);
465
+ const local = await run(lw);
466
+ assert.equal(local.terminalReason, "host-task"); assert.equal(local.code, "env.capability_remote");
467
+ assert.ok(lw.edits.some((e) => e.includes("--add-label") && e.includes("host-task")), JSON.stringify(lw.edits));
468
+ assert.ok(!lw.edits.some((e) => e.includes("--add-label") && e.includes("needs-human")), JSON.stringify(lw.edits));
469
+ const remote = await run(world(prod), { placement: { where: "remote", reason: "mode remote" } });
470
+ assert.equal(remote.code, "env.capability_remote"); assert.notEqual(remote.terminalReason, "host-task");
471
+ const real = await run(world({ result: { status: "blocked", summary: "needs the production API key to read prod data", block: { kind: "human", why: "credentials" } } }));
472
+ assert.equal(real.code, "builder.blocked");
473
+ });
474
+
446
475
  // #4124: a builder block is typed; only kind "human" with an allowed reason may reach needs-human.
447
476
  const depWorld = (o, deps) => {
448
477
  const w = world(o);
@@ -928,6 +957,7 @@ describe("conformance gate (frozen contract, precheck, structured review)", () =
928
957
  assert.equal(w.followups.length, 1);
929
958
  assert.deepEqual(w.followups[0].items.map((i) => i.key), ["R2: Existing callers of foo keep working."]);
930
959
  assert.match(w.followups[0].items[0].why, /^post-deploy: \[bucket:post-deploy\] callers live in another module/);
960
+ assert.deepEqual(r.followups, [{ issue: 900, class: "post-deploy", count: 1 }]); // #5078: the batch report's follow-ups line
931
961
  const body = w.prs[0][w.prs[0].indexOf("--body") + 1];
932
962
  assert.match(body, /## Remaining work tracked\n- #900\n\nCloses #7$/);
933
963
  assert.ok(w.comments.some((c) => c.includes("<!-- FORGE:TRACKED -->") && c.includes("#900")));
@@ -1938,6 +1968,17 @@ describe("bug lanes freeze a red-green repro_test (#4271)", () => {
1938
1968
  assert.equal((await run(fixed, { conformance: "shadow" })).terminalReason, "merged"); assert.equal(fixed.rgCalls.length, 2);
1939
1969
  });
1940
1970
 
1971
+ it("#5080 a repro that already passes on the base ends already-fixed: the issue closes as completed citing the base, nothing merges, no resume", async () => {
1972
+ for (const conformance of ["enforce", "shadow"]) {
1973
+ const w = rworld([{ base: "RAN", head: "RAN" }], { changed: ["test/foo.test.js"] });
1974
+ const r = await run(w, { conformance });
1975
+ assert.equal(r.terminalReason, "invalid", conformance); assert.equal(w.merges.length, 0);
1976
+ assert.equal(builders(w).length, 2, "no resume"); assert.equal(reviewers(w).length, 0);
1977
+ assert.equal(w.closed.length, 1); assert.equal(w.closed[0][w.closed[0].indexOf("--reason") + 1], "completed");
1978
+ assert.match(w.closed[0][w.closed[0].indexOf("--comment") + 1], /Already fixed on the base/);
1979
+ }
1980
+ });
1981
+
1941
1982
  it("a repro test the harness cannot run on head (or no harness result) is env.repro_unrunnable: recoverable, no resume, no reviewer (#4334)", async () => {
1942
1983
  for (const conformance of ["enforce", "shadow"]) {
1943
1984
  for (const rg of [{ base: "SKIPPED", head: "UNRUNNABLE" }, null]) {
@@ -3298,6 +3339,45 @@ describe("usage limit, lease takeover, worktree setup, run tmp (#4101-#4104)", (
3298
3339
  assert.match(r.detail, /resets at unknown/);
3299
3340
  });
3300
3341
 
3342
+ // #5094: an account rotator re-sends the limited call on the next account at once, with the same args (resume too).
3343
+ const fakeRotator = (o = {}) => ({ kind: "tokenbaton", accounts: o.accounts ?? [{ n: 1 }, { n: 2 }], headroom: () => o.headroom ?? true, resumeAt: () => null,
3344
+ currentIdentity: () => o.id ?? "acct-2", accountIdentity: (n) => `acct-${n}`, isAccount: () => true });
3345
+ const captureBuilders = (w, err, times) => { const orig = w.io.runner; const sent = []; let n = 0;
3346
+ w.io.runner = async (c) => { if (c.role === "builder") { sent.push({ prompt: c.prompt, resume: c.resume ?? null, model: c.model ?? null }); if (n++ < times) throw err; } return orig(c); }; return sent; };
3347
+
3348
+ it("#5094 a rotator switches accounts mid-lane: the limited call is re-sent on the next account at once and the lane continues", async () => {
3349
+ const w = world(); const sleeps = []; const dir = tmpDir("core-");
3350
+ const s0 = w.io.sleep; w.io.sleep = async (ms) => { sleeps.push(ms); return s0(ms); };
3351
+ const sent = captureBuilders(w, limitErr({ resetAtEpochMs: 3 * 3600 * 1000, resetAt: "8:30pm" }), 1);
3352
+ w.io.rotator = fakeRotator();
3353
+ const r = await run(w, { dir });
3354
+ assert.equal(r.terminalReason, "merged", "no deferral: the next account ran the call");
3355
+ assert.equal(sleeps.includes(3 * 3600 * 1000), false, "no pause for the exhausted account's reset");
3356
+ assert.deepEqual(sent[1], sent[0], "the same step is re-sent (same prompt, same resume session): no state lost");
3357
+ const clean = world(); await run(clean);
3358
+ assert.equal(builders(w).length, builders(clean).length, "no builder phase is repeated beyond the re-sent call");
3359
+ assert.ok(readLog(dir, 7).some((e) => e.event === "PHASE_ACCOUNT_ROTATED" && e.phase === "builder"));
3360
+ });
3361
+
3362
+ it("#5094 rotations stop at the account count, then the lane defers naming the rotator's current account", async () => {
3363
+ const w = world();
3364
+ const sent = captureBuilders(w, limitErr({ resetAtEpochMs: 3 * 3600 * 1000, resetAt: "8:30pm" }), 99);
3365
+ w.io.rotator = fakeRotator({ id: "acct-2" });
3366
+ const r = await run(w);
3367
+ assert.equal(r.terminalReason, "deferred"); assert.equal(r.code, "rate_limited");
3368
+ assert.equal(sent.length, 3, "first call plus one retry per account");
3369
+ assert.equal(r.backendId, "acct-2");
3370
+ });
3371
+
3372
+ it("#5094 a rotator without headroom does not retry: the usual pause/defer applies", async () => {
3373
+ const w = world();
3374
+ const sent = captureBuilders(w, limitErr({ resetAtEpochMs: 3 * 3600 * 1000, resetAt: "8:30pm" }), 99);
3375
+ w.io.rotator = fakeRotator({ headroom: false });
3376
+ const r = await run(w);
3377
+ assert.equal(r.terminalReason, "deferred");
3378
+ assert.equal(sent.length, 1);
3379
+ });
3380
+
3301
3381
  it("#4101 a non-limit runner error is still engine-error", async () => {
3302
3382
  const w = world(); failFirstBuilder(w, new Error("boom"));
3303
3383
  assert.equal((await run(w)).terminalReason, "engine-error");
@@ -13,8 +13,12 @@ import { join } from "node:path";
13
13
  import { backendIdentity } from "../engine.mjs";
14
14
  import { writeAtomic } from "./batch.mjs";
15
15
 
16
- /** Identity of the account a core lane runs on: the lane always uses the claude CLI with the dispatcher's env. */
17
- export function laneBackendId(env = process.env) {
16
+ /**
17
+ * Identity of the account a core lane runs on: the lane always uses the claude CLI with the dispatcher's env. With an
18
+ * account rotator (#5094, rotator.mjs) the shim picks the account per call, so the rotator's current account is the identity.
19
+ */
20
+ export function laneBackendId(env = process.env, rotator = null) {
21
+ if (rotator) { try { const id = rotator.currentIdentity(); if (id) return id; } catch { /* fail-open: the env identity */ } }
18
22
  return backendIdentity({ backend: "cli", env });
19
23
  }
20
24