forgedock 1.43.1 → 1.45.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 (57) hide show
  1. package/bin/engine/outcome-codes.mjs +2 -2
  2. package/bin/engine/telemetry-seam.mjs +1 -1
  3. package/bin/engine-cli.mjs +50 -11
  4. package/bin/forgedock.mjs +50 -24
  5. package/bin/gc.mjs +3 -1
  6. package/bin/hooks/pre-tool-use.mjs +54 -3
  7. package/bin/lean/autochildren.mjs +13 -5
  8. package/bin/lean/autochildren.test.mjs +36 -18
  9. package/bin/lean/batch.test.mjs +46 -0
  10. package/bin/lean/conformance.mjs +107 -16
  11. package/bin/lean/conformance.test.mjs +126 -13
  12. package/bin/lean/decision-engine.mjs +1 -1
  13. package/bin/lean/decision-engine.test.mjs +11 -0
  14. package/bin/lean/execution.mjs +7 -4
  15. package/bin/lean/execution.test.mjs +21 -0
  16. package/bin/lean/gatewatch.test.mjs +5 -5
  17. package/bin/lean/lane.mjs +22 -4
  18. package/bin/lean/lane.test.mjs +33 -1
  19. package/bin/lean/lean.mjs +49 -16
  20. package/bin/lean/lean.test.mjs +66 -0
  21. package/bin/lean/orchestrate-blocked.test.mjs +35 -0
  22. package/bin/lean/orchestrate-children.test.mjs +16 -4
  23. package/bin/lean/orchestrate.mjs +61 -13
  24. package/bin/lean/pmap.mjs +17 -0
  25. package/bin/lean/pmap.test.mjs +27 -0
  26. package/bin/lean/postdeploy-check.test.mjs +3 -1
  27. package/bin/lean/preflight.mjs +8 -3
  28. package/bin/lean/preflight.test.mjs +13 -1
  29. package/bin/lean/prodwatch-triage.mjs +6 -0
  30. package/bin/lean/prodwatch-triage.test.mjs +14 -1
  31. package/bin/lean/prodwatch.mjs +1 -3
  32. package/bin/lean/prompts.mjs +1 -1
  33. package/bin/lean/recovery.test.mjs +21 -4
  34. package/bin/lean/retry.mjs +11 -0
  35. package/bin/lean/retry.test.mjs +16 -0
  36. package/bin/lean/sandbox-provider.contract.test.mjs +15 -4
  37. package/bin/lean/sandbox-remote.mjs +19 -7
  38. package/bin/lean/sched.mjs +25 -2
  39. package/bin/lean/sched.test.mjs +58 -1
  40. package/bin/lean/selftest-live.mjs +56 -31
  41. package/bin/lean/selftest-live.test.mjs +48 -6
  42. package/bin/lean/selftest-nightly.mjs +7 -3
  43. package/bin/lean/selftest-nightly.test.mjs +24 -2
  44. package/bin/lean/selftest-static.mjs +18 -11
  45. package/bin/lean/selftest-static.test.mjs +8 -0
  46. package/bin/lean/signoff.mjs +16 -3
  47. package/bin/lean/signoff.test.mjs +52 -1
  48. package/bin/pool/agent.mjs +3 -3
  49. package/bin/pool/cli.mjs +29 -1
  50. package/bin/pool/router.mjs +17 -6
  51. package/commands/orchestrate.md +5 -2
  52. package/docs/CONFIG.md +10 -4
  53. package/docs/DECISIONS.md +5 -1
  54. package/docs/OUTCOME-CODES.md +2 -2
  55. package/docs/TELEMETRY.md +1 -1
  56. package/package.json +1 -1
  57. package/scripts/plan-validate-live.mjs +15 -8
package/bin/lean/lane.mjs CHANGED
@@ -41,11 +41,25 @@ export async function defaultBranches({ repo, io, branches = {} }) {
41
41
  return [...new Set([branches.default, gh].filter(Boolean))];
42
42
  }
43
43
 
44
+ /** #4963: one batch's shared lookups: the default branches once, `ls-remote` once per lane. Failures are evicted, so a retry re-reads. */
45
+ export function makeLaneCache() { return new Map(); }
46
+ const once = (cache, key, load, keep = () => true) => {
47
+ let p = cache.get(key);
48
+ if (!p) {
49
+ p = Promise.resolve().then(load);
50
+ cache.set(key, p);
51
+ p.then((v) => { if (!keep(v) && cache.get(key) === p) cache.delete(key); }, () => { if (cache.get(key) === p) cache.delete(key); });
52
+ }
53
+ return p;
54
+ };
55
+
44
56
  /**
57
+ * `cache` (makeLaneCache) shares the default-branch and ls-remote reads across issues; `milestone` (a title, or null for none)
58
+ * is a pre-read value that skips the per-issue `gh issue view`; undefined reads it.
45
59
  * @returns {Promise<{ok:true, lane:string, expected:string, milestone:?string, overridden:boolean, defaults:string[]}
46
60
  * | {ok:false, code:string, detail:string}>}
47
61
  */
