forgedock 1.24.3 → 1.24.4

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.
@@ -36,6 +36,9 @@ export const OUTCOME_CODES = Object.freeze({
36
36
  BASE_CONFLICT: "base.conflict",
37
37
  RATE_LIMITED: "rate_limited",
38
38
  HOST_BUSY: "host.busy",
39
+ MERGE_TRANSIENT: "merge.transient",
40
+ PUSH_TRANSIENT: "push.transient",
41
+ CLI_IDLE: "cli.idle",
39
42
  PATH_SWITCH_CONFLICT: "path.switch_conflict",
40
43
  ENGINE_ERROR: "engine.error",
41
44
  INFRA_DISK_QUOTA: "infra.disk_quota",
@@ -113,6 +113,24 @@ export function readAdmission(cwd = process.cwd()) {
113
113
  max_load_per_core: num(e.max_load_per_core ?? e.admission_max_load_per_core) };
114
114
  } catch { return {}; }
115
115
  }
116
+ /**
117
+ * forge.yaml lane caps and CLI timeouts for the core path (#4115, #4112). The parser flattens one nested level, so
118
+ * `pipeline:`/`engine:` then `lane_limits:` with `memory_max`/`cpu_quota` arrives as `memory_max`/`cpu_quota` (or flat
119
+ * `lane_limits_memory_max`/`lane_limits_cpu_quota`). `engine.cli_idle_minutes` (default 15) and `engine.cli_max_minutes` (default 180).
120
+ */
121
+ export function readCoreLimits(cwd = process.cwd()) {
122
+ try {
123
+ const y = parseForgeYaml(readFileSync(join(cwd, "forge.yaml"), "utf8")) ?? {};
124
+ const pick = (k) => { for (const sec of [y.pipeline, y.engine]) { const v = sec?.[k] ?? sec?.[`lane_limits_${k}`]; if (v !== undefined && v !== "") return String(v).trim(); } return undefined; };
125
+ const mem = pick("memory_max"), cpu = pick("cpu_quota");
126
+ const posNum = (v) => { const n = Number(v); return Number.isFinite(n) && n > 0 ? n : undefined; };
127
+ return {
128
+ ...(mem || cpu ? { laneLimits: { ...(mem ? { memory_max: mem } : {}), ...(cpu ? { cpu_quota: cpu } : {}) } } : {}),
129
+ ...(posNum(y.engine?.cli_idle_minutes) ? { cliIdleMinutes: posNum(y.engine.cli_idle_minutes) } : {}),
130
+ ...(posNum(y.engine?.cli_max_minutes) ? { cliMaxMinutes: posNum(y.engine.cli_max_minutes) } : {}),
131
+ };
132
+ } catch { return {}; }
133
+ }
116
134
  /** forge.yaml `engine.merge_wait` (required|all|none); undefined when absent or invalid. */
117
135
  export function readMergeWait(cwd = process.cwd()) {
118
136
  try {
@@ -480,7 +498,7 @@ export async function runFromCli(argv, deps = {}) {
480
498
  const leanIo = deps.leanIo ?? (await import("./lean/effects.mjs")).makeLeanIo({ repo: leanRepo });
481
499
  const lres = await runCore({ issue, repo: leanRepo, lane, cwd: process.cwd(), dir, io: leanIo, path: enginePath, onProgress,
482
500
  emitTelemetry: deps.emitTelemetry ?? makeEmitTelemetry({ repo: leanRepo }),
483
- admission: readAdmission(), ...(readMergeWait() ? { mergeWait: readMergeWait() } : {}), ...(model ? { builderModel: model } : {}), ...(readLeanSetting("reviewer_model") ? { reviewerModel: readLeanSetting("reviewer_model") } : {}) });
501
+ admission: readAdmission(), ...readCoreLimits(), ...(readMergeWait() ? { mergeWait: readMergeWait() } : {}), ...(model ? { builderModel: model } : {}), ...(readLeanSetting("reviewer_model") ? { reviewerModel: readLeanSetting("reviewer_model") } : {}) });
484
502
  if (lres.code === "host.busy") { console.log(`issue #${issue} → deferred (${lres.detail})`); return lres; }
485
503
  console.log(`issue #${issue} → ${lres.terminalReason}`);
486
504
  if (!["merged", "invalid"].includes(lres.terminalReason)) console.log(lres.detail ?? "");
@@ -673,7 +691,7 @@ export async function orchestrateFromCli(argv, deps = {}) {
673
691
  issues, parallel,
674
692
  readBody: async (n) => JSON.parse(await leanIo.gh(["issue", "view", String(n), "-R", repo, "--json", "body"])).body,
675
693
  runOne: (n) => runCore({ issue: n, repo, lane, cwd: process.cwd(), dir, io: leanIo, agentId: `orch_${process.pid}_${n}`, emitTelemetry,
676
- admission: readAdmission(), ...(readMergeWait() ? { mergeWait: readMergeWait() } : {}), ...(model ? { builderModel: model } : {}), ...(readLeanSetting("reviewer_model") ? { reviewerModel: readLeanSetting("reviewer_model") } : {}) }),
694
+ admission: readAdmission(), ...readCoreLimits(), ...(readMergeWait() ? { mergeWait: readMergeWait() } : {}), ...(model ? { builderModel: model } : {}), ...(readLeanSetting("reviewer_model") ? { reviewerModel: readLeanSetting("reviewer_model") } : {}) }),
677
695
  onUpdate: (r) => console.log(`#${r.issue} → ${r.outcome}${r.detail && r.outcome !== "merged" ? ` (${r.detail})` : ""}`),
678
696
  });
679
697
  console.log("\n" + formatTable(rows));
@@ -1,7 +1,7 @@
1
1
  // SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
2
2
  // SPDX-License-Identifier: AGPL-3.0-or-later
3
3
  /** Core work-on: the real effects behind runCore's injected `io`. Argv only, no shell. */
4
- import { execFile } from "node:child_process";
4
+ import { execFile, execFileSync } from "node:child_process";
5
5
  import { promisify } from "node:util";
6
6
  import { readFileSync, appendFileSync, mkdirSync, statSync, existsSync, rmSync, writeFileSync, readdirSync, lstatSync } from "node:fs";
7
7
  import { homedir, hostname, freemem, loadavg, cpus, tmpdir } from "node:os";
@@ -16,14 +16,32 @@ const pexec = promisify(execFile);
16
16
  const script = (n) => fileURLToPath(new URL(`../../scripts/${n}`, import.meta.url));
17
17
 
18
18
  /** Claude CLI session runner: fresh session, or `--resume <id>` for the builder. Resolves usage + session_id. */
19
- export function makeLeanRunner({ runCli = runCliBackendAsync } = {}) {
20
- return async ({ role, issue, cwd, model, resume, system, prompt, readOnly = false, env = null }) => {
19
+ let systemdProbe = null;
20
+ /** `systemd-run --user --scope` usable here? Probed once and cached. */
21
+ export function systemdRunAvailable() {
22
+ if (systemdProbe === null) {
23
+ try { execFileSync("systemd-run", ["--user", "--scope", "-q", "true"], { stdio: "ignore", timeout: 5000 }); systemdProbe = true; } catch { systemdProbe = false; }
24
+ }
25
+ return systemdProbe;
26
+ }
27
+ const MEM_RE = /^\d+(\.\d+)?[KMGT]?$/i, CPU_RE = /^\d+%$/;
28
+ /** Cap the whole lane process tree: spawn under `systemd-run --user --scope -p MemoryMax=.. -p CPUQuota=..`. Unset or invalid limits leave the spawn unchanged. */
29
+ export function wrapLaneLimits(bin, args, limits) {
30
+ const mem = limits?.memory_max && MEM_RE.test(String(limits.memory_max)) ? String(limits.memory_max) : null;
31
+ const cpu = limits?.cpu_quota && CPU_RE.test(String(limits.cpu_quota)) ? String(limits.cpu_quota) : null;
32
+ if (!mem && !cpu) return { bin, args };
33
+ return { bin: "systemd-run", args: ["--user", "--scope", "-q", ...(mem ? ["-p", `MemoryMax=${mem}`] : []), ...(cpu ? ["-p", `CPUQuota=${cpu}`] : []), "--", bin, ...args] };
34
+ }
35
+
36
+ export function makeLeanRunner({ runCli = runCliBackendAsync, probe = systemdRunAvailable } = {}) {
37
+ return async ({ role, issue, cwd, model, resume, system, prompt, readOnly = false, env = null, idleMs = 0, maxMs = 0, laneLimits = null, control = null }) => {
21
38
  const bin = resolveClaudeCliBinary(cwd) || "claude";
22
39
  const caps = getClaudeCliCapabilities(cwd);
23
40
  const flags = [...buildLeanSessionFlags(caps), ...(readOnly ? buildReadOnlyToolFlags(caps) : [])];
24
41
  const r = await runCli({ spec: { name: `core-${role}` }, userMessage: prompt, systemPrompt: resume ? "" : system,
25
42
  args: [String(issue)], cwd, bin, model, env, sessionFlags: flags, extraArgs: resume ? ["--resume", resume] : [],
26
- logger: { log() {} } });
43
+ logger: { log() {} },
44
+ idleMs, maxMs, stream: idleMs > 0, control, wrap: laneLimits && probe() ? (b, a) => wrapLaneLimits(b, a, laneLimits) : null });
27
45
  return { text: r.text, sessionId: r.sessionId, usage: r.usage, costUsd: r.costUsd, durationMs: r.durationMs, turns: r.turns, model: r.cliModel ?? "default" };
28
46
  };
29
47
  }
package/bin/lean/lean.mjs CHANGED
@@ -12,6 +12,7 @@ import { codeForTerminalReason, OUTCOME_CODES } from "../engine/outcome-codes.mj
12
12
  import { ciPollDelayMs, ENGINE_VERSION, DEFAULT_MAX_SESSION_LIMIT_PAUSES, DEFAULT_SESSION_LIMIT_WAIT_MS, parseLeaseHolder, sanitizeLeaseHost } from "../engine.mjs";
13
13
  import { openDependency } from "../engine/phases.mjs";
14
14
  import { checkAdmission, ADMISSION_RETRY_SECONDS } from "./admission.mjs";
15
+ import { retrying, PUSH_TRANSIENT_RE, PUSH_PERMANENT_RE, GH_TRANSIENT_RE, MERGE_TRANSIENT_RE, MERGE_CONFLICT_RE } from "./retry.mjs";
15
16
  import { renderInvestigation, renderPlan, renderImplementation, renderReview, renderPrBody, renderTrajectory, capComment, sanitizeMd } from "./render.mjs";
16
17
  import { REVIEWER_SYSTEM, LEAN_DIR, RESULT_FILE, INVESTIGATION_FILE, PLAN_FILE, IMPLEMENTATION_FILE, builderPrompt, resumeBuilderPrompt, reviewerPrompt, parseVerdict } from "./prompts.mjs";
17
18
 
@@ -33,7 +34,7 @@ const num = (v) => (Number.isFinite(v) ? v : null);
33
34
  */
34
35
  export async function runCore(o) {
35
36
  const { issue, repo, lane, cwd, dir, io, builderModel = null, reviewerModel = DEFAULT_REVIEWER_MODEL,
36
- ciMaxMinutes = 360, mergeWait = "required", maxSessionLimitPauses = DEFAULT_MAX_SESSION_LIMIT_PAUSES, sessionLimitMaxWaitMs = DEFAULT_SESSION_LIMIT_WAIT_MS, onProgress = () => {}, emitTelemetry = null, random = Math.random } = o;
37
+ ciMaxMinutes = 360, mergeWait = "required", maxSessionLimitPauses = DEFAULT_MAX_SESSION_LIMIT_PAUSES, cliIdleMinutes = 15, cliMaxMinutes = 180, laneLimits = null, maxMergeRetries = 5, sessionLimitMaxWaitMs = DEFAULT_SESSION_LIMIT_WAIT_MS, onProgress = () => {}, emitTelemetry = null, random = Math.random } = o;
37
38
  const now = io.now ?? (() => Date.now());
38
39
  const sleep = io.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
39
40
  const base = `origin/${lane}`;
@@ -46,6 +47,9 @@ export async function runCore(o) {
46
47
  const ev = (e) => appendEvent(dir, issue, e);
47
48
  const emit = async (rec) => { try { await emitTelemetry?.({ ...rec, issue }); } catch { /* never fails a run */ } };
48
49
  const ghc = (args) => io.gh(args);
50
+ const sleepFn = io.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
51
+ /** gh writes (comment, pr create, issue edit/close): retried for 5xx and secondary rate limits (#4114). */
52
+ const ghw = (args) => retrying(() => io.gh(args), { transient: GH_TRANSIENT_RE, attempts: 4, sleep: sleepFn, random });
49
53
  const git = (args) => io.git(args, { cwd: wt });
50
54
 
51
55
  async function attempt(phase, n, r) {
@@ -59,10 +63,10 @@ export async function runCore(o) {
59
63
  }
60
64
  const post = async (marker, body) => {
61
65
  const marks = (Array.isArray(marker) ? marker : [marker]).map((m) => `<!-- ${m} -->`).join("\n");
62
- const out = String(await ghc(["issue", "comment", String(issue), "-R", repo, "--body", capComment(`${marks}\n${body}`)])).trim();
66
+ const out = String(await ghw(["issue", "comment", String(issue), "-R", repo, "--body", capComment(`${marks}\n${body}`)])).trim();
63
67
  return /https?:\/\/\S+/.exec(out)?.[0] ?? null;
64
68
  };
65
- const label = async (state) => { try { await io.transitionLabel(issue, state); } catch { /* labels are best effort */ } };
69
+ const label = async (state) => { try { await retrying(() => io.transitionLabel(issue, state), { transient: GH_TRANSIENT_RE, attempts: 4, sleep: sleepFn, random }); } catch { /* labels are best effort */ } };
66
70
  const usageLine = () => `cost $${usage.cost_usd.toFixed(4)}, tokens ${usage.input} in / ${usage.output} out / ${usage.cache} cache, wall ${Math.round((now() - t0) / 60000 * 10) / 10} min`;
67
71
 
68
72
  // ---- lease (same FORGE:STATE lease the full path uses) ----
@@ -190,11 +194,76 @@ export async function runCore(o) {
190
194
  }
191
195
  }
192
196
 
197
+ /** git push with transient retry (#4114): 4 attempts; permission/protected-branch rejections fail at once. */
198
+ const gitPush = async (args) => {
199
+ try { return await retrying(() => git(args), { transient: PUSH_TRANSIENT_RE, permanent: PUSH_PERMANENT_RE, attempts: 4, sleep: sleepFn, random }); }
200
+ catch (e) {
201
+ 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)}`; }
202
+ else if (e?.permanent) e.pushRejected = true;
203
+ throw e;
204
+ }
205
+ };
206
+
207
+ /** After a timeout/idle kill: commit uncommitted work as a checkpoint and push the lane branch so a rerun resumes from it (#4112). */
208
+ async function checkpoint(reason) {
209
+ try {
210
+ const dirty = String(await git(["status", "--porcelain"])).trim();
211
+ if (dirty) { await git(["add", "-A"]); await git(["commit", "--no-verify", "-m", `wip(forge): checkpoint after ${reason}`]); }
212
+ const ahead = String(await git(["diff", "--name-only", `${base}...HEAD`])).trim();
213
+ if (ahead) await gitPush(["push", "-u", "origin", branch]);
214
+ ev({ event: "CHECKPOINT", reason, dirty: Boolean(dirty), pushed: Boolean(ahead) });
215
+ } catch (e) { ev({ event: "CHECKPOINT_FAILED", reason, error: String(e?.message ?? e).slice(0, 300) }); }
216
+ }
217
+ const CONTINUE_NOTE = "\n\nThe branch already holds work from an earlier session that was interrupted (see the latest `wip(forge): checkpoint` commit). Continue from it: read `git log` and `git diff origin/BASE...HEAD`, do not start over.";
218
+
219
+ /** Continuous admission (#4115): while the builder runs, pause its process group when the host is over the ceiling, resume when under. */
220
+ function startGovernor(control) {
221
+ if (!io.host || o.governor === false || o.admission?.enabled === false) return { stop() {} };
222
+ const pollMs = o.governorPollMs ?? 15000, maxPauseMs = o.maxPauseMs ?? 600000;
223
+ let stopped = false, paused = false, forced = false, pausedAt = 0, wake = null, t = null;
224
+ const loop = (async () => {
225
+ while (!stopped) {
226
+ await new Promise((res) => { wake = res; t = setTimeout(res, pollMs); });
227
+ clearTimeout(t);
228
+ if (stopped) break;
229
+ let ok = true;
230
+ try { ok = (await checkAdmission({ cfg: o.admission, host: io.host })).ok; } catch { ok = true; }
231
+ const nowMs = Date.now();
232
+ if (!ok && !paused && !forced && control.pause) { if (control.pause()) { paused = true; pausedAt = nowMs; ev({ event: "LANE_PAUSED", reason: "host over ceiling" }); onProgress({ event: "phase_paused", phase: "builder", reason: "host-busy", detail: "host over the admission ceiling; lane paused" }); } }
233
+ else if (ok && paused) { control.resume?.(); paused = false; forced = false; ev({ event: "LANE_RESUMED", reason: "host recovered" }); }
234
+ else if (ok) forced = false;
235
+ else if (paused && nowMs - pausedAt >= maxPauseMs) { control.resume?.(); paused = false; forced = true; ev({ event: "LANE_RESUMED", reason: "max pause reached" }); }
236
+ }
237
+ })();
238
+ return { async stop() { stopped = true; wake?.(); clearTimeout(t); await loop; if (paused) { control.resume?.(); paused = false; } } };
239
+ }
240
+
193
241
  async function builder(n, prompt, resume, live = false) {
242
+ let timeoutRetries = 0, p = prompt;
243
+ for (;;) {
244
+ try { return await builderOnce(n, p, resume, live); } catch (e) {
245
+ if (!(e?.code === "CLI_IDLE" || e?.code === "CLI_TIMEOUT")) throw e;
246
+ const idle = e.code === "CLI_IDLE";
247
+ const reason = idle ? "idle timeout" : "timeout";
248
+ ev({ event: idle ? "CLI_IDLE_KILL" : "CLI_TIMEOUT_KILL", reason, lastTool: e.lastTool ?? null, children: e.children ?? [] });
249
+ await checkpoint(reason);
250
+ if (timeoutRetries++ >= 1) { e.deferCode = idle ? OUTCOME_CODES.CLI_IDLE : OUTCOME_CODES.CLI_TIMEOUT; e.deferDetail = `builder ${reason}; work checkpointed to ${branch}; rerun resumes from it. ${String(e.message).slice(0, 300)}`; throw e; }
251
+ T.anomalies.push(`builder ${reason}: checkpointed, retried once`);
252
+ T.decisions.push(`Builder ${reason}: uncommitted work checkpointed to the lane branch; retrying once from the checkpoint`);
253
+ p = builderPrompt(issue) + CONTINUE_NOTE.replace("BASE", base); resume = null;
254
+ }
255
+ }
256
+ }
257
+
258
+ async function builderOnce(n, prompt, resume, live = false) {
194
259
  onProgress({ event: "phase_enter", phase: "build" });
195
260
  await renew();
196
- const call = Promise.resolve(callRunner({ role: "builder", issue, cwd: wt, model: builderModel, resume, system: "", prompt }));
261
+ const control = {};
262
+ const governor = startGovernor(control);
263
+ const call = Promise.resolve(callRunner({ role: "builder", issue, cwd: wt, model: builderModel, resume, system: "", prompt, control,
264
+ idleMs: Math.round(cliIdleMinutes * 60000), maxMs: Math.round(cliMaxMinutes * 60000), laneLimits }));
197
265
  let r;
266
+ call.catch(() => governor.stop());
198
267
  if (live && io.fs.stat) {
199
268
  let settled = false;
200
269
  const tracked = call.then((v) => { settled = true; return v; }, (e) => { settled = true; throw e; });
@@ -206,7 +275,8 @@ export async function runCore(o) {
206
275
  if (!settled) { try { await livePoll(); } catch { /* live posting is best effort */ } }
207
276
  }
208
277
  r = await tracked;
209
- } else r = await call;
278
+ } else { try { r = await call; } finally { /* governor stops below */ } }
279
+ await governor.stop();
210
280
  await renew();
211
281
  if (r?.sessionId) sessionId = r.sessionId;
212
282
  await attempt("implement", n, r);
@@ -309,9 +379,11 @@ export async function runCore(o) {
309
379
 
310
380
  // ---- 3. builder ----
311
381
  let result = null, attemptN = 0, reviewN = 0;
312
- if (!(resumed && (await changedFiles()).length > 0)) {
382
+ const lastSubject = resumed ? String(await git(["log", "-1", "--format=%s"]).catch(() => "")).trim() : "";
383
+ const fromCheckpoint = lastSubject.startsWith("wip(forge): checkpoint");
384
+ if (!(resumed && (await changedFiles()).length > 0) || fromCheckpoint) {
313
385
  await label("building");
314
- await builder(++attemptN, builderPrompt(issue), null, true);
386
+ await builder(++attemptN, builderPrompt(issue) + (fromCheckpoint ? CONTINUE_NOTE.replace("BASE", base) : ""), null, true);
315
387
  result = await readResult();
316
388
  const files0 = await changedFiles();
317
389
  const evidence = (Array.isArray(result?.evidence) ? result.evidence : []).filter((x) => typeof x === "string" && x.trim());
@@ -322,7 +394,7 @@ export async function runCore(o) {
322
394
  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);
323
395
  if (closing) {
324
396
  T.plan = "skipped"; T.implement = "skipped"; T.review = "skipped"; T.pr = "none"; T.merge = "n/a";
325
- await ghc(["issue", "close", String(issue), "-R", repo, "--reason", "not planned"]);
397
+ await ghw(["issue", "close", String(issue), "-R", repo, "--reason", "not planned"]);
326
398
  await label("invalid");
327
399
  return await finish("invalid", result.summary ?? result.status);
328
400
  }
@@ -368,8 +440,8 @@ export async function runCore(o) {
368
440
 
369
441
  // ---- 5. push + PR ----
370
442
  const files = await changedFiles();
371
- await git(["push", "-u", "origin", branch]);
372
- const prOut = resumed?.pr ? `/pull/${resumed.pr}` : String(await ghc(["pr", "create", "-R", repo, "--base", lane, "--head", branch,
443
+ await gitPush(["push", "-u", "origin", branch]);
444
+ const prOut = resumed?.pr ? `/pull/${resumed.pr}` : String(await ghw(["pr", "create", "-R", repo, "--base", lane, "--head", branch,
373
445
  "--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();
374
446
  const pr = Number(/\/pull\/(\d+)/.exec(prOut)?.[1] ?? /(\d+)\s*$/.exec(prOut)?.[1]);
375
447
  if (!Number.isInteger(pr)) return await needsHuman(`Could not parse the PR number from: ${prOut.slice(0, 200)}`);
@@ -388,7 +460,7 @@ export async function runCore(o) {
388
460
  T.decisions.push(`merge_wait=${mergeWait}: ${policyText}`);
389
461
 
390
462
  // ---- base-sync (#4100): merge the lane into the branch when the PR conflicts; merge only, never rebase or force-push ----
391
- let syncs = 0;
463
+ let syncs = 0, mergeRetries = 0;
392
464
  const MAX_SYNCS = 2;
393
465
  const gitOut = async (args) => { try { return String(await git(args)).trim(); } catch { return ""; } };
394
466
  /** CONFLICTING / DIRTY, or UNKNOWN after one short re-poll. Unreadable state counts as not conflicting. */
@@ -434,14 +506,14 @@ export async function runCore(o) {
434
506
  }
435
507
  const after = await gitOut(["rev-parse", "HEAD"]);
436
508
  if (soft && clean && after === before) { syncs--; return null; } // UNKNOWN state and nothing to merge: not a conflict
437
- if (after !== before) await git(["push", "origin", branch]); // plain push of a merge commit: never force
509
+ if (after !== before) await gitPush(["push", "origin", branch]); // plain push of a merge commit: never force
438
510
  if (!clean) {
439
511
  let rv = await doReview();
440
512
  if (rv?.fail) {
441
513
  T.decisions.push(`Base-sync re-review FAIL: builder resumed with the reason`);
442
514
  await builder(++attemptN, resumeBuilderPrompt(rv.reason), sessionId ?? null);
443
515
  if (await unresolved()) return await needsHuman("Base-sync re-review fix left conflict markers.", OUTCOME_CODES.BASE_CONFLICT);
444
- if ((await gitOut(["rev-parse", "HEAD"])) !== after) await git(["push", "origin", branch]);
516
+ if ((await gitOut(["rev-parse", "HEAD"])) !== after) await gitPush(["push", "origin", branch]);
445
517
  rv = await doReview();
446
518
  }
447
519
  if (!rv) return await needsHuman("Reviewer returned no parseable verdict after base-sync.", OUTCOME_CODES.REVIEW_NO_VERDICT);
@@ -500,11 +572,19 @@ export async function runCore(o) {
500
572
  await ghc(["pr", "merge", String(pr), "-R", repo, "--merge", "--match-head-commit", localHead]);
501
573
  break;
502
574
  } catch (e) {
503
- if (!/not mergeable|merge conflict|conflict/i.test(String(e?.stderr ?? "") + String(e?.message ?? ""))) throw e;
575
+ const msg = `${e?.stderr ?? ""}${e?.message ?? ""}`;
576
+ if (MERGE_TRANSIENT_RE.test(msg) && !/not mergeable|merge conflict/i.test(msg)) {
577
+ // "Base branch was modified", GraphQL and 5xx are transient: back off, then the loop top re-reads mergeable and the head.
578
+ if (++mergeRetries > maxMergeRetries) return await finish("deferred", `gh pr merge kept failing transiently (${mergeRetries - 1} retries): ${msg.slice(0, 300)}`, { pr, code: OUTCOME_CODES.MERGE_TRANSIENT }, OUTCOME_CODES.MERGE_TRANSIENT);
579
+ T.anomalies.push(`gh pr merge transient failure (retry ${mergeRetries}): ${msg.split("\n")[0].slice(0, 120)}`);
580
+ await sleepFn(Math.min(60000, 1000 * 2 ** (mergeRetries - 1)) + Math.floor(random() * 250));
581
+ continue;
582
+ }
583
+ if (!MERGE_CONFLICT_RE.test(msg)) throw e;
504
584
  const r = await baseSync("gh pr merge reported a conflict"); if (r) return r;
505
585
  }
506
586
  }
507
- await ghc(["issue", "close", String(issue), "-R", repo, "--reason", "completed"]).catch(() => {});
587
+ await ghw(["issue", "close", String(issue), "-R", repo, "--reason", "completed"]).catch(() => {});
508
588
  await label("merged");
509
589
  return await finish("merged", `PR #${pr}`, { pr });
510
590
  } catch (e) {
@@ -512,6 +592,8 @@ export async function runCore(o) {
512
592
  const when = e.resetAt || (Number.isFinite(e.resetAtEpochMs) ? new Date(e.resetAtEpochMs).toISOString() : "unknown");
513
593
  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);
514
594
  }
595
+ if (e?.deferCode) return finish("deferred", e.deferDetail ?? String(e.message), { code: e.deferCode }, e.deferCode);
596
+ if (e?.pushRejected) return await needsHuman(`git push was rejected (no retry): ${String(e?.stderr ?? e?.message ?? e).slice(0, 400)}`);
515
597
  return finish("engine-error", e?.message ?? String(e));
516
598
  }
517
599
  }
@@ -9,7 +9,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
+ import { makeRunTmp, wrapLaneLimits, systemdRunAvailable, makeLeanRunner } from "./effects.mjs";
13
+ import { retrying, PUSH_TRANSIENT_RE } from "./retry.mjs";
13
14
  import { checkAdmission } from "./admission.mjs";
14
15
  import { OUTCOME_CODES } from "../engine/outcome-codes.mjs";
15
16
  import { readAdmission } from "../engine-cli.mjs";
@@ -30,7 +31,7 @@ function world(o = {}) {
30
31
  comments: [], labels: [], closed: [], edits: [], prs: [], merges: [], calls: [], telemetry: [], files: {}, head: "aaaa1111aaaa1111",
31
32
  changed: o.changed ?? ["src/foo.js", "test/foo.test.js"], reviews: [...(o.reviews ?? [])], waits: [...(o.waits ?? [])], clock: 0,
32
33
  result: o.result ?? { status: "fixed", summary: "fixed foo", evidence: [], tests: ["test/foo.test.js"] },
33
- 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,
34
+ prList: o.prList ?? [], remote: o.remote ?? "", resumeResult: o.resumeResult, seq: [], mtimes: {}, envs: [], pushes: 0, pushErrors: [...(o.pushErrors ?? [])], commentErrors: [...(o.commentErrors ?? [])], dirty: o.dirty ?? false, lastSubject: o.lastSubject ?? "", prView: [...(o.prView ?? [])], mergeResults: [...(o.mergeResults ?? [])], mergeErrors: [...(o.mergeErrors ?? [])], conflictFiles: [], mergeInProgress: false, markers: false, mergeAttempts: 0,
34
35
  stages: { ...STAGE_MD, ...(o.stages ?? {}) },
35
36
  };
36
37
  const put = (name, text) => { const p = `${WT}/.forge-lean/${name}`; if (w.files[p] !== text) w.mtimes[p] = Date.now(); w.files[p] = text; };
@@ -43,6 +44,7 @@ function world(o = {}) {
43
44
  if (a[0] === "api") return "";
44
45
  if (k === "pr list") return JSON.stringify(w.prList);
45
46
  if (k === "issue view") return JSON.stringify({ number: 7, title: "foo breaks", body: "Bug in foo", state: "OPEN" });
47
+ if (k === "issue comment" && w.commentErrors.length) throw new Error(w.commentErrors.shift());
46
48
  if (k === "issue comment") { w.comments.push(a[a.indexOf("--body") + 1]); w.seq.push("comment"); return `https://github.com/o/r/issues/7#issuecomment-${w.comments.length}\n`; }
47
49
  if (k === "issue close") { w.closed.push(a); w.seq.push("close"); return ""; }
48
50
  if (k === "issue edit") { w.edits.push(a); return ""; }
@@ -59,6 +61,9 @@ function world(o = {}) {
59
61
  w.lockFails--; const e = new Error("git failed"); e.stderr = "error: could not lock config file .git/config: File exists"; throw e;
60
62
  }
61
63
  if (a[0] === "rev-parse" && a[1] === "--git-path") return "/repo/.git/info/exclude";
64
+ if (a[0] === "status" && a[1] === "--porcelain") return w.dirty ? " M src/foo.js\n" : "";
65
+ if (a[0] === "log" && a.includes("--format=%s")) return w.lastSubject;
66
+ if (a[0] === "push") { w.pushes++; if (w.pushErrors.length) { const m = w.pushErrors.shift(); throw Object.assign(new Error(m), { stderr: m }); } }
62
67
  if (a[0] === "merge" && a[1] === "--no-edit") {
63
68
  const r = w.mergeResults.shift() ?? "clean";
64
69
  if (r === "clean") { w.head = `cccc${w.calls.length}`.padEnd(16, "0"); return ""; }
@@ -739,7 +744,7 @@ describe("base-sync (#4100)", () => {
739
744
  });
740
745
 
741
746
  it("an unrelated gh pr merge error is not a base-sync", async () => {
742
- const w = world({ mergeErrors: ["HTTP 500 boom"] });
747
+ const w = world({ mergeErrors: ["GraphQL: validation failed (422)"] });
743
748
  const r = await run(w);
744
749
  assert.equal(r.terminalReason, "engine-error");
745
750
  assert.ok(!syncComment(w));
@@ -979,3 +984,215 @@ describe("host admission (#4105)", () => {
979
984
  assert.match(spec, /rate_limited[\s\S]*stop launching\s+new lanes until that reset time/);
980
985
  });
981
986
  });
987
+
988
+ describe("batch robustness (#4112-#4115)", () => {
989
+ const mergeCount = (w) => w.merges.length;
990
+
991
+ // ---- #4115 ----
992
+ it("#4115a the builder prompt pins targeted tests, no extra checkouts, no browsers, scratch in $TMPDIR (within budget)", () => {
993
+ const p = builderPrompt(7);
994
+ for (const needle of ["run only targeted tests", "never the full suite", "Never create extra checkouts, copies or base clones", "git show or git diff origin/<base>", "Never install browsers", "$TMPDIR"]) assert.ok(p.includes(needle), needle);
995
+ assert.ok(approxTokens(p) <= 2800, `builder prompt is ${approxTokens(p)} tokens`);
996
+ });
997
+
998
+ it("#4115b lane limits wrap the spawn in systemd-run; unset or invalid limits do not", () => {
999
+ const w = wrapLaneLimits("claude", ["--print", "x"], { memory_max: "3G", cpu_quota: "150%" });
1000
+ assert.deepEqual(w, { bin: "systemd-run", args: ["--user", "--scope", "-q", "-p", "MemoryMax=3G", "-p", "CPUQuota=150%", "--", "claude", "--print", "x"] });
1001
+ assert.deepEqual(wrapLaneLimits("claude", ["a"], null), { bin: "claude", args: ["a"] });
1002
+ assert.deepEqual(wrapLaneLimits("claude", ["a"], { memory_max: "lots; rm -rf /", cpu_quota: "x" }), { bin: "claude", args: ["a"] });
1003
+ assert.equal(wrapLaneLimits("claude", ["a"], { memory_max: "1G" }).args.includes("CPUQuota=150%"), false);
1004
+ });
1005
+
1006
+ it("#4115b the runner wraps only when limits are set AND systemd-run probes available (clean fallback)", async () => {
1007
+ const seen = [];
1008
+ const runCli = async (o) => { seen.push(o.wrap ? o.wrap("claude", ["a"]).bin : null); return { text: "", usage: null, costUsd: 0, durationMs: 0, turns: 0, sessionId: "s" }; };
1009
+ const base = { role: "builder", issue: 1, cwd: process.cwd(), prompt: "p", system: "" };
1010
+ await makeLeanRunner({ runCli, probe: () => true })({ ...base, laneLimits: { memory_max: "3G" } });
1011
+ await makeLeanRunner({ runCli, probe: () => false })({ ...base, laneLimits: { memory_max: "3G" } });
1012
+ await makeLeanRunner({ runCli, probe: () => true })({ ...base });
1013
+ assert.deepEqual(seen, ["systemd-run", null, null]);
1014
+ });
1015
+
1016
+ it("#4115b integration: a lane under systemd-run --scope really runs in a capped scope (skipped without systemd-run)", { skip: !systemdRunAvailable() }, async () => {
1017
+ const { writeFileSync, chmodSync } = await import("node:fs");
1018
+ const { runCliBackendAsync } = await import("../runner.mjs");
1019
+ const d = tmpDir("core-scope-");
1020
+ writeFileSync(`${d}/fake-claude`, '#!/bin/sh\nprintf \'{"result":"%s"}\\n\' "$(cat /proc/self/cgroup | tr -d \'\\n\' | tr / :)"\n'); chmodSync(`${d}/fake-claude`, 0o755);
1021
+ const r = await runCliBackendAsync({ spec: { name: "t" }, userMessage: "go", args: ["1"], cwd: d, bin: `${d}/fake-claude`, logger: { log() {} }, wrap: (b, a) => wrapLaneLimits(b, a, { memory_max: "512M", cpu_quota: "100%" }) });
1022
+ assert.match(r.text, /\.scope/);
1023
+ });
1024
+
1025
+ const hostFor = (state) => ({ memAvailableMb: () => 99999, freeMb: async () => 99999, tmpPaths: () => ["/t"], loadAvg1: () => state.load(), cores: () => 2 });
1026
+ const slowBuilder = (w, ms, hook) => { const orig = w.io.runner; w.io.runner = async (c) => { if (c.role !== "builder") return orig(c); hook?.(c); await new Promise((r) => setTimeout(r, ms)); return orig(c); }; };
1027
+
1028
+ it("#4115c the governor pauses the builder's process group over the ceiling and resumes under it; never pauses past the max", async () => {
1029
+ const w = world(); const calls = [];
1030
+ let load = 20;
1031
+ w.io.host = hostFor({ load: () => load });
1032
+ slowBuilder(w, 400, (c) => { c.control.pause = () => { calls.push("pause"); return true; }; c.control.resume = () => { calls.push("resume"); load = 1; return true; }; setTimeout(() => { load = 1; }, 150); });
1033
+ // host starts under the ceiling at admission, then goes over while the builder runs
1034
+ let first = true; const lf = w.io.host.loadAvg1; w.io.host.loadAvg1 = () => { if (first) { first = false; return 1; } return lf(); };
1035
+ const r = await run(w, { governorPollMs: 20, admission: { max_load_per_core: 4 } });
1036
+ assert.equal(r.terminalReason, "merged");
1037
+ assert.deepEqual(calls.slice(0, 2), ["pause", "resume"]);
1038
+ // max pause: stays over the whole time; resumed anyway after maxPauseMs and not re-paused
1039
+ const w2 = world(); const calls2 = [];
1040
+ w2.io.host = hostFor({ load: () => 20 }); let f2 = true; const l2 = w2.io.host.loadAvg1; w2.io.host.loadAvg1 = () => { if (f2) { f2 = false; return 1; } return l2(); };
1041
+ slowBuilder(w2, 500, (c) => { c.control.pause = () => { calls2.push("pause"); return true; }; c.control.resume = () => { calls2.push("resume"); return true; }; });
1042
+ await run(w2, { governorPollMs: 20, maxPauseMs: 100, admission: { max_load_per_core: 4 } });
1043
+ assert.deepEqual(calls2, ["pause", "resume"], "one pause, one forced resume, no flapping");
1044
+ });
1045
+
1046
+ it("#4115c 3 CPU-heavy lanes on a 2-core host: admission refuses the third, the governor pauses/resumes the running ones, load stays bounded", async () => {
1047
+ const st = { running: 0, paused: 0, peak: 0 };
1048
+ const load = () => (st.running - st.paused) * 1.5; // each active lane adds 1.5 load; 2 cores; ceiling 1.0 per core
1049
+ const lane = async (i) => {
1050
+ const w = world(); w.io.host = hostFor({ load });
1051
+ w.io.runner = (orig => async (c) => {
1052
+ if (c.role !== "builder") return orig(c);
1053
+ st.running++; let paused = false;
1054
+ c.control.pause = () => { if (paused) return false; paused = true; st.paused++; return true; };
1055
+ c.control.resume = () => { if (!paused) return false; paused = false; st.paused--; return true; };
1056
+ st.peak = Math.max(st.peak, load());
1057
+ await new Promise((r) => setTimeout(r, 300));
1058
+ if (paused) { paused = false; st.paused--; }
1059
+ st.running--;
1060
+ return orig(c);
1061
+ })(w.io.runner);
1062
+ return { w, r: await run(w, { issue: 7, governorPollMs: 80, maxPauseMs: 60, admission: { max_load_per_core: 1 } }) };
1063
+ };
1064
+ const a = lane(1); await new Promise((r) => setTimeout(r, 20)); const b = lane(2); await new Promise((r) => setTimeout(r, 20)); const c = lane(3);
1065
+ const res = await Promise.all([a, b, c]);
1066
+ assert.equal(res[2].r.code, "host.busy", "third lane refused at admission while two run");
1067
+ assert.deepEqual(res.slice(0, 2).map((x) => x.r.terminalReason), ["merged", "merged"]);
1068
+ assert.ok(st.peak <= 2 * 1.0 + 1.0, `observed load stays near the ceiling (${st.peak})`);
1069
+ });
1070
+
1071
+ // ---- #4113 ----
1072
+ it("#4113 a merge failing once with 'Base branch was modified' then succeeding ends merged in a single run", async () => {
1073
+ const w = world({ mergeErrors: ["GraphQL: Base branch was modified. Review and try the merge again. (mergePullRequest)"] });
1074
+ const r = await run(w);
1075
+ assert.equal(r.terminalReason, "merged");
1076
+ assert.equal(w.mergeAttempts, 2);
1077
+ assert.match(traj(w)[0], /gh pr merge transient failure/);
1078
+ });
1079
+
1080
+ it("#4113 persistent transient merge failures end deferred merge.transient, never engine-error", async () => {
1081
+ const w = world({ mergeErrors: Array(20).fill("HTTP 502: Bad Gateway") });
1082
+ const r = await run(w, { maxMergeRetries: 3 });
1083
+ assert.equal(r.terminalReason, "deferred"); assert.equal(r.code, "merge.transient");
1084
+ assert.equal(w.mergeAttempts, 4);
1085
+ assert.equal(w.edits.some((e) => e.includes("needs-human")), false);
1086
+ });
1087
+
1088
+ it("#4113 conflicts still route through base-sync", async () => {
1089
+ const w = world({ prView: [{ mergeable: "MERGEABLE", mergeStateStatus: "CLEAN" }], mergeErrors: ["GraphQL: Pull request is not mergeable (mergePullRequest)"], mergeResults: ["clean"] });
1090
+ assert.equal((await run(w)).terminalReason, "merged");
1091
+ assert.ok(w.comments.some((c) => c.includes("FORGE:BASE_SYNC")));
1092
+ });
1093
+
1094
+ // ---- #4114 ----
1095
+ it("#4114 a push failing once with 'remote unpack failed' then succeeding completes the run", async () => {
1096
+ const w = world({ pushErrors: ["error: remote unpack failed: unable to create temporary object directory"] });
1097
+ const r = await run(w);
1098
+ assert.equal(r.terminalReason, "merged");
1099
+ assert.equal(w.pushes, 2);
1100
+ });
1101
+
1102
+ it("#4114 exhausted push retries end deferred push.transient after 4 attempts", async () => {
1103
+ const w = world({ pushErrors: Array(10).fill("fatal: the remote end hung up unexpectedly\nerror: RPC failed; HTTP 503") });
1104
+ const r = await run(w);
1105
+ assert.equal(r.terminalReason, "deferred"); assert.equal(r.code, "push.transient");
1106
+ assert.equal(w.pushes, 4);
1107
+ assert.equal(w.prs.length, 0);
1108
+ });
1109
+
1110
+ it("#4114 a protected-branch or permission rejection fails immediately with no retries", async () => {
1111
+ for (const msg of ["remote: error: GH006: Protected branch update failed for refs/heads/x", "fatal: unable to access: The requested URL returned error: 403", "! [remote rejected] x -> x (pre-receive hook declined)"]) {
1112
+ const w = world({ pushErrors: [msg, msg, msg] });
1113
+ const r = await run(w);
1114
+ assert.equal(w.pushes, 1, msg);
1115
+ assert.equal(r.terminalReason, "needs-human");
1116
+ }
1117
+ });
1118
+
1119
+ it("#4114 gh writes (comment) retry on 5xx and secondary rate limits; non-transient errors do not", async () => {
1120
+ const w = world({ commentErrors: ["HTTP 502: Bad Gateway", "You have exceeded a secondary rate limit. Please wait a few minutes"] });
1121
+ assert.equal((await run(w)).terminalReason, "merged");
1122
+ assert.ok(w.comments.length >= 5);
1123
+ let n = 0;
1124
+ await assert.rejects(retrying(async () => { n++; throw new Error("HTTP 404 Not Found"); }, { transient: PUSH_TRANSIENT_RE, attempts: 4, sleep: async () => {} }), /404/);
1125
+ assert.equal(n, 1);
1126
+ });
1127
+
1128
+ // ---- #4112 ----
1129
+ const fakeCli = async (name, body) => {
1130
+ const { writeFileSync, chmodSync } = await import("node:fs");
1131
+ const d = tmpDir("core-cli-"); writeFileSync(`${d}/${name}`, `#!/bin/sh\n${body}\n`); chmodSync(`${d}/${name}`, 0o755);
1132
+ return { d, bin: `${d}/${name}` };
1133
+ };
1134
+ const TOOL = `echo '{"type":"assistant","message":{"content":[{"type":"tool_use","name":"Bash","input":{"command":"pytest -x"}}]}}'`;
1135
+ const RESULT = `echo '{"type":"result","result":"all done","session_id":"s-9","total_cost_usd":0.5,"num_turns":3,"usage":{"input_tokens":1,"output_tokens":2}}'`;
1136
+
1137
+ it("#4112 no stream activity past the idle threshold is killed as CLI_IDLE; the last tool call and live children are recorded", async () => {
1138
+ const { runCliBackendAsync } = await import("../runner.mjs");
1139
+ const { d, bin } = await fakeCli("idle", `${TOOL}\nsleep 30 &\nwait`);
1140
+ const t0 = Date.now();
1141
+ await assert.rejects(runCliBackendAsync({ spec: { name: "t" }, userMessage: "go", args: ["1"], cwd: d, bin, logger: { log() {} }, stream: true, idleMs: 400, maxMs: 20000 }), (e) => {
1142
+ assert.equal(e.code, "CLI_IDLE");
1143
+ assert.equal(e.lastTool.name, "Bash"); assert.match(e.lastTool.input, /pytest -x/);
1144
+ assert.ok(e.children.some((c) => /sleep 30/.test(c.cmd)), JSON.stringify(e.children));
1145
+ return true;
1146
+ });
1147
+ assert.ok(Date.now() - t0 < 8000, "killed promptly, not at the ceiling");
1148
+ });
1149
+
1150
+ it("#4112 a session that keeps making progress is not idle-killed; the final stream result is the envelope", async () => {
1151
+ const { runCliBackendAsync } = await import("../runner.mjs");
1152
+ const { d, bin } = await fakeCli("busy", `for i in 1 2 3 4 5 6 7 8; do ${TOOL}; sleep 0.15; done\n${RESULT}`);
1153
+ const r = await runCliBackendAsync({ spec: { name: "t" }, userMessage: "go", args: ["1"], cwd: d, bin, logger: { log() {} }, stream: true, idleMs: 500, maxMs: 20000 });
1154
+ assert.equal(r.text, "all done"); assert.equal(r.sessionId, "s-9"); assert.equal(r.costUsd, 0.5);
1155
+ });
1156
+
1157
+ it("#4112 the absolute ceiling still kills a session that never goes idle (CLI_TIMEOUT)", async () => {
1158
+ const { runCliBackendAsync } = await import("../runner.mjs");
1159
+ const { d, bin } = await fakeCli("forever", `while true; do ${TOOL}; sleep 0.1; done`);
1160
+ await assert.rejects(runCliBackendAsync({ spec: { name: "t" }, userMessage: "go", args: ["1"], cwd: d, bin, logger: { log() {} }, stream: true, idleMs: 800, maxMs: 700 }), (e) => e.code === "CLI_TIMEOUT");
1161
+ });
1162
+
1163
+ it("#4112 idle kill: uncommitted work is checkpointed and pushed, the builder retries once from the checkpoint, run log names the last tool", async () => {
1164
+ const w = world({ dirty: true }); const dir = tmpDir("core-idle-");
1165
+ const orig = w.io.runner; let n = 0;
1166
+ w.io.runner = async (c) => { if (c.role === "builder" && n++ === 0) throw Object.assign(new Error("idle"), { code: "CLI_IDLE", lastTool: { name: "Bash", input: "pytest" }, children: [{ pid: 9, cmd: "pytest" }] }); return orig(c); };
1167
+ const r = await run(w, { dir });
1168
+ assert.equal(r.terminalReason, "merged");
1169
+ const g = w.calls.filter((c) => c[0] === "git").map((c) => c.slice(1));
1170
+ assert.ok(g.some((x) => x[0] === "add" && x[1] === "-A"));
1171
+ assert.ok(g.some((x) => x[0] === "commit" && x.includes("wip(forge): checkpoint after idle timeout")));
1172
+ assert.ok(w.pushes >= 2, "checkpoint push plus the final push");
1173
+ assert.match(builders(w)[0][4], /Continue from it/);
1174
+ const killEv = readLog(dir, 7).find((e) => e.event === "CLI_IDLE_KILL");
1175
+ assert.equal(killEv.lastTool.name, "Bash"); assert.equal(killEv.children[0].cmd, "pytest");
1176
+ assert.match(traj(w)[0], /builder idle timeout: checkpointed, retried once/);
1177
+ });
1178
+
1179
+ it("#4112 a second idle or timeout kill ends deferred with cli.idle / cli.timeout (not engine-error), work checkpointed", async () => {
1180
+ for (const [code, expected] of [["CLI_IDLE", "cli.idle"], ["CLI_TIMEOUT", "cli.timeout"]]) {
1181
+ const w = world({ dirty: true });
1182
+ w.io.runner = async (c) => { if (c.role === "builder") throw Object.assign(new Error(code), { code }); return { text: "" }; };
1183
+ const r = await run(w);
1184
+ assert.equal(r.terminalReason, "deferred", code); assert.equal(r.code, expected);
1185
+ assert.equal(w.edits.some((e) => e.includes("needs-human")), false);
1186
+ assert.ok(w.pushes >= 1);
1187
+ }
1188
+ });
1189
+
1190
+ it("#4112 a rerun resumes from the checkpoint branch and tells the builder to continue", async () => {
1191
+ const w = world({ prList: [], remote: "abc\trefs/heads/feat/issue-7-lean", lastSubject: "wip(forge): checkpoint after idle timeout" });
1192
+ const r = await run(w);
1193
+ assert.equal(r.terminalReason, "merged");
1194
+ assert.equal(builders(w).length, 1, "builder runs again (a checkpoint is not finished work)");
1195
+ assert.match(builders(w)[0][4], /Continue from it/);
1196
+ assert.ok(w.calls.some((c) => c[0] === "git" && c[1] === "worktree" && c[2] === "add" && c.includes("origin/feat/issue-7-lean")));
1197
+ });
1198
+ });
@@ -12,6 +12,8 @@ export const IMPLEMENTATION_FILE = `${LEAN_DIR}/implementation.md`;
12
12
  export function builderPrompt(n) {
13
13
  return `Resolve issue #${n} (read it with gh issue view ${n} --comments). If it is already fixed or invalid on this code, explain why with file:line evidence and change nothing. Otherwise implement a complete fix with a regression test following repo conventions, run the relevant tests, and commit. Do not push or touch GitHub.
14
14
 
15
+ Host rules (many lanes share this machine): run only targeted tests for the code you changed (specific test files or -k filters), never the full suite. Never create extra checkouts, copies or base clones of the repo; compare against the base with git show or git diff origin/<base> instead. Never install browsers. Keep all scratch files under $TMPDIR.
16
+
15
17
  The engine publishes three stage notes to the issue for you. Write each as markdown with exactly these headings, in this order, concise and concrete (file:line wherever you cite code). All files live in ${LEAN_DIR}/ (git-excluded, never commit them). Do not write HTML comments.
16
18
 
17
19
  1. BEFORE coding, write ${INVESTIGATION_FILE}:
@@ -0,0 +1,31 @@
1
+ // SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
2
+ // SPDX-License-Identifier: AGPL-3.0-or-later
3
+ /** Transient-failure retry for the core work-on's network writes (git push, gh writes, gh pr merge). */
4
+
5
+ export const PUSH_TRANSIENT_RE = /remote unpack failed|index-pack failed|RPC failed|unexpected disconnect|early EOF|the remote end hung up|HTTP\/?\s?5\d\d|returned error: 5\d\d|\b50[0234]\b|internal server error|bad gateway|service unavailable|gateway time-?out|connection (?:reset|timed out|refused)|timed out|could not resolve host|broken pipe|ETIMEDOUT|ECONNRESET|temporarily unavailable/i;
6
+ export const PUSH_PERMANENT_RE = /protected branch|GH006|GH013|repository rule violations|permission (?:denied|to .* denied)|denied to|\b403\b|authentication failed|not allowed|hook declined|pre-receive hook declined|\[remote rejected\]|\[rejected\]|non-fast-forward|fetch first/i;
7
+ export const GH_TRANSIENT_RE = /HTTP 5\d\d|\b50[0234]\b|secondary rate limit|rate limit exceeded|abuse detection|something went wrong|internal server error|bad gateway|service unavailable|ETIMEDOUT|ECONNRESET|connection reset|timed out|\bEOF\b|temporarily unavailable/i;
8
+ export const MERGE_TRANSIENT_RE = new RegExp(`${GH_TRANSIENT_RE.source}|base branch was modified|head branch was modified|mergeability.*unknown|try again`, "i");
9
+ export const MERGE_CONFLICT_RE = /not mergeable|merge conflict|conflict/i;
10
+
11
+ export const errText = (e) => `${e?.stderr ?? ""}\n${e?.message ?? ""}\n${e?.stdout ?? ""}`;
12
+
13
+ /**
14
+ * Run `fn` up to `attempts` times. A `permanent` match throws immediately with `e.permanent = true`; a `transient`
15
+ * match retries with exponential backoff; anything else throws as-is. When every attempt failed transiently the last
16
+ * error carries `transientExhausted = true`.
17
+ */
18
+ export async function retrying(fn, { transient, permanent = null, attempts = 4, sleep, baseMs = 1000, random = Math.random }) {
19
+ let last;
20
+ for (let i = 0; i < attempts; i++) {
21
+ try { return await fn(i); } catch (e) {
22
+ const t = errText(e);
23
+ if (permanent && permanent.test(t)) { e.permanent = true; throw e; }
24
+ if (!transient.test(t)) throw e;
25
+ last = e;
26
+ if (i < attempts - 1) await sleep(baseMs * 2 ** i + Math.floor(random() * 250));
27
+ }
28
+ }
29
+ last.transientExhausted = true;
30
+ throw last;
31
+ }
package/bin/runner.mjs CHANGED
@@ -1925,15 +1925,31 @@ export function runCliBackend({
1925
1925
  }
1926
1926
  }
1927
1927
 
1928
+ /** Descendant processes of `pid` (pid + command line), best effort. Used to record what was alive when a session was killed. */
1929
+ export function listDescendants(pid, { psFn = null } = {}) {
1930
+ try {
1931
+ const out = psFn ? psFn() : execSync("ps -eo pid=,ppid=,args=", { encoding: "utf-8", timeout: 3000, maxBuffer: 8 * 1024 * 1024 });
1932
+ const rows = String(out).split("\n").map((l) => /^\s*(\d+)\s+(\d+)\s+(.*)$/.exec(l)).filter(Boolean).map((m) => ({ pid: Number(m[1]), ppid: Number(m[2]), cmd: m[3].slice(0, 200) }));
1933
+ const kids = []; const seen = new Set([Number(pid)]);
1934
+ for (let grew = true; grew;) { grew = false; for (const r of rows) if (!seen.has(r.pid) && seen.has(r.ppid)) { seen.add(r.pid); kids.push({ pid: r.pid, cmd: r.cmd }); grew = true; } }
1935
+ return kids.slice(0, 30);
1936
+ } catch { return []; }
1937
+ }
1938
+
1928
1939
  /**
1929
1940
  * Async twin of runCliBackend (core work-on live comments): the CLI runs under child_process.spawn so the caller's event
1930
1941
  * loop keeps turning while the session runs. It returns the SAME envelope (and throws the same errors) because the
1931
1942
  * argv/env building and the envelope parsing are the sync function itself: pass 1 captures the spawn arguments, the
1932
1943
  * child runs asynchronously, pass 2 replays the collected result through runCliBackend's `spawnFn` seam.
1933
- * `onSpawn(child)` receives the ChildProcess (e.g. to kill it).
1944
+ *
1945
+ * Extras (#4112, #4115): `idleMs` kills a session with no stdout activity (CLI_IDLE; the last tool call and the live
1946
+ * child processes ride on the error); `maxMs` is the absolute ceiling (CLI_TIMEOUT); `stream` switches the CLI to
1947
+ * stream-json so progress is observable (the final `result` line is the envelope); `wrap(bin,args)` rewrites the spawn
1948
+ * (cgroup caps); `control` receives {pause, resume} (SIGSTOP/SIGCONT of the child's whole process group) and paused time
1949
+ * does not count as idle. `onSpawn(child)` receives the ChildProcess.
1934
1950
  */
1935
1951
  export async function runCliBackendAsync(opts) {
1936
- const { spawnFn: _ignored, onSpawn = null, ...rest } = opts;
1952
+ const { spawnFn: _ignored, onSpawn = null, idleMs = 0, maxMs = 0, stream = false, wrap = null, control = null, ...rest } = opts;
1937
1953
  let captured = null;
1938
1954
  try {
1939
1955
  runCliBackend({ ...rest, spawnFn: (bin, args, o) => {
@@ -1944,33 +1960,82 @@ export async function runCliBackendAsync(opts) {
1944
1960
  return { status: 0, stdout: "{}", stderr: "" };
1945
1961
  } });
1946
1962
  } catch (e) { if (!captured) throw e; }
1947
- const { bin, args, o, sysIdx, sys } = captured;
1963
+ let { bin, args } = captured;
1964
+ const { o, sysIdx, sys } = captured;
1948
1965
  let sysDir = null;
1949
1966
  if (sysIdx >= 0 && sys !== null) {
1950
1967
  sysDir = mkdtempSync(join(os.tmpdir(), "forgedock-cli-system-prompt-"));
1951
1968
  args[sysIdx + 1] = join(sysDir, "system-prompt.txt");
1952
1969
  writeFileSync(args[sysIdx + 1], sys, "utf-8");
1953
1970
  }
1954
- let collected;
1971
+ if (stream) {
1972
+ const j = args.indexOf("--output-format");
1973
+ if (j >= 0) { args[j + 1] = "stream-json"; if (!args.includes("--verbose")) args.splice(j + 2, 0, "--verbose"); }
1974
+ }
1975
+ if (typeof wrap === "function") ({ bin, args } = wrap(bin, args));
1976
+ const ceiling = maxMs > 0 ? maxMs : o.timeout;
1977
+ let collected, idleInfo = null;
1955
1978
  try {
1956
1979
  collected = await new Promise((resolveP) => {
1957
- let stdout = "", stderr = "", settled = false, timedOut = false;
1980
+ let stdout = "", stderr = "", settled = false, timedOut = false, paused = false;
1981
+ let lineBuf = "", resultLine = null, lastTool = null;
1958
1982
  const max = o.maxBuffer ?? DEFAULT_SPAWN_MAX_BUFFER_BYTES;
1959
- let timer = null;
1960
- const finish = (r) => { if (settled) return; settled = true; clearTimeout(timer); resolveP(r); };
1983
+ const startedAt = Date.now();
1984
+ let lastActivity = startedAt, pausedAt = 0, pausedTotal = 0, timer = null;
1985
+ const finish = (r) => { if (settled) return; settled = true; clearInterval(timer); resolveP(r); };
1961
1986
  let child;
1962
- try { child = spawn(bin, args, { cwd: o.cwd, env: o.env, stdio: ["ignore", "pipe", "pipe"] }); }
1987
+ try { child = spawn(bin, args, { cwd: o.cwd, env: o.env, stdio: ["ignore", "pipe", "pipe"], detached: true }); }
1963
1988
  catch (error) { return finish({ status: null, signal: null, pid: 0, error, stdout: "", stderr: "" }); }
1989
+ const signalGroup = (sig) => { try { process.kill(-child.pid, sig); } catch { try { child.kill(sig); } catch { /* gone */ } } };
1990
+ const ctl = {
1991
+ pause() { if (paused || settled) return false; paused = true; pausedAt = Date.now(); signalGroup("SIGSTOP"); return true; },
1992
+ resume() { if (!paused) return false; paused = false; const d = Date.now() - pausedAt; pausedTotal += d; lastActivity = Date.now(); signalGroup("SIGCONT"); return true; },
1993
+ get paused() { return paused; },
1994
+ };
1995
+ if (control && typeof control === "object") Object.assign(control, { pause: ctl.pause, resume: ctl.resume, isPaused: () => paused });
1964
1996
  onSpawn?.(child);
1965
- timer = setTimeout(() => { timedOut = true; try { child.kill("SIGTERM"); } catch { /* gone */ } }, o.timeout);
1966
- const add = (cur, d) => (cur.length >= max ? cur : cur + d.toString("utf-8"));
1967
- child.stdout.on("data", (d) => { stdout = add(stdout, d); });
1968
- child.stderr.on("data", (d) => { stderr = add(stderr, d); });
1997
+ const kill = () => { if (paused) ctl.resume(); signalGroup("SIGTERM"); setTimeout(() => signalGroup("SIGKILL"), 5000).unref?.(); };
1998
+ const tick = Math.max(20, Math.min(1000, (idleMs || 4000) / 4));
1999
+ timer = setInterval(() => {
2000
+ const now = Date.now();
2001
+ if (!paused && idleMs > 0 && now - lastActivity >= idleMs && !idleInfo) {
2002
+ idleInfo = { lastTool, children: listDescendants(child.pid), idleMs, pid: child.pid };
2003
+ kill();
2004
+ } else if (!timedOut && !idleInfo && now - startedAt - pausedTotal - (paused ? now - pausedAt : 0) >= ceiling) { timedOut = true; kill(); }
2005
+ }, tick);
2006
+ timer.unref?.();
2007
+ const noteLine = (line) => {
2008
+ if (!line.startsWith("{") || line.length > 4_000_000) return;
2009
+ let j; try { j = JSON.parse(line); } catch { return; }
2010
+ if (j?.type === "result") resultLine = line;
2011
+ else if (j?.type === "assistant" && Array.isArray(j.message?.content)) {
2012
+ for (const c of j.message.content) if (c?.type === "tool_use") lastTool = { name: c.name, input: JSON.stringify(c.input ?? {}).slice(0, 200), at: Date.now() };
2013
+ }
2014
+ };
2015
+ child.stdout.on("data", (d) => {
2016
+ lastActivity = Date.now();
2017
+ if (stream) {
2018
+ lineBuf += d.toString("utf-8");
2019
+ let nl;
2020
+ while ((nl = lineBuf.indexOf("\n")) >= 0) { noteLine(lineBuf.slice(0, nl).trim()); lineBuf = lineBuf.slice(nl + 1); }
2021
+ if (lineBuf.length > 4_000_000) lineBuf = "";
2022
+ } else if (stdout.length < max) stdout += d.toString("utf-8");
2023
+ });
2024
+ child.stderr.on("data", (d) => { lastActivity = Date.now(); if (stderr.length < max) stderr += d.toString("utf-8"); });
1969
2025
  child.on("error", (error) => finish({ status: null, signal: null, pid: child.pid ?? 0, error, stdout, stderr }));
1970
- child.on("close", (status, signal) => finish({ status: timedOut ? null : status, signal, pid: child.pid ?? 0, stdout, stderr,
1971
- ...(timedOut ? { error: Object.assign(new Error("spawnSync ETIMEDOUT"), { code: "ETIMEDOUT" }) } : {}) }));
2026
+ child.on("close", (status, signal) => {
2027
+ if (stream && lineBuf.trim()) noteLine(lineBuf.trim());
2028
+ finish({ status: timedOut ? null : status, signal, pid: child.pid ?? 0, stdout: stream ? (resultLine ?? "") : stdout, stderr,
2029
+ ...(timedOut ? { error: Object.assign(new Error("spawnSync ETIMEDOUT"), { code: "ETIMEDOUT" }) } : {}) });
2030
+ });
1972
2031
  });
1973
2032
  } finally { if (sysDir) rmSync(sysDir, { recursive: true, force: true }); }
2033
+ if (idleInfo) {
2034
+ const mins = Math.round((idleInfo.idleMs / 60000) * 10) / 10;
2035
+ const err = new Error(`claude CLI had no stream activity for ${mins} min and was killed (idle). Last tool: ${idleInfo.lastTool ? `${idleInfo.lastTool.name} ${idleInfo.lastTool.input}` : "none"}. Live children: ${idleInfo.children.map((c) => `${c.pid}:${c.cmd}`).join("; ") || "none"}`);
2036
+ err.code = "CLI_IDLE"; err.lastTool = idleInfo.lastTool; err.children = idleInfo.children; err.idleMs = idleInfo.idleMs;
2037
+ throw err;
2038
+ }
1974
2039
  return runCliBackend({ ...rest, spawnFn: () => collected });
1975
2040
  }
1976
2041
 
package/docs/CONFIG.md CHANGED
@@ -482,6 +482,9 @@ engine:
482
482
  | `<key>_max_turns` | integer | No | Positive integer passed as `--max-turns`. Invalid values are ignored. |
483
483
 
484
484
  | `path` | string | No | `core` (default), `full`, `lean` or `auto`. `core` is the lean work-on (`bin/lean/`): one plain builder session, one read-only two-question reviewer, GitHub as the register (see RELEASE-NOTES v1.22). `lean` and `auto` are aliases of `core`; `full` runs the legacy multi-phase pipeline and is required for `--retry` and `--wait-lease`. The `--path` flag overrides. `lean_reviewer_model` sets the reviewer model (default `claude-sonnet-5-5`); the builder uses `--model` or the CLI default. `forgedock orchestrate <issues...> [--parallel K] [--lane L]` runs the core per issue (K default 3, lane default `staging`) and honours `**Depends on**` ordering among the given issues. |
485
+ | `cli_idle_minutes` | number | No | Core builder idle timeout (#4112): the builder CLI runs with `--output-format stream-json` and is killed (code `cli.idle`) when it shows no stream activity for this many minutes. The run log records the last tool call and the live child processes. Default 15. |
486
+ | `cli_max_minutes` | number | No | Absolute ceiling for a core builder session (code `cli.timeout`), replacing the fixed 60-minute kill. A session that keeps making progress is never idle-killed. Default 180. On either kill, uncommitted work is committed as `wip(forge): checkpoint after <reason>` and the lane branch is pushed (with the push retry), the builder retries once from the checkpoint, and a second kill ends `deferred`; a rerun resumes from the branch. |
487
+ | `lane_limits` | map | No | Cap each core lane's whole process tree (#4115), under `pipeline:` or `engine:`: `memory_max` (for example `3G`) and `cpu_quota` (for example `150%`). When set and `systemd-run --user --scope` works (probed once), the builder CLI is spawned as `systemd-run --user --scope -q -p MemoryMax=... -p CPUQuota=... -- claude ...`; elsewhere the caps are ignored. Write it nested or flat (`lane_limits_memory_max`, `lane_limits_cpu_quota`). While a builder runs the engine polls host load and memory every 15 s against the `admission` thresholds: over them it SIGSTOPs the lane's process group, under them SIGCONT, and never pauses longer than 10 minutes. |
485
488
  | `admission` | map | No | Host-resource admission for the core work-on (#4105), checked before the lease, worktree or any GitHub call. When a check fails the run ends `deferred` (code `host.busy`, exit 75) with a last line such as `issue #N → deferred (host busy: mem 1.2G < 2G; retry in 60s)`; no lease is taken and nothing is posted. Keys: `enabled` (default `true`; `false` disables admission), `min_free_mem_mb` (default 2048; `MemAvailable` from `/proc/meminfo`, else `os.freemem()`), `min_free_tmp_mb` (default 1024; free space of the run TMPDIR filesystem, `~/.forge/tmp`, and of `/tmp`, via `statfs`), `max_load_per_core` (default 4; 1-minute load average divided by cores). Write it nested (`engine:` then `admission:` with the keys indented) or flat as `admission_enabled`, `admission_min_free_mem_mb`, `admission_min_free_tmp_mb`, `admission_max_load_per_core`; the parser reads one nested level into `engine`. `/orchestrate` requeues a host-busy lane and lowers K by 1 for 5 minutes. |
486
489
  | `merge_wait` | string | No | Core work-on merge gate. `required` (default): wait only for the status checks the base branch requires under branch protection (`gh api repos/{o}/{r}/branches/{base}/protection/required_status_checks`, contexts and checks); with no protection or no required checks (404 or empty) merge right after review PASS. A failing required check is `needs-human`; a pending one is polled with backoff and never `needs-human`. `all`: wait for every check (`scripts/wait-ci-green.sh`). `none`: merge right after review. Always `--match-head-commit` at the reviewed head. The policy used is recorded in the `FORGE:WORK-ON` comment. |
487
490
  | `ci_pending_max_minutes` | integer | No | Minutes the engine waits for CI when the review phase ends BLOCKED with "checks still pending", or the remediate phase holds at its M7 re-gate on "CI pending" (the merge gate's wait window ended while CI was still running). Default: 90. Invalid values are ignored. This key is flat like the others but is not part of the `<key>_model` / `<key>_max_turns` pattern. |
@@ -34,6 +34,9 @@ An absent or unknown code is recorded as `unclassified`, with the raw reason kep
34
34
  | `ci.timeout` | CI timed out | reserved | `REVIEW_RESULT` carries no CI field yet |
35
35
  | `base.conflict` | Base-branch conflict blocked the PR | reserved | `REVIEW_RESULT` carries no conflict field yet |
36
36
  | `path.switch_conflict` | The full path was started on an issue that has an open core-path PR (`feat/issue-<n>-lean`); refused to avoid a second implementation. Rerun on the default path (base-sync) or close that PR first | engine | `bin/engine.mjs` admission |
37
+ | `merge.transient` | `gh pr merge` kept failing with a transient error (`Base branch was modified`, GraphQL or 5xx) after the retry budget; the run is `deferred` and a rerun resumes at the merge | lean | `bin/lean/lean.mjs` |
38
+ | `push.transient` | `git push` kept failing with a transient error (`remote unpack failed`, `index-pack failed`, `RPC failed`, 5xx, reset, timeout) after 4 attempts; `deferred`, rerun resumes | lean | `bin/lean/lean.mjs` |
39
+ | `cli.idle` | The builder CLI showed no stream activity for `engine.cli_idle_minutes` and was killed; uncommitted work was checkpointed to the lane branch; `deferred` after one automatic retry | lean | `bin/runner.mjs` |
37
40
  | `host.busy` | Core run deferred at admission: free memory, free tmp space or load per core outside `engine.admission` limits; no lease taken, nothing posted | lean | `bin/lean/admission.mjs` |
38
41
  | `rate_limited` | Session or usage limit outlasted the pause budget | engine | `err.usageLimit` / `err.resetAtEpochMs` |
39
42
  | `engine.error` | Engine or tool failure (missing key/SDK, CLI crash, max turns, unresolved args, runner threw every attempt) | engine | `err.code` |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "forgedock",
3
- "version": "1.24.3",
3
+ "version": "1.24.4",
4
4
  "description": "Autonomous development pipeline for coding agents. GitHub as institutional memory, with native Claude Code and OpenCode entrypoints.",
5
5
  "bin": {
6
6
  "forgedock": "bin/forgedock.mjs"