forgedock 1.23.1 → 1.24.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -4,14 +4,20 @@ import { describe, it } from "node:test";
4
4
  import assert from "node:assert/strict";
5
5
  import { tmpDir } from "../tests/_tmp.mjs";
6
6
  import { runCore } from "./lean.mjs";
7
- import { renderValidate, renderPlan, renderReview, renderRegister, renderPrBody, cell, NR } from "./render.mjs";
7
+ import { renderInvestigation, renderPlan, renderImplementation, renderReview, renderPrBody, renderTrajectory, sanitizeMd, capComment, section, cell, NR, MAX_COMMENT_CHARS } from "./render.mjs";
8
8
  import { orchestrate, parseDeps, formatTable } from "./orchestrate.mjs";
9
9
  import { REVIEWER_SYSTEM, approxTokens, builderPrompt, reviewerPrompt, parseVerdict } from "./prompts.mjs";
10
10
  import { mapRecord } from "../engine/telemetry-seam.mjs";
11
11
  import { readLog } from "../engine/runlog.mjs";
12
+ import { makeRunTmp } from "./effects.mjs";
12
13
  import { runFromCli, orchestrateFromCli, ENGINE_PATHS } from "../engine-cli.mjs";
13
14
 
14
15
  const WT = "/wt/lean-7";
16
+ const STAGE_MD = {
17
+ investigation: "**Verdict**: CONFIRMED\n**Confidence**: high\n**Severity**: medium\n**Task Type**: bug\n\n### What Was Claimed\nfoo breaks\n\n### What We Found\n- src/foo.js:3 derefs null\n\n### Root Cause\nfoo derefs null at src/foo.js:3\n\n### Affected Files\n- src/foo.js: add guard\n\n### Sibling Sweep\n- src/bar.js:8 same guard\n\n### History\nintroduced in abc123\n\n### Recommendation\nguard null\n\n### Related Issues\nnone",
18
+ plan: "### Approach\nguard null in foo\n\n### Deliverables\n| File | Change | Why |\n| --- | --- | --- |\n| src/foo.js | add guard | avoid crash |\n\n### Acceptance Criteria\n- [ ] foo handles null\n\n### Implementation Order\n1. guard\n\n### Out of Scope\nrefactor: later\n\n### Risk Assessment\n| Risk | Severity | Mitigation |\n| --- | --- | --- |\n| none | low | test |",
19
+ implementation: "### Approach\nadded a guard\n\n### Changes\n- src/foo.js: guard\n\n### Acceptance Criteria Status\n- ✅ foo handles null\n\n### Verification\n- node --test test/foo.test.js: pass, fails without fix: yes\n\n### Notes\nnone",
20
+ };
15
21
  const ok = (extra = {}) => "```json\n" + JSON.stringify({ verdict: "PASS", fixes_issue: true, regression: false, reason: "src/foo.js:3 fixed", ...extra }) + "\n```";
16
22
  const bad = (reason = "src/foo.js:9 still wrong") => ok({ verdict: "FAIL", fixes_issue: false, reason });
17
23
 
@@ -21,8 +27,12 @@ function world(o = {}) {
21
27
  comments: [], labels: [], closed: [], edits: [], prs: [], merges: [], calls: [], telemetry: [], files: {}, head: "aaaa1111aaaa1111",
22
28
  changed: o.changed ?? ["src/foo.js", "test/foo.test.js"], reviews: [...(o.reviews ?? [])], waits: [...(o.waits ?? [])], clock: 0,
23
29
  result: o.result ?? { status: "fixed", summary: "fixed foo", evidence: [], tests: ["test/foo.test.js"] },
24
- prList: o.prList ?? [], remote: o.remote ?? "", resumeResult: o.resumeResult, seq: [],
30
+ prList: o.prList ?? [], remote: o.remote ?? "", resumeResult: o.resumeResult, seq: [], mtimes: {}, envs: [], prView: [...(o.prView ?? [])], mergeResults: [...(o.mergeResults ?? [])], mergeErrors: [...(o.mergeErrors ?? [])], conflictFiles: [], mergeInProgress: false, markers: false, mergeAttempts: 0,
31
+ stages: { ...STAGE_MD, ...(o.stages ?? {}) },
25
32
  };
