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.
package/bin/lean/lean.mjs CHANGED
@@ -5,16 +5,17 @@
5
5
  * ForgeDock registers everything on GitHub (labels, one register comment, PR, merge). No investigation, synthesis,
6
6
  * severity taxonomy, risk tiers or local CI gates. All effects are injected through `io` so it is unit-testable.
7
7
  */
8
- import { join } from "node:path";
8
+ import { join, dirname, basename } from "node:path";
9
+ import { hostname as osHostname } from "node:os";
9
10
  import { appendEvent } from "../engine/runlog.mjs";
10
11
  import { codeForTerminalReason, OUTCOME_CODES } from "../engine/outcome-codes.mjs";
11
- import { ciPollDelayMs, ENGINE_VERSION } from "../engine.mjs";
12
+ import { ciPollDelayMs, ENGINE_VERSION, DEFAULT_MAX_SESSION_LIMIT_PAUSES, DEFAULT_SESSION_LIMIT_WAIT_MS, parseLeaseHolder, sanitizeLeaseHost } from "../engine.mjs";
12
13
  import { openDependency } from "../engine/phases.mjs";
13
- import { renderValidate, renderPlan, renderReview, renderRegister, renderPrBody } from "./render.mjs";
14
- import { REVIEWER_SYSTEM, LEAN_DIR, RESULT_FILE, builderPrompt, resumeBuilderPrompt, reviewerPrompt, parseVerdict } from "./prompts.mjs";
14
+ import { renderInvestigation, renderPlan, renderImplementation, renderReview, renderPrBody, renderTrajectory, capComment, sanitizeMd } from "./render.mjs";
15
+ import { REVIEWER_SYSTEM, LEAN_DIR, RESULT_FILE, INVESTIGATION_FILE, PLAN_FILE, IMPLEMENTATION_FILE, builderPrompt, resumeBuilderPrompt, reviewerPrompt, parseVerdict } from "./prompts.mjs";
15
16
 
16
17
  export const DEFAULT_REVIEWER_MODEL = "claude-sonnet-5-5";
17
- export const REGISTER_MARKER = "FORGE:WORK-ON";
18
+ export const REGISTER_MARKER = "FORGE:WORK-ON"; // alias marker carried by the FORGE:TRAJECTORY comment
18
19
  const num = (v) => (Number.isFinite(v) ? v : null);
19
20
 
20
21
  /**
@@ -31,7 +32,7 @@ const num = (v) => (Number.isFinite(v) ? v : null);
31
32
  */
32
33
  export async function runCore(o) {
33
34
  const { issue, repo, lane, cwd, dir, io, builderModel = null, reviewerModel = DEFAULT_REVIEWER_MODEL,
34
- ciMaxMinutes = 360, mergeWait = "required", onProgress = () => {}, emitTelemetry = null, random = Math.random } = o;
35
+ ciMaxMinutes = 360, mergeWait = "required", maxSessionLimitPauses = DEFAULT_MAX_SESSION_LIMIT_PAUSES, sessionLimitMaxWaitMs = DEFAULT_SESSION_LIMIT_WAIT_MS, onProgress = () => {}, emitTelemetry = null, random = Math.random } = o;
35
36
  const now = io.now ?? (() => Date.now());
36
37
  const sleep = io.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
37
38
  const base = `origin/${lane}`;
@@ -56,7 +57,8 @@ export async function runCore(o) {
56
57
  usage.cache += (u.cache_read_input_tokens ?? 0) + (u.cache_creation_input_tokens ?? 0);
57
58
  }
58
59
  const post = async (marker, body) => {
59
- const out = String(await ghc(["issue", "comment", String(issue), "-R", repo, "--body", `<!-- ${marker} -->\n${body}`])).trim();
60
+ const marks = (Array.isArray(marker) ? marker : [marker]).map((m) => `<!-- ${m} -->`).join("\n");
61
+ const out = String(await ghc(["issue", "comment", String(issue), "-R", repo, "--body", capComment(`${marks}\n${body}`)])).trim();
60
62
  return /https?:\/\/\S+/.exec(out)?.[0] ?? null;
61
63
  };
62
64
  const label = async (state) => { try { await io.transitionLabel(issue, state); } catch { /* labels are best effort */ } };