48
- export async function resolveLane({ issue, repo, io, cwd, branches = {}, requested = null, override = false }) {
62
+ export async function resolveLane({ issue, repo, io, cwd, branches = {}, requested = null, override = false, cache = null, milestone: known }) {
49
63
  const refuse = (code, detail) => ({ ok: false, code, detail });
50
64
  const transient = (detail) => ({ ...refuse(OUTCOME_CODES.LANE_UNRESOLVED, detail), transient: true }); // #4791: a gh/git command failed; retry
51
65
  const staging = branches.staging || DEFAULT_STAGING_BRANCH;
@@ -53,9 +67,12 @@ export async function resolveLane({ issue, repo, io, cwd, branches = {}, request
53
67
  if (!pattern.includes("{slug}")) return refuse(OUTCOME_CODES.LANE_UNRESOLVED, `forge.yaml branches.feature_pattern "${pattern}" has no {slug}`);
54
68
  let milestone = null, defaults;
55
69
  try {
56
- milestone = JSON.parse(String(await io.gh(["issue", "view", String(issue), "-R", repo, "--json", "milestone"])))?.milestone?.title || null;
70
+ if (known !== undefined) milestone = known || null;
71
+ else milestone = JSON.parse(String(await io.gh(["issue", "view", String(issue), "-R", repo, "--json", "milestone"])))?.milestone?.title || null;
57
72
  } catch (e) { return transient(`cannot read the milestone of #${issue}: ${String(e?.message ?? e).slice(0, 200)}`); }
58
- try { defaults = await defaultBranches({ repo, io, branches }); } catch (e) {
73
+ try {
74
+ defaults = cache ? await once(cache, "default-branches", () => defaultBranches({ repo, io, branches })) : await defaultBranches({ repo, io, branches });
75
+ } catch (e) {
59
76
  return transient(`cannot read the default branch of ${repo}: ${String(e?.message ?? e).slice(0, 200)}`);
60
77
  }
61
78
  let expected = staging;
@@ -78,7 +95,8 @@ export async function resolveLane({ issue, repo, io, cwd, branches = {}, request
78
95
  }
79
96
  if (!LANE_RE.test(lane) || lane.includes("..")) return refuse(OUTCOME_CODES.LANE_UNRESOLVED, `invalid lane ${JSON.stringify(lane)}`);
80
97
  let out;
81
- try { out = String(await io.git(["ls-remote", "--heads", "origin", `refs/heads/${lane}`], { cwd })); } catch (e) {
98
+ const lsRemote = () => io.git(["ls-remote", "--heads", "origin", `refs/heads/${lane}`], { cwd });
99
+ try { out = String(cache ? await once(cache, `lane:${lane}`, async () => String(await lsRemote()), (v) => v.trim() !== "") : await lsRemote()); } catch (e) {
82
100
  return transient(`cannot check origin for ${lane}: ${String(e?.message ?? e).slice(0, 200)}`);
83
101
  }
84
102
  if (!out.trim()) return refuse(OUTCOME_CODES.LANE_BRANCH_MISSING, `lane branch ${lane} (${why}) does not exist on origin; create it first (e.g. /milestone start) or fix the milestone title`);
@@ -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 } from "./lane.mjs";
7
+ import { milestoneSlug, readBranches, resolveLane, makeLaneCache } 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 } = {}) {
@@ -101,3 +101,35 @@ describe("lane resolution (#4125)", () => {
101
101
  assert.deepEqual([slug.code, !!slug.transient], ["lane.unresolved", false]);
102
102
  });
103
103
  });
104
+
105
+ describe("per-batch lane lookups (#4963)", () => {
106
+ it("one default-branch read and one ls-remote per lane across issues; a supplied milestone skips the issue view", async () => {
107
+ const fake = io({ onOrigin: ["staging", "main", "milestone/m1"] }), cache = makeLaneCache();
108
+ const rs = await Promise.all([1, 2, 3, 4].map((issue) => resolveLane({ issue, repo: "o/r", io: fake, cwd: "/r", cache, milestone: issue % 2 ? "M1" : null })));
109
+ assert.deepEqual(rs.map((r) => r.lane), ["milestone/m1", "staging", "milestone/m1", "staging"]);
110
+ const n = (k, ...m) => fake.calls.filter((c) => c[0] === k && m.every((x, i) => c[i + 1] === x)).length;
111
+ assert.equal(n("gh", "repo", "view"), 1);
112
+ assert.equal(n("gh", "issue", "view"), 0);
113
+ assert.equal(fake.calls.filter((c) => c[1] === "ls-remote").length, 2);
114
+ });
115
+ it("without cache or milestone every call reads (unchanged)", async () => {
116
+ const fake = io();
117
+ await resolveLane({ issue: 1, repo: "o/r", io: fake, cwd: "/r" });
118
+ await resolveLane({ issue: 2, repo: "o/r", io: fake, cwd: "/r" });
119
+ assert.equal(fake.calls.filter((c) => c[1] === "issue").length, 2);
120
+ assert.equal(fake.calls.filter((c) => c[1] === "repo").length, 2);
121
+ });
122
+ it("a failed lookup is not cached: the retry re-reads", async () => {
123
+ const cache = makeLaneCache();
124
+ const bad = await resolveLane({ issue: 1, repo: "o/r", io: io({ ghFails: "repo" }), cwd: "/r", cache, milestone: null });
125
+ assert.equal(bad.transient, true);
126
+ const ok = await resolveLane({ issue: 1, repo: "o/r", io: io(), cwd: "/r", cache, milestone: null });
127
+ assert.equal(ok.ok, true);
128
+ });
129
+ it("a missing lane branch is not cached either", async () => {
130
+ const cache = makeLaneCache();
131
+ const miss = await resolveLane({ issue: 1, repo: "o/r", io: io({ onOrigin: ["main"] }), cwd: "/r", cache, milestone: null });
132
+ assert.equal(miss.ok, false);
133
+ assert.equal((await resolveLane({ issue: 1, repo: "o/r", io: io(), cwd: "/r", cache, milestone: null })).ok, true);
134
+ });
135
+ });
package/bin/lean/lean.mjs CHANGED
@@ -15,7 +15,7 @@ import { ciPollDelayMs, ENGINE_VERSION, DEFAULT_MAX_SESSION_LIMIT_PAUSES, DEFAUL
15
15
  import { openDependency, laneMergedPrs, dependencyOnLane, isTrustedComment, decomposedSubissues, isDecomposedRecord, reopenRevokedDecomposition } from "../engine/phases.mjs";
16
16
  import { createHash, randomBytes } from "node:crypto";
17
17
  import { otherLaneOwner } from "./lanelock.mjs";
18
- import { resolveForgedockPaths, describesUnrunnableCommand, removedPathsOf, TRACKED, normalizeKindModes, blockingFailures, unverifiedRequirements, mutationTargets, mutationFailures, runMutationCheck, MUTATION_SURVIVED, isBugIssue, scopeText, explainFailures, inIssue, validateContract, cliUsageError, normalizeRunnerCommand, applyAmendments, precheck, repoTracks, dropScopeValidates, tagOutOfBand, parseReview, decide, mergeReviewRows, evidencePathOnly, closePlan, followupItems, bucketTally, pendingTracking, unnamedEdges, structureNote, impliedDocDeliverables } from "./conformance.mjs";
18
+ import { applyValidateRecipes, deferChecklessPostDeploy, reproEnvMiss, resolveForgedockPaths, describesUnrunnableCommand, removedPathsOf, TRACKED, normalizeKindModes, blockingFailures, unverifiedRequirements, mutationTargets, mutationFailures, runMutationCheck, MUTATION_SURVIVED, isBugIssue, scopeText, explainFailures, inIssue, validateContract, cliUsageError, normalizeRunnerCommand, applyAmendments, precheck, repoTracks, dropScopeValidates, tagOutOfBand, parseReview, decide, mergeReviewRows, evidencePathOnly, closePlan, followupItems, bucketTally, pendingTracking, unnamedEdges, structureNote, impliedDocDeliverables } from "./conformance.mjs";
19
19
  import { checkAdmission, hostParallelism, ADMISSION_RETRY_SECONDS } from "./admission.mjs";
20
20
  import { namedPaths, isTrustedIssueComment } from "./prefilter.mjs";
21
21
  import { checksAt } from "./postdeploy-check.mjs";
@@ -64,7 +64,7 @@ import { buildResumeDigest } from "./resume.mjs";
64
64
  import { parsePredicate, evalPredicate, premiseAnnotation, registerAsk, registryPkgVersion, closedEdgeNote, onlyAsk, fileRemainder } from "./needs-human-premise.mjs";
65
65
  import { lightEligibility } from "./light.mjs";
66
66
  import { untrackToolState, unstageToolState, isToolState, clearLaneScratch } from "./toolstate.mjs";
67
- import { ensureSignedOff } from "./signoff.mjs";
67
+ import { ensureSignedOff, salvageBranch, dropSalvageTracking } from "./signoff.mjs";
68
68
  import { createLedger, flattenPhases } from "./phaseledger.mjs";
69
69
  import { runLightStep, runLightTests, lightBuildPrompt, upgradeNote, renderLiteReceipt } from "./lightlane.mjs";
70
70
 
@@ -82,6 +82,9 @@ export const RECOVER_HOW = Object.freeze({
82
82
  [OUTCOME_CODES.ENV_TOOL_MISSING]: "a validate command could not run because the host lacks the tool or module named in the earlier run's detail; the change may already be correct. Do not edit the deliverables for it and never install anything globally: run the same check through the repo's own toolchain (its virtualenv, lockfile-pinned runner or package scripts), record that exit under the same validate id and name the command you used in your summary",
83
83
  [ENV_VALIDATE_TIMEOUT]: "a validate command timed out (exit 124) on the engine's rerun, even after one longer retry: host load, not a failed check; the change may already be correct. Do not edit the deliverables for it: run the same command narrowed to the changed files (or through the repo's own cached toolchain) with a longer timeout, record that exit under the same validate id and name the command in your summary",
84
84
  [OUTCOME_CODES.ENV_CAPABILITY_REMOTE]: "the earlier run ran in a remote sandbox that lacks a host-only capability (SOPS/age key, ops credentials, read-only prod monitor SSH); this host has it. Do the work that needed it now, with the host's own tools and credentials, and never print or commit a secret in the clear",
85
+ [OUTCOME_CODES.CONFORMANCE_UNIMPLEMENTED]: "an earlier run's conformance check found a requirement or validate command not done after one builder resume; the branch holds its work. For each named gap deliver the requirement against the frozen contract, adding any file it needs as an `add-deliverable` amendment, and re-run the validate commands",
86
+ [OUTCOME_CODES.CONFORMANCE_OMISSION]: "an earlier run's review found a requirement the change left out or narrowed 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 an `add-requirement` / `defer` amendment with a why so the engine files a tracked follow-up instead of stopping",
87
+ [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",
85
88
  [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",
86
89
  });
87
90
  /** #4323/#4334: precheck failure codes that are environment errors (state "environment"), never a builder resume. */
@@ -222,7 +225,7 @@ export function isProtectedLaneBranch(b, { issue, lane = null, branches = {}, de
222
225
  */
223
226
  export async function runCore(o) {
224
227
  const { issue, repo, lane: requestedLane = null, laneOverride = false, branches = {}, cwd, dir, io, builderModel = null, reviewerModel = DEFAULT_REVIEWER_MODEL, fixModel = null,
225
- toolchainOverride = null, testRunners = null, envSetup = null, ciMaxMinutes = 360, conformance = "shadow", conformanceKinds = null, conformanceMutation = "off", reviewIncomplete = "needs-human", reproTest = "prefer", closeParent = false, mergeWait = "required", maxSessionLimitPauses = DEFAULT_MAX_SESSION_LIMIT_PAUSES, cliIdleMinutes = 15, cliMaxMinutes = 180, cliBusyMinutes = 45, contextCeilingTokens = 120000, turnBudgets = null, laneLimits = null, maxMergeRetries = 5, sessionLimitMaxWaitMs = DEFAULT_SESSION_LIMIT_WAIT_MS, onProgress = () => {}, emitTelemetry = null, random = Math.random } = o;
228
+ 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;
226
229
  const now = io.now ?? (() => Date.now());
227
230
  const vcfg = { ...parseVerify({}), ...(o.verify ?? {}) };
228
231
  const learnedCommands = (Array.isArray(o.precheck?.commands) ? o.precheck.commands : []).map((c) => String(c ?? "").trim()).filter(Boolean);
@@ -513,6 +516,10 @@ export async function runCore(o) {
513
516
  return true;
514
517
  } catch { return false; }
515
518
  }
519
+ const labelFailed = (step, e) => { // #4905: a failed needs-human comment/label call is recorded, not swallowed (the issue showed needs-human with no label)
520
+ const error = String(e?.message ?? e).slice(0, 200);
521
+ T.anomalies.push(`needs-human ${step} failed: ${error}`); ev({ event: "NEEDS_HUMAN_LABEL_FAILED", step, error });
522
+ };
516
523
  async function needsHuman(why, code = OUTCOME_CODES.ENGINE_ERROR, { ask: askText, note: askNote = "", ...extra } = {}) {
517
524
  // #4725: a recorded premise (`Premise: <kind>:<arg>`) that already holds is no blocker: no label, the lane runs again.
518
525
  const predicate = parsePredicate(why);
@@ -528,8 +535,10 @@ export async function runCore(o) {
528
535
  link = reg.existing ? `Same owner ask as #${reg.existing} (one ask, linked): answering it unblocks this issue too.` : reg.entry ?? "";
529
536
  }
530
537
  const notes = [premiseAnnotation(predicate), closedEdgeNote(closedDeps), link, askNote].filter(Boolean).join("\n");
531
- await post("FORGE:NEEDS_HUMAN", `## Needs human\n${why}${notes ? `\n\n${notes}` : ""}${recoverUnlabelled ? `\n\n${laneRecoveryEvidence("needs-human", code, "")}` : ""}\n\n_${usageLine()}_`);
532
- await ghc(["issue", "edit", String(issue), "-R", repo, "--add-label", "needs-human"]);
538
+ try { await post("FORGE:NEEDS_HUMAN", `## Needs human\n${why}${notes ? `\n\n${notes}` : ""}${recoverUnlabelled ? `\n\n${laneRecoveryEvidence("needs-human", code, "")}` : ""}\n\n_${usageLine()}_`); }
539
+ catch (e) { labelFailed("comment", e); }
540
+ try { await ghc(["issue", "edit", String(issue), "-R", repo, "--add-label", "needs-human"]); }
541
+ catch (e) { labelFailed("label", e); }
533
542
  } catch { /* best effort */ }
534
543
  return finish("needs-human", why, { code, ...extra, ...(recover ? { recovery: true } : {}) }, code);
535
544
  }
@@ -642,11 +651,17 @@ export async function runCore(o) {
642
651
  const signOffUnpushed = async () => {
643
652
  try {
644
653
  const r = await ensureSignedOff({ git, lane, branch });
645
- if (r.rewritten) { T.decisions.push(`Rewrote ${r.rewritten} unpushed commit(s) with the lane identity and a sign-off (placeholder author or no Signed-off-by, #4651)`); ev({ event: "DCO_SIGNOFF_REWRITE", count: r.rewritten }); }
654
+ if (r.rewritten) { T.decisions.push(`Rewrote ${r.rewritten} unpushed commit(s) with the lane identity and a sign-off (placeholder author or no Signed-off-by, #4651)`); ev({ event: "DCO_SIGNOFF_REWRITE", count: r.rewritten, ...(r.dirty ? { dirty: r.dirty } : {}) }); }
646
655
  } catch (e) { throw Object.assign(e, e?.noIdentity === true ? { laneCode: OUTCOME_CODES.CI_DCO } : { engineCode: OUTCOME_CODES.ENGINE_DCO_REWRITE }); } // #4997: a failed rewrite is mechanical (engine-error, recoverable), only a missing identity is a human block
647
656
  };
648
657
  const gitPush = async (args) => {
649
- if (!args.includes("--delete")) { await untrackToolState({ git, base, ev, keep: await keepPaths() }); await signOffUnpushed(); } // #4590: never push .forge-lean/ or tool caches this branch added
658
+ if (!args.includes("--delete")) { await untrackToolState({ git, base, ev, keep: await keepPaths() });
659
+ try { await signOffUnpushed(); }
660
+ catch (e) { // #4905: a failed rewrite must not cost the lane's finished commits: push them as-is before reporting; #5047: to a salvage ref, never the lane branch (the sign-off range excludes origin/<branch>)
661
+ if (!e?.noIdentity) { try { const sha = String(await git(["rev-parse", "HEAD"])).trim(), ref = salvageBranch(issue, sha); await git(["push", "origin", `${sha}:refs/heads/${ref}`]); laneHead = { branch, sha, pushedTo: ref }; ev({ event: "DCO_RESCUE_PUSH", branch: ref }); } catch (pe) { ev({ event: "DCO_RESCUE_PUSH_FAILED", error: String(pe?.message ?? pe).slice(0, 200) }); } }
662
+ throw e;
663
+ }
664
+ } // #4590: never push .forge-lean/ or tool caches this branch added
650
665
  try { return await retrying(() => git(args), { transient: PUSH_TRANSIENT_RE, permanent: PUSH_PERMANENT_RE, attempts: 4, sleep: sleepFn, random }); }
651
666
  catch (e) {
652
667
  if (e?.transientExhausted) { e.deferCode = OUTCOME_CODES.PUSH_TRANSIENT; e.deferDetail = `git push kept failing transiently after 4 attempts: ${String(e?.stderr ?? e?.message ?? e).slice(0, 300)}`; }
@@ -664,14 +679,14 @@ export async function runCore(o) {
664
679
  if (ahead) { laneHead = { branch, sha: String(await git(["rev-parse", "HEAD"])).trim(), pushedTo: null }; await gitPush(["push", "-u", "origin", branch]); laneHead.pushedTo = branch; }
665
680
  ev({ event: "CHECKPOINT", reason, dirty: Boolean(dirty), pushed: Boolean(ahead) });
666
681
  return true;
667
- } catch (e) { ev({ event: "CHECKPOINT_FAILED", reason, error: String(e?.message ?? e).slice(0, 300) }); return laneHead ? await rescuePush(reason, e?.pushRejected) : false; }
682
+ } catch (e) { ev({ event: "CHECKPOINT_FAILED", reason, error: String(e?.message ?? e).slice(0, 300) }); return laneHead ? await rescuePush(reason, e?.pushRejected || e?.engineCode === OUTCOME_CODES.ENGINE_DCO_REWRITE) : false; }
668
683
  }
669
684
  /** #4958: the normal push failed (sign-off rewrite, rejection): push HEAD as is to the lane branch, else to a salvage branch, so a torn-down remote sandbox never takes the commits with it. */
670
685
  async function rescuePush(reason, rejected) {
671
686
  // #5014: gitPush untracks tool state and rewrites unpushed commits for DCO before it pushes, so HEAD may have moved since the capture
672
687
  try { const now = String(await git(["rev-parse", "HEAD"])).trim(); if (now) laneHead.sha = now; } catch { /* keep the captured sha */ }
673
688
  // a rejected lane-branch push (protected branch, permissions) would be rejected again: go straight to the salvage branch
674
- for (const ref of [...(rejected ? [] : [branch]), `forge-salvage/${issue}-${laneHead.sha.slice(0, 10)}`]) {
689
+ for (const ref of [...(rejected ? [] : [branch]), salvageBranch(issue, laneHead.sha)]) {
675
690
  try {
676
691
  await git(["push", "origin", `${laneHead.sha}:refs/heads/${ref}`]);
677
692
  laneHead.pushedTo = ref;
@@ -1074,14 +1089,14 @@ export async function runCore(o) {
1074
1089
 
1075
1090
  // ---- 2. worktree (resume an open PR / remote branch) ----
1076
1091
  try { await io.git(["fetch", "origin", lane], { cwd }); } catch { /* offline: use the local ref */ }
1077
- let resumed = null;
1092
+ let resumed = null, fromSalvage = false;
1078
1093
  try {
1079
1094
  const prs = JSON.parse(String(await ghc(["pr", "list", "-R", repo, "--head", branch, "--state", "open", "--json", "number,headRefOid,baseRefName"])) || "[]");
1080
1095
  const remote = String(await io.git(["ls-remote", "--heads", "origin", branch], { cwd })).trim();
1081
1096
  if (prs.length || remote) resumed = { pr: prs[0]?.number ?? null, base: prs[0]?.baseRefName ?? null };
1082
1097
  if (!prs.length && !remote) { // #4958: commits the exit rescue could only push to a salvage branch resume from there
1083
1098
  const salv = String(await io.git(["ls-remote", "--heads", "origin", `refs/heads/forge-salvage/${issue}-*`], { cwd })).split("\n").map((l) => l.trim().split(/\s+/)[1]).filter(Boolean).sort().at(-1);
1084
- if (salv) { await io.git(["fetch", "origin", `${salv}:refs/remotes/origin/${branch}`], { cwd }); resumed = { pr: null, base: null }; ev({ event: "RESUME_FROM_SALVAGE", ref: salv }); }
1099
+ if (salv) { await io.git(["fetch", "origin", `${salv}:refs/remotes/origin/${branch}`], { cwd }); resumed = { pr: null, base: null }; fromSalvage = true; ev({ event: "RESUME_FROM_SALVAGE", ref: salv }); }
1085
1100
  }
1086
1101
  } catch { /* no resume info: start fresh */ }
1087
1102
  // An open PR on another base (a hand-retargeted PR, or one opened before lane resolution) is never merged (#4125).
@@ -1168,6 +1183,7 @@ export async function runCore(o) {
1168
1183
  wtInfo = await addWorktree(["worktree", "add", "-B", branch, wt, base], base);
1169
1184
  }
1170
1185
  wtCreated = true;
1186
+ if (fromSalvage) await dropSalvageTracking({ git, branch }); // #5047: the salvaged commits are unpublished: the sign-off range must include them
1171
1187
  if (wtInfo?.sparse) {
1172
1188
  sparse = { excluded: wtInfo.excluded, maxKb: sparseMaxKb };
1173
1189
  ev({ event: "WORKTREE_SPARSE", excluded: wtInfo.excluded, excluded_bytes: wtInfo.bytes, max_kb: sparseMaxKb });
@@ -1271,7 +1287,13 @@ export async function runCore(o) {
1271
1287
  return null;
1272
1288
  }
1273
1289
  const rg = await heavyStep("precheck", () => io.redGreen({ cwd: wt, base, command: String(t.command), env: laneEnv() })).catch(() => null);
1274
- const words = rg?.base && rg?.head ? rg : { base: "UNRUNNABLE", head: "UNRUNNABLE" };
1290
+ let words = rg?.base && rg?.head ? rg : { base: "UNRUNNABLE", head: "UNRUNNABLE" };
1291
+ if (words.head === "FAILED" && io.runCommand) { // #5020: a head that failed because a tool or env is missing is env.repro_unrunnable, not a red-green verdict
1292
+ const hr = await io.runCommand({ command: String(t.command), cwd: wt, timeoutMs: 120000, env: laneEnv() }).then((x) => x?.r ?? x).catch(() => null);
1293
+ // release review BUG-1: a module the repo tracks is the change's own failure, never an env miss (same repoHas as the validate rerun)
1294
+ const repoHas = repoTracks(String(await git(["ls-files"]).catch(() => "")).split("\n").map((x) => x.trim()).filter(Boolean));
1295
+ if (reproEnvMiss(hr, { command: String(t.command), repoHas })) words = { ...words, head: "UNRUNNABLE" };
1296
+ }
1275
1297
  ev({ event: "REPRO_RED_GREEN", ...words });
1276
1298
  T.decisions.push(`repro_test ${t.path ?? ""}${t.id ? ` ${t.id}` : ""}: BASE=${words.base} HEAD=${words.head}`);
1277
1299
  return words;
@@ -1376,6 +1398,14 @@ export async function runCore(o) {
1376
1398
  * #4328: probe the host for the tools and modules a contract's validate commands need; #4393: then flag `$NAME`s the lane
1377
1399
  * env leaves empty and run each remaining command once on the base worktree (io.preflightRun). The misses; [] without either hook.
1378
1400
  */
1401
+ /** #5020: forge.yaml validate.recipes rewrite frozen commands; a check-less post-deploy requirement becomes a defer to the orchestrator. */
1402
+ function freezeRewrites(c) {
1403
+ const r = applyValidateRecipes(c, validateRecipes);
1404
+ for (const x of r.applied) T.decisions.push(`validate recipe rewrote ${x.id}: \`${x.from}\` -> \`${x.to}\``);
1405
+ const d = deferChecklessPostDeploy(r.contract);
1406
+ if (d.converted.length) T.decisions.push(`Post-deploy with no registered check deferred to the orchestrator: ${d.converted.join(", ")}`);
1407
+ return d.contract;
1408
+ }
1379
1409
  async function preflightValidates(contract) {
1380
1410
  if ((!io.probeCommand && !io.preflightRun) || !contract || typeof contract !== "object") return [];
1381
1411
  const files = String(await git(["ls-files"]).catch(() => "")).split("\n").map((x) => x.trim()).filter(Boolean);
@@ -1652,6 +1682,7 @@ export async function runCore(o) {
1652
1682
  return o.contract;
1653
1683
  };
1654
1684
  let contract = dropScope(await readContract());
1685
+ contract = freezeRewrites(contract);
1655
1686
  await refreshIssue("contract validation");
1656
1687
  let vc = validateContract(contract, issueText, { repro: reproOn() });
1657
1688
  const missed = await preflightValidates(contract); // #4328: validate commands the host cannot run share the one correction
@@ -1660,7 +1691,7 @@ export async function runCore(o) {
1660
1691
  const problems = [...vc.problems, ...preflightProblems(missed), ...testRunnerProblems(contract, testRunners)];
1661
1692
  T.decisions.push(`Contract rejected (${problems[0]}); resumed once`);
1662
1693
  await builder(++attemptN, contractCorrectionPrompt(problems), sessionId, false, planPrompt, { trigger: "contract_correction" });
1663
- contract = dropScope(await readContract());
1694
+ contract = freezeRewrites(dropScope(await readContract()));
1664
1695
  await refreshIssue("contract validation");
1665
1696
  vc = validateContract(contract, issueText, { repro: reproOn() });
1666
1697
  if (missed.length) misses = await preflightValidates(contract);
@@ -2116,7 +2147,7 @@ export async function runCore(o) {
2116
2147
  async function amend() {
2117
2148
  genPaths ??= (hotFacts ? hotFacts.generated_paths : light ? [] : (await hotspotsOf({ paths: [] }))?.generated_paths) ?? [];
2118
2149
  const wantsDecision = (Array.isArray(result?.amendments) ? result.amendments : []).some((x) => x?.kind === "decision");
2119
- baseTree ??= wantsDecision ? String(await git(["ls-tree", "-r", "--name-only", base]).catch(() => "")).split("\n").filter(Boolean) : null;
2150
+ baseTree ??= wantsDecision ? String(await git(["ls-tree", "-r", "-z", "--name-only", base]).catch(() => "")).split("\0").filter(Boolean) : null;
2120
2151
  const addedPaths = wantsDecision ? String(await git(["diff", "--name-only", "--diff-filter=A", `${base}...HEAD`]).catch(() => "")).split("\n").filter(Boolean) : [];
2121
2152
  const own = [...(Array.isArray(result?.amendments) ? result.amendments : []), ...impliedDocs];
2122
2153
  const ownKeys = new Set(own.map((x) => JSON.stringify(x)));
@@ -2125,7 +2156,8 @@ export async function runCore(o) {
2125
2156
  const sig = JSON.stringify(a.applied);
2126
2157
  if (a.applied.length && sig !== lastAmend) {
2127
2158
  lastAmend = sig;
2128
- await post("FORGE:AMENDMENT", renderAmendments(a.applied, {}, { contract: hash, amendments: all }));
2159
+ try { await post("FORGE:AMENDMENT", renderAmendments(a.applied, {}, { contract: hash, amendments: all })); } // #5020: retried by ghw; a still-failing post is an anomaly, never an engine-error
2160
+ catch (e) { T.anomalies.push(`FORGE:AMENDMENT comment not posted: ${String(e?.message ?? e).slice(0, 160)}`); }
2129
2161
  T.decisions.push(`Contract amended: ${a.applied.map((x) => `${x.kind} ${x.target}`).join(", ")}`);
2130
2162
  }
2131
2163
  pendingDecisions = a.decisions; // #4737: persisted only once the PR merges
@@ -2298,7 +2330,8 @@ export async function runCore(o) {
2298
2330
  // #4626: post-deploy check ids resolve against the lane base's checks file or this PR's
2299
2331
  const checkIds = effective.requirements?.some((r) => r.disposition === "post-deploy") ? new Set(Object.keys(await checksAt(git, [base, "HEAD"], wt))) : null;
2300
2332
  const removedPaths = (effective.validate ?? []).some((v) => v.substituted) ? await removedPathsOf(git, base) : []; // #4990
2301
- 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 });
2333
+ const subBaseFiles = (effective.validate ?? []).some((v) => v.substituted) ? String(await git(["ls-tree", "-r", "-z", "--name-only", base]).catch(() => "")).split("\0").filter(Boolean) : []; // #5003
2334
+ 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 });
2302
2335
  // #4350: a scope-check validate stale only by amended deliverables is judged by the engine's scope gate instead
2303
2336
  if (pc.superseded?.length) {
2304
2337
  ev({ event: "VALIDATE_SUPERSEDED", runs: pc.superseded });
@@ -288,6 +288,15 @@ describe("core work-on", () => {
288
288
  assert.equal((await run(w)).terminalReason, "invalid");
289
289
  });
290
290
 
291
+ it("a failed needs-human comment is recorded as an anomaly and the lane still ends needs-human (#4905)", async () => {
292
+ const w = world({ changed: [], result: { status: "invalid", summary: "trust me", evidence: [], tests: [] } });
293
+ const gh = w.io.gh;
294
+ w.io.gh = async (a, opts) => { if (a[0] === "issue" && a[1] === "edit" && a.includes("--add-label")) throw new Error("label boom"); return gh(a, opts); };
295
+ const r = await run(w);
296
+ assert.equal(r.terminalReason, "needs-human");
297
+ assert.match(traj(w)[0], /needs-human label failed: label boom/);
298
+ });
299
+
291
300
  it("invalid without evidence goes to needs-human and does not close", async () => {
292
301
  const w = world({ changed: [], result: { status: "invalid", summary: "trust me", evidence: [], tests: [] } });
293
302
  const r = await run(w);
@@ -2705,6 +2714,28 @@ describe("orchestrate", () => {
2705
2714
  assert.match(t, /skipped \(dependency #1/);
2706
2715
  });
2707
2716
 
2717
+ it("orchestrateFromCli reads the milestone with the body: no second issue view, one default-branch read, one ls-remote per lane (#4963)", async () => {
2718
+ const calls = [];
2719
+ const leanIo = {
2720
+ gh: async (a) => {
2721
+ calls.push(["gh", ...a]);
2722
+ if (a[0] === "repo" && a[1] === "view") return "main\n";
2723
+ if (a[0] === "api") return "OWNER\n";
2724
+ return JSON.stringify({ body: "", authorAssociation: "OWNER", milestone: a[2] === "2" ? null : { title: "M One" } });
2725
+ },
2726
+ git: async (a) => { calls.push(["git", ...a]); return a[0] === "ls-remote" && ["refs/heads/staging", "refs/heads/milestone/m-one"].includes(a.at(-1)) ? "abc\tref\n" : ""; },
2727
+ };
2728
+ const deps = { dir: tmpDir("core-orch-"), batchRoot: tmpDir("core-orch-batch-"), allowTmpfs: true, emitTelemetry: async () => {}, io: { gh: async () => "o/r\n" }, leanIo,
2729
+ leanModule: { runCore: async (o) => ({ terminalReason: "merged", pr: 50 + o.issue, cost_usd: 1, wall_ms: 1 }) } };
2730
+ const l = console.log; console.log = () => {};
2731
+ let res;
2732
+ try { res = await orchestrateFromCli(["1", "2", "3", "--parallel", "2", "--repo", "o/r", "--no-prefilter"], deps); } finally { console.log = l; }
2733
+ assert.equal(res.ok, true);
2734
+ assert.equal(calls.filter((c) => c.includes("milestone") && !c.some((x) => /body/.test(x))).length, 0, "no milestone-only view");
2735
+ assert.equal(calls.filter((c) => c[1] === "repo" && c[2] === "view").length, 2, "the up-front --lane check plus one batch-wide lane lookup, not one per issue");
2736
+ assert.equal(calls.filter((c) => c[0] === "git" && c[1] === "ls-remote").length, 2);
2737
+ });
2738
+
2708
2739
  it("orchestrateFromCli routes each issue to its own lane (no --lane), reports cross-lane edges, refuses --lane main", async () => {
2709
2740
  const seen = [];
2710
2741
  const logs = [];
@@ -3867,6 +3898,34 @@ describe("batch robustness (#4112-#4115)", () => {
3867
3898
  assert.ok(traj(w).some((c) => /Lane commits pushed: branch `forge-salvage\/7-/.test(c)));
3868
3899
  });
3869
3900
 
3901
+ it("#5047 a failed DCO rewrite rescue-pushes to a salvage ref, never the lane branch", async () => {
3902
+ const w = world();
3903
+ const g = w.io.git;
3904
+ const SEP = "\x1f", END = "\x1e";
3905
+ w.io.git = async (a, o2) => {
3906
+ if (a[0] === "log" && a.some((x) => String(x).startsWith("--format=%H"))) return `deadbeef${SEP}x@localhost${SEP}x@localhost${SEP}${SEP}parent${END}`;
3907
+ if (a[0] === "config") return "Lane Bot";
3908
+ if (a[0] === "filter-branch") throw new Error("boom");
3909
+ return g(a, o2);
3910
+ };
3911
+ const r = await run(w);
3912
+ const pushes = w.calls.filter((c) => c[0] === "git" && c[1] === "push").map((c) => c.join(" "));
3913
+ assert.ok(pushes.length > 0);
3914
+ assert.ok(pushes.some((p) => /origin [0-9a-z]+:refs\/heads\/forge-salvage\/7-/.test(p)), "the commits are published under a salvage ref");
3915
+ assert.ok(!pushes.some((p) => /origin feat\/issue-7-lean$|:refs\/heads\/feat\/issue-7-lean$/.test(p)), `never the lane branch: ${pushes.join(" | ")}`);
3916
+ assert.match(String(r.laneHead?.pushedTo), /^forge-salvage\/7-/);
3917
+ });
3918
+
3919
+ it("#5047 a lane resumed from a salvage branch drops the origin/<branch> tracking ref so the sign-off range covers its commits", async () => {
3920
+ const w = world({ prList: [] });
3921
+ const g = w.io.git;
3922
+ w.io.git = async (a, o2) => (a[0] === "ls-remote" && a.at(-1) === "refs/heads/forge-salvage/7-*" ? "abc\trefs/heads/forge-salvage/7-1234567890\n" : g(a, o2));
3923
+ await run(w);
3924
+ const wtAdd = w.calls.findIndex((c) => c[1] === "worktree" && c[2] === "add");
3925
+ const drop = w.calls.findIndex((c) => c[1] === "update-ref" && c[2] === "-d" && c[3] === "refs/remotes/origin/feat/issue-7-lean");
3926
+ assert.ok(wtAdd >= 0 && drop > wtAdd, "dropped after the worktree is built from it");
3927
+ });
3928
+
3870
3929
  it("#4958 when every rescue push is rejected the head is reported as NOT pushed", async () => {
3871
3930
  const msg = "remote: error: GH006: Protected branch update failed for refs/heads/x";
3872
3931
  const w = world({ pushErrors: [msg, msg, msg, msg] });
@@ -4017,6 +4076,13 @@ describe("batch robustness (#4112-#4115)", () => {
4017
4076
  assert.equal(n, 1);
4018
4077
  });
4019
4078
 
4079
+ it("#5020 forge.yaml validate.recipes rewrite the frozen validate command and the freeze records it", async () => {
4080
+ const w = world({});
4081
+ const r = await run(w, { validateRecipes: [{ match: "^node --test ", replace: "node --test --test-reporter=dot " }] });
4082
+ assert.equal(r.terminalReason, "merged");
4083
+ assert.ok(w.comments.some((c) => c.includes("FORGE:CONTRACT") && c.includes("node --test --test-reporter=dot test/foo.test.js")));
4084
+ });
4085
+
4020
4086
  // ---- #4112 ----
4021
4087
  const fakeCli = async (name, body) => {
4022
4088
  const { writeFileSync, chmodSync } = await import("node:fs");
@@ -85,3 +85,38 @@ describe("#4917: a deferred issue still waiting when the last running lane ends"
85
85
  assert.deepEqual(saved.running, {});
86
86
  });
87
87
  });
88
+
89
+ describe("remote lanes with a registry lacking the #4964 methods", () => {
90
+ it("a remote-placed lane runs and settles when the registry stub has no noteRemote/dropRemote/notePool", async () => {
91
+ const calls = [];
92
+ const registry = { tryAcquire: () => { calls.push("acquire"); return { ok: true }; }, offer() {}, withdraw() {}, release() {}, setLanePid() {}, adoptSlot() {}, holder: () => null };
93
+ const rows = await orchestrate({ issues: [4], parallel: 1, pollMs: 5, trusted: () => true, readBody: async () => "", sched: { registry, batch: "B", repo: "o/r" },
94
+ remote: { max: 1, isRemote: () => true, capacity: () => null },
95
+ runOne: async (n, lane, ctx) => { ctx.onStart?.({ handle: { id: "s-4" }, remote: true }); return merged; } });
96
+ assert.equal(rows[0].outcome, "merged");
97
+ assert.deepEqual(calls, [], "a remote lane never takes a host slot");
98
+ });
99
+ });
100
+
101
+ describe("#5048: the pool is unreachable only after a failed capacity probe", () => {
102
+ const run = async (capacity) => {
103
+ const notes = [];
104
+ const registry = { tryAcquire: () => ({ ok: true }), offer() {}, withdraw() {}, release() {}, setLanePid() {}, adoptSlot() {}, holder: () => null, notePool: (b, st) => notes.push(st === null ? "clear" : "down") };
105
+ const rows = await orchestrate({ issues: [4, 5, 6], parallel: 1, pollMs: 5, trusted: () => true, readBody: async () => "", sched: { registry, batch: "B", repo: "o/r" },
106
+ remote: { max: 1, isRemote: () => true, ...(capacity ? { capacity } : {}) },
107
+ runOne: async (n, lane, ctx) => { ctx.onStart?.({ handle: { id: `s-${n}` }, remote: true }); return merged; } });
108
+ assert.ok(rows.every((r) => r.outcome === "merged"));
109
+ return notes;
110
+ };
111
+ it("unknown capacity (null) does not mark the pool unreachable", async () => {
112
+ assert.deepEqual(await run(() => null), []);
113
+ });
114
+ it("a provider without capacity() never marks the pool unreachable", async () => {
115
+ assert.deepEqual(await run(null), []);
116
+ });
117
+ it("a throwing probe marks it down and a later successful sample clears it", async () => {
118
+ let calls = 0;
119
+ const notes = await run(() => { if (++calls === 1) throw new Error("boom"); return { free: 1, online: 1, at: calls }; });
120
+ assert.deepEqual(notes, ["down", "clear"]);
121
+ });
122
+ });
@@ -18,7 +18,7 @@ async function run({ mode, list, pollS = 0.001, elsewhere = [], epics = [], bodi
18
18
  runOne: async (n) => { started.push(n); if (n === 1) await g; return merged; }, ...extra });
19
19
  return { started, log, decisions, calls: () => calls, open, done };
20
20
  }
21
- const L = (...kids) => [{ number: 1, body: "", labels: [] }, ...kids.map((n) => ({ number: n, body: "**Parent**: #1", labels: [] }))];
21
+ const L = (...kids) => [{ number: 1, body: "", labels: [], authorAssociation: "MEMBER" }, ...kids.map((n) => ({ number: n, body: "**Parent**: #1", labels: [], authorAssociation: "MEMBER" }))];
22
22
 
23
23
  describe("#4965 auto_add_children", () => {
24
24
  it("epic: adds open children of a batch issue, one decision and one added row listing them", async () => {
@@ -40,8 +40,20 @@ describe("#4965 auto_add_children", () => {
40
40
  assert.deepEqual(r.started, [1]);
41
41
  }
42
42
  });
43
+ it("#4982 an untrusted author's child is not added and logs one skipped row", async () => {
44
+ const list = [...L(2), { number: 3, body: "**Parent**: #1", labels: [], authorAssociation: "NONE" }];
45
+ const r = await run({ mode: "epic", list });
46
+ await until(() => r.started.includes(2) && r.log.some((x) => x.outcome === "skipped"));
47
+ await new Promise((x) => setTimeout(x, 40));
48
+ r.open(); await r.done;
49
+ assert.ok(!r.started.includes(3));
50
+ const sk = r.log.filter((x) => x.outcome === "skipped");
51
+ assert.equal(sk.length, 1);
52
+ assert.equal(sk[0].issue, 3);
53
+ assert.match(sk[0].detail, /#3 .*untrusted author/);
54
+ });
43
55
  it("needs-human children and children live in another batch are not added", async () => {
44
- const list = [...L(2, 3), { number: 4, body: "**Parent**: #1", labels: [{ name: "needs-human" }] }];
56
+ const list = [...L(2, 3), { number: 4, body: "**Parent**: #1", labels: [{ name: "needs-human" }], authorAssociation: "MEMBER" }];
45
57
  const r = await run({ mode: "all", list, elsewhere: [3] });
46
58
  await until(() => r.started.includes(2));
47
59
  await new Promise((x) => setTimeout(x, 40));
@@ -49,10 +61,10 @@ describe("#4965 auto_add_children", () => {
49
61
  assert.deepEqual([...r.started].sort(), [1, 2]);
50
62
  });
51
63
  it("an --epic root outside the batch adds its children; late children join on a later pass", async () => {
52
- let list = [{ number: 50, body: "", labels: [] }, { number: 51, body: "**Parent**: #50", labels: [] }];
64
+ let list = [{ number: 50, body: "", labels: [], authorAssociation: "MEMBER" }, { number: 51, body: "**Parent**: #50", labels: [], authorAssociation: "MEMBER" }];
53
65
  const r = await run({ mode: "epic", epics: [50], list: () => list });
54
66
  await until(() => r.started.includes(51));
55
- list = [...list, { number: 52, body: "**Parent**: #50", labels: [] }];
67
+ list = [...list, { number: 52, body: "**Parent**: #50", labels: [], authorAssociation: "MEMBER" }];
56
68
  await until(() => r.started.includes(52));
57
69
  r.open(); await r.done;
58
70
  assert.equal(r.log.filter((x) => x.outcome === "added").length, 2);