33
+ const put = (name, text) => { const p = `${WT}/.forge-lean/${name}`; if (w.files[p] !== text) w.mtimes[p] = Date.now(); w.files[p] = text; };
34
+ const writeStages = (keys) => { for (const k of keys) if (w.stages[k] != null) put(`${k}.md`, w.stages[k]); };
35
+ w.writeStages = writeStages;
26
36
  w.io = {
27
37
  now: () => w.clock, sleep: async (ms) => { w.clock += ms; },
28
38
  gh: async (a) => {
@@ -34,12 +44,28 @@ function world(o = {}) {
34
44
  if (k === "issue close") { w.closed.push(a); w.seq.push("close"); return ""; }
35
45
  if (k === "issue edit") { w.edits.push(a); return ""; }
36
46
  if (k === "pr create") { w.prs.push(a); w.seq.push("pr"); return "https://github.com/o/r/pull/77\n"; }
47
+ if (k === "pr view" && a.includes("mergeable,mergeStateStatus")) { const v = w.prView.length > 1 ? w.prView.shift() : (w.prView[0] ?? { mergeable: "MERGEABLE", mergeStateStatus: "CLEAN" }); return JSON.stringify(v); }
48
+ if (k === "pr merge" && w.mergeErrors.length) { w.mergeAttempts++; throw new Error(w.mergeErrors.shift()); }
49
+ if (k === "pr merge") { w.mergeAttempts++; }
37
50
  if (k === "pr merge") { w.merges.push(a); w.seq.push("merge"); return ""; }
38
51
  throw new Error(`unexpected gh ${a.join(" ")}`);
39
52
  },
40
53
  git: async (a) => {
41
54
  w.calls.push(["git", ...a]);
55
+ if (a[0] === "worktree" && a[1] === "add" && (w.lockFails ?? 0) > 0) {
56
+ w.lockFails--; const e = new Error("git failed"); e.stderr = "error: could not lock config file .git/config: File exists"; throw e;
57
+ }
42
58
  if (a[0] === "rev-parse" && a[1] === "--git-path") return "/repo/.git/info/exclude";
59
+ if (a[0] === "merge" && a[1] === "--no-edit") {
60
+ const r = w.mergeResults.shift() ?? "clean";
61
+ if (r === "clean") { w.head = `cccc${w.calls.length}`.padEnd(16, "0"); return ""; }
62
+ w.conflictFiles = o.conflictFiles ?? ["src/foo.js"]; w.mergeInProgress = true; throw new Error("CONFLICT (content): Merge conflict in src/foo.js");
63
+ }
64
+ if (a[0] === "rev-parse" && a.includes("MERGE_HEAD")) return w.mergeInProgress ? "deadbeef" : "";
65
+ if (a[0] === "diff" && a.includes("--diff-filter=U")) return w.conflictFiles.join("\n");
66
+ if (a[0] === "diff" && a[1] === "--check") return "";
67
+ if (a[0] === "grep") return w.markers ? "src/foo.js:3:<<<<<<< HEAD" : "";
68
+ if (a[0] === "merge" && a[1] === "--abort") { w.mergeInProgress = false; return ""; }
43
69
  if (a[0] === "rev-parse") return w.head;
44
70
  if (a[0] === "diff" && a[1] === "--name-only") return w.changed.join("\n");
45
71
  if (a[0] === "diff") return "diff --git a/src/foo.js b/src/foo.js\n+fix";
@@ -50,17 +76,20 @@ function world(o = {}) {
50
76
  runner: async (c) => {
51
77
  w.calls.push(["runner", c.role, c.resume ?? null, c.model ?? null, c.prompt, c.readOnly ?? false, c.system]);
52
78
  w.seq.push(c.role + (c.resume ? "-resume" : ""));
79
+ w.envs.push(c.env ?? null);
53
80
  if (c.role === "builder") {
54
81
  const r = c.resume ? (w.resumeResult ?? w.result) : w.result;
55
- if (r) w.files[`${WT}/.forge-lean/result.json`] = JSON.stringify(r);
56
- if (c.resume) w.head = `bbbb${w.calls.length}`.padEnd(16, "0");
82
+ if (o.live) await o.live(w, put);
83
+ else if (!c.resume && !o.noStages) writeStages(["investigation", "plan", "implementation"]);
84
+ if (r) put("result.json", JSON.stringify(r));
85
+ if (c.resume || /Merging origin\//.test(c.prompt)) { w.head = `bbbb${w.calls.length}`.padEnd(16, "0"); if (!o.leaveConflicts) { w.conflictFiles = []; w.mergeInProgress = false; } }
57
86
  return { text: "done", sessionId: "sess-1", usage: { input_tokens: 10, output_tokens: 5, cache_read_input_tokens: 100 }, costUsd: 0.1, durationMs: 1000, turns: 3, model: "default" };
58
87
  }
59
88
  return { text: w.reviews.length ? w.reviews.shift() : ok(), sessionId: "rev", usage: { input_tokens: 4, output_tokens: 2 }, costUsd: 0.05, durationMs: 500, turns: 2, model: c.model };
60
89
  },
61
90
  waitCiGreen: async () => (w.waits.length ? w.waits.shift() : { rc: 0, text: `CI_GATE_HEAD: ${w.head}` }),
62
91
  transitionLabel: async (n, s) => { w.labels.push(s); w.seq.push(`label:${s}`); },
63
- fs: { read: async (p) => { if (!(p in w.files)) throw new Error("ENOENT"); return w.files[p]; }, append: async (p, t) => { w.files[p] = (w.files[p] ?? "") + t; } },
92
+ fs: { stat: async (p) => (p in w.files ? { mtimeMs: w.mtimes[p] ?? 1 } : null), read: async (p) => { if (!(p in w.files)) throw new Error("ENOENT"); return w.files[p]; }, append: async (p, t) => { w.files[p] = (w.files[p] ?? "") + t; } },
64
93
  };
65
94
  return w;
66
95
  }
@@ -68,22 +97,34 @@ const run = (w, extra = {}) => runCore({ issue: 7, repo: "o/r", lane: "staging",
68
97
  io: w.io, emitTelemetry: async (r) => w.telemetry.push(r), random: () => 0.5, ...extra });
69
98
  const builders = (w) => w.calls.filter((c) => c[0] === "runner" && c[1] === "builder");
70
99
  const reviewers = (w) => w.calls.filter((c) => c[0] === "runner" && c[1] === "reviewer");
71
- const registers = (w) => w.comments.filter((c) => c.startsWith("<!-- FORGE:WORK-ON -->"));
100
+ const traj = (w) => w.comments.filter((c) => c.includes("<!-- FORGE:TRAJECTORY -->"));
101
+ const markerOf = (c) => /<!-- (FORGE:[A-Z_-]+) -->/.exec(c)[1];
72
102
 
73
103
  describe("core work-on", () => {
74
- it("happy path: started comment, one register comment with everything, merge at the reviewed head", async () => {
104
+ it("happy path: investigation, plan, implementation, review, trajectory in order; merge at the reviewed head", async () => {
75
105
  const w = world();
76
106
  const dir = tmpDir("core-");
77
107
  const r = await run(w, { dir });
78
108
  assert.equal(r.terminalReason, "merged");
79
109
  assert.equal(r.pr, 77);
80
- assert.match(w.comments[0], /FORGE:STARTED/);
81
- const reg = registers(w);
82
- assert.equal(reg.length, 1);
83
- for (const needle of ["fixed foo", "src/foo.js", "test/foo.test.js", "PASS", "src/foo.js:3 fixed", "https://github.com/o/r/pull/77", "cost $0.15", "tokens 14 in / 7 out / 100 cache", "wall"]) {
84
- assert.ok(reg[0].includes(needle), `register comment lacks ${needle}:\n${reg[0]}`);
110
+ assert.deepEqual(w.comments.map(markerOf), ["FORGE:STARTED", "FORGE:INVESTIGATION", "FORGE:PLAN", "FORGE:IMPLEMENTATION", "FORGE:REVIEW", "FORGE:TRAJECTORY"]);
111
+ assert.match(w.comments[1], /<!-- FORGE:VALIDATE -->/, "VALIDATE stays as an alias marker");
112
+ assert.match(w.comments[1], /^<!-- FORGE:INVESTIGATION -->\n<!-- FORGE:VALIDATE -->\n## Investigation Report\n\*\*Verdict\*\*: CONFIRMED/);
113
+ assert.match(w.comments[2], /## Implementation Plan\n### Approach/);
114
+ assert.match(w.comments[3], /## Implementation Complete\n\*\*Branch:\*\* `feat\/issue-7-lean`\n\*\*Files changed:\*\*\n- `src\/foo.js`\n- `test\/foo.test.js`/);
115
+ assert.match(w.comments[4], /## Review\n\*\*Verdict:\*\* PASS/);
116
+ assert.match(w.comments[4], /Fixes the issue:\*\* yes \(src\/foo.js:3 fixed\)/);
117
+ const t = traj(w);
118
+ assert.equal(t.length, 1);
119
+ assert.match(t[0], /<!-- FORGE:TRAJECTORY -->\n<!-- FORGE:WORK-ON -->\n## Pipeline Trajectory — #7/);
120
+ for (const needle of ["| Validate | CONFIRMED |", "| Plan | posted |", "| Implement |", "| Review | PASS |", "| PR | #77 |", "| Merge |", "| Close | issue closed (completed) |", "**Decisions**", "**Anomalies**", "cost $0.1500", "tokens 14 in / 7 out / 100 cache", "wall"]) {
121
+ assert.ok(t[0].includes(needle), `trajectory lacks ${needle}:\n${t[0]}`);
85
122
  }
86
- assert.match(w.prs[0][w.prs[0].indexOf("--body") + 1], /Closes #7/);
123
+ const body = w.prs[0][w.prs[0].indexOf("--body") + 1];
124
+ assert.match(body, /## Root cause\nfoo derefs null at src\/foo.js:3/);
125
+ assert.match(body, /## Deliverables\n\| File \| Change \| Why \|/);
126
+ assert.match(body, /## Verification\n- node --test/);
127
+ assert.match(body, /Closes #7$/);
87
128
  assert.deepEqual(w.merges[0].slice(-2), ["--match-head-commit", w.head]);
88
129
  assert.equal(w.closed.length, 1);
89
130
  assert.equal(builders(w).length, 1);
@@ -92,22 +133,14 @@ describe("core work-on", () => {
92
133
  assert.equal(ev[0], "RUN_START"); assert.equal(ev.at(-1), "RUN_TERMINAL");
93
134
  });
94
135
 
95
- it("validation comment is posted before the reviewer and the in-review label; content for a fixed issue; register links it", async () => {
96
- const w = world({ result: { status: "fixed", summary: "fixed foo\nmore", evidence: ["src/foo.js:3 null deref"], tests: ["t"] } });
136
+ it("investigation and plan comments are posted before the reviewer runs and before the in-review label", async () => {
137
+ const w = world();
97
138
  await run(w);
98
- const vi = w.comments.findIndex((c) => c.startsWith("<!-- FORGE:VALIDATE -->"));
99
- assert.ok(vi >= 0);
100
- const v = w.comments[vi];
101
- assert.match(v, /Verdict:\*\* valid/);
102
- assert.match(v, /src\/foo.js:3 null deref/);
103
- assert.match(v, /Summary:\*\* fixed foo$/);
104
- assert.ok(!v.includes("more"));
105
- const pos = (x) => w.seq.indexOf(x);
106
- const commentSeq = w.seq.map((s, i) => (s === "comment" ? i : -1)).filter((i) => i >= 0);
107
- assert.ok(commentSeq[vi] > pos("builder") && commentSeq[vi] < pos("reviewer"));
108
- assert.ok(commentSeq[vi] < pos("label:in-review"));
109
- assert.match(registers(w)[0], /Validation: https:\/\/github.com\/o\/r\/issues\/7#issuecomment-\d+/);
110
- assert.equal(w.comments.filter((c) => c.includes("FORGE:VALIDATE")).length, 1);
139
+ const idx = []; w.seq.forEach((x, i) => { if (x === "comment") idx.push(i); });
140
+ const rv = w.seq.indexOf("reviewer");
141
+ assert.ok(idx[1] < rv && idx[2] < rv && idx[3] < rv, "investigation, plan, implementation precede the reviewer");
142
+ assert.ok(idx[3] < w.seq.indexOf("label:in-review"));
143
+ assert.ok(idx[4] > rv, "review comment follows the reviewer");
111
144
  });
112
145
 
113
146
  it("labels transition in order, and register is posted after the PR and before the merge", async () => {
@@ -116,7 +149,7 @@ describe("core work-on", () => {
116
149
  assert.deepEqual(w.labels, ["investigating", "building", "in-review", "merged"]);
117
150
  const order = w.seq.filter((s) => ["label:investigating", "label:building", "builder", "label:in-review", "reviewer", "push", "pr", "merge", "label:merged"].includes(s));
118
151
  assert.deepEqual(order, ["label:investigating", "label:building", "builder", "label:in-review", "reviewer", "push", "pr", "merge", "label:merged"]);
119
- assert.ok(w.seq.indexOf("pr") < w.seq.lastIndexOf("comment") && w.seq.lastIndexOf("comment") < w.seq.indexOf("merge"));
152
+ assert.ok(w.seq.lastIndexOf("comment") > w.seq.indexOf("merge"), "the trajectory is posted last");
120
153
  });
121
154
 
122
155
  it("emits phase_attempt for builder and reviewer plus run_terminal, stats-compatible", async () => {
@@ -129,17 +162,17 @@ describe("core work-on", () => {
129
162
  assert.equal(mapped.at(-1).payload.reason, "merged");
130
163
  });
131
164
 
132
- it("invalid with evidence closes the issue as invalid and opens no PR", async () => {
133
- const w = world({ changed: [], result: { status: "invalid", summary: "not a bug", evidence: ["src/foo.js:3 already guards null"], tests: [] } });
165
+ it("invalid with evidence: the investigation is the evidence comment, closes, trajectory rows", async () => {
166
+ const w = world({ changed: [], stages: { investigation: "**Verdict**: INVALID\n**Confidence**: high\n\n### What We Found\n- src/foo.js:3 already guards null\n\n### Root Cause\nnone, not a bug" },
167
+ result: { status: "invalid", summary: "not a bug", evidence: ["src/foo.js:3 already guards null"], tests: [] } });
134
168
  const r = await run(w);
135
169
  assert.equal(r.terminalReason, "invalid");
136
170
  assert.deepEqual(w.labels, ["investigating", "building", "invalid"]);
137
- const vs = w.comments.filter((c) => c.startsWith("<!-- FORGE:VALIDATE -->"));
138
- assert.equal(vs.length, 1, "the validation comment is the evidence comment");
139
- assert.match(vs[0], /Verdict:\*\* invalid/);
140
- assert.match(vs[0], /src\/foo.js:3 already guards null/);
141
- assert.match(vs[0], /Summary:\*\* not a bug/);
142
- assert.equal(registers(w).length, 0, "no duplicate evidence comment");
171
+ assert.deepEqual(w.comments.map(markerOf), ["FORGE:STARTED", "FORGE:INVESTIGATION", "FORGE:TRAJECTORY"]);
172
+ assert.match(w.comments[1], /Verdict\*\*: INVALID/);
173
+ assert.match(w.comments[1], /src\/foo.js:3 already guards null/);
174
+ const t = traj(w)[0];
175
+ for (const needle of ["| Validate | INVALID |", "| Plan | skipped |", "| Implement | skipped |", "| Review | skipped |", "| PR | none |", "| Close | issue closed (not planned) |"]) assert.ok(t.includes(needle), needle);
143
176
  assert.equal(w.closed.length, 1);
144
177
  assert.equal(w.prs.length, 0);
145
178
  assert.equal(reviewers(w).length, 0);
@@ -157,6 +190,10 @@ describe("core work-on", () => {
157
190
  assert.equal(w.closed.length, 0);
158
191
  assert.ok(w.edits.some((e) => e.includes("needs-human")));
159
192
  assert.ok(w.comments.some((c) => c.includes("FORGE:NEEDS_HUMAN") && c.includes("without evidence")));
193
+ const t = traj(w)[0];
194
+ assert.match(t, /\| Close \| left open \| label needs-human \|/);
195
+ assert.match(t, /\*\*Anomalies\*\*\n- needs-human: Builder reported "invalid" without evidence/);
196
+ assert.match(t, /\| Plan \| not reached \|/);
160
197
  });
161
198
 
162
199
  it("review FAIL -> builder resumed via --resume with the reason -> re-review PASS -> merge", async () => {
@@ -169,7 +206,9 @@ describe("core work-on", () => {
169
206
  assert.match(b[1][4], /src\/foo.js:9 still wrong/);
170
207
  assert.equal(reviewers(w).length, 2);
171
208
  assert.equal(w.prs.length, 1);
172
- assert.equal(registers(w).length, 1);
209
+ assert.equal(traj(w).length, 1);
210
+ assert.match(traj(w)[0], /2 rounds/);
211
+ assert.match(traj(w)[0], /Review FAIL \(round 1\)/);
173
212
  assert.deepEqual(w.merges[0].slice(-2), ["--match-head-commit", w.head]);
174
213
  });
175
214
 
@@ -194,6 +233,15 @@ describe("core work-on", () => {
194
233
  assert.equal((await run(world({ changed: [] }))).terminalReason, "needs-human");
195
234
  });
196
235
 
236
+ it("worktree add retries a concurrent .git lock collision, then gives up after 5 tries", async () => {
237
+ const w = world(); w.lockFails = 2;
238
+ assert.equal((await run(w)).terminalReason, "merged");
239
+ assert.equal(w.calls.filter((c) => c[1] === "worktree" && c[2] === "add").length, 3);
240
+ const w2 = world(); w2.lockFails = 99;
241
+ assert.notEqual((await run(w2)).terminalReason, "merged");
242
+ assert.equal(w2.calls.filter((c) => c[1] === "worktree" && c[2] === "add").length, 5);
243
+ });
244
+
197
245
  it("needs-human terminals carry a specific outcome code, never unclassified", async () => {
198
246
  const code = async (w, extra) => {
199
247
  const dir = tmpDir("lean-code-");
@@ -260,54 +308,131 @@ describe("core work-on", () => {
260
308
  });
261
309
 
262
310
  describe("comment templates", () => {
263
- const full = { status: "fixed", summary: "fixed foo", evidence: ["src/foo.js:3 null deref"], root_cause: "foo derefs null at src/foo.js:3",
264
- approach: "guard null", sibling_sites_checked: ["src/bar.js:8: same guard added"], changes: [{ file: "src/foo.js", change: "add guard", why: "avoid crash" }],
265
- tests: [{ command: "node --test test/foo.test.js", result: "pass", fails_without_fix: true }], risks: ["none"], follow_ups: ["audit baz"] };
266
- const rv = { verdict: "PASS", fixes_issue: true, regression: false, reason: "src/foo.js:3 ok", checked: "callers of foo" };
267
-
268
- it("renders full fields", () => {
269
- const v = renderValidate(full, "valid"); const p = renderPlan(full, ["src/foo.js"]);
270
- assert.match(v, /Root cause:\*\* foo derefs null at src\/foo.js:3/);
271
- assert.match(p, /Approach:\*\* guard null/); assert.match(p, /src\/bar.js:8: same guard added/); assert.match(p, /node --test test\/foo.test.js/);
272
- assert.match(renderReview(rv), /Checked:\*\* callers of foo/);
273
- const reg = renderRegister({ result: full, gitFiles: [], review: rv, links: { validate: "V", plan: "P", review: "R" }, prUrl: "PRURL", usage: "cost $1", policy: "required" });
274
- for (const n of ["| src/foo.js | add guard | avoid crash |", "fails without fix: yes", "Risks:** none", "Follow-ups:** audit baz", "Validation: V", "Plan: P", "Review: R", "PR: PRURL", "Merge policy:** required", "cost $1"]) assert.ok(reg.includes(n), n);
275
- const pr = renderPrBody({ issue: 7, result: full });
276
- assert.match(pr, /^Closes #7/); assert.match(pr, /\| file \| change \| why \|/); assert.match(pr, /node --test/);
277
- });
278
-
279
- it("missing fields render as 'not reported', never broken markdown", () => {
280
- const reg = renderRegister({ result: {}, gitFiles: ["a.js"], review: { ...rv, checked: undefined }, links: {}, prUrl: "U", usage: "u", policy: "none" });
281
- assert.ok(reg.includes(NR));
282
- assert.match(reg, /\| a.js \| not reported \| not reported \|/);
283
- assert.match(reg, /Verification\*\*\nnot reported/);
284
- assert.doesNotMatch(reg, /undefined|null|\[object/);
285
- assert.doesNotMatch(renderValidate(null, "blocked"), /undefined|null/);
286
- assert.doesNotMatch(renderPlan(undefined), /undefined/);
287
- assert.doesNotMatch(renderReview({ verdict: "PASS", fixes_issue: true, regression: false }), /undefined/);
288
- });
289
-
290
- it("escapes pipes and newlines in table cells", () => {
311
+ const rv = { verdict: "PASS", fixes_issue: true, regression: false, reason: "src/foo.js:3 ok", evidence: "src/foo.js:3 guard", checked: "callers of foo, test/foo.test.js" };
312
+ const result = { status: "fixed", summary: "fixed foo", evidence: ["src/foo.js:3 null deref"], root_cause: "derefs null at src/foo.js:3", changes: [{ file: "src/foo.js", change: "add guard", why: "avoid crash" }],
313
+ tests: [{ command: "node --test test/foo.test.js", result: "pass", fails_without_fix: true }], risks: ["none"], follow_ups: ["audit baz"], approach: "guard null" };
314
+
315
+ it("renders builder-written files under the fixed headings", () => {
316
+ assert.match(renderInvestigation(STAGE_MD.investigation, null), /^## Investigation Report\n\*\*Verdict\*\*: CONFIRMED/);
317
+ assert.match(renderPlan(STAGE_MD.plan, null), /^## Implementation Plan\n### Approach/);
318
+ const im = renderImplementation(STAGE_MD.implementation, null, { branch: "b", files: ["a.js"] });
319
+ assert.match(im, /^## Implementation Complete\n\*\*Branch:\*\* `b`\n\*\*Files changed:\*\*\n- `a.js`\n\n### Approach/);
320
+ const r = renderReview(rv);
321
+ assert.match(r, /^## Review\n\*\*Verdict:\*\* PASS/);
322
+ assert.match(r, /Fixes the issue:\*\* yes \(src\/foo.js:3 guard\)/);
323
+ assert.match(r, /Regression check:\*\* no regression \(checked: callers of foo, test\/foo.test.js\)/);
324
+ assert.match(r, /Notes:\*\* src\/foo.js:3 ok/);
325
+ assert.match(renderReview({ ...rv, verdict: "FAIL", fixes_issue: false, regression: true }), /Fixes the issue:\*\* no[\s\S]*Regression check:\*\* yes, regression/);
326
+ });
327
+
328
+ it("missing files fall back to the result.json fields, never broken markdown", () => {
329
+ const inv = renderInvestigation(null, result);
330
+ for (const h of ["**Verdict**: CONFIRMED", "### What We Found\n- src/foo.js:3 null deref", "### Root Cause\nderefs null at src/foo.js:3", "### Related Issues\n" + NR]) assert.ok(inv.includes(h), h);
331
+ const plan = renderPlan("", result, []);
332
+ assert.match(plan, /### Deliverables\n\| File \| Change \| Why \|\n\| --- \| --- \| --- \|\n\| src\/foo.js \| add guard \| avoid crash \|/);
333
+ const im = renderImplementation(undefined, result, { branch: "b", files: ["src/foo.js"] });
334
+ assert.match(im, /### Verification\n- `node --test test\/foo.test.js`: pass; fails without fix: yes/);
335
+ for (const x of [inv, plan, im, renderInvestigation(null, null), renderPlan(null, null, []), renderImplementation(null, null, {}), renderReview({ verdict: "PASS", fixes_issue: true, regression: false })]) {
336
+ assert.doesNotMatch(x, /undefined|\[object/);
337
+ }
338
+ assert.ok(renderInvestigation(null, null).includes(NR));
339
+ assert.match(renderInvestigation(" \n ", null), /not reported/, "a blank file counts as missing");
340
+ });
341
+
342
+ it("sanitizes FORGE markers the builder wrote (closed and unterminated)", () => {
343
+ const evil = "ok text here that is long enough\n<!-- FORGE:STATE {\"lease\":1} -->\nmore <!--FORGE:REVIEW--> text\n<!-- FORGE:NEEDS_HUMAN trailing";
344
+ const out = sanitizeMd(evil);
345
+ assert.doesNotMatch(out, /FORGE:/);
346
+ assert.match(out, /ok text here/); assert.match(out, /more\s+text/);
347
+ assert.doesNotMatch(renderInvestigation(evil, null), /<!-- ?FORGE:/);
348
+ });
349
+
350
+ it("caps every comment at 60k characters, in the engine's posting path too", async () => {
351
+ const out = capComment("x".repeat(100000));
352
+ assert.ok(out.length <= MAX_COMMENT_CHARS); assert.match(out, /truncated/);
353
+ assert.equal(capComment("short"), "short");
354
+ const w = world({ stages: { investigation: STAGE_MD.investigation + "\n" + "y".repeat(90000) } });
355
+ await run(w);
356
+ assert.ok(w.comments.every((c) => c.length <= MAX_COMMENT_CHARS));
357
+ assert.match(w.comments[1], /truncated/);
358
+ assert.match(w.comments[1], /^<!-- FORGE:INVESTIGATION -->/);
359
+ });
360
+
361
+ it("escapes pipes and newlines in table cells; section() reads PR-body parts", () => {
291
362
  assert.equal(cell("a|b\nc"), "a\\|b c");
292
363
  const t = renderPrBody({ issue: 1, result: { changes: [{ file: "a|b.js", change: "x\ny", why: "p|q" }] } });
293
- const row = t.split("\n").find((l) => l.startsWith("| a"));
294
- assert.equal(row, "| a\\|b.js | x y | p\\|q |");
364
+ assert.equal(t.split("\n").find((l) => l.startsWith("| a")), "| a\\|b.js | x y | p\\|q |");
365
+ assert.equal(section(STAGE_MD.investigation, "Root Cause"), "foo derefs null at src/foo.js:3");
366
+ assert.match(renderPrBody({ issue: 1, result: null }), /## Root cause\nnot reported[\s\S]*Closes #1$/);
367
+ });
368
+
369
+ it("trajectory rows: merged, invalid, needs-human; missing rows read 'not reached'", () => {
370
+ const base = { issue: 9, validate: ["CONFIRMED", "2 evidence item(s)"], plan: "posted", implement: ["2 file(s)", "b"], review: ["PASS", "1 round"], pr: ["#5", "opened"], merge: ["all", "merged at abc"], close: ["issue closed (completed)", "label workflow:merged"], decisions: ["d1"], anomalies: [], usage: "cost $1" };
371
+ const m = renderTrajectory(base);
372
+ assert.match(m, /^## Pipeline Trajectory — #9\n\n\| Phase \| Result \| Notes \|/);
373
+ for (const row of ["| Validate | CONFIRMED | 2 evidence item(s) |", "| Review | PASS | 1 round |", "| PR | #5 | opened |", "| Merge | all | merged at abc |", "| Close | issue closed (completed) | label workflow:merged |", "- d1", "- none", "_cost $1_"]) assert.ok(m.includes(row), row);
374
+ const nh = renderTrajectory({ issue: 9, validate: "CONFIRMED", anomalies: ["needs-human: Review FAIL twice"], close: ["left open", "label needs-human"] });
375
+ assert.match(nh, /\| Implement \| not reached \| - \|/);
376
+ assert.match(nh, /\*\*Anomalies\*\*\n- needs-human: Review FAIL twice/);
377
+ assert.match(renderTrajectory({ issue: 9, validate: ["a|b", "x\ny"] }), /\| Validate \| a\\\|b \| x y \|/);
295
378
  });
296
379
 
297
- it("posts VALIDATE, PLAN, REVIEW, WORK-ON in order; VALIDATE and PLAN before the reviewer", async () => {
298
- const w = world({ result: full });
380
+ it("a merged run's trajectory and a needs-human run's trajectory carry the right rows", async () => {
381
+ const w = world({ reviews: [bad(), bad("still bad")] });
299
382
  await run(w);
300
- const m = w.comments.map((c) => /FORGE:[A-Z_-]+/.exec(c)[0]);
301
- assert.deepEqual(m, ["FORGE:STARTED", "FORGE:VALIDATE", "FORGE:PLAN", "FORGE:REVIEW", "FORGE:WORK-ON"]);
302
- const idx = []; w.seq.forEach((s, i) => { if (s === "comment") idx.push(i); });
303
- assert.ok(idx[1] < w.seq.indexOf("reviewer") && idx[2] < w.seq.indexOf("reviewer") && idx[3] > w.seq.indexOf("reviewer"));
304
- const pr = w.prs[0][w.prs[0].indexOf("--body") + 1];
305
- assert.match(pr, /Root cause/); assert.match(pr, /\| src\/foo.js \| add guard \| avoid crash \|/);
383
+ const t = traj(w)[0];
384
+ assert.match(t, /\| Validate \| CONFIRMED \|/);
385
+ assert.match(t, /\| Plan \| posted \|/);
386
+ assert.match(t, /\| Review \| not reached \|/);
387
+ assert.match(t, /\| Close \| left open \| label needs-human \|/);
388
+ assert.match(t, /Review FAIL twice/);
306
389
  });
307
390
 
308
- it("builder prompt with the structured fields stays within 1.2k tokens", () => {
309
- assert.ok(approxTokens(builderPrompt(7)) <= 1200);
310
- assert.match(builderPrompt(7), /root_cause/); assert.match(builderPrompt(7), /sibling_sites_checked/);
391
+ it("missing stage files in a real run are rendered from result.json and noted as anomalies", async () => {
392
+ const w = world({ noStages: true, result: { status: "fixed", summary: "fixed foo", evidence: ["src/foo.js:3 x"], root_cause: "bad at src/foo.js:3", changes: [{ file: "src/foo.js", change: "guard", why: "w" }], tests: [{ command: "t", result: "pass", fails_without_fix: false }] } });
393
+ await run(w);
394
+ assert.deepEqual(w.comments.map(markerOf), ["FORGE:STARTED", "FORGE:INVESTIGATION", "FORGE:PLAN", "FORGE:IMPLEMENTATION", "FORGE:REVIEW", "FORGE:TRAJECTORY"]);
395
+ assert.match(w.comments[1], /Root Cause\nbad at src\/foo.js:3/);
396
+ assert.match(w.comments[3], /fails without fix: no/);
397
+ assert.match(traj(w)[0], /investigation.md missing or empty: rendered from result.json/);
398
+ for (const c of w.comments) assert.doesNotMatch(c, /undefined|\[object/);
399
+ });
400
+
401
+ it("live posting: stage notes appear while the builder session is still running", async () => {
402
+ const w = world({ live: async (wd, put) => {
403
+ const until = async (marker) => { for (let i = 0; i < 300 && !wd.comments.some((c) => c.includes(`<!-- ${marker} -->`)); i++) await new Promise((r) => setTimeout(r, 10)); return wd.comments.map(markerOf); };
404
+ wd.liveSeen = {};
405
+ put("investigation.md", STAGE_MD.investigation); wd.liveSeen.afterInvestigation = await until("FORGE:INVESTIGATION");
406
+ put("plan.md", STAGE_MD.plan); wd.liveSeen.afterPlan = await until("FORGE:PLAN");
407
+ put("implementation.md", STAGE_MD.implementation); wd.liveSeen.afterImplementation = await until("FORGE:IMPLEMENTATION");
408
+ } });
409
+ const r = await run(w, { livePollMs: 10, stableMs: 30 });
410
+ assert.equal(r.terminalReason, "merged");
411
+ assert.ok(w.liveSeen.afterInvestigation.includes("FORGE:INVESTIGATION"), "investigation posted before the session ended");
412
+ assert.ok(w.liveSeen.afterPlan.includes("FORGE:PLAN"));
413
+ assert.ok(w.liveSeen.afterImplementation.includes("FORGE:IMPLEMENTATION"));
414
+ assert.deepEqual(w.comments.map(markerOf), ["FORGE:STARTED", "FORGE:INVESTIGATION", "FORGE:PLAN", "FORGE:IMPLEMENTATION", "FORGE:REVIEW", "FORGE:TRAJECTORY"], "each posted exactly once, in order");
415
+ });
416
+
417
+ it("a file still being written is not posted until its mtime is stable", async () => {
418
+ let seenEarly = null;
419
+ const w = world({ live: async (wd, put) => {
420
+ const sleepMs = (ms) => new Promise((r) => setTimeout(r, ms));
421
+ for (let i = 0; i < 6; i++) { put("investigation.md", STAGE_MD.investigation + "\n".repeat(i)); await sleepMs(15); }
422
+ seenEarly = wd.comments.map(markerOf);
423
+ await sleepMs(900);
424
+ } });
425
+ await run(w, { livePollMs: 10, stableMs: 400 });
426
+ assert.ok(!seenEarly.includes("FORGE:INVESTIGATION"), "not posted while the file keeps changing");
427
+ assert.equal(w.comments.filter((c) => c.includes("FORGE:INVESTIGATION")).length, 1);
428
+ });
429
+
430
+ it("builder prompt (three stage templates) stays within 2.5k tokens", () => {
431
+ const p = builderPrompt(7);
432
+ assert.ok(approxTokens(p) <= 2500, `builder prompt is ${approxTokens(p)} tokens`);
433
+ for (const h of ["### What Was Claimed", "### What We Found", "### Root Cause", "### Affected Files", "### Sibling Sweep", "### History", "### Recommendation", "### Related Issues",
434
+ "### Approach", "### Deliverables", "### Acceptance Criteria", "### Implementation Order", "### Out of Scope", "### Risk Assessment", "### Changes", "### Acceptance Criteria Status", "### Verification", "### Notes",
435
+ "investigation.md", "plan.md", "implementation.md", "result.json", "BEFORE coding", "BEFORE editing code"]) assert.ok(p.includes(h), h);
311
436
  });
312
437
  });
313
438
 
@@ -319,7 +444,7 @@ describe("merge_wait policy", () => {
319
444
  w.io.waitCiGreen = async () => { polled++; return { rc: 2, text: "" }; };
320
445
  const r = await run(w);
321
446
  assert.equal(r.terminalReason, "merged"); assert.equal(polled, 0);
322
- assert.match(registers(w)[0], /Merge policy:\*\* required \(no required checks/);
447
+ assert.match(traj(w)[0], /\| Merge \| required \(no required checks: merged right after review\) \|/);
323
448
  });
324
449
  it("required with a pending required check waits (backoff) then merges", async () => {
325
450
  const w = world(); const seqs = [states({ build: "pending" }), states({ build: "pending" }), states({ build: "success", other: "failure" })]; let sleeps = 0;
@@ -327,7 +452,7 @@ describe("merge_wait policy", () => {
327
452
  const s = w.io.sleep; w.io.sleep = async (ms) => { sleeps++; return s(ms); };
328
453
  const r = await run(w);
329
454
  assert.equal(r.terminalReason, "merged"); assert.equal(sleeps, 2);
330
- assert.match(registers(w)[0], /required \(build\)/);
455
+ assert.match(traj(w)[0], /\| Merge \| required \(build\) \|/);
331
456
  assert.deepEqual(w.merges[0].slice(-2), ["--match-head-commit", w.head]);
332
457
  });
333
458
  it("required with a failing required check is needs-human; slow required check is ci-pending", async () => {
@@ -340,7 +465,7 @@ describe("merge_wait policy", () => {
340
465
  const w = world({ waits: [{ rc: 2, text: "" }] }); let req = 0; w.io.requiredChecks = async () => { req++; return []; };
341
466
  assert.equal((await run(w, { mergeWait: "all" })).terminalReason, "merged");
342
467
  assert.equal(w.waits.length, 0); assert.equal(req, 0);
343
- assert.match(registers(w)[0], /Merge policy:\*\* all/);
468
+ assert.match(traj(w)[0], /\| Merge \| all \|/);
344
469
  const w2 = world({ waits: [{ rc: 1, text: "red" }] });
345
470
  assert.equal((await run(w2, { mergeWait: "none" })).terminalReason, "merged");
346
471
  assert.equal(w2.waits.length, 1); assert.deepEqual(w2.merges[0].slice(-2), ["--match-head-commit", w2.head]);
@@ -348,9 +473,8 @@ describe("merge_wait policy", () => {
348
473
  });
349
474
 
350
475
  describe("prompts", () => {
351
- it("builder prompt is at most 1k tokens and short and plain", () => {
476
+ it("builder prompt keeps the short developer opening and the no-push rule", () => {
352
477
  const p = builderPrompt(7);
353
- assert.ok(approxTokens(p) <= 1000);
354
478
  assert.match(p, /Resolve issue #7 \(read it with gh issue view 7 --comments\)/);
355
479
  assert.match(p, /Do not push or touch GitHub/);
356
480
  assert.match(p, /result\.json/);
@@ -373,6 +497,7 @@ describe("prompts", () => {
373
497
  });
374
498
 
375
499
  it("parseVerdict: flags decide, garbage is null", () => {
500
+ assert.equal(parseVerdict(ok({ evidence: "a.js:1", checked: "c" })).evidence, "a.js:1");
376
501
  assert.equal(parseVerdict(ok()).fail, false);
377
502
  assert.equal(parseVerdict(ok({ regression: true })).fail, true);
378
503
  assert.equal(parseVerdict(bad()).fail, true);
@@ -497,3 +622,286 @@ describe("orchestrate", () => {
497
622
  await assert.rejects(orchestrateFromCli([], {}), /usage/);
498
623
  });
499
624
  });
625
+
626
+ describe("runCliBackendAsync", () => {
627
+ it("returns the same envelope as runCliBackend, without blocking the event loop", async () => {
628
+ const { writeFileSync, chmodSync } = await import("node:fs");
629
+ const { runCliBackend, runCliBackendAsync } = await import("../runner.mjs");
630
+ const d = tmpDir("core-async-");
631
+ const bin = `${d}/fake-claude`;
632
+ writeFileSync(bin, '#!/bin/sh\nsleep 0.3\necho \'{"result":"hello","session_id":"s-1","total_cost_usd":0.25,"num_turns":2,"duration_ms":300,"usage":{"input_tokens":3,"output_tokens":4}}\'\n');
633
+ chmodSync(bin, 0o755);
634
+ const base = { spec: { name: "t" }, userMessage: "go", systemPrompt: "sys", args: ["1"], cwd: d, bin, logger: { log() {} } };
635
+ let ticks = 0; const iv = setInterval(() => { ticks++; }, 20);
636
+ const a = await runCliBackendAsync(base);
637
+ clearInterval(iv);
638
+ const s = runCliBackend(base);
639
+ assert.ok(ticks >= 5, `event loop kept turning (${ticks} ticks)`);
640
+ assert.deepEqual({ ...a, durationMs: 0 }, { ...s, durationMs: 0 });
641
+ assert.equal(a.sessionId, "s-1"); assert.equal(a.text, "hello"); assert.equal(a.costUsd, 0.25);
642
+ });
643
+
644
+ it("surfaces a failing CLI as the same CLI_BACKEND_FAILED error", async () => {
645
+ const { writeFileSync, chmodSync } = await import("node:fs");
646
+ const { runCliBackendAsync } = await import("../runner.mjs");
647
+ const d = tmpDir("core-async-");
648
+ writeFileSync(`${d}/bad`, "#!/bin/sh\necho boom >&2\nexit 3\n"); chmodSync(`${d}/bad`, 0o755);
649
+ await assert.rejects(runCliBackendAsync({ spec: { name: "t" }, userMessage: "go", args: ["1"], cwd: d, bin: `${d}/bad`, logger: { log() {} } }), (e) => e.code === "CLI_BACKEND_FAILED" && /boom/.test(e.message));
650
+ });
651
+ });
652
+
653
+ describe("base-sync (#4100)", () => {
654
+ const CONFLICTING = { mergeable: "CONFLICTING", mergeStateStatus: "DIRTY" };
655
+ const OKSTATE = { mergeable: "MERGEABLE", mergeStateStatus: "CLEAN" };
656
+ const gitCalls = (w) => w.calls.filter((c) => c[0] === "git").map((c) => c.slice(1));
657
+ const noRebaseNoForce = (w) => {
658
+ for (const g of gitCalls(w)) {
659
+ assert.notEqual(g[0], "rebase");
660
+ if (g[0] === "push") assert.ok(!g.some((x) => /^(--force|-f$|\+)/.test(x)), `force push: ${g.join(" ")}`);
661
+ }
662
+ };
663
+ const syncComment = (w) => w.comments.find((c) => c.includes("<!-- FORGE:BASE_SYNC -->"));
664
+
665
+ it("conflicting PR, clean merge: fetch, merge, push, merge the PR at the new head; no re-review", async () => {
666
+ const w = world({ prView: [CONFLICTING, OKSTATE], mergeResults: ["clean"] });
667
+ const r = await run(w);
668
+ assert.equal(r.terminalReason, "merged");
669
+ assert.equal(reviewers(w).length, 1, "no re-review for a conflict-free base merge");
670
+ assert.equal(builders(w).length, 1);
671
+ const g = gitCalls(w);
672
+ assert.ok(g.some((x) => x[0] === "fetch" && x[1] === "origin" && x[2] === "staging"));
673
+ assert.ok(g.some((x) => x[0] === "merge" && x[1] === "--no-edit" && x[2] === "origin/staging"));
674
+ assert.equal(g.filter((x) => x[0] === "push").length, 2, "initial push plus the sync push");
675
+ assert.match(syncComment(w), /## Base Sync[\s\S]*clean merge[\s\S]*not required \(conflict-free base merge\)/);
676
+ assert.deepEqual(w.merges[0].slice(-2), ["--match-head-commit", w.head]);
677
+ assert.match(traj(w)[0], /\| Base Sync \| 1 sync\(s\) \|/);
678
+ noRebaseNoForce(w);
679
+ });
680
+
681
+ it("no conflict: no Base Sync row detail and no sync comment", async () => {
682
+ const w = world();
683
+ await run(w);
684
+ assert.ok(!syncComment(w));
685
+ assert.match(traj(w)[0], /\| Base Sync \| not needed \| - \|/);
686
+ });
687
+
688
+ it("conflicting PR, real conflict: builder resumed with the files, reviewer PASS on the full diff, merge", async () => {
689
+ const w = world({ prView: [CONFLICTING, OKSTATE], mergeResults: ["conflict"], reviews: [ok(), ok()] });
690
+ const r = await run(w);
691
+ assert.equal(r.terminalReason, "merged");
692
+ const b = builders(w);
693
+ assert.equal(b.length, 2);
694
+ assert.equal(b[1][2], "sess-1", "same session resumed");
695
+ assert.match(b[1][4], /Merging origin\/staging into this branch conflicts in: src\/foo\.js\. Resolve the conflicts preserving both this fix's intent and the base's changes, run the relevant tests, and commit the merge\. Do not push\./);
696
+ assert.equal(reviewers(w).length, 2, "re-review after the conflict resolution");
697
+ assert.match(syncComment(w), /conflicts in `src\/foo\.js`[\s\S]*builder resolved 1 conflicted file[\s\S]*Re-review:\*\* PASS/);
698
+ assert.equal(w.merges.length, 1);
699
+ assert.deepEqual(w.merges[0].slice(-2), ["--match-head-commit", w.head]);
700
+ noRebaseNoForce(w);
701
+ });
702
+
703
+ it("conflict resolution that the re-review FAILs gets one retry then merges on PASS", async () => {
704
+ const w = world({ prView: [CONFLICTING, OKSTATE], mergeResults: ["conflict"], reviews: [ok(), bad("src/foo.js:4 lost base change"), ok()] });
705
+ assert.equal((await run(w)).terminalReason, "merged");
706
+ assert.equal(reviewers(w).length, 3);
707
+ assert.equal(builders(w).length, 3);
708
+ });
709
+
710
+ it("still conflicting after 2 base-syncs ends needs-human with base.conflict, never engine-error", async () => {
711
+ const w = world({ prView: [CONFLICTING], mergeResults: ["clean", "clean", "clean"] });
712
+ const r = await run(w);
713
+ assert.equal(r.terminalReason, "needs-human");
714
+ assert.equal(r.code, "base.conflict");
715
+ assert.equal(gitCalls(w).filter((x) => x[0] === "merge" && x[1] === "--no-edit").length, 2, "at most 2 base-syncs");
716
+ assert.equal(w.merges.length, 0);
717
+ assert.ok(w.edits.some((e) => e.includes("needs-human")));
718
+ });
719
+
720
+ it("unresolved conflict markers after the builder ends needs-human with base.conflict", async () => {
721
+ const w = world({ prView: [CONFLICTING], mergeResults: ["conflict"] });
722
+ w.markers = true;
723
+ const r = await run(w);
724
+ assert.equal(r.terminalReason, "needs-human"); assert.equal(r.code, "base.conflict");
725
+ assert.equal(w.merges.length, 0);
726
+ assert.ok(gitCalls(w).some((x) => x[0] === "merge" && x[1] === "--abort"));
727
+ });
728
+
729
+ it("a gh pr merge conflict error triggers base-sync, then the merge is retried", async () => {
730
+ const w = world({ prView: [OKSTATE], mergeErrors: ["GraphQL: Pull request is not mergeable (mergePullRequest)"], mergeResults: ["clean"] });
731
+ const r = await run(w);
732
+ assert.equal(r.terminalReason, "merged");
733
+ assert.equal(w.mergeAttempts, 2);
734
+ assert.match(syncComment(w), /gh pr merge reported a conflict/);
735
+ noRebaseNoForce(w);
736
+ });
737
+
738
+ it("an unrelated gh pr merge error is not a base-sync", async () => {
739
+ const w = world({ mergeErrors: ["HTTP 500 boom"] });
740
+ const r = await run(w);
741
+ assert.equal(r.terminalReason, "engine-error");
742
+ assert.ok(!syncComment(w));
743
+ });
744
+
745
+ it("a resumed run with a conflicting PR syncs (fresh builder when the conflict needs resolving)", async () => {
746
+ const w = world({ prList: [{ number: 77, headRefOid: "x" }], remote: "abc\trefs/heads/feat/issue-7-lean", prView: [CONFLICTING, OKSTATE], mergeResults: ["conflict"], reviews: [ok(), ok()] });
747
+ const r = await run(w);
748
+ assert.equal(r.terminalReason, "merged");
749
+ const b = builders(w);
750
+ assert.equal(b.length, 1);
751
+ assert.equal(b[0][2], null, "no session id on resume: a fresh builder");
752
+ assert.match(b[0][4], /Merging origin\/staging into this branch conflicts in: src\/foo\.js/);
753
+ assert.equal(w.prs.length, 0);
754
+ noRebaseNoForce(w);
755
+ });
756
+
757
+ it("UNKNOWN mergeability with nothing to merge is not treated as a conflict", async () => {
758
+ const w = world({ prView: [{ mergeable: "UNKNOWN", mergeStateStatus: "UNKNOWN" }, { mergeable: "UNKNOWN", mergeStateStatus: "UNKNOWN" }, OKSTATE], mergeResults: ["clean"] });
759
+ // a clean merge that changes head would count; here make the merge a no-op by pinning the head
760
+ const orig = w.io.git;
761
+ w.io.git = async (a, o2) => { const r = await orig(a, o2); if (a[0] === "merge" && a[1] === "--no-edit") w.head = "aaaa1111aaaa1111"; return r; };
762
+ const r = await run(w);
763
+ assert.equal(r.terminalReason, "merged");
764
+ assert.ok(!syncComment(w));
765
+ });
766
+ });
767
+
768
+ describe("usage limit, lease takeover, worktree setup, run tmp (#4101-#4104)", () => {
769
+ const limitErr = (extra) => Object.assign(new Error("claude CLI exited with status 1. You've hit your session limit"), { code: "CLI_BACKEND_FAILED", usageLimit: true, ...extra });
770
+ const failFirstBuilder = (w, err, times = 1) => { const orig = w.io.runner; let n = 0; w.io.runner = async (c) => { if (c.role === "builder" && n++ < times) throw err; return orig(c); }; };
771
+ const projector = (w, lease) => { w.leaseWrites = []; w.io.projector = { readState: async () => (lease ? { v: 1, lease } : null), writeState: async (_i, st) => { w.leaseWrites.push(st); } }; };
772
+
773
+ it("#4101 a usage limit with a near reset pauses until the reset, then resumes the same step", async () => {
774
+ const w = world(); const sleeps = [];
775
+ const s0 = w.io.sleep; w.io.sleep = async (ms) => { sleeps.push(ms); return s0(ms); };
776
+ failFirstBuilder(w, limitErr({ resetAtEpochMs: 90000, resetAt: "8:30pm" }));
777
+ const r = await run(w);
778
+ assert.equal(r.terminalReason, "merged");
779
+ assert.ok(sleeps.includes(90000), `slept until the reset: ${sleeps}`);
780
+ assert.equal(builders(w).length, 1);
781
+ });
782
+
783
+ it("#4101 a usage limit with a far reset ends deferred/rate_limited with the reset time, lease released, no needs-human", async () => {
784
+ const w = world(); projector(w, null);
785
+ failFirstBuilder(w, limitErr({ resetAtEpochMs: 3 * 3600 * 1000, resetAt: "8:30pm (Asia/Kolkata)" }));
786
+ const r = await run(w);
787
+ assert.equal(r.terminalReason, "deferred");
788
+ assert.equal(r.code, "rate_limited");
789
+ assert.match(r.detail, /resets at 8:30pm \(Asia\/Kolkata\)/);
790
+ assert.equal(w.edits.some((e) => e.includes("needs-human")), false);
791
+ assert.equal(traj(w).length, 0, "no trajectory comment for a deferral");
792
+ assert.equal(w.leaseWrites.at(-1).lease, null, "lease released");
793
+ assert.equal(w.merges.length, 0);
794
+ });
795
+
796
+ it("#4101 unknown reset pauses a bounded number of times, then defers", async () => {
797
+ const w = world(); let sleeps = 0;
798
+ const s0 = w.io.sleep; w.io.sleep = async (ms) => { sleeps++; return s0(ms); };
799
+ failFirstBuilder(w, limitErr(), 99);
800
+ const r = await run(w, { maxSessionLimitPauses: 2 });
801
+ assert.equal(r.terminalReason, "deferred"); assert.equal(r.code, "rate_limited");
802
+ assert.equal(sleeps, 2);
803
+ assert.match(r.detail, /resets at unknown/);
804
+ });
805
+
806
+ it("#4101 a non-limit runner error is still engine-error", async () => {
807
+ const w = world(); failFirstBuilder(w, new Error("boom"));
808
+ assert.equal((await run(w)).terminalReason, "engine-error");
809
+ });
810
+
811
+ it("#4102 a same-host holder whose pid is gone is taken over immediately; a live one or another host is not", async () => {
812
+ const future = Date.now() + 1e6;
813
+ const dead = Object.assign(new Error("no such process"), { code: "ESRCH" });
814
+ const w = world(); projector(w, { by: "core_4242", until: future, host: "h1", pid: 4242 });
815
+ const r = await run(w, { hostName: "h1", killFn: () => { throw dead; } });
816
+ assert.equal(r.terminalReason, "merged");
817
+ assert.ok(w.leaseWrites.some((x) => x.lease?.host === "h1" && Number.isSafeInteger(x.lease?.pid)), "lease records host and pid");
818
+ const w2 = world(); projector(w2, { by: "core_4242", until: 1e12, host: "h1", pid: 4242 });
819
+ assert.equal((await run(w2, { hostName: "h1", killFn: () => true })).terminalReason, "deferred");
820
+ const w3 = world(); projector(w3, { by: "core_4242", until: 1e12, host: "other-host", pid: 4242 });
821
+ assert.equal((await run(w3, { hostName: "h1", killFn: () => { throw dead; } })).terminalReason, "deferred");
822
+ assert.equal(builders(w3).length, 0);
823
+ });
824
+
825
+ it("#4103 a leftover unregistered worktree dir is removed (only this issue's lean dir) before add", async () => {
826
+ const w = world(); const removed = [];
827
+ w.io.fs.exists = async (p) => p === "/wt/lean-7"; w.io.fs.rmdir = async (p) => { removed.push(p); };
828
+ await run(w);
829
+ assert.deepEqual(removed, ["/wt/lean-7"]);
830
+ // a registered worktree dir is left to `git worktree remove`
831
+ const w2 = world(); const removed2 = [];
832
+ w2.io.fs.exists = async () => true; w2.io.fs.rmdir = async (p) => { removed2.push(p); };
833
+ const g = w2.io.git; w2.io.git = async (a, o2) => (a[0] === "worktree" && a[1] === "list" ? "worktree /wt/lean-7\nHEAD abc\n" : g(a, o2));
834
+ await run(w2);
835
+ assert.deepEqual(removed2, []);
836
+ // a worktree root that is not the lean root never deletes anything
837
+ const w3 = world(); const removed3 = [];
838
+ w3.io.fs.exists = async () => true; w3.io.fs.rmdir = async (p) => { removed3.push(p); };
839
+ await run(w3, { worktreeRoot: "/wt" , issue: 7 });
840
+ assert.ok(removed3.every((p) => p === "/wt/lean-7"));
841
+ });
842
+
843
+ it("#4103 'already exists' on add cleans the leftover and retries; index.lock and packed-refs.lock are retried", async () => {
844
+ for (const msg of ["fatal: '/wt/lean-7' already exists", "fatal: Unable to create '/r/.git/index.lock': File exists.", "error: cannot lock ref: Unable to create '/r/.git/packed-refs.lock': File exists", "fatal: could not lock config file /r/.git/config: File exists"]) {
845
+ const w = world(); let failed = false; const removed = [];
846
+ w.io.fs.exists = async (p) => failed && p === "/wt/lean-7"; w.io.fs.rmdir = async (p) => { removed.push(p); };
847
+ const g = w.io.git;
848
+ w.io.git = async (a, o2) => { if (a[0] === "worktree" && a[1] === "add" && !failed) { failed = true; throw Object.assign(new Error(msg), { stderr: msg }); } return g(a, o2); };
849
+ const r = await run(w);
850
+ assert.equal(r.terminalReason, "merged", msg);
851
+ if (/already exists/.test(msg)) assert.deepEqual(removed, ["/wt/lean-7"]);
852
+ }
853
+ });
854
+
855
+ it("#4104 the builder and reviewer run with a per-run TMPDIR that is removed on every terminal", async () => {
856
+ const mk = (w) => { w.tmpMade = []; w.tmpRemoved = []; w.io.runTmp = { prune: async () => { w.pruned = true; }, make: async (i, id) => { const d = `/home/x/.forge/tmp/runs/${id}`; w.tmpMade.push(d); return d; }, remove: async (d) => { w.tmpRemoved.push(d); } }; };
857
+ const cases = {
858
+ merged: (w) => w,
859
+ "needs-human": (w) => { w.reviews.push(bad(), bad()); return w; },
860
+ "engine-error": (w) => { failFirstBuilder(w, new Error("boom")); return w; },
861
+ deferred: (w) => { failFirstBuilder(w, limitErr({ resetAtEpochMs: 3 * 3600 * 1000 })); return w; },
862
+ };
863
+ for (const [reason, prep] of Object.entries(cases)) {
864
+ const w = world(); mk(w); prep(w);
865
+ const r = await run(w);
866
+ assert.equal(r.terminalReason, reason);
867
+ assert.equal(w.pruned, true);
868
+ assert.equal(w.tmpMade.length, 1);
869
+ assert.match(w.tmpMade[0], /\/\.forge\/tmp\/runs\/7-/);
870
+ assert.deepEqual(w.tmpRemoved, w.tmpMade, `${reason}: removed`);
871
+ for (const e of w.envs) assert.equal(e?.TMPDIR, w.tmpMade[0]);
872
+ if (["merged", "needs-human"].includes(reason)) assert.ok(w.envs.length >= 1);
873
+ }
874
+ });
875
+
876
+ it("#4104 makeRunTmp: removes only its own dirs; prune takes only old dirs with a dead owner and our marker", async () => {
877
+ const { mkdirSync, writeFileSync, existsSync, utimesSync } = await import("node:fs");
878
+ const root = tmpDir("core-runs-");
879
+ let t = 1_000_000_000_000;
880
+ const rt = makeRunTmp({ root, now: () => t, host: "h", alive: (pid) => pid === 111 });
881
+ const mine = await rt.make(7, "7-1");
882
+ assert.ok(existsSync(mine));
883
+ assert.equal(await rt.remove(mine), true); assert.ok(!existsSync(mine));
884
+ assert.equal(await rt.remove(`${root}/not-made`), false);
885
+ const outside = tmpDir("core-outside-"); writeFileSync(`${outside}/f`, "x");
886
+ assert.equal(await rt.remove(outside), false); assert.ok(existsSync(outside));
887
+ const own = (name, o2) => { mkdirSync(`${root}/${name}`); writeFileSync(`${root}/${name}/.forge-run-owner`, JSON.stringify({ host: "h", ...o2 })); };
888
+ own("old-dead", { pid: 999, created: t - 7 * 3600 * 1000 });
889
+ own("old-alive", { pid: 111, created: t - 7 * 3600 * 1000 });
890
+ own("fresh-dead", { pid: 999, created: t - 3600 * 1000 });
891
+ own("old-otherhost", { pid: 999, host: "zzz", created: t - 7 * 3600 * 1000 });
892
+ mkdirSync(`${root}/foreign`); writeFileSync(`${root}/foreign/data`, "not ours");
893
+ t += 1;
894
+ const removed = await rt.prune();
895
+ assert.deepEqual(removed.sort(), ["old-dead"]);
896
+ for (const keep of ["old-alive", "fresh-dead", "foreign"]) assert.ok(existsSync(`${root}/${keep}`), keep);
897
+ });
898
+
899
+ it("#4104 runCliBackend applies the per-run env to the child", async () => {
900
+ const { runCliBackend } = await import("../runner.mjs");
901
+ let seen;
902
+ runCliBackend({ spec: { name: "t" }, userMessage: "go", args: ["1"], cwd: tmpDir("core-env-"), logger: { log() {} }, env: { TMPDIR: "/x/run-tmp", CLAUDE_CODE_TMPDIR: "/x/run-tmp" },
903
+ spawnFn: (_b, _a, o2) => { seen = o2.env; return { status: 0, stdout: "{}", stderr: "" }; } });
904
+ assert.equal(seen.TMPDIR, "/x/run-tmp"); assert.equal(seen.CLAUDE_CODE_TMPDIR, "/x/run-tmp");
905
+ assert.equal(seen.ANTHROPIC_API_KEY, undefined);
906
+ });
907
+ });