@@ -64,10 +66,19 @@ export async function runCore(o) {
64
66
 
65
67
  // ---- lease (same FORGE:STATE lease the full path uses) ----
66
68
  const agentId = o.agentId ?? `core_${process.pid}`;
69
+ const myHost = o.hostName ?? osHostname();
70
+ const leaseObj = () => ({ by: agentId, until: now() + leaseTtlMs, host: myHost, pid: o.pid ?? process.pid });
71
+ /** Provably dead: same host and `kill(pid, 0)` throws ESRCH. A different host keeps the TTL behaviour. */
72
+ const holderDead = (l) => {
73
+ const parsed = parseLeaseHolder(l?.by);
74
+ const h = l?.host ?? parsed?.host, pid = l?.pid ?? parsed?.pid;
75
+ if (!h || !Number.isSafeInteger(pid) || pid <= 0 || sanitizeLeaseHost(h) !== sanitizeLeaseHost(myHost)) return false;
76
+ try { (o.killFn ?? process.kill.bind(process))(pid, 0); return false; } catch (e) { return e?.code === "ESRCH"; }
77
+ };
67
78
  const leaseTtlMs = o.leaseTtlMs ?? 25 * 60000; // below the projector's 30 min honour cap
68
79
  let leaseState = null, leaseTimer = null;
69
80
  const writeLease = (lease, extra = {}) => io.projector.writeState(issue, { ...leaseState, ...extra, lease });
70
- const renew = async () => { if (io.projector && leaseState) { try { await writeLease({ by: agentId, until: now() + leaseTtlMs }); } catch { /* best effort */ } } };
81
+ const renew = async () => { if (io.projector && leaseState) { try { await writeLease(leaseObj()); } catch { /* best effort */ } } };
71
82
  async function releaseLease(reason) {
72
83
  if (leaseTimer) { clearInterval(leaseTimer); leaseTimer = null; }
73
84
  if (!io.projector || !leaseState) return;
@@ -75,7 +86,11 @@ export async function runCore(o) {
75
86
  try { await writeLease(null, terminal ? { terminal: true, terminalReason: reason } : {}); } catch { /* best effort */ }
76
87
  }
77
88
 
89
+ // Pipeline trajectory (posted once at the end): phase rows, decisions, anomalies.
90
+ const T = { issue, validate: null, plan: null, implement: null, review: null, pr: null, merge: null, close: null, decisions: [], anomalies: [] };
78
91
  let done = null, wtCreated = false;
92
+ const runId = `${issue}-${t0}`;
93
+ let runTmp = null;
79
94
  async function finish(reason, detail = "", extra = {}, codeOverride = null) {
80
95
  if (done) return done; // idempotent: exactly one RUN_TERMINAL and one telemetry emit
81
96
  const code = codeOverride ?? codeForTerminalReason(reason);
@@ -85,7 +100,14 @@ export async function runCore(o) {
85
100
  const wf = ["investigating", "building", "in-review"].map((x) => `workflow:${x}`).join(",");
86
101
  try { await ghc(["issue", "edit", String(issue), "-R", repo, "--remove-label", wf]); } catch { /* best effort */ }
87
102
  }
103
+ if (["merged", "invalid", "needs-human"].includes(reason)) {
104
+ if (reason === "merged") T.close = ["issue closed (completed)", "label workflow:merged"];
105
+ else if (reason === "invalid") T.close = ["issue closed (not planned)", "label workflow:invalid"];
106
+ else { T.close = ["left open", "label needs-human"]; T.anomalies.push(`needs-human: ${String(detail).split("\n")[0].slice(0, 300)}`); }
107
+ try { await post(["FORGE:TRAJECTORY", REGISTER_MARKER], renderTrajectory({ ...T, usage: usageLine() })); } catch { /* best effort */ }
108
+ }
88
109
  await releaseLease(reason);
110
+ if (runTmp) { try { await io.runTmp.remove(runTmp); } catch { /* best effort */ } runTmp = null; } // every terminal, including engine-error and deferred
89
111
  if (wtCreated) {
90
112
  try { await io.git(["worktree", "remove", "--force", wt], { cwd }); } catch { /* best effort */ }
91
113
  if (reason === "invalid" && !extra.pr) { try { await io.git(["branch", "-D", branch], { cwd }); } catch { /* best effort */ } }
@@ -101,10 +123,89 @@ export async function runCore(o) {
101
123
  return finish("needs-human", why, { code }, code);
102
124
  }
103
125
 
104
- async function builder(n, prompt, resume) {
126
+ // ---- stage notes: investigation.md, plan.md, implementation.md become their own comments ----
127
+ const STAGES = [
128
+ { key: "investigation", file: INVESTIGATION_FILE, marker: ["FORGE:INVESTIGATION", "FORGE:VALIDATE"], render: (md, ctx) => renderInvestigation(md, ctx.result) },
129
+ { key: "plan", file: PLAN_FILE, marker: ["FORGE:PLAN"], render: (md, ctx) => renderPlan(md, ctx.result, ctx.files) },
130
+ { key: "implementation", file: IMPLEMENTATION_FILE, marker: ["FORGE:IMPLEMENTATION"], render: (md, ctx) => renderImplementation(md, ctx.result, { branch, files: ctx.files }) },
131
+ ];
132
+ const posted = {}; // key -> comment url (or "" when posted without a link)
133
+ const readStage = async (st) => { try { return await io.fs.read(`${wt}/${st.file}`); } catch { return null; } };
134
+ const usableMd = (md) => sanitizeMd(md).replace(/\s+/g, " ").length >= 20;
135
+ const stageFiles = async () => {
136
+ const out = String(await git(["diff", "--name-only", `${base}...HEAD`]).catch(() => "")).split("\n").map((x) => x.trim()).filter(Boolean);
137
+ if (out.length) return out;
138
+ return String(await git(["diff", "--name-only", "HEAD"]).catch(() => "")).split("\n").map((x) => x.trim()).filter(Boolean);
139
+ };
140
+ async function postStage(st, result, md) {
141
+ const files = st.key === "implementation" ? await stageFiles() : [];
142
+ const url = await post(st.marker, st.render(md, { result, files }));
143
+ posted[st.key] = url ?? "";
144
+ }
145
+ /** Post stage notes in order that exist, are stable (mtime unchanged for stableMs) and not yet posted. */
146
+ const seen = {};
147
+ async function livePoll() {
148
+ for (const st of STAGES) {
149
+ if (st.key in posted) continue;
150
+ let m = null;
151
+ try { m = await io.fs.stat(`${wt}/${st.file}`); } catch { m = null; }
152
+ if (!m) return;
153
+ const e = seen[st.key];
154
+ if (!e || e.mtime !== m.mtimeMs) { seen[st.key] = { mtime: m.mtimeMs, since: Date.now() }; return; }
155
+ if (Date.now() - e.since < (o.stableMs ?? 2000)) return;
156
+ const md = await readStage(st);
157
+ if (!usableMd(md)) return;
158
+ try { await postStage(st, null, md); } catch { return; }
159
+ }
160
+ }
161
+ /** Post whatever was not posted live, in order; a missing file falls back to the result.json fields. */
162
+ async function flushStages(result, keys) {
163
+ for (const st of STAGES) {
164
+ if (!keys.includes(st.key) || st.key in posted) continue;
165
+ const md = await readStage(st);
166
+ if (!usableMd(md)) {
167
+ if (!result && st.key === "investigation") continue; // nothing to render
168
+ T.anomalies.push(`${st.file} missing or empty: rendered from result.json`);
169
+ }
170
+ await postStage(st, result, md);
171
+ }
172
+ }
173
+
174
+ /** Runner call with the per-run TMPDIR; a usage limit pauses and resumes the same step, or defers past the cap (#4101). */
175
+ async function callRunner(args) {
176
+ let pauses = 0;
177
+ for (;;) {
178
+ try { return await io.runner({ ...args, ...(runTmp ? { env: { TMPDIR: runTmp, CLAUDE_CODE_TMPDIR: runTmp } } : {}) }); }
179
+ catch (e) {
180
+ const epoch = Number.isFinite(e?.resetAtEpochMs) ? e.resetAtEpochMs : null;
181
+ if (!(e?.usageLimit === true || epoch !== null)) throw e;
182
+ const waitMs = epoch !== null && epoch > now() ? epoch - now() : DEFAULT_SESSION_LIMIT_WAIT_MS;
183
+ if (pauses >= maxSessionLimitPauses || waitMs > sessionLimitMaxWaitMs) { e.deferUsageLimit = true; throw e; }
184
+ pauses++;
185
+ ev({ event: "PHASE_RATE_LIMITED", phase: args.role, resetAt: epoch, resetAtDisplay: e.resetAt ?? null, waitMs });
186
+ onProgress({ event: "phase_paused", phase: args.role, reason: "session-limit", detail: `session limit hit; pausing until ${e.resetAt || new Date(now() + waitMs).toISOString()}` });
187
+ await sleep(waitMs);
188
+ }
189
+ }
190
+ }
191
+
192
+ async function builder(n, prompt, resume, live = false) {
105
193
  onProgress({ event: "phase_enter", phase: "build" });
106
194
  await renew();
107
- const r = await io.runner({ role: "builder", issue, cwd: wt, model: builderModel, resume, system: "", prompt });
195
+ const call = Promise.resolve(callRunner({ role: "builder", issue, cwd: wt, model: builderModel, resume, system: "", prompt }));
196
+ let r;
197
+ if (live && io.fs.stat) {
198
+ let settled = false;
199
+ const tracked = call.then((v) => { settled = true; return v; }, (e) => { settled = true; throw e; });
200
+ tracked.catch(() => {});
201
+ while (!settled) {
202
+ let t;
203
+ await Promise.race([tracked.catch(() => {}), new Promise((res) => { t = setTimeout(res, o.livePollMs ?? 1000); })]);
204
+ clearTimeout(t);
205
+ if (!settled) { try { await livePoll(); } catch { /* live posting is best effort */ } }
206
+ }
207
+ r = await tracked;
208
+ } else r = await call;
108
209
  await renew();
109
210
  if (r?.sessionId) sessionId = r.sessionId;
110
211
  await attempt("implement", n, r);
@@ -126,15 +227,22 @@ export async function runCore(o) {
126
227
  if (io.projector) {
127
228
  let st = null;
128
229
  try { st = await io.projector.readState(issue); } catch { /* unreadable: proceed */ }
129
- if (st?.lease && st.lease.until > now() && st.lease.by !== agentId) {
230
+ if (st?.lease && st.lease.until > now() && st.lease.by !== agentId && holderDead(st.lease)) {
231
+ ev({ event: "LEASE_TAKEOVER", from: st.lease.by, host: st.lease.host ?? null, pid: st.lease.pid ?? null });
232
+ onProgress({ event: "lease_takeover", detail: `taking over the lease of dead holder ${st.lease.by} (pid ${st.lease.pid ?? "?"} gone)` });
233
+ } else if (st?.lease && st.lease.until > now() && st.lease.by !== agentId) {
130
234
  return { terminalReason: "deferred", code: "lease-held", detail: `issue ${issue} leased by ${st.lease.by} until ${new Date(st.lease.until).toISOString()}`, branch, worktree: wt };
131
235
  }
132
236
  leaseState = { ...(st ?? { v: 1, run: `core_${issue}`, issue, lane, committed: [], phase: null, branch: null, pr: null, terminalReason: null }), terminal: false };
133
- try { await writeLease({ by: agentId, until: now() + leaseTtlMs }); } catch { /* best effort */ }
134
- leaseTimer = setInterval(() => { writeLease({ by: agentId, until: now() + leaseTtlMs }).catch(() => {}); }, o.leaseRenewMs ?? 120000);
237
+ try { await writeLease(leaseObj()); } catch { /* best effort */ }
238
+ leaseTimer = setInterval(() => { writeLease(leaseObj()).catch(() => {}); }, o.leaseRenewMs ?? 120000);
135
239
  leaseTimer.unref?.();
136
240
  }
137
241
 
242
+ if (io.runTmp) {
243
+ try { await io.runTmp.prune?.(); } catch { /* best effort */ }
244
+ try { runTmp = await io.runTmp.make(issue, runId); } catch { runTmp = null; }
245
+ }
138
246
  ev({ event: "RUN_START", run: `core_${issue}_${t0}`, issue, lane, path: "core", version: ENGINE_VERSION, forge_version: ENGINE_VERSION, repo });
139
247
  let iss;
140
248
  try {
@@ -157,11 +265,36 @@ export async function runCore(o) {
157
265
  } catch { /* no resume info: start fresh */ }
158
266
  try { await io.git(["worktree", "remove", "--force", wt], { cwd }); } catch { /* none */ }
159
267
  try { await io.git(["worktree", "prune"], { cwd }); } catch { /* best effort */ }
268
+ // Concurrent lanes in one repo race on .git/config.lock / index.lock during `worktree add`; retry with jitter.
269
+ // A leftover dir that git no longer lists as a worktree blocks `worktree add`. Remove ONLY this issue's lean dir under the lean root.
270
+ const wtRoot = o.worktreeRoot ?? join(cwd, ".claude", "worktrees");
271
+ const leftoverDir = async () => {
272
+ if (!io.fs.exists || !io.fs.rmdir || dirname(wt) !== wtRoot || basename(wt) !== `lean-${issue}`) return false;
273
+ if (!(await io.fs.exists(wt))) return false;
274
+ const listed = String(await io.git(["worktree", "list", "--porcelain"], { cwd }).catch(() => "")).split("\n").some((l) => l === `worktree ${wt}`);
275
+ if (listed) return false;
276
+ await io.fs.rmdir(wt);
277
+ ev({ event: "WORKTREE_LEFTOVER_REMOVED", path: wt });
278
+ return true;
279
+ };
280
+ await leftoverDir();
281
+ const LOCK_RE = /could not lock|cannot lock ref|\.lock'?: File exists|Unable to create .*\.lock|index\.lock|packed-refs\.lock/i;
282
+ const addWorktree = async (args) => {
283
+ let cleaned = false;
284
+ for (let i = 0; ; i++) {
285
+ try { return await io.git(args, { cwd }); } catch (e) {
286
+ const msg = String(e?.stderr ?? e?.message ?? e);
287
+ if (!cleaned && /already exists/i.test(msg) && (await leftoverDir())) { cleaned = true; continue; }
288
+ if (i >= 4 || !LOCK_RE.test(msg)) throw e;
289
+ await sleep(500 * (i + 1) + Math.floor(random() * 500));
290
+ }
291
+ }
292
+ };
160
293
  if (resumed) {
161
294
  await io.git(["fetch", "origin", branch], { cwd });
162
- await io.git(["worktree", "add", "-B", branch, wt, `origin/${branch}`], { cwd });
295
+ await addWorktree(["worktree", "add", "-B", branch, wt, `origin/${branch}`]);
163
296
  } else {
164
- await io.git(["worktree", "add", "-B", branch, wt, base], { cwd });
297
+ await addWorktree(["worktree", "add", "-B", branch, wt, base]);
165
298
  }
166
299
  wtCreated = true;
167
300
  const excl = String(await git(["rev-parse", "--git-path", "info/exclude"])).trim();
@@ -169,36 +302,41 @@ export async function runCore(o) {
169
302
  const changedFiles = async () => String(await git(["diff", "--name-only", `${base}...HEAD`])).split("\n").map((s) => s.trim()).filter(Boolean);
170
303
 
171
304
  // ---- 3. builder ----
172
- let result = null, attemptN = 0, validateUrl = null, planUrl = null, reviewUrl = null;
305
+ let result = null, attemptN = 0, reviewN = 0;
173
306
  if (!(resumed && (await changedFiles()).length > 0)) {
174
307
  await label("building");
175
- await builder(++attemptN, builderPrompt(issue), null);
308
+ await builder(++attemptN, builderPrompt(issue), null, true);
176
309
  result = await readResult();
177
310
  const files0 = await changedFiles();
178
311
  const evidence = (Array.isArray(result?.evidence) ? result.evidence : []).filter((x) => typeof x === "string" && x.trim());
179
- const verdict = result ? (result.status === "fixed" ? "valid" : result.status) : null;
180
312
  const closing = ["invalid", "already-fixed"].includes(result?.status);
313
+ await flushStages(result, ["investigation"]); // first stage note: the evidence comment for invalid/already-fixed
314
+ T.validate = [result ? (result.status === "fixed" ? "CONFIRMED" : String(result.status).toUpperCase()) : "no result.json", `${evidence.length} evidence item(s)`];
315
+ T.decisions.push(`Builder verdict: ${result?.status ?? "none"}`);
181
316
  if (closing && !evidence.length) return await needsHuman(`Builder reported "${result.status}" without evidence, so the issue is not closed automatically.\n\n${result.summary ?? "(no summary)"}`, OUTCOME_CODES.BUILDER_NO_EVIDENCE);
182
- // The validation comment (no model call): verdict, evidence, one-line summary. For invalid/already-fixed it IS the evidence comment.
183
- if (verdict) validateUrl = await post("FORGE:VALIDATE", renderValidate(result, verdict));
184
317
  if (closing) {
318
+ T.plan = "skipped"; T.implement = "skipped"; T.review = "skipped"; T.pr = "none"; T.merge = "n/a";
185
319
  await ghc(["issue", "close", String(issue), "-R", repo, "--reason", "not planned"]);
186
320
  await label("invalid");
187
321
  return await finish("invalid", result.summary ?? result.status);
188
322
  }
189
323
  if (result?.status === "blocked") return await needsHuman(`Builder is blocked: ${result.summary ?? "(no summary)"}`, OUTCOME_CODES.BUILDER_BLOCKED);
190
324
  if (!files0.length) return await needsHuman(`Builder made no commit${result ? ` (status "${result.status}")` : " and wrote no result.json"}.\n\n${result?.summary ?? ""}`, OUTCOME_CODES.BUILDER_NO_COMMIT);
191
- planUrl = await post("FORGE:PLAN", renderPlan(result, files0));
325
+ await flushStages(result, ["plan", "implementation"]);
326
+ T.plan = "posted"; T.implement = [`${files0.length} file(s)`, branch];
327
+ } else {
328
+ T.validate = "resumed (existing branch)"; T.plan = "skipped (resumed)"; T.implement = ["existing commits", branch];
329
+ T.decisions.push("Open PR or remote branch found: builder skipped, reviewing existing commits");
192
330
  }
193
331
 
194
332
  // ---- 4. review (one retry through the same builder session) ----
195
333
  await label("in-review");
196
- let review = null, reviewN = 0;
334
+ let review = null;
197
335
  const doReview = async () => {
198
336
  for (let t = 0; t < 2; t++) {
199
337
  const diff = String(await git(["diff", `${base}...HEAD`]));
200
338
  await renew();
201
- const rr = await io.runner({ role: "reviewer", issue, cwd: wt, model: reviewerModel, resume: null, system: REVIEWER_SYSTEM, readOnly: true,
339
+ const rr = await callRunner({ role: "reviewer", issue, cwd: wt, model: reviewerModel, resume: null, system: REVIEWER_SYSTEM, readOnly: true,
202
340
  prompt: reviewerPrompt({ issue: iss, diff }) });
203
341
  await renew();
204
342
  await attempt("review", ++reviewN, rr);
@@ -211,72 +349,163 @@ export async function runCore(o) {
211
349
  if (!review) return await needsHuman("Reviewer returned no parseable verdict twice.", OUTCOME_CODES.REVIEW_NO_VERDICT);
212
350
  if (review.fail) {
213
351
  if (!sessionId) return await needsHuman(`Review FAIL and the builder session id is unknown, cannot resume: ${review.reason}`, OUTCOME_CODES.REVIEW_FAILED);
352
+ T.decisions.push(`Review FAIL (round 1): builder resumed with the reason: ${String(review.reason).slice(0, 200)}`);
214
353
  await builder(++attemptN, resumeBuilderPrompt(review.reason), sessionId);
215
354
  result = (await readResult()) ?? result;
216
355
  review = await doReview();
217
356
  if (!review) return await needsHuman("Reviewer returned no parseable verdict twice (re-review).", OUTCOME_CODES.REVIEW_NO_VERDICT);
218
357
  if (review.fail) return await needsHuman(`Review FAIL twice. Last reason: ${review.reason}`, OUTCOME_CODES.REVIEW_FAILED);
219
358
  }
220
- reviewUrl = await post("FORGE:REVIEW", renderReview(review));
359
+ T.review = [review.verdict, `${reviewN} reviewer run(s), ${attemptN > 1 ? "2 rounds" : "1 round"}`];
360
+ await post("FORGE:REVIEW", renderReview(review));
221
361
  ev({ event: "PHASE_COMMIT", phase: "review" });
222
362
 
223
363
  // ---- 5. push + PR ----
224
364
  const files = await changedFiles();
225
365
  await git(["push", "-u", "origin", branch]);
226
366
  const prOut = resumed?.pr ? `/pull/${resumed.pr}` : String(await ghc(["pr", "create", "-R", repo, "--base", lane, "--head", branch,
227
- "--title", `${iss.title} (#${issue})`, "--body", renderPrBody({ issue, result, gitFiles: files })])).trim();
367
+ "--title", `${iss.title} (#${issue})`, "--body", renderPrBody({ issue, investigation: await readStage(STAGES[0]), plan: await readStage(STAGES[1]), implementation: await readStage(STAGES[2]), result, gitFiles: files })])).trim();
228
368
  const pr = Number(/\/pull\/(\d+)/.exec(prOut)?.[1] ?? /(\d+)\s*$/.exec(prOut)?.[1]);
229
369
  if (!Number.isInteger(pr)) return await needsHuman(`Could not parse the PR number from: ${prOut.slice(0, 200)}`);
230
370
  ev({ event: "PR_OPENED", pr });
371
+ T.pr = [`#${pr}`, resumed?.pr ? "existing" : "opened"];
231
372
  const prUrl = /https?:\/\/\S+/.exec(prOut)?.[0] ?? `https://github.com/${repo}/pull/${pr}`;
232
- const localHead = String(await git(["rev-parse", "HEAD"])).trim();
373
+ let localHead = String(await git(["rev-parse", "HEAD"])).trim();
233
374
 
234
- // ---- 6. the register comment ----
235
- // merge policy: required (default) | all | none
375
+ // ---- 6. merge policy: required (default) | all | none ----
236
376
  let policy = mergeWait, required = [];
237
377
  if (mergeWait === "required") {
238
378
  try { required = (await io.requiredChecks?.(lane)) ?? []; } catch { required = []; }
239
379
  }
240
380
  const policyText = mergeWait === "required" ? (required.length ? `required (${required.join(", ")})` : "required (no required checks: merged right after review)") : mergeWait;
241
- await post(REGISTER_MARKER, renderRegister({ result, gitFiles: files, review, links: { validate: validateUrl, plan: planUrl, review: reviewUrl }, prUrl, usage: usageLine(), policy: policyText }));
381
+ T.merge = [policyText, "waiting"];
382
+ T.decisions.push(`merge_wait=${mergeWait}: ${policyText}`);
242
383
 
243
- // ---- 7. wait per policy, merge at the reviewed head ----
244
- if (policy === "none" || (policy === "required" && !required.length)) {
245
- // merge right after review
246
- } else {
247
- const startedAt = now();
248
- let pollIndex = 0, gateHead = null;
249
- for (;;) {
250
- await renew();
251
- if (policy === "all") {
252
- const w = await io.waitCiGreen(pr, repo, 0).catch(() => ({ rc: 2, text: "" }));
253
- if (w.rc === 0) { gateHead = /CI_GATE_HEAD:\s*([0-9a-f]{7,40})/.exec(w.text ?? "")?.[1] ?? null; break; }
254
- if (w.rc === 1) return await needsHuman(`Remote CI failed on PR #${pr}:\n${String(w.text).slice(-2000)}`, OUTCOME_CODES.CI_RED);
255
- } else {
256
- let states = [];
257
- try { states = (await io.checkStates(pr)) ?? []; } catch { states = []; }
258
- const st = (n) => states.find((x) => x.name === n)?.state ?? "pending";
259
- const failed = required.filter((n) => st(n) === "failure");
260
- if (failed.length) return await needsHuman(`Required check(s) failed on PR #${pr}: ${failed.join(", ")}`, OUTCOME_CODES.CI_RED);
261
- if (required.every((n) => st(n) === "success")) break;
384
+ // ---- base-sync (#4100): merge the lane into the branch when the PR conflicts; merge only, never rebase or force-push ----
385
+ let syncs = 0;
386
+ const MAX_SYNCS = 2;
387
+ const gitOut = async (args) => { try { return String(await git(args)).trim(); } catch { return ""; } };
388
+ /** CONFLICTING / DIRTY, or UNKNOWN after one short re-poll. Unreadable state counts as not conflicting. */
389
+ const prConflicting = async () => {
390
+ for (let i = 0; i < 2; i++) {
391
+ let j;
392
+ try { j = JSON.parse(String(await ghc(["pr", "view", String(pr), "-R", repo, "--json", "mergeable,mergeStateStatus"])) || "{}"); } catch { return false; }
393
+ const m = String(j.mergeable ?? "").toUpperCase(), st = String(j.mergeStateStatus ?? "").toUpperCase();
394
+ if (m === "CONFLICTING" || st === "DIRTY") return "conflict";
395
+ if (m !== "UNKNOWN") return false;
396
+ if (i === 0) await sleep(2000);
397
+ }
398
+ return "unknown";
399
+ };
400
+ const conflictFiles = async () => (await gitOut(["diff", "--name-only", "--diff-filter=U"])).split("\n").map((x) => x.trim()).filter(Boolean);
401
+ const unresolved = async () => (await conflictFiles()).length > 0 || (await gitOut(["grep", "-n", "-e", "^<<<<<<< ", "-e", "^>>>>>>> "])) !== ""
402
+ || (await gitOut(["diff", "--check"])) !== "" || (await gitOut(["rev-parse", "-q", "--verify", "MERGE_HEAD"])) !== "";
403
+ const syncRow = [];
404
+ /** Returns null to continue with the merge flow, or a terminal result. */
405
+ async function baseSync(trigger, soft = false) {
406
+ if (syncs >= MAX_SYNCS) return await needsHuman(`PR #${pr} still conflicts with ${lane} after ${MAX_SYNCS} base-syncs (${trigger}).`, OUTCOME_CODES.BASE_CONFLICT);
407
+ syncs++;
408
+ await renew();
409
+ try { await git(["fetch", "origin", lane]); } catch { /* offline: use the local ref */ }
410
+ const baseSha = (await gitOut(["rev-parse", base])).slice(0, 12);
411
+ const before = await gitOut(["rev-parse", "HEAD"]);
412
+ let clean = true, files = [];
413
+ try { await git(["merge", "--no-edit", base]); } catch { clean = false; files = await conflictFiles(); }
414
+ let how, tests = "not required (clean merge)", rereview = "not required (conflict-free base merge)";
415
+ if (clean) {
416
+ how = "clean merge of the base into the branch";
417
+ } else {
418
+ T.decisions.push(`Base-sync ${syncs}: merging ${base} conflicts in ${files.join(", ") || "unknown files"}; builder resolves`);
419
+ const prompt = `Merging ${base} into this branch conflicts in: ${files.join(", ") || "(see git status)"}. 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.`;
420
+ await builder(++attemptN, prompt, sessionId ?? null);
421
+ if (await unresolved()) {
422
+ await gitOut(["merge", "--abort"]);
423
+ return await needsHuman(`Base-sync could not resolve the conflicts in ${files.join(", ")} (conflict markers or an unfinished merge remain).`, OUTCOME_CODES.BASE_CONFLICT);
262
424
  }
263
- if (now() - startedAt >= ciMaxMinutes * 60000) {
264
- // Slow CI is never a needs-human: leave the PR open and resumable.
265
- ev({ event: "CI_PENDING_WAIT", state: "end", pr, result: "timeout" });
266
- return await finish("ci-pending", `PR #${pr} still waiting on CI after ${ciMaxMinutes} min`, { pr });
425
+ how = `builder resolved ${files.length} conflicted file(s) and committed the merge`;
426
+ const r2 = await readResult();
427
+ tests = Array.isArray(r2?.tests) && r2.tests.length ? r2.tests.map((t) => (typeof t === "string" ? t : `${t.command}: ${t.result}`)).join("; ") : NR_TESTS;
428
+ }
429
+ const after = await gitOut(["rev-parse", "HEAD"]);
430
+ if (soft && clean && after === before) { syncs--; return null; } // UNKNOWN state and nothing to merge: not a conflict
431
+ if (after !== before) await git(["push", "origin", branch]); // plain push of a merge commit: never force
432
+ if (!clean) {
433
+ let rv = await doReview();
434
+ if (rv?.fail) {
435
+ T.decisions.push(`Base-sync re-review FAIL: builder resumed with the reason`);
436
+ await builder(++attemptN, resumeBuilderPrompt(rv.reason), sessionId ?? null);
437
+ if (await unresolved()) return await needsHuman("Base-sync re-review fix left conflict markers.", OUTCOME_CODES.BASE_CONFLICT);
438
+ if ((await gitOut(["rev-parse", "HEAD"])) !== after) await git(["push", "origin", branch]);
439
+ rv = await doReview();
440
+ }
441
+ if (!rv) return await needsHuman("Reviewer returned no parseable verdict after base-sync.", OUTCOME_CODES.REVIEW_NO_VERDICT);
442
+ if (rv.fail) return await needsHuman(`Review FAIL after base-sync: ${rv.reason}`, OUTCOME_CODES.REVIEW_FAILED);
443
+ rereview = `${rv.verdict}: ${rv.reason || "no concrete failure"}`;
444
+ }
445
+ localHead = await gitOut(["rev-parse", "HEAD"]);
446
+ syncRow.push(`${trigger}: ${clean ? "clean" : "conflicts"} (${baseSha})`);
447
+ T.baseSync = [`${syncs} sync(s)`, syncRow.join("; ")];
448
+ await post("FORGE:BASE_SYNC", `## Base Sync\n**Trigger:** ${trigger}\n**Base merged:** \`${base}\` at \`${baseSha}\`\n**Result:** ${clean ? "clean merge" : `conflicts in ${files.map((f) => `\`${f}\``).join(", ") || "unknown files"}`}\n**Resolution:** ${how}\n**Tests run:** ${tests}\n**Re-review:** ${rereview}\n**New head:** \`${localHead.slice(0, 12)}\``);
449
+ return null;
450
+ }
451
+ const NR_TESTS = "not reported";
452
+
453
+ // ---- 7. (sync) wait per policy, (sync) merge at the reviewed head ----
454
+ // A rerun on an existing PR goes through the same conflict check, so a conflicting PR from an earlier run recovers.
455
+ for (;;) {
456
+ const c0 = await prConflicting();
457
+ if (c0) { const r = await baseSync(c0 === "unknown" ? "PR mergeability unknown" : "PR is conflicting", c0 === "unknown"); if (r) return r; }
458
+ if (policy === "none" || (policy === "required" && !required.length)) {
459
+ // merge right after review
460
+ T.merge = [policyText, "merged right after review"];
461
+ } else {
462
+ const startedAt = now();
463
+ let pollIndex = 0, gateHead = null;
464
+ for (;;) {
465
+ await renew();
466
+ if (policy === "all") {
467
+ const w = await io.waitCiGreen(pr, repo, 0).catch(() => ({ rc: 2, text: "" }));
468
+ if (w.rc === 0) { gateHead = /CI_GATE_HEAD:\s*([0-9a-f]{7,40})/.exec(w.text ?? "")?.[1] ?? null; break; }
469
+ if (w.rc === 1) return await needsHuman(`Remote CI failed on PR #${pr}:\n${String(w.text).slice(-2000)}`, OUTCOME_CODES.CI_RED);
470
+ } else {
471
+ let states = [];
472
+ try { states = (await io.checkStates(pr)) ?? []; } catch { states = []; }
473
+ const st = (n) => states.find((x) => x.name === n)?.state ?? "pending";
474
+ const failed = required.filter((n) => st(n) === "failure");
475
+ if (failed.length) return await needsHuman(`Required check(s) failed on PR #${pr}: ${failed.join(", ")}`, OUTCOME_CODES.CI_RED);
476
+ if (required.every((n) => st(n) === "success")) break;
477
+ }
478
+ if (now() - startedAt >= ciMaxMinutes * 60000) {
479
+ // Slow CI is never a needs-human: leave the PR open and resumable.
480
+ ev({ event: "CI_PENDING_WAIT", state: "end", pr, result: "timeout" });
481
+ T.anomalies.push("CI still pending at the wait budget");
482
+ return await finish("ci-pending", `PR #${pr} still waiting on CI after ${ciMaxMinutes} min`, { pr });
483
+ }
484
+ onProgress({ event: "phase_paused", phase: "merge", detail: `CI pending on PR #${pr}` });
485
+ await sleep(ciPollDelayMs(pollIndex++, random));
486
+ }
487
+ if (gateHead && !(localHead.startsWith(gateHead) || gateHead.startsWith(localHead))) {
488
+ return await needsHuman(`CI passed for ${gateHead.slice(0, 12)} but the reviewed head is ${localHead.slice(0, 12)}; refusing to merge an unreviewed head.`, OUTCOME_CODES.MERGE_HEAD_MISMATCH);
267
489
  }
268
- onProgress({ event: "phase_paused", phase: "merge", detail: `CI pending on PR #${pr}` });
269
- await sleep(ciPollDelayMs(pollIndex++, random));
270
490
  }
271
- if (gateHead && !(localHead.startsWith(gateHead) || gateHead.startsWith(localHead))) {
272
- return await needsHuman(`CI passed for ${gateHead.slice(0, 12)} but the reviewed head is ${localHead.slice(0, 12)}; refusing to merge an unreviewed head.`, OUTCOME_CODES.MERGE_HEAD_MISMATCH);
491
+ if (syncs > 0 && (await prConflicting()) === "conflict") continue; // the base moved again while waiting
492
+ T.merge = [policyText, `merged at ${localHead.slice(0, 12)}`];
493
+ try {
494
+ await ghc(["pr", "merge", String(pr), "-R", repo, "--merge", "--match-head-commit", localHead]);
495
+ break;
496
+ } catch (e) {
497
+ if (!/not mergeable|merge conflict|conflict/i.test(String(e?.stderr ?? "") + String(e?.message ?? ""))) throw e;
498
+ const r = await baseSync("gh pr merge reported a conflict"); if (r) return r;
273
499
  }
274
500
  }
275
- await ghc(["pr", "merge", String(pr), "-R", repo, "--merge", "--match-head-commit", localHead]);
276
501
  await ghc(["issue", "close", String(issue), "-R", repo, "--reason", "completed"]).catch(() => {});
277
502
  await label("merged");
278
503
  return await finish("merged", `PR #${pr}`, { pr });
279
504
  } catch (e) {
505
+ if (e?.deferUsageLimit) {
506
+ const when = e.resetAt || (Number.isFinite(e.resetAtEpochMs) ? new Date(e.resetAtEpochMs).toISOString() : "unknown");
507
+ return finish("deferred", `usage limit hit; resets at ${when}`, { code: OUTCOME_CODES.RATE_LIMITED, resetAt: when, resetAtEpochMs: Number.isFinite(e.resetAtEpochMs) ? e.resetAtEpochMs : null }, OUTCOME_CODES.RATE_LIMITED);
508
+ }
280
509
  return finish("engine-error", e?.message ?? String(e));
281
510
  }
282
511
  }