forgedock 1.24.0 → 1.24.2

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.
@@ -35,6 +35,7 @@ export const OUTCOME_CODES = Object.freeze({
35
35
  CI_TIMEOUT: "ci.timeout",
36
36
  BASE_CONFLICT: "base.conflict",
37
37
  RATE_LIMITED: "rate_limited",
38
+ HOST_BUSY: "host.busy",
38
39
  ENGINE_ERROR: "engine.error",
39
40
  INFRA_DISK_QUOTA: "infra.disk_quota",
40
41
  INFRA_CLI_SPAWN_TRANSIENT: "infra.cli_spawn_transient",
@@ -99,6 +99,20 @@ function readLeanSetting(key, cwd = process.cwd()) {
99
99
  } catch { return undefined; }
100
100
  }
101
101
  // core is the default; lean and auto are aliases of core. full is the legacy multi-phase pipeline.
102
+ /**
103
+ * forge.yaml `engine.admission` (#4105). The parser flattens one nested level into `engine`, so the keys are read as
104
+ * `engine.enabled|min_free_mem_mb|min_free_tmp_mb|max_load_per_core`, or flat `admission_*` equivalents.
105
+ */
106
+ export function readAdmission(cwd = process.cwd()) {
107
+ try {
108
+ const e = parseForgeYaml(readFileSync(join(cwd, "forge.yaml"), "utf8"))?.engine ?? {};
109
+ const num = (v) => { const n = Number(v); return v !== undefined && v !== "" && Number.isFinite(n) && n >= 0 ? n : undefined; };
110
+ const en = String(e.admission_enabled ?? e.enabled ?? "").trim().toLowerCase();
111
+ return { ...(en === "false" ? { enabled: false } : {}),
112
+ min_free_mem_mb: num(e.min_free_mem_mb ?? e.admission_min_free_mem_mb), min_free_tmp_mb: num(e.min_free_tmp_mb ?? e.admission_min_free_tmp_mb),
113
+ max_load_per_core: num(e.max_load_per_core ?? e.admission_max_load_per_core) };
114
+ } catch { return {}; }
115
+ }
102
116
  /** forge.yaml `engine.merge_wait` (required|all|none); undefined when absent or invalid. */
103
117
  export function readMergeWait(cwd = process.cwd()) {
104
118
  try {
@@ -466,7 +480,8 @@ export async function runFromCli(argv, deps = {}) {
466
480
  const leanIo = deps.leanIo ?? (await import("./lean/effects.mjs")).makeLeanIo({ repo: leanRepo });
467
481
  const lres = await runCore({ issue, repo: leanRepo, lane, cwd: process.cwd(), dir, io: leanIo, path: enginePath, onProgress,
468
482
  emitTelemetry: deps.emitTelemetry ?? makeEmitTelemetry({ repo: leanRepo }),
469
- ...(readMergeWait() ? { mergeWait: readMergeWait() } : {}), ...(model ? { builderModel: model } : {}), ...(readLeanSetting("reviewer_model") ? { reviewerModel: readLeanSetting("reviewer_model") } : {}) });
483
+ admission: readAdmission(), ...(readMergeWait() ? { mergeWait: readMergeWait() } : {}), ...(model ? { builderModel: model } : {}), ...(readLeanSetting("reviewer_model") ? { reviewerModel: readLeanSetting("reviewer_model") } : {}) });
484
+ if (lres.code === "host.busy") { console.log(`issue #${issue} → deferred (${lres.detail})`); return lres; }
470
485
  console.log(`issue #${issue} → ${lres.terminalReason}`);
471
486
  if (!["merged", "invalid"].includes(lres.terminalReason)) console.log(lres.detail ?? "");
472
487
  return lres;
@@ -658,7 +673,7 @@ export async function orchestrateFromCli(argv, deps = {}) {
658
673
  issues, parallel,
659
674
  readBody: async (n) => JSON.parse(await leanIo.gh(["issue", "view", String(n), "-R", repo, "--json", "body"])).body,
660
675
  runOne: (n) => runCore({ issue: n, repo, lane, cwd: process.cwd(), dir, io: leanIo, agentId: `orch_${process.pid}_${n}`, emitTelemetry,
661
- ...(readMergeWait() ? { mergeWait: readMergeWait() } : {}), ...(model ? { builderModel: model } : {}), ...(readLeanSetting("reviewer_model") ? { reviewerModel: readLeanSetting("reviewer_model") } : {}) }),
676
+ admission: readAdmission(), ...(readMergeWait() ? { mergeWait: readMergeWait() } : {}), ...(model ? { builderModel: model } : {}), ...(readLeanSetting("reviewer_model") ? { reviewerModel: readLeanSetting("reviewer_model") } : {}) }),
662
677
  onUpdate: (r) => console.log(`#${r.issue} → ${r.outcome}${r.detail && r.outcome !== "merged" ? ` (${r.detail})` : ""}`),
663
678
  });
664
679
  console.log("\n" + formatTable(rows));
package/bin/forgedock.mjs CHANGED
@@ -3209,6 +3209,7 @@ function help() {
3209
3209
  ["npx forgedock run <cmd> [args]", "Run a command headlessly (local claude CLI or Anthropic API)"],
3210
3210
  ["npx forgedock run-issue <issue> --lane <lane> [--path core|full] [--retry] [--wait-lease]", "Run the lean core work-on on one issue (default; --path full = legacy multi-phase pipeline) (--retry reopens an engine-error run, or a needs-human run once the label is cleared; --wait-lease waits for a held lease; exit 75 = deferred, lease held)"],
3211
3211
  ["npx forgedock orchestrate <issue...> [--parallel K] [--lane L]", "Run core work-on on several issues with bounded concurrency and Depends-on ordering"],
3212
+ ["npx forgedock gc", "Prune stale per-run scratch dirs under ~/.forge/tmp/runs (older than 6h, owner dead)"],
3212
3213
  ["npx forgedock resume-stalled [--dry-run]", "Fleet stall recovery — re-dispatch expired-lease issues"],
3213
3214
  ["npx forgedock demo", "Set up a risk-free demo repo and print next steps"],
3214
3215
  ["npx forgedock labels [setup] [--repo owner/repo]", "Bootstrap ForgeDock-managed labels on a GitHub repo (idempotent)"],
@@ -3782,10 +3783,10 @@ async function watch() {
3782
3783
  // The journey-routed commands render their own branded marks (hero/compact);
3783
3784
  // splash() would double-brand them. Engine-surface commands, help, and
3784
3785
  // unknown-command output keep the logo.
3785
- const SPLASH_COMMANDS = new Set(["run", "run-issue", "orchestrate", "resume-stalled", "demo", "doctor", "watch", "help", "--help", "-h"]);
3786
+ const SPLASH_COMMANDS = new Set(["run", "run-issue", "orchestrate", "gc", "resume-stalled", "demo", "doctor", "watch", "help", "--help", "-h"]);
3786
3787
  const KNOWN_COMMANDS = new Set([
3787
3788
  "install", "init", "enable", "disable", "status", "uninstall", "update",
3788
- "run", "backend-check", "run-issue", "orchestrate", "resume-stalled", "demo", "doctor", "watch", "labels", "config", "stats", "help", "--help", "-h",
3789
+ "run", "backend-check", "run-issue", "orchestrate", "gc", "resume-stalled", "demo", "doctor", "watch", "labels", "config", "stats", "help", "--help", "-h",
3789
3790
  "version", "--version", "-v",
3790
3791
  ]);
3791
3792
  if (SPLASH_COMMANDS.has(command) || !KNOWN_COMMANDS.has(command)) splash(command);
@@ -3983,6 +3984,13 @@ switch (command) {
3983
3984
  }
3984
3985
  break;
3985
3986
  }
3987
+ case "gc": {
3988
+ // Best-effort prune of core-run scratch dirs (older than 6h, owner pid dead). Never touches dirs we did not create.
3989
+ const { makeRunTmp } = await import("./lean/effects.mjs");
3990
+ const removed = await makeRunTmp().prune();
3991
+ process.stdout.write(`gc: removed ${removed.length} stale run tmp dir(s)\n`);
3992
+ break;
3993
+ }
3986
3994
  case "orchestrate": {
3987
3995
  const { orchestrateFromCli } = await import("./engine-cli.mjs");
3988
3996
  try {
@@ -0,0 +1,32 @@
1
+ // SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
2
+ // SPDX-License-Identifier: AGPL-3.0-or-later
3
+ /**
4
+ * Host-resource admission for the core work-on (#4105): refuse to start a run when the host is out of headroom, so a
5
+ * batch of lanes does not push the machine into OOM or a full /tmp. Every probe is injected through `host`.
6
+ */
7
+ export const ADMISSION_DEFAULTS = Object.freeze({ enabled: true, min_free_mem_mb: 2048, min_free_tmp_mb: 1024, max_load_per_core: 4 });
8
+ export const ADMISSION_RETRY_SECONDS = 60;
9
+
10
+ const gb = (mb) => { const g = mb / 1024; return `${Number.isInteger(g) ? g : g.toFixed(1)}G`; };
11
+
12
+ /**
13
+ * @param {{cfg?: object, host: {memAvailableMb?: Function, freeMb?: Function, loadAvg1?: Function, cores?: Function, tmpPaths?: Function}}} p
14
+ * @returns {Promise<{ok: boolean, reasons: string[], detail: string}>}
15
+ */
16
+ export async function checkAdmission({ cfg = {}, host }) {
17
+ const c = { ...ADMISSION_DEFAULTS, ...Object.fromEntries(Object.entries(cfg ?? {}).filter(([, v]) => v !== undefined && v !== null)) };
18
+ if (c.enabled === false || !host) return { ok: true, reasons: [], detail: "" };
19
+ const reasons = [];
20
+ const safe = async (fn, ...a) => { try { const v = await fn?.(...a); return Number.isFinite(v) ? v : null; } catch { return null; } };
21
+ const mem = await safe(host.memAvailableMb);
22
+ if (mem !== null && mem < c.min_free_mem_mb) reasons.push(`mem ${gb(mem)} < ${gb(c.min_free_mem_mb)}`);
23
+ let paths = [];
24
+ try { paths = (await host.tmpPaths?.()) ?? []; } catch { paths = []; }
25
+ for (const p of paths) {
26
+ const free = await safe(host.freeMb, p);
27
+ if (free !== null && free < c.min_free_tmp_mb) reasons.push(`tmp ${p} ${gb(free)} < ${gb(c.min_free_tmp_mb)}`);
28
+ }
29
+ const load = await safe(host.loadAvg1), cores = await safe(host.cores);
30
+ if (load !== null && cores && load / cores > c.max_load_per_core) reasons.push(`load ${(load / cores).toFixed(1)}/core > ${c.max_load_per_core}`);
31
+ return { ok: reasons.length === 0, reasons, detail: reasons.length ? `host busy: ${reasons.join(", ")}; retry in ${ADMISSION_RETRY_SECONDS}s` : "" };
32
+ }
@@ -3,8 +3,10 @@
3
3
  /** Core work-on: the real effects behind runCore's injected `io`. Argv only, no shell. */
4
4
  import { execFile } from "node:child_process";
5
5
  import { promisify } from "node:util";
6
- import { readFileSync, appendFileSync, mkdirSync, statSync } from "node:fs";
7
- import { dirname } from "node:path";
6
+ import { readFileSync, appendFileSync, mkdirSync, statSync, existsSync, rmSync, writeFileSync, readdirSync, lstatSync } from "node:fs";
7
+ import { homedir, hostname, freemem, loadavg, cpus, tmpdir } from "node:os";
8
+ import { promises as fsp } from "node:fs";
9
+ import { dirname, join, resolve, sep } from "node:path";
8
10
  import { fileURLToPath } from "node:url";
9
11
  import { runCliBackendAsync, resolveClaudeCliBinary, getClaudeCliCapabilities, buildLeanSessionFlags, buildReadOnlyToolFlags } from "../runner.mjs";
10
12
  import { makeIo } from "../engine-cli.mjs";
@@ -15,22 +17,87 @@ const script = (n) => fileURLToPath(new URL(`../../scripts/${n}`, import.meta.ur
15
17
 
16
18
  /** Claude CLI session runner: fresh session, or `--resume <id>` for the builder. Resolves usage + session_id. */
17
19
  export function makeLeanRunner({ runCli = runCliBackendAsync } = {}) {
18
- return async ({ role, issue, cwd, model, resume, system, prompt, readOnly = false }) => {
20
+ return async ({ role, issue, cwd, model, resume, system, prompt, readOnly = false, env = null }) => {
19
21
  const bin = resolveClaudeCliBinary(cwd) || "claude";
20
22
  const caps = getClaudeCliCapabilities(cwd);
21
23
  const flags = [...buildLeanSessionFlags(caps), ...(readOnly ? buildReadOnlyToolFlags(caps) : [])];
22
24
  const r = await runCli({ spec: { name: `core-${role}` }, userMessage: prompt, systemPrompt: resume ? "" : system,
23
- args: [String(issue)], cwd, bin, model, sessionFlags: flags, extraArgs: resume ? ["--resume", resume] : [],
25
+ args: [String(issue)], cwd, bin, model, env, sessionFlags: flags, extraArgs: resume ? ["--resume", resume] : [],
24
26
  logger: { log() {} } });
25
27
  return { text: r.text, sessionId: r.sessionId, usage: r.usage, costUsd: r.costUsd, durationMs: r.durationMs, turns: r.turns, model: r.cliModel ?? "default" };
26
28
  };
27
29
  }
28
30
 
31
+ export const RUN_TMP_MAX_AGE_MS = 6 * 3600 * 1000;
32
+ export const runTmpRoot = () => join(homedir(), ".forge", "tmp", "runs");
33
+ const OWNER = ".forge-run-owner";
34
+ const pidAlive = (pid) => { try { process.kill(pid, 0); return true; } catch (e) { return e?.code !== "ESRCH"; } };
35
+
36
+ /** Per-run scratch dirs under ~/.forge/tmp/runs/<issue>-<run>/. Only dirs carrying our owner marker are ever removed. */
37
+ export function makeRunTmp({ root = runTmpRoot(), now = () => Date.now(), alive = pidAlive, host = hostname() } = {}) {
38
+ const inside = (d) => { const r = resolve(d); return r.startsWith(resolve(root) + sep) && !r.slice(resolve(root).length + 1).includes(sep); };
39
+ return {
40
+ async make(issue, runId) {
41
+ const dir = join(root, String(runId));
42
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
43
+ writeFileSync(join(dir, OWNER), JSON.stringify({ issue, pid: process.pid, host, created: now() }));
44
+ return dir;
45
+ },
46
+ /** Remove a dir this module created (marker present, direct child of the runs root). */
47
+ async remove(dir) {
48
+ if (!dir || !inside(dir) || !existsSync(join(dir, OWNER))) return false;
49
+ rmSync(dir, { recursive: true, force: true });
50
+ return true;
51
+ },
52
+ /** Best-effort: dirs older than 6h whose owner pid is dead on this host. Never touches dirs without our marker. */
53
+ async prune({ maxAgeMs = RUN_TMP_MAX_AGE_MS } = {}) {
54
+ const removed = [];
55
+ let names = [];
56
+ try { names = readdirSync(root); } catch { return removed; }
57
+ for (const n of names) {
58
+ const dir = join(root, n);
59
+ try {
60
+ if (!lstatSync(dir).isDirectory()) continue;
61
+ const own = JSON.parse(readFileSync(join(dir, OWNER), "utf8"));
62
+ if (now() - Number(own.created) < maxAgeMs) continue;
63
+ if (own.host === host && Number.isSafeInteger(own.pid) && alive(own.pid)) continue;
64
+ if (own.host !== host) continue;
65
+ rmSync(dir, { recursive: true, force: true });
66
+ removed.push(n);
67
+ } catch { /* not ours or unreadable: leave it */ }
68
+ }
69
+ return removed;
70
+ },
71
+ };
72
+ }
73
+
74
+ /** Real host probes for admission (#4105). MemAvailable from /proc/meminfo (fallback os.freemem), statfs for tmp. */
75
+ export function makeHostProbe() {
76
+ return {
77
+ memAvailableMb() {
78
+ try {
79
+ const m = /^MemAvailable:\s+(\d+)\s+kB/m.exec(readFileSync("/proc/meminfo", "utf8"));
80
+ if (m) return Math.floor(Number(m[1]) / 1024);
81
+ } catch { /* not Linux */ }
82
+ return Math.floor(freemem() / 1048576);
83
+ },
84
+ async freeMb(p) {
85
+ let dir = p;
86
+ while (dir && !existsSync(dir) && dirname(dir) !== dir) dir = dirname(dir);
87
+ const s = await fsp.statfs(dir);
88
+ return Math.floor((Number(s.bavail) * Number(s.bsize)) / 1048576);
89
+ },
90
+ loadAvg1: () => loadavg()[0],
91
+ cores: () => cpus().length || 1,
92
+ tmpPaths: () => [...new Set([join(homedir(), ".forge", "tmp"), tmpdir(), "/tmp"].filter((x) => existsSync(x) || x === join(homedir(), ".forge", "tmp")))],
93
+ };
94
+ }
95
+
29
96
  export function makeLeanIo({ repo }) {
30
97
  const base = makeIo();
31
98
  const run = (bin) => async (args, opts = {}) => (await pexec(bin, args, { cwd: opts.cwd, maxBuffer: 100 * 1024 * 1024, timeout: 120000 })).stdout;
32
99
  return {
33
- gh: run("gh"), git: run("git"), projector: makeProjector({ gh: base.gh }), runner: makeLeanRunner(), waitCiGreen: base.waitCiGreen,
100
+ gh: run("gh"), git: run("git"), runTmp: makeRunTmp(), host: makeHostProbe(), projector: makeProjector({ gh: base.gh }), runner: makeLeanRunner(), waitCiGreen: base.waitCiGreen,
34
101
  /** Names of the checks the base branch requires (contexts + checks). [] on 404 / no protection. */
35
102
  async requiredChecks(lane) {
36
103
  try {
@@ -50,6 +117,8 @@ export function makeLeanIo({ repo }) {
50
117
  async transitionLabel(issue, state) { await pexec("bash", [script("transition-label.sh"), String(issue), "-R", repo, state], { timeout: 60000 }); },
51
118
  fs: {
52
119
  read: async (p) => readFileSync(p, "utf8"),
120
+ exists: async (p) => existsSync(p),
121
+ rmdir: async (p) => rmSync(p, { recursive: true, force: true }),
53
122
  stat: async (p) => { try { return { mtimeMs: statSync(p).mtimeMs }; } catch { return null; } },
54
123
  append: async (p, t) => { mkdirSync(dirname(p), { recursive: true }); appendFileSync(p, t); },
55
124
  },
package/bin/lean/lean.mjs CHANGED
@@ -5,11 +5,13 @@
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";
14
+ import { checkAdmission, ADMISSION_RETRY_SECONDS } from "./admission.mjs";
13
15
  import { renderInvestigation, renderPlan, renderImplementation, renderReview, renderPrBody, renderTrajectory, capComment, sanitizeMd } from "./render.mjs";
14
16
  import { REVIEWER_SYSTEM, LEAN_DIR, RESULT_FILE, INVESTIGATION_FILE, PLAN_FILE, IMPLEMENTATION_FILE, builderPrompt, resumeBuilderPrompt, reviewerPrompt, parseVerdict } from "./prompts.mjs";
15
17
 
@@ -31,7 +33,7 @@ const num = (v) => (Number.isFinite(v) ? v : null);
31
33
  */
32
34
  export async function runCore(o) {
33
35
  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;
36
+ ciMaxMinutes = 360, mergeWait = "required", maxSessionLimitPauses = DEFAULT_MAX_SESSION_LIMIT_PAUSES, sessionLimitMaxWaitMs = DEFAULT_SESSION_LIMIT_WAIT_MS, onProgress = () => {}, emitTelemetry = null, random = Math.random } = o;
35
37
  const now = io.now ?? (() => Date.now());
36
38
  const sleep = io.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
37
39
  const base = `origin/${lane}`;
@@ -65,10 +67,19 @@ export async function runCore(o) {
65
67
 
66
68
  // ---- lease (same FORGE:STATE lease the full path uses) ----
67
69
  const agentId = o.agentId ?? `core_${process.pid}`;
70
+ const myHost = o.hostName ?? osHostname();
71
+ const leaseObj = () => ({ by: agentId, until: now() + leaseTtlMs, host: myHost, pid: o.pid ?? process.pid });
72
+ /** Provably dead: same host and `kill(pid, 0)` throws ESRCH. A different host keeps the TTL behaviour. */
73
+ const holderDead = (l) => {
74
+ const parsed = parseLeaseHolder(l?.by);
75
+ const h = l?.host ?? parsed?.host, pid = l?.pid ?? parsed?.pid;
76
+ if (!h || !Number.isSafeInteger(pid) || pid <= 0 || sanitizeLeaseHost(h) !== sanitizeLeaseHost(myHost)) return false;
77
+ try { (o.killFn ?? process.kill.bind(process))(pid, 0); return false; } catch (e) { return e?.code === "ESRCH"; }
78
+ };
68
79
  const leaseTtlMs = o.leaseTtlMs ?? 25 * 60000; // below the projector's 30 min honour cap
69
80
  let leaseState = null, leaseTimer = null;
70
81
  const writeLease = (lease, extra = {}) => io.projector.writeState(issue, { ...leaseState, ...extra, lease });
71
- const renew = async () => { if (io.projector && leaseState) { try { await writeLease({ by: agentId, until: now() + leaseTtlMs }); } catch { /* best effort */ } } };
82
+ const renew = async () => { if (io.projector && leaseState) { try { await writeLease(leaseObj()); } catch { /* best effort */ } } };
72
83
  async function releaseLease(reason) {
73
84
  if (leaseTimer) { clearInterval(leaseTimer); leaseTimer = null; }
74
85
  if (!io.projector || !leaseState) return;
@@ -79,6 +90,8 @@ export async function runCore(o) {
79
90
  // Pipeline trajectory (posted once at the end): phase rows, decisions, anomalies.
80
91
  const T = { issue, validate: null, plan: null, implement: null, review: null, pr: null, merge: null, close: null, decisions: [], anomalies: [] };
81
92
  let done = null, wtCreated = false;
93
+ const runId = `${issue}-${t0}`;
94
+ let runTmp = null;
82
95
  async function finish(reason, detail = "", extra = {}, codeOverride = null) {
83
96
  if (done) return done; // idempotent: exactly one RUN_TERMINAL and one telemetry emit
84
97
  const code = codeOverride ?? codeForTerminalReason(reason);
@@ -95,6 +108,7 @@ export async function runCore(o) {
95
108
  try { await post(["FORGE:TRAJECTORY", REGISTER_MARKER], renderTrajectory({ ...T, usage: usageLine() })); } catch { /* best effort */ }
96
109
  }
97
110
  await releaseLease(reason);
111
+ if (runTmp) { try { await io.runTmp.remove(runTmp); } catch { /* best effort */ } runTmp = null; } // every terminal, including engine-error and deferred
98
112
  if (wtCreated) {
99
113
  try { await io.git(["worktree", "remove", "--force", wt], { cwd }); } catch { /* best effort */ }
100
114
  if (reason === "invalid" && !extra.pr) { try { await io.git(["branch", "-D", branch], { cwd }); } catch { /* best effort */ } }
@@ -158,10 +172,28 @@ export async function runCore(o) {
158
172
  }
159
173
  }
160
174
 
175
+ /** Runner call with the per-run TMPDIR; a usage limit pauses and resumes the same step, or defers past the cap (#4101). */
176
+ async function callRunner(args) {
177
+ let pauses = 0;
178
+ for (;;) {
179
+ try { return await io.runner({ ...args, ...(runTmp ? { env: { TMPDIR: runTmp, CLAUDE_CODE_TMPDIR: runTmp } } : {}) }); }
180
+ catch (e) {
181
+ const epoch = Number.isFinite(e?.resetAtEpochMs) ? e.resetAtEpochMs : null;
182
+ if (!(e?.usageLimit === true || epoch !== null)) throw e;
183
+ const waitMs = epoch !== null && epoch > now() ? epoch - now() : DEFAULT_SESSION_LIMIT_WAIT_MS;
184
+ if (pauses >= maxSessionLimitPauses || waitMs > sessionLimitMaxWaitMs) { e.deferUsageLimit = true; throw e; }
185
+ pauses++;
186
+ ev({ event: "PHASE_RATE_LIMITED", phase: args.role, resetAt: epoch, resetAtDisplay: e.resetAt ?? null, waitMs });
187
+ onProgress({ event: "phase_paused", phase: args.role, reason: "session-limit", detail: `session limit hit; pausing until ${e.resetAt || new Date(now() + waitMs).toISOString()}` });
188
+ await sleep(waitMs);
189
+ }
190
+ }
191
+ }
192
+
161
193
  async function builder(n, prompt, resume, live = false) {
162
194
  onProgress({ event: "phase_enter", phase: "build" });
163
195
  await renew();
164
- const call = Promise.resolve(io.runner({ role: "builder", issue, cwd: wt, model: builderModel, resume, system: "", prompt }));
196
+ const call = Promise.resolve(callRunner({ role: "builder", issue, cwd: wt, model: builderModel, resume, system: "", prompt }));
165
197
  let r;
166
198
  if (live && io.fs.stat) {
167
199
  let settled = false;
@@ -183,6 +215,11 @@ export async function runCore(o) {
183
215
  }
184
216
  const readResult = async () => { try { const j = JSON.parse(await io.fs.read(`${wt}/${RESULT_FILE}`)); return j && typeof j === "object" ? j : null; } catch { return null; } };
185
217
 
218
+ // ---- host admission (#4105): before the lease, the worktree or any GitHub call ----
219
+ if (io.host && o.admission?.enabled !== false) {
220
+ const a = await checkAdmission({ cfg: o.admission, host: io.host });
221
+ if (!a.ok) return { terminalReason: "deferred", code: OUTCOME_CODES.HOST_BUSY, detail: a.detail, retryInSeconds: ADMISSION_RETRY_SECONDS };
222
+ }
186
223
  // ---- admission: Depends-on refusal, then lease ----
187
224
  {
188
225
  let dep = null;
@@ -196,15 +233,22 @@ export async function runCore(o) {
196
233
  if (io.projector) {
197
234
  let st = null;
198
235
  try { st = await io.projector.readState(issue); } catch { /* unreadable: proceed */ }
199
- if (st?.lease && st.lease.until > now() && st.lease.by !== agentId) {
236
+ if (st?.lease && st.lease.until > now() && st.lease.by !== agentId && holderDead(st.lease)) {
237
+ ev({ event: "LEASE_TAKEOVER", from: st.lease.by, host: st.lease.host ?? null, pid: st.lease.pid ?? null });
238
+ onProgress({ event: "lease_takeover", detail: `taking over the lease of dead holder ${st.lease.by} (pid ${st.lease.pid ?? "?"} gone)` });
239
+ } else if (st?.lease && st.lease.until > now() && st.lease.by !== agentId) {
200
240
  return { terminalReason: "deferred", code: "lease-held", detail: `issue ${issue} leased by ${st.lease.by} until ${new Date(st.lease.until).toISOString()}`, branch, worktree: wt };
201
241
  }
202
242
  leaseState = { ...(st ?? { v: 1, run: `core_${issue}`, issue, lane, committed: [], phase: null, branch: null, pr: null, terminalReason: null }), terminal: false };
203
- try { await writeLease({ by: agentId, until: now() + leaseTtlMs }); } catch { /* best effort */ }
204
- leaseTimer = setInterval(() => { writeLease({ by: agentId, until: now() + leaseTtlMs }).catch(() => {}); }, o.leaseRenewMs ?? 120000);
243
+ try { await writeLease(leaseObj()); } catch { /* best effort */ }
244
+ leaseTimer = setInterval(() => { writeLease(leaseObj()).catch(() => {}); }, o.leaseRenewMs ?? 120000);
205
245
  leaseTimer.unref?.();
206
246
  }
207
247
 
248
+ if (io.runTmp) {
249
+ try { await io.runTmp.prune?.(); } catch { /* best effort */ }
250
+ try { runTmp = await io.runTmp.make(issue, runId); } catch { runTmp = null; }
251
+ }
208
252
  ev({ event: "RUN_START", run: `core_${issue}_${t0}`, issue, lane, path: "core", version: ENGINE_VERSION, forge_version: ENGINE_VERSION, repo });
209
253
  let iss;
210
254
  try {
@@ -227,11 +271,36 @@ export async function runCore(o) {
227
271
  } catch { /* no resume info: start fresh */ }
228
272
  try { await io.git(["worktree", "remove", "--force", wt], { cwd }); } catch { /* none */ }
229
273
  try { await io.git(["worktree", "prune"], { cwd }); } catch { /* best effort */ }
274
+ // Concurrent lanes in one repo race on .git/config.lock / index.lock during `worktree add`; retry with jitter.
275
+ // 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.
276
+ const wtRoot = o.worktreeRoot ?? join(cwd, ".claude", "worktrees");
277
+ const leftoverDir = async () => {
278
+ if (!io.fs.exists || !io.fs.rmdir || dirname(wt) !== wtRoot || basename(wt) !== `lean-${issue}`) return false;
279
+ if (!(await io.fs.exists(wt))) return false;
280
+ const listed = String(await io.git(["worktree", "list", "--porcelain"], { cwd }).catch(() => "")).split("\n").some((l) => l === `worktree ${wt}`);
281
+ if (listed) return false;
282
+ await io.fs.rmdir(wt);
283
+ ev({ event: "WORKTREE_LEFTOVER_REMOVED", path: wt });
284
+ return true;
285
+ };
286
+ await leftoverDir();
287
+ const LOCK_RE = /could not lock|cannot lock ref|\.lock'?: File exists|Unable to create .*\.lock|index\.lock|packed-refs\.lock/i;
288
+ const addWorktree = async (args) => {
289
+ let cleaned = false;
290
+ for (let i = 0; ; i++) {
291
+ try { return await io.git(args, { cwd }); } catch (e) {
292
+ const msg = String(e?.stderr ?? e?.message ?? e);
293
+ if (!cleaned && /already exists/i.test(msg) && (await leftoverDir())) { cleaned = true; continue; }
294
+ if (i >= 4 || !LOCK_RE.test(msg)) throw e;
295
+ await sleep(500 * (i + 1) + Math.floor(random() * 500));
296
+ }
297
+ }
298
+ };
230
299
  if (resumed) {
231
300
  await io.git(["fetch", "origin", branch], { cwd });
232
- await io.git(["worktree", "add", "-B", branch, wt, `origin/${branch}`], { cwd });
301
+ await addWorktree(["worktree", "add", "-B", branch, wt, `origin/${branch}`]);
233
302
  } else {
234
- await io.git(["worktree", "add", "-B", branch, wt, base], { cwd });
303
+ await addWorktree(["worktree", "add", "-B", branch, wt, base]);
235
304
  }
236
305
  wtCreated = true;
237
306
  const excl = String(await git(["rev-parse", "--git-path", "info/exclude"])).trim();
@@ -273,7 +342,7 @@ export async function runCore(o) {
273
342
  for (let t = 0; t < 2; t++) {
274
343
  const diff = String(await git(["diff", `${base}...HEAD`]));
275
344
  await renew();
276
- const rr = await io.runner({ role: "reviewer", issue, cwd: wt, model: reviewerModel, resume: null, system: REVIEWER_SYSTEM, readOnly: true,
345
+ const rr = await callRunner({ role: "reviewer", issue, cwd: wt, model: reviewerModel, resume: null, system: REVIEWER_SYSTEM, readOnly: true,
277
346
  prompt: reviewerPrompt({ issue: iss, diff }) });
278
347
  await renew();
279
348
  await attempt("review", ++reviewN, rr);
@@ -307,10 +376,9 @@ export async function runCore(o) {
307
376
  ev({ event: "PR_OPENED", pr });
308
377
  T.pr = [`#${pr}`, resumed?.pr ? "existing" : "opened"];
309
378
  const prUrl = /https?:\/\/\S+/.exec(prOut)?.[0] ?? `https://github.com/${repo}/pull/${pr}`;
310
- const localHead = String(await git(["rev-parse", "HEAD"])).trim();
379
+ let localHead = String(await git(["rev-parse", "HEAD"])).trim();
311
380
 
312
- // ---- 6. the register comment ----
313
- // merge policy: required (default) | all | none
381
+ // ---- 6. merge policy: required (default) | all | none ----
314
382
  let policy = mergeWait, required = [];
315
383
  if (mergeWait === "required") {
316
384
  try { required = (await io.requiredChecks?.(lane)) ?? []; } catch { required = []; }
@@ -319,46 +387,131 @@ export async function runCore(o) {
319
387
  T.merge = [policyText, "waiting"];
320
388
  T.decisions.push(`merge_wait=${mergeWait}: ${policyText}`);
321
389
 
322
- // ---- 7. wait per policy, merge at the reviewed head ----
323
- if (policy === "none" || (policy === "required" && !required.length)) {
324
- // merge right after review
325
- T.merge = [policyText, "merged right after review"];
326
- } else {
327
- const startedAt = now();
328
- let pollIndex = 0, gateHead = null;
329
- for (;;) {
330
- await renew();
331
- if (policy === "all") {
332
- const w = await io.waitCiGreen(pr, repo, 0).catch(() => ({ rc: 2, text: "" }));
333
- if (w.rc === 0) { gateHead = /CI_GATE_HEAD:\s*([0-9a-f]{7,40})/.exec(w.text ?? "")?.[1] ?? null; break; }
334
- if (w.rc === 1) return await needsHuman(`Remote CI failed on PR #${pr}:\n${String(w.text).slice(-2000)}`, OUTCOME_CODES.CI_RED);
335
- } else {
336
- let states = [];
337
- try { states = (await io.checkStates(pr)) ?? []; } catch { states = []; }
338
- const st = (n) => states.find((x) => x.name === n)?.state ?? "pending";
339
- const failed = required.filter((n) => st(n) === "failure");
340
- if (failed.length) return await needsHuman(`Required check(s) failed on PR #${pr}: ${failed.join(", ")}`, OUTCOME_CODES.CI_RED);
341
- if (required.every((n) => st(n) === "success")) break;
390
+ // ---- base-sync (#4100): merge the lane into the branch when the PR conflicts; merge only, never rebase or force-push ----
391
+ let syncs = 0;
392
+ const MAX_SYNCS = 2;
393
+ const gitOut = async (args) => { try { return String(await git(args)).trim(); } catch { return ""; } };
394
+ /** CONFLICTING / DIRTY, or UNKNOWN after one short re-poll. Unreadable state counts as not conflicting. */
395
+ const prConflicting = async () => {
396
+ for (let i = 0; i < 2; i++) {
397
+ let j;
398
+ try { j = JSON.parse(String(await ghc(["pr", "view", String(pr), "-R", repo, "--json", "mergeable,mergeStateStatus"])) || "{}"); } catch { return false; }
399
+ const m = String(j.mergeable ?? "").toUpperCase(), st = String(j.mergeStateStatus ?? "").toUpperCase();
400
+ if (m === "CONFLICTING" || st === "DIRTY") return "conflict";
401
+ if (m !== "UNKNOWN") return false;
402
+ if (i === 0) await sleep(2000);
403
+ }
404
+ return "unknown";
405
+ };
406
+ const conflictFiles = async () => (await gitOut(["diff", "--name-only", "--diff-filter=U"])).split("\n").map((x) => x.trim()).filter(Boolean);
407
+ const unresolved = async () => (await conflictFiles()).length > 0 || (await gitOut(["grep", "-n", "-e", "^<<<<<<< ", "-e", "^>>>>>>> "])) !== ""
408
+ || (await gitOut(["diff", "--check"])) !== "" || (await gitOut(["rev-parse", "-q", "--verify", "MERGE_HEAD"])) !== "";
409
+ const syncRow = [];
410
+ /** Returns null to continue with the merge flow, or a terminal result. */
411
+ async function baseSync(trigger, soft = false) {
412
+ if (syncs >= MAX_SYNCS) return await needsHuman(`PR #${pr} still conflicts with ${lane} after ${MAX_SYNCS} base-syncs (${trigger}).`, OUTCOME_CODES.BASE_CONFLICT);
413
+ syncs++;
414
+ await renew();
415
+ try { await git(["fetch", "origin", lane]); } catch { /* offline: use the local ref */ }
416
+ const baseSha = (await gitOut(["rev-parse", base])).slice(0, 12);
417
+ const before = await gitOut(["rev-parse", "HEAD"]);
418
+ let clean = true, files = [];
419
+ try { await git(["merge", "--no-edit", base]); } catch { clean = false; files = await conflictFiles(); }
420
+ let how, tests = "not required (clean merge)", rereview = "not required (conflict-free base merge)";
421
+ if (clean) {
422
+ how = "clean merge of the base into the branch";
423
+ } else {
424
+ T.decisions.push(`Base-sync ${syncs}: merging ${base} conflicts in ${files.join(", ") || "unknown files"}; builder resolves`);
425
+ 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.`;
426
+ await builder(++attemptN, prompt, sessionId ?? null);
427
+ if (await unresolved()) {
428
+ await gitOut(["merge", "--abort"]);
429
+ return await needsHuman(`Base-sync could not resolve the conflicts in ${files.join(", ")} (conflict markers or an unfinished merge remain).`, OUTCOME_CODES.BASE_CONFLICT);
342
430
  }
343
- if (now() - startedAt >= ciMaxMinutes * 60000) {
344
- // Slow CI is never a needs-human: leave the PR open and resumable.
345
- ev({ event: "CI_PENDING_WAIT", state: "end", pr, result: "timeout" });
346
- T.anomalies.push("CI still pending at the wait budget");
347
- return await finish("ci-pending", `PR #${pr} still waiting on CI after ${ciMaxMinutes} min`, { pr });
431
+ how = `builder resolved ${files.length} conflicted file(s) and committed the merge`;
432
+ const r2 = await readResult();
433
+ tests = Array.isArray(r2?.tests) && r2.tests.length ? r2.tests.map((t) => (typeof t === "string" ? t : `${t.command}: ${t.result}`)).join("; ") : NR_TESTS;
434
+ }
435
+ const after = await gitOut(["rev-parse", "HEAD"]);
436
+ 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
438
+ if (!clean) {
439
+ let rv = await doReview();
440
+ if (rv?.fail) {
441
+ T.decisions.push(`Base-sync re-review FAIL: builder resumed with the reason`);
442
+ await builder(++attemptN, resumeBuilderPrompt(rv.reason), sessionId ?? null);
443
+ 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]);
445
+ rv = await doReview();
348
446
  }
349
- onProgress({ event: "phase_paused", phase: "merge", detail: `CI pending on PR #${pr}` });
350
- await sleep(ciPollDelayMs(pollIndex++, random));
447
+ if (!rv) return await needsHuman("Reviewer returned no parseable verdict after base-sync.", OUTCOME_CODES.REVIEW_NO_VERDICT);
448
+ if (rv.fail) return await needsHuman(`Review FAIL after base-sync: ${rv.reason}`, OUTCOME_CODES.REVIEW_FAILED);
449
+ rereview = `${rv.verdict}: ${rv.reason || "no concrete failure"}`;
351
450
  }
352
- if (gateHead && !(localHead.startsWith(gateHead) || gateHead.startsWith(localHead))) {
353
- 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);
451
+ localHead = await gitOut(["rev-parse", "HEAD"]);
452
+ syncRow.push(`${trigger}: ${clean ? "clean" : "conflicts"} (${baseSha})`);
453
+ T.baseSync = [`${syncs} sync(s)`, syncRow.join("; ")];
454
+ 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)}\``);
455
+ return null;
456
+ }
457
+ const NR_TESTS = "not reported";
458
+
459
+ // ---- 7. (sync) wait per policy, (sync) merge at the reviewed head ----
460
+ // A rerun on an existing PR goes through the same conflict check, so a conflicting PR from an earlier run recovers.
461
+ for (;;) {
462
+ const c0 = await prConflicting();
463
+ if (c0) { const r = await baseSync(c0 === "unknown" ? "PR mergeability unknown" : "PR is conflicting", c0 === "unknown"); if (r) return r; }
464
+ if (policy === "none" || (policy === "required" && !required.length)) {
465
+ // merge right after review
466
+ T.merge = [policyText, "merged right after review"];
467
+ } else {
468
+ const startedAt = now();
469
+ let pollIndex = 0, gateHead = null;
470
+ for (;;) {
471
+ await renew();
472
+ if (policy === "all") {
473
+ const w = await io.waitCiGreen(pr, repo, 0).catch(() => ({ rc: 2, text: "" }));
474
+ if (w.rc === 0) { gateHead = /CI_GATE_HEAD:\s*([0-9a-f]{7,40})/.exec(w.text ?? "")?.[1] ?? null; break; }
475
+ if (w.rc === 1) return await needsHuman(`Remote CI failed on PR #${pr}:\n${String(w.text).slice(-2000)}`, OUTCOME_CODES.CI_RED);
476
+ } else {
477
+ let states = [];
478
+ try { states = (await io.checkStates(pr)) ?? []; } catch { states = []; }
479
+ const st = (n) => states.find((x) => x.name === n)?.state ?? "pending";
480
+ const failed = required.filter((n) => st(n) === "failure");
481
+ if (failed.length) return await needsHuman(`Required check(s) failed on PR #${pr}: ${failed.join(", ")}`, OUTCOME_CODES.CI_RED);
482
+ if (required.every((n) => st(n) === "success")) break;
483
+ }
484
+ if (now() - startedAt >= ciMaxMinutes * 60000) {
485
+ // Slow CI is never a needs-human: leave the PR open and resumable.
486
+ ev({ event: "CI_PENDING_WAIT", state: "end", pr, result: "timeout" });
487
+ T.anomalies.push("CI still pending at the wait budget");
488
+ return await finish("ci-pending", `PR #${pr} still waiting on CI after ${ciMaxMinutes} min`, { pr });
489
+ }
490
+ onProgress({ event: "phase_paused", phase: "merge", detail: `CI pending on PR #${pr}` });
491
+ await sleep(ciPollDelayMs(pollIndex++, random));
492
+ }
493
+ if (gateHead && !(localHead.startsWith(gateHead) || gateHead.startsWith(localHead))) {
494
+ 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);
495
+ }
496
+ }
497
+ if (syncs > 0 && (await prConflicting()) === "conflict") continue; // the base moved again while waiting
498
+ T.merge = [policyText, `merged at ${localHead.slice(0, 12)}`];
499
+ try {
500
+ await ghc(["pr", "merge", String(pr), "-R", repo, "--merge", "--match-head-commit", localHead]);
501
+ break;
502
+ } catch (e) {
503
+ if (!/not mergeable|merge conflict|conflict/i.test(String(e?.stderr ?? "") + String(e?.message ?? ""))) throw e;
504
+ const r = await baseSync("gh pr merge reported a conflict"); if (r) return r;
354
505
  }
355
506
  }
356
- T.merge = [policyText, `merged at ${localHead.slice(0, 12)}`];
357
- await ghc(["pr", "merge", String(pr), "-R", repo, "--merge", "--match-head-commit", localHead]);
358
507
  await ghc(["issue", "close", String(issue), "-R", repo, "--reason", "completed"]).catch(() => {});
359
508
  await label("merged");
360
509
  return await finish("merged", `PR #${pr}`, { pr });
361
510
  } catch (e) {
511
+ if (e?.deferUsageLimit) {
512
+ const when = e.resetAt || (Number.isFinite(e.resetAtEpochMs) ? new Date(e.resetAtEpochMs).toISOString() : "unknown");
513
+ 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
+ }
362
515
  return finish("engine-error", e?.message ?? String(e));
363
516
  }
364
517
  }
@@ -9,6 +9,10 @@ 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";
13
+ import { checkAdmission } from "./admission.mjs";
14
+ import { OUTCOME_CODES } from "../engine/outcome-codes.mjs";
15
+ import { readAdmission } from "../engine-cli.mjs";
12
16
  import { runFromCli, orchestrateFromCli, ENGINE_PATHS } from "../engine-cli.mjs";
13
17
 
14
18
  const WT = "/wt/lean-7";
@@ -26,7 +30,7 @@ function world(o = {}) {
26
30
  comments: [], labels: [], closed: [], edits: [], prs: [], merges: [], calls: [], telemetry: [], files: {}, head: "aaaa1111aaaa1111",
27
31
  changed: o.changed ?? ["src/foo.js", "test/foo.test.js"], reviews: [...(o.reviews ?? [])], waits: [...(o.waits ?? [])], clock: 0,
28
32
  result: o.result ?? { status: "fixed", summary: "fixed foo", evidence: [], tests: ["test/foo.test.js"] },
29
- prList: o.prList ?? [], remote: o.remote ?? "", resumeResult: o.resumeResult, seq: [], mtimes: {},
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,
30
34
  stages: { ...STAGE_MD, ...(o.stages ?? {}) },
31
35
  };
32
36
  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,12 +47,28 @@ function world(o = {}) {
43
47
  if (k === "issue close") { w.closed.push(a); w.seq.push("close"); return ""; }
44
48
  if (k === "issue edit") { w.edits.push(a); return ""; }
45
49
  if (k === "pr create") { w.prs.push(a); w.seq.push("pr"); return "https://github.com/o/r/pull/77\n"; }
50
+ if (k === "pr view" && a.includes("mergeable,mergeStateStatus")) { const v = w.prView.length > 1 ? w.prView.shift() : (w.prView[0] ?? { mergeable: "MERGEABLE", mergeStateStatus: "CLEAN" }); return JSON.stringify(v); }
51
+ if (k === "pr merge" && w.mergeErrors.length) { w.mergeAttempts++; throw new Error(w.mergeErrors.shift()); }
52
+ if (k === "pr merge") { w.mergeAttempts++; }
46
53
  if (k === "pr merge") { w.merges.push(a); w.seq.push("merge"); return ""; }
47
54
  throw new Error(`unexpected gh ${a.join(" ")}`);
48
55
  },
49
56
  git: async (a) => {
50
57
  w.calls.push(["git", ...a]);
58
+ if (a[0] === "worktree" && a[1] === "add" && (w.lockFails ?? 0) > 0) {
59
+ w.lockFails--; const e = new Error("git failed"); e.stderr = "error: could not lock config file .git/config: File exists"; throw e;
60
+ }
51
61
  if (a[0] === "rev-parse" && a[1] === "--git-path") return "/repo/.git/info/exclude";
62
+ if (a[0] === "merge" && a[1] === "--no-edit") {
63
+ const r = w.mergeResults.shift() ?? "clean";
64
+ if (r === "clean") { w.head = `cccc${w.calls.length}`.padEnd(16, "0"); return ""; }
65
+ w.conflictFiles = o.conflictFiles ?? ["src/foo.js"]; w.mergeInProgress = true; throw new Error("CONFLICT (content): Merge conflict in src/foo.js");
66
+ }
67
+ if (a[0] === "rev-parse" && a.includes("MERGE_HEAD")) return w.mergeInProgress ? "deadbeef" : "";
68
+ if (a[0] === "diff" && a.includes("--diff-filter=U")) return w.conflictFiles.join("\n");
69
+ if (a[0] === "diff" && a[1] === "--check") return "";
70
+ if (a[0] === "grep") return w.markers ? "src/foo.js:3:<<<<<<< HEAD" : "";
71
+ if (a[0] === "merge" && a[1] === "--abort") { w.mergeInProgress = false; return ""; }
52
72
  if (a[0] === "rev-parse") return w.head;
53
73
  if (a[0] === "diff" && a[1] === "--name-only") return w.changed.join("\n");
54
74
  if (a[0] === "diff") return "diff --git a/src/foo.js b/src/foo.js\n+fix";
@@ -59,12 +79,13 @@ function world(o = {}) {
59
79
  runner: async (c) => {
60
80
  w.calls.push(["runner", c.role, c.resume ?? null, c.model ?? null, c.prompt, c.readOnly ?? false, c.system]);
61
81
  w.seq.push(c.role + (c.resume ? "-resume" : ""));
82
+ w.envs.push(c.env ?? null);
62
83
  if (c.role === "builder") {
63
84
  const r = c.resume ? (w.resumeResult ?? w.result) : w.result;
64
85
  if (o.live) await o.live(w, put);
65
86
  else if (!c.resume && !o.noStages) writeStages(["investigation", "plan", "implementation"]);
66
87
  if (r) put("result.json", JSON.stringify(r));
67
- if (c.resume) w.head = `bbbb${w.calls.length}`.padEnd(16, "0");
88
+ if (c.resume || /Merging origin\//.test(c.prompt)) { w.head = `bbbb${w.calls.length}`.padEnd(16, "0"); if (!o.leaveConflicts) { w.conflictFiles = []; w.mergeInProgress = false; } }
68
89
  return { text: "done", sessionId: "sess-1", usage: { input_tokens: 10, output_tokens: 5, cache_read_input_tokens: 100 }, costUsd: 0.1, durationMs: 1000, turns: 3, model: "default" };
69
90
  }
70
91
  return { text: w.reviews.length ? w.reviews.shift() : ok(), sessionId: "rev", usage: { input_tokens: 4, output_tokens: 2 }, costUsd: 0.05, durationMs: 500, turns: 2, model: c.model };
@@ -215,6 +236,15 @@ describe("core work-on", () => {
215
236
  assert.equal((await run(world({ changed: [] }))).terminalReason, "needs-human");
216
237
  });
217
238
 
239
+ it("worktree add retries a concurrent .git lock collision, then gives up after 5 tries", async () => {
240
+ const w = world(); w.lockFails = 2;
241
+ assert.equal((await run(w)).terminalReason, "merged");
242
+ assert.equal(w.calls.filter((c) => c[1] === "worktree" && c[2] === "add").length, 3);
243
+ const w2 = world(); w2.lockFails = 99;
244
+ assert.notEqual((await run(w2)).terminalReason, "merged");
245
+ assert.equal(w2.calls.filter((c) => c[1] === "worktree" && c[2] === "add").length, 5);
246
+ });
247
+
218
248
  it("needs-human terminals carry a specific outcome code, never unclassified", async () => {
219
249
  const code = async (w, extra) => {
220
250
  const dir = tmpDir("lean-code-");
@@ -622,3 +652,330 @@ describe("runCliBackendAsync", () => {
622
652
  await assert.rejects(runCliBackendAsync({ spec: { name: "t" }, userMessage: "go", args: ["1"], cwd: d, bin: `${d}/bad`, logger: { log() {} } }), (e) => e.code === "CLI_BACKEND_FAILED" && /boom/.test(e.message));
623
653
  });
624
654
  });
655
+
656
+ describe("base-sync (#4100)", () => {
657
+ const CONFLICTING = { mergeable: "CONFLICTING", mergeStateStatus: "DIRTY" };
658
+ const OKSTATE = { mergeable: "MERGEABLE", mergeStateStatus: "CLEAN" };
659
+ const gitCalls = (w) => w.calls.filter((c) => c[0] === "git").map((c) => c.slice(1));
660
+ const noRebaseNoForce = (w) => {
661
+ for (const g of gitCalls(w)) {
662
+ assert.notEqual(g[0], "rebase");
663
+ if (g[0] === "push") assert.ok(!g.some((x) => /^(--force|-f$|\+)/.test(x)), `force push: ${g.join(" ")}`);
664
+ }
665
+ };
666
+ const syncComment = (w) => w.comments.find((c) => c.includes("<!-- FORGE:BASE_SYNC -->"));
667
+
668
+ it("conflicting PR, clean merge: fetch, merge, push, merge the PR at the new head; no re-review", async () => {
669
+ const w = world({ prView: [CONFLICTING, OKSTATE], mergeResults: ["clean"] });
670
+ const r = await run(w);
671
+ assert.equal(r.terminalReason, "merged");
672
+ assert.equal(reviewers(w).length, 1, "no re-review for a conflict-free base merge");
673
+ assert.equal(builders(w).length, 1);
674
+ const g = gitCalls(w);
675
+ assert.ok(g.some((x) => x[0] === "fetch" && x[1] === "origin" && x[2] === "staging"));
676
+ assert.ok(g.some((x) => x[0] === "merge" && x[1] === "--no-edit" && x[2] === "origin/staging"));
677
+ assert.equal(g.filter((x) => x[0] === "push").length, 2, "initial push plus the sync push");
678
+ assert.match(syncComment(w), /## Base Sync[\s\S]*clean merge[\s\S]*not required \(conflict-free base merge\)/);
679
+ assert.deepEqual(w.merges[0].slice(-2), ["--match-head-commit", w.head]);
680
+ assert.match(traj(w)[0], /\| Base Sync \| 1 sync\(s\) \|/);
681
+ noRebaseNoForce(w);
682
+ });
683
+
684
+ it("no conflict: no Base Sync row detail and no sync comment", async () => {
685
+ const w = world();
686
+ await run(w);
687
+ assert.ok(!syncComment(w));
688
+ assert.match(traj(w)[0], /\| Base Sync \| not needed \| - \|/);
689
+ });
690
+
691
+ it("conflicting PR, real conflict: builder resumed with the files, reviewer PASS on the full diff, merge", async () => {
692
+ const w = world({ prView: [CONFLICTING, OKSTATE], mergeResults: ["conflict"], reviews: [ok(), ok()] });
693
+ const r = await run(w);
694
+ assert.equal(r.terminalReason, "merged");
695
+ const b = builders(w);
696
+ assert.equal(b.length, 2);
697
+ assert.equal(b[1][2], "sess-1", "same session resumed");
698
+ assert.match(b[1][4], /Merging origin\/staging into this branch conflicts in: src\/foo\.js\. Resolve the conflicts preserving both this fix's intent and the base's changes, run the relevant tests, and commit the merge\. Do not push\./);
699
+ assert.equal(reviewers(w).length, 2, "re-review after the conflict resolution");
700
+ assert.match(syncComment(w), /conflicts in `src\/foo\.js`[\s\S]*builder resolved 1 conflicted file[\s\S]*Re-review:\*\* PASS/);
701
+ assert.equal(w.merges.length, 1);
702
+ assert.deepEqual(w.merges[0].slice(-2), ["--match-head-commit", w.head]);
703
+ noRebaseNoForce(w);
704
+ });
705
+
706
+ it("conflict resolution that the re-review FAILs gets one retry then merges on PASS", async () => {
707
+ const w = world({ prView: [CONFLICTING, OKSTATE], mergeResults: ["conflict"], reviews: [ok(), bad("src/foo.js:4 lost base change"), ok()] });
708
+ assert.equal((await run(w)).terminalReason, "merged");
709
+ assert.equal(reviewers(w).length, 3);
710
+ assert.equal(builders(w).length, 3);
711
+ });
712
+
713
+ it("still conflicting after 2 base-syncs ends needs-human with base.conflict, never engine-error", async () => {
714
+ const w = world({ prView: [CONFLICTING], mergeResults: ["clean", "clean", "clean"] });
715
+ const r = await run(w);
716
+ assert.equal(r.terminalReason, "needs-human");
717
+ assert.equal(r.code, "base.conflict");
718
+ assert.equal(gitCalls(w).filter((x) => x[0] === "merge" && x[1] === "--no-edit").length, 2, "at most 2 base-syncs");
719
+ assert.equal(w.merges.length, 0);
720
+ assert.ok(w.edits.some((e) => e.includes("needs-human")));
721
+ });
722
+
723
+ it("unresolved conflict markers after the builder ends needs-human with base.conflict", async () => {
724
+ const w = world({ prView: [CONFLICTING], mergeResults: ["conflict"] });
725
+ w.markers = true;
726
+ const r = await run(w);
727
+ assert.equal(r.terminalReason, "needs-human"); assert.equal(r.code, "base.conflict");
728
+ assert.equal(w.merges.length, 0);
729
+ assert.ok(gitCalls(w).some((x) => x[0] === "merge" && x[1] === "--abort"));
730
+ });
731
+
732
+ it("a gh pr merge conflict error triggers base-sync, then the merge is retried", async () => {
733
+ const w = world({ prView: [OKSTATE], mergeErrors: ["GraphQL: Pull request is not mergeable (mergePullRequest)"], mergeResults: ["clean"] });
734
+ const r = await run(w);
735
+ assert.equal(r.terminalReason, "merged");
736
+ assert.equal(w.mergeAttempts, 2);
737
+ assert.match(syncComment(w), /gh pr merge reported a conflict/);
738
+ noRebaseNoForce(w);
739
+ });
740
+
741
+ it("an unrelated gh pr merge error is not a base-sync", async () => {
742
+ const w = world({ mergeErrors: ["HTTP 500 boom"] });
743
+ const r = await run(w);
744
+ assert.equal(r.terminalReason, "engine-error");
745
+ assert.ok(!syncComment(w));
746
+ });
747
+
748
+ it("a resumed run with a conflicting PR syncs (fresh builder when the conflict needs resolving)", async () => {
749
+ const w = world({ prList: [{ number: 77, headRefOid: "x" }], remote: "abc\trefs/heads/feat/issue-7-lean", prView: [CONFLICTING, OKSTATE], mergeResults: ["conflict"], reviews: [ok(), ok()] });
750
+ const r = await run(w);
751
+ assert.equal(r.terminalReason, "merged");
752
+ const b = builders(w);
753
+ assert.equal(b.length, 1);
754
+ assert.equal(b[0][2], null, "no session id on resume: a fresh builder");
755
+ assert.match(b[0][4], /Merging origin\/staging into this branch conflicts in: src\/foo\.js/);
756
+ assert.equal(w.prs.length, 0);
757
+ noRebaseNoForce(w);
758
+ });
759
+
760
+ it("UNKNOWN mergeability with nothing to merge is not treated as a conflict", async () => {
761
+ const w = world({ prView: [{ mergeable: "UNKNOWN", mergeStateStatus: "UNKNOWN" }, { mergeable: "UNKNOWN", mergeStateStatus: "UNKNOWN" }, OKSTATE], mergeResults: ["clean"] });
762
+ // a clean merge that changes head would count; here make the merge a no-op by pinning the head
763
+ const orig = w.io.git;
764
+ w.io.git = async (a, o2) => { const r = await orig(a, o2); if (a[0] === "merge" && a[1] === "--no-edit") w.head = "aaaa1111aaaa1111"; return r; };
765
+ const r = await run(w);
766
+ assert.equal(r.terminalReason, "merged");
767
+ assert.ok(!syncComment(w));
768
+ });
769
+ });
770
+
771
+ describe("usage limit, lease takeover, worktree setup, run tmp (#4101-#4104)", () => {
772
+ const limitErr = (extra) => Object.assign(new Error("claude CLI exited with status 1. You've hit your session limit"), { code: "CLI_BACKEND_FAILED", usageLimit: true, ...extra });
773
+ const failFirstBuilder = (w, err, times = 1) => { const orig = w.io.runner; let n = 0; w.io.runner = async (c) => { if (c.role === "builder" && n++ < times) throw err; return orig(c); }; };
774
+ const projector = (w, lease) => { w.leaseWrites = []; w.io.projector = { readState: async () => (lease ? { v: 1, lease } : null), writeState: async (_i, st) => { w.leaseWrites.push(st); } }; };
775
+
776
+ it("#4101 a usage limit with a near reset pauses until the reset, then resumes the same step", async () => {
777
+ const w = world(); const sleeps = [];
778
+ const s0 = w.io.sleep; w.io.sleep = async (ms) => { sleeps.push(ms); return s0(ms); };
779
+ failFirstBuilder(w, limitErr({ resetAtEpochMs: 90000, resetAt: "8:30pm" }));
780
+ const r = await run(w);
781
+ assert.equal(r.terminalReason, "merged");
782
+ assert.ok(sleeps.includes(90000), `slept until the reset: ${sleeps}`);
783
+ assert.equal(builders(w).length, 1);
784
+ });
785
+
786
+ it("#4101 a usage limit with a far reset ends deferred/rate_limited with the reset time, lease released, no needs-human", async () => {
787
+ const w = world(); projector(w, null);
788
+ failFirstBuilder(w, limitErr({ resetAtEpochMs: 3 * 3600 * 1000, resetAt: "8:30pm (Asia/Kolkata)" }));
789
+ const r = await run(w);
790
+ assert.equal(r.terminalReason, "deferred");
791
+ assert.equal(r.code, "rate_limited");
792
+ assert.match(r.detail, /resets at 8:30pm \(Asia\/Kolkata\)/);
793
+ assert.equal(w.edits.some((e) => e.includes("needs-human")), false);
794
+ assert.equal(traj(w).length, 0, "no trajectory comment for a deferral");
795
+ assert.equal(w.leaseWrites.at(-1).lease, null, "lease released");
796
+ assert.equal(w.merges.length, 0);
797
+ });
798
+
799
+ it("#4101 unknown reset pauses a bounded number of times, then defers", async () => {
800
+ const w = world(); let sleeps = 0;
801
+ const s0 = w.io.sleep; w.io.sleep = async (ms) => { sleeps++; return s0(ms); };
802
+ failFirstBuilder(w, limitErr(), 99);
803
+ const r = await run(w, { maxSessionLimitPauses: 2 });
804
+ assert.equal(r.terminalReason, "deferred"); assert.equal(r.code, "rate_limited");
805
+ assert.equal(sleeps, 2);
806
+ assert.match(r.detail, /resets at unknown/);
807
+ });
808
+
809
+ it("#4101 a non-limit runner error is still engine-error", async () => {
810
+ const w = world(); failFirstBuilder(w, new Error("boom"));
811
+ assert.equal((await run(w)).terminalReason, "engine-error");
812
+ });
813
+
814
+ it("#4102 a same-host holder whose pid is gone is taken over immediately; a live one or another host is not", async () => {
815
+ const future = Date.now() + 1e6;
816
+ const dead = Object.assign(new Error("no such process"), { code: "ESRCH" });
817
+ const w = world(); projector(w, { by: "core_4242", until: future, host: "h1", pid: 4242 });
818
+ const r = await run(w, { hostName: "h1", killFn: () => { throw dead; } });
819
+ assert.equal(r.terminalReason, "merged");
820
+ assert.ok(w.leaseWrites.some((x) => x.lease?.host === "h1" && Number.isSafeInteger(x.lease?.pid)), "lease records host and pid");
821
+ const w2 = world(); projector(w2, { by: "core_4242", until: 1e12, host: "h1", pid: 4242 });
822
+ assert.equal((await run(w2, { hostName: "h1", killFn: () => true })).terminalReason, "deferred");
823
+ const w3 = world(); projector(w3, { by: "core_4242", until: 1e12, host: "other-host", pid: 4242 });
824
+ assert.equal((await run(w3, { hostName: "h1", killFn: () => { throw dead; } })).terminalReason, "deferred");
825
+ assert.equal(builders(w3).length, 0);
826
+ });
827
+
828
+ it("#4103 a leftover unregistered worktree dir is removed (only this issue's lean dir) before add", async () => {
829
+ const w = world(); const removed = [];
830
+ w.io.fs.exists = async (p) => p === "/wt/lean-7"; w.io.fs.rmdir = async (p) => { removed.push(p); };
831
+ await run(w);
832
+ assert.deepEqual(removed, ["/wt/lean-7"]);
833
+ // a registered worktree dir is left to `git worktree remove`
834
+ const w2 = world(); const removed2 = [];
835
+ w2.io.fs.exists = async () => true; w2.io.fs.rmdir = async (p) => { removed2.push(p); };
836
+ const g = w2.io.git; w2.io.git = async (a, o2) => (a[0] === "worktree" && a[1] === "list" ? "worktree /wt/lean-7\nHEAD abc\n" : g(a, o2));
837
+ await run(w2);
838
+ assert.deepEqual(removed2, []);
839
+ // a worktree root that is not the lean root never deletes anything
840
+ const w3 = world(); const removed3 = [];
841
+ w3.io.fs.exists = async () => true; w3.io.fs.rmdir = async (p) => { removed3.push(p); };
842
+ await run(w3, { worktreeRoot: "/wt" , issue: 7 });
843
+ assert.ok(removed3.every((p) => p === "/wt/lean-7"));
844
+ });
845
+
846
+ it("#4103 'already exists' on add cleans the leftover and retries; index.lock and packed-refs.lock are retried", async () => {
847
+ for (const msg of ["fatal: '/wt/lean-7' already exists", "fatal: Unable to create '/r/.git/index.lock': File exists.", "error: cannot lock ref: Unable to create '/r/.git/packed-refs.lock': File exists", "fatal: could not lock config file /r/.git/config: File exists"]) {
848
+ const w = world(); let failed = false; const removed = [];
849
+ w.io.fs.exists = async (p) => failed && p === "/wt/lean-7"; w.io.fs.rmdir = async (p) => { removed.push(p); };
850
+ const g = w.io.git;
851
+ w.io.git = async (a, o2) => { if (a[0] === "worktree" && a[1] === "add" && !failed) { failed = true; throw Object.assign(new Error(msg), { stderr: msg }); } return g(a, o2); };
852
+ const r = await run(w);
853
+ assert.equal(r.terminalReason, "merged", msg);
854
+ if (/already exists/.test(msg)) assert.deepEqual(removed, ["/wt/lean-7"]);
855
+ }
856
+ });
857
+
858
+ it("#4104 the builder and reviewer run with a per-run TMPDIR that is removed on every terminal", async () => {
859
+ const mk = (w) => { w.tmpMade = []; w.tmpRemoved = []; w.io.runTmp = { prune: async () => { w.pruned = true; }, make: async (i, id) => { const d = `/home/x/.forge/tmp/runs/${id}`; w.tmpMade.push(d); return d; }, remove: async (d) => { w.tmpRemoved.push(d); } }; };
860
+ const cases = {
861
+ merged: (w) => w,
862
+ "needs-human": (w) => { w.reviews.push(bad(), bad()); return w; },
863
+ "engine-error": (w) => { failFirstBuilder(w, new Error("boom")); return w; },
864
+ deferred: (w) => { failFirstBuilder(w, limitErr({ resetAtEpochMs: 3 * 3600 * 1000 })); return w; },
865
+ };
866
+ for (const [reason, prep] of Object.entries(cases)) {
867
+ const w = world(); mk(w); prep(w);
868
+ const r = await run(w);
869
+ assert.equal(r.terminalReason, reason);
870
+ assert.equal(w.pruned, true);
871
+ assert.equal(w.tmpMade.length, 1);
872
+ assert.match(w.tmpMade[0], /\/\.forge\/tmp\/runs\/7-/);
873
+ assert.deepEqual(w.tmpRemoved, w.tmpMade, `${reason}: removed`);
874
+ for (const e of w.envs) assert.equal(e?.TMPDIR, w.tmpMade[0]);
875
+ if (["merged", "needs-human"].includes(reason)) assert.ok(w.envs.length >= 1);
876
+ }
877
+ });
878
+
879
+ it("#4104 makeRunTmp: removes only its own dirs; prune takes only old dirs with a dead owner and our marker", async () => {
880
+ const { mkdirSync, writeFileSync, existsSync, utimesSync } = await import("node:fs");
881
+ const root = tmpDir("core-runs-");
882
+ let t = 1_000_000_000_000;
883
+ const rt = makeRunTmp({ root, now: () => t, host: "h", alive: (pid) => pid === 111 });
884
+ const mine = await rt.make(7, "7-1");
885
+ assert.ok(existsSync(mine));
886
+ assert.equal(await rt.remove(mine), true); assert.ok(!existsSync(mine));
887
+ assert.equal(await rt.remove(`${root}/not-made`), false);
888
+ const outside = tmpDir("core-outside-"); writeFileSync(`${outside}/f`, "x");
889
+ assert.equal(await rt.remove(outside), false); assert.ok(existsSync(outside));
890
+ const own = (name, o2) => { mkdirSync(`${root}/${name}`); writeFileSync(`${root}/${name}/.forge-run-owner`, JSON.stringify({ host: "h", ...o2 })); };
891
+ own("old-dead", { pid: 999, created: t - 7 * 3600 * 1000 });
892
+ own("old-alive", { pid: 111, created: t - 7 * 3600 * 1000 });
893
+ own("fresh-dead", { pid: 999, created: t - 3600 * 1000 });
894
+ own("old-otherhost", { pid: 999, host: "zzz", created: t - 7 * 3600 * 1000 });
895
+ mkdirSync(`${root}/foreign`); writeFileSync(`${root}/foreign/data`, "not ours");
896
+ t += 1;
897
+ const removed = await rt.prune();
898
+ assert.deepEqual(removed.sort(), ["old-dead"]);
899
+ for (const keep of ["old-alive", "fresh-dead", "foreign"]) assert.ok(existsSync(`${root}/${keep}`), keep);
900
+ });
901
+
902
+ it("#4104 runCliBackend applies the per-run env to the child", async () => {
903
+ const { runCliBackend } = await import("../runner.mjs");
904
+ let seen;
905
+ runCliBackend({ spec: { name: "t" }, userMessage: "go", args: ["1"], cwd: tmpDir("core-env-"), logger: { log() {} }, env: { TMPDIR: "/x/run-tmp", CLAUDE_CODE_TMPDIR: "/x/run-tmp" },
906
+ spawnFn: (_b, _a, o2) => { seen = o2.env; return { status: 0, stdout: "{}", stderr: "" }; } });
907
+ assert.equal(seen.TMPDIR, "/x/run-tmp"); assert.equal(seen.CLAUDE_CODE_TMPDIR, "/x/run-tmp");
908
+ assert.equal(seen.ANTHROPIC_API_KEY, undefined);
909
+ });
910
+ });
911
+
912
+ describe("host admission (#4105)", () => {
913
+ const host = (o = {}) => ({ memAvailableMb: () => o.mem ?? 8192, freeMb: async (p) => (o.free ?? { "/tmp": 8192, "/home/x/.forge/tmp": 8192 })[p], loadAvg1: () => o.load ?? 1, cores: () => o.cores ?? 8, tmpPaths: () => ["/home/x/.forge/tmp", "/tmp"] });
914
+ const withHost = (w, h) => { w.io.host = h; w.ghCalls = 0; w.leaseWrites = []; w.io.projector = { readState: async () => { w.leaseReads = (w.leaseReads ?? 0) + 1; return null; }, writeState: async (_i, st) => { w.leaseWrites.push(st); } };
915
+ const g = w.io.gh; w.io.gh = async (a) => { w.ghCalls++; return g(a); }; return w; };
916
+
917
+ it("the outcome code is registered", async () => {
918
+ assert.equal(OUTCOME_CODES.HOST_BUSY, "host.busy");
919
+ const { readFileSync } = await import("node:fs");
920
+ assert.match(readFileSync(new URL("../../docs/OUTCOME-CODES.md", import.meta.url), "utf8"), /`host\.busy`/);
921
+ });
922
+
923
+ it("passes with headroom and runs normally", async () => {
924
+ const w = withHost(world(), host());
925
+ assert.equal((await run(w)).terminalReason, "merged");
926
+ assert.ok(w.ghCalls > 0);
927
+ });
928
+
929
+ it("low memory: deferred host.busy, no lease, no GitHub call, no worktree", async () => {
930
+ const w = withHost(world(), host({ mem: 1228 }));
931
+ const r = await run(w);
932
+ assert.equal(r.terminalReason, "deferred"); assert.equal(r.code, "host.busy");
933
+ assert.equal(r.detail, "host busy: mem 1.2G < 2G; retry in 60s");
934
+ assert.equal(w.ghCalls, 0); assert.equal(w.leaseWrites.length, 0); assert.equal(w.leaseReads ?? 0, 0);
935
+ assert.equal(w.calls.length, 0, "no git or runner calls");
936
+ assert.deepEqual(w.labels, []);
937
+ });
938
+
939
+ it("low tmp space on the run TMPDIR filesystem or on /tmp is deferred host.busy", async () => {
940
+ for (const free of [{ "/tmp": 8192, "/home/x/.forge/tmp": 512 }, { "/tmp": 100, "/home/x/.forge/tmp": 8192 }]) {
941
+ const w = withHost(world(), host({ free }));
942
+ const r = await run(w);
943
+ assert.equal(r.code, "host.busy"); assert.match(r.detail, /tmp \S+ \S+G < 1G/);
944
+ assert.equal(w.ghCalls, 0);
945
+ }
946
+ });
947
+
948
+ it("high load per core is deferred; disabled admission runs even on a starved host", async () => {
949
+ const wl = withHost(world(), host({ load: 40, cores: 8 }));
950
+ assert.match((await run(wl)).detail, /load 5\.0\/core > 4/);
951
+ const wd = withHost(world(), host({ mem: 1, free: { "/tmp": 1, "/home/x/.forge/tmp": 1 }, load: 99 }));
952
+ assert.equal((await run(wd, { admission: { enabled: false } })).terminalReason, "merged");
953
+ });
954
+
955
+ it("thresholds are configurable; a failing probe does not block", async () => {
956
+ assert.equal((await checkAdmission({ cfg: { min_free_mem_mb: 100 }, host: host({ mem: 500 }) })).ok, true);
957
+ assert.equal((await checkAdmission({ cfg: { min_free_mem_mb: 1000 }, host: host({ mem: 500 }) })).ok, false);
958
+ assert.equal((await checkAdmission({ host: { memAvailableMb: () => { throw new Error("x"); }, tmpPaths: () => { throw new Error("y"); } } })).ok, true);
959
+ });
960
+
961
+ it("forge.yaml engine.admission is read (nested or flat) and the CLI prints the deferral as the last line", async () => {
962
+ const { writeFileSync } = await import("node:fs");
963
+ const d = tmpDir("core-adm-");
964
+ writeFileSync(`${d}/forge.yaml`, "engine:\n admission:\n enabled: false\n min_free_mem_mb: 4096\n");
965
+ const a = readAdmission(d);
966
+ assert.equal(a.enabled, false); assert.equal(a.min_free_mem_mb, 4096);
967
+ const lines = []; const l = console.log; console.log = (x) => lines.push(String(x));
968
+ try {
969
+ await runFromCli(["7", "--lane", "staging", "--repo", "o/r"], { dir: tmpDir("core-adm-"), emitTelemetry: async () => {}, leanIo: {}, io: { gh: async () => "o/r\n" },
970
+ leanModule: { runCore: async () => ({ terminalReason: "deferred", code: "host.busy", detail: "host busy: mem 1.2G < 2G; retry in 60s" }) } });
971
+ } finally { console.log = l; }
972
+ assert.equal(lines.at(-1), "issue #7 → deferred (host busy: mem 1.2G < 2G; retry in 60s)");
973
+ });
974
+
975
+ it("/orchestrate spec requeues host-busy lanes with a lower K and pauses on rate_limited", async () => {
976
+ const { readFileSync } = await import("node:fs");
977
+ const spec = readFileSync(new URL("../../commands/orchestrate.md", import.meta.url), "utf8");
978
+ assert.match(spec, /host busy[\s\S]*lower the effective K by\s+1 for the next 5 minutes, never below 1/);
979
+ assert.match(spec, /rate_limited[\s\S]*stop launching\s+new lanes until that reset time/);
980
+ });
981
+ });
@@ -96,7 +96,7 @@ export function renderPrBody({ issue, investigation, plan, implementation, resul
96
96
 
97
97
  // ---- trajectory ----
98
98
  export function renderTrajectory(T) {
99
- const rows = [["Validate", T.validate], ["Plan", T.plan], ["Implement", T.implement], ["Review", T.review], ["PR", T.pr], ["Merge", T.merge], ["Close", T.close]];
99
+ const rows = [["Validate", T.validate], ["Plan", T.plan], ["Implement", T.implement], ["Review", T.review], ["PR", T.pr], ["Base Sync", T.baseSync ?? "not needed"], ["Merge", T.merge], ["Close", T.close]];
100
100
  const cellPair = (v) => {
101
101
  const [res, notes] = Array.isArray(v) ? v : [v, ""];
102
102
  return `${cell(res ?? "not reached")} | ${notes ? cell(notes) : "-"}`;
package/bin/runner.mjs CHANGED
@@ -1507,6 +1507,8 @@ export function runCliBackend({
1507
1507
  sessionFlags = [],
1508
1508
  // #4081: extra argv (e.g. ["--resume", id]) appended verbatim, never dropped by FORGEDOCK_FULL_SESSION.
1509
1509
  extraArgs = [],
1510
+ // Core work-on: extra child env (e.g. a per-run TMPDIR) applied last.
1511
+ env: extraEnv = null,
1510
1512
  }) {
1511
1513
  // forge#3589: opt-in flags, validated again here as defence in depth. Callers
1512
1514
  // (runCommand) are responsible for the version gate. Unset -> argv unchanged.
@@ -1581,6 +1583,9 @@ export function runCliBackend({
1581
1583
  // intact — only the Anthropic key is removed.
1582
1584
  const childEnv = withLeaseHolder(withRunTmpdir(withForgedockHome({ ...process.env }), { issue: issueFromArgs(args), cwd }), leaseHolder);
1583
1585
  delete childEnv.ANTHROPIC_API_KEY;
1586
+ if (extraEnv && typeof extraEnv === "object") {
1587
+ for (const [k, v] of Object.entries(extraEnv)) if (typeof v === "string" && k !== "ANTHROPIC_API_KEY") childEnv[k] = v;
1588
+ }
1584
1589
 
1585
1590
  // No `shell` option (defaults to false): argv is passed as discrete,
1586
1591
  // unparsed elements — see the SECURITY note above. `bin` is expected to
@@ -36,7 +36,8 @@ Do this yourself from issue text and the code. Spawn no sub-agents here.
36
36
  `git grep` of the symbols it names. Keep this to a few greps per issue, not an investigation.
37
37
  - **Size.** S, M or L, from the scope the issue describes.
38
38
  3. **Build the DAG.** Edge X → N means N waits until X merges:
39
- - **Explicit:** `Depends on #X` / `Blocked by #X` where X is in the set. An open X outside the set
39
+ - **Explicit:** `Depends on #X` / `Blocked by #X` where X is in the set. Match it case-insensitively and
40
+ also in bold or list form (`**Depends on** #X`, `- Depends on: #X, #Y`). An open X outside the set
40
41
  means N is skipped as blocked.
41
42
  - **Conflict:** two issues that will edit the same file or the same function run one after the
42
43
  other (smaller first), so their PRs don't conflict. Different files run in parallel.
@@ -46,7 +47,9 @@ Do this yourself from issue text and the code. Spawn no sub-agents here.
46
47
  4. **Show the plan** as one table: issue, title, size, likely files, waits-on. There are no waves. Then proceed
47
48
  without asking.
48
49
 
49
- `K` = `--parallel` (default 3). `L` = `--lane` if given, otherwise the repo's base lane from forge.yaml.
50
+ `K` = `--parallel` if given, otherwise `orchestration.max_concurrent` from the repo's `forge.yaml`
51
+ (`yq '.orchestration.max_concurrent' forge.yaml`), otherwise 3. State the value of K and where it came
52
+ from in the plan. `L` = `--lane` if given, otherwise the repo's base lane from forge.yaml.
50
53
 
51
54
  ## 3. Launch lanes
52
55
 
@@ -55,8 +58,10 @@ waits on nothing). Keep at most K lanes running. Whenever a lane is free, start
55
58
  issue right away (priority label first, then the smaller size). Never hold a ready issue back until other
56
59
  lanes finish.
57
60
 
58
- - **Shell lane (default, when `forgedock` is on PATH).** One `Bash` call per issue with
59
- `run_in_background: true`:
61
+ - **Shell lane (default, when `forgedock` is on PATH).** Each lane is its own `Bash` call with
62
+ `run_in_background: true`, so every running lane appears as its own background shell in this session.
63
+ Do NOT write a scheduler, wrapper or queue script that launches lanes for you. You are the scheduler:
64
+ start a lane, and when a lane's completion notification arrives, start the next ready issue.
60
65
 
61
66
  ```bash
62
67
  forgedock run-issue <N> --lane <L>
@@ -74,6 +79,15 @@ lanes finish.
74
79
  The agent does not merge.
75
80
 
76
81
  When a lane finishes:
82
+ - `deferred` (lease held by another or a dead session): put it back in the queue and retry after the
83
+ lease time printed in its output.
84
+ - `deferred` with "host busy" (code `host.busy`: the host is short of memory, tmp space or CPU headroom;
85
+ no lease was taken and nothing was posted): put the issue back in the queue and lower the effective K by
86
+ 1 for the next 5 minutes, never below 1. Before raising K back, start one lane and check that it is not
87
+ host busy again.
88
+ - `deferred` with `rate_limited` (usage limit; the last output line carries the reset time): stop launching
89
+ new lanes until that reset time, let running lanes finish, then resume the queue.
90
+ - `dependency-open`: the engine found a dependency you missed. Add the edge and requeue the issue behind it.
77
91
  - `merged` or `invalid`: start the next ready issue.
78
92
  - `needs-human`: read the issue's last comment for the reason, and mark its dependents skipped.
79
93
  - `engine-error`: relaunch it once.
@@ -655,7 +655,7 @@ bash "$RF" classify --pr "$PR_NUMBER" --repo "$REPO" --context "$CTX" --out-dir
655
655
  cat "$OUT_DIR/note-list.txt" 2>/dev/null
656
656
  ```
657
657
 
658
- `extract` reads the trusted synthesized block for the head, else its trusted agent bodies (`BASE_SYNC_ONLY`: the carried head; `DELTA`: this head plus the earlier reviewed one), parses every `<!-- FINDING:... -->` marker (an unparseable marker blocks the review), dedupes by file + line range ±5 and by title (3+ shared keywords, higher confidence wins), and applies the **§6B.4 gates** in order, each keeping the finding when its input is unreadable: (1) **head-SHA**: a cited line past the end of the file at `REVIEW_SHA` is stale and DROPPED (`findings_dropped_stale`); (2) **introduced-line**: a MEDIUM-or-lower finding whose range (±5) misses the PR's added lines is `pre-existing` (**CRITICAL/HIGH are never routed as pre-existing**, whatever their confidence, `protocols.md` §5; input comes from the diff, never a reviewer's `**Scope**` tag; deleted, renamed and patch-less files always intersect); (3) **concrete-failure**: a MEDIUM-or-lower finding without a `**Failure scenario**` becomes a NOTE (`notes_demoted_no_scenario`).
658
+ `extract` reads the trusted synthesized block for the head, else its trusted agent bodies (`BASE_SYNC_ONLY`: the carried head; `DELTA`: this head plus the earlier reviewed one), parses every `<!-- FINDING:... -->` marker (an unparseable marker blocks the review), dedupes by file + line range ±5 and by title (3+ shared keywords, higher confidence wins), and applies the **§6B.4 gates** in order, each keeping the finding when its input is unreadable: (1) **head-SHA**: a cited line past the end of the file at `REVIEW_SHA` is stale and DROPPED (`findings_dropped_stale`); (2) **introduced-line**: a MEDIUM-or-lower finding whose range (±5) misses the PR's added lines is `pre-existing` (**CRITICAL/HIGH are never routed as pre-existing**, whatever their confidence, `protocols.md` §5; input comes from the diff, never a reviewer's `**Scope**` tag; removed files, old names of renames and patch-less files are scope `unknown`, never pre-existing and never introduced; a renamed file with a patch is scored by its added lines); (3) **concrete-failure**: a MEDIUM-or-lower finding without a `**Failure scenario**` becomes a NOTE (`notes_demoted_no_scenario`).
659
659
 
660
660
  Read `STRUCTURED_FINDINGS`, `FINDINGS_KEPT`, `FINDINGS_DROPPED_STALE`, `FINDINGS_PREEXISTING` and `NOTES_DEMOTED_NO_SCENARIO` for the record and summary. The caller's part of 6A: add the Phase 2.5 `INTEG-N` findings no reviewer covered (CONFIRMED HIGH, so ISSUE, filed in 6C); findings come only from `FINDING:` markers, never from free text. If `AGENTS_WITHOUT_BLOCK` is non-empty those agents are undelivered: re-dispatch them once (Phase 4 rule), else take the degraded-panel path; `CLEAN_STRUCTURED` is withheld while it is non-empty. On `CLEAN_STRUCTURED=true` and no `INTEG-N` the review is clean: skip to Phase 7. A finding that somehow has no marker is classified with `classify-finding.sh --unstructured` (empty or unparseable severity gives `NOTE unstructured`, never ISSUE). **Pre-existing route**: a pre-existing finding (MEDIUM or lower) is never filed as `review-finding`/`needs-validation` nor linked to this PR (it stays out of findings-per-PR and the amplification ratio); it still passes the §6B.5 classifier, is listed once in the summary, and at most 3 per review (highest severity first) are filed with the `pre-existing` label plus `priority:*`, citing this PR (`preexisting_filed`, not `findings_filed`).
661
661
 
package/docs/CONFIG.md CHANGED
@@ -482,6 +482,7 @@ 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
+ | `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. |
485
486
  | `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. |
486
487
  | `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. |
487
488
 
@@ -33,6 +33,7 @@ An absent or unknown code is recorded as `unclassified`, with the raw reason kep
33
33
  | `ci.red` | CI failed | reserved | `REVIEW_RESULT` carries no CI field yet |
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
+ | `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` |
36
37
  | `rate_limited` | Session or usage limit outlasted the pause budget | engine | `err.usageLimit` / `err.resetAtEpochMs` |
37
38
  | `engine.error` | Engine or tool failure (missing key/SDK, CLI crash, max turns, unresolved args, runner threw every attempt) | engine | `err.code` |
38
39
  | `infra.disk_quota` | Disk full or quota exhausted (EDQUOT/ENOSPC, errno -28/-122); retryable infra failure, never `needs-human` | engine | Runner error text |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "forgedock",
3
- "version": "1.24.0",
3
+ "version": "1.24.2",
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"
@@ -417,9 +417,12 @@ EOF
417
417
  /^\+/{print f":"n; print f "\t" substr($(0),2) > tf; n++;next}
418
418
  /^-/{next}
419
419
  {n++}' > "$ADDED_LINES_FILE" || { : > "$ADDED_LINES_FILE"; : > "$ADDED_TEXT_FILE"; }
420
- # Files the PR deleted or renamed (the old name too): findings on them always intersect.
420
+ # Files the PR deleted or renamed (the old name too): Gate 1 never drops findings on them as stale.
421
421
  GONE_FILES_FILE="$WORKDIR/gone-files"
422
422
  jq -r '.[] | select(.status=="removed" or .status=="renamed") | .filename, (.previous_filename // empty)' "$FILES_JSON" > "$GONE_FILES_FILE" 2>/dev/null || : > "$GONE_FILES_FILE"
423
+ # Removed files and old names of renames: no head line to score, so Gate 2 scope stays unknown (#3985).
424
+ NOSCOPE_FILES_FILE="$WORKDIR/noscope-files"
425
+ jq -r '.[] | (select(.status=="removed") | .filename), (.previous_filename // empty)' "$FILES_JSON" > "$NOSCOPE_FILES_FILE" 2>/dev/null || : > "$NOSCOPE_FILES_FILE"
423
426
  # Files present with no `patch` (large/truncated diff): their added lines are UNKNOWN, so findings on them are always kept.
424
427
  NOPATCH_FILES_FILE="$WORKDIR/nopatch-files"
425
428
  jq -r '.[] | select(.patch == null and .status != "removed") | .filename' "$FILES_JSON" > "$NOPATCH_FILES_FILE" 2>/dev/null || : > "$NOPATCH_FILES_FILE"
@@ -494,8 +497,9 @@ SYMEOF
494
497
  ROUTE="file"
495
498
  # Validate before any arithmetic: never expand reviewer text in $(( )).
496
499
  case "$LINE" in ""|*[!0-9]*) LINE="" ;; esac
497
- GONE=0; NOPATCH=0
500
+ GONE=0; NOPATCH=0; NOSCOPE=0
498
501
  grep -qxF -- "$FILE" "$GONE_FILES_FILE" && GONE=1
502
+ grep -qxF -- "$FILE" "$NOSCOPE_FILES_FILE" && NOSCOPE=1
499
503
  grep -qxF -- "$FILE" "$NOPATCH_FILES_FILE" && NOPATCH=1
500
504
  # Gate 1 (head-SHA): the cited line must exist at REVIEW_SHA. A file the PR deleted or renamed is kept (checked first).
501
505
  if [ "$GONE" -eq 0 ] && [ -n "$LINE" ]; then
@@ -509,13 +513,12 @@ SYMEOF
509
513
  esac
510
514
  if [ "$STALE" -eq 1 ]; then N_STALE=$((N_STALE + 1)); continue; fi
511
515
  fi
512
- # Gate 2 (introduced-line): scope is computed for every finding (any severity) from the added-line map. A file the PR deleted or renamed is introduced;
513
- # a patch-less file, an empty map or a missing line is unknown. MEDIUM or lower with no added line within +-5 is routed pre-existing.
516
+ # Gate 2 (introduced-line): scope is computed for every finding (any severity) from the added-line map. A removed file, the old name
517
+ # of a rename, a patch-less file (a pure rename too), an empty map or a missing line is unknown: never introduced, never pre-existing (#3985).
518
+ # A renamed file with a patch is scored like any other. MEDIUM or lower with no added line within +-5 is routed pre-existing.
514
519
  # CRITICAL/HIGH and unknown severities never are (their scope is still recorded).
515
520
  SCOPE="unknown"
516
- if [ "$GONE" -eq 1 ]; then
517
- SCOPE="introduced"
518
- elif [ -n "$LINE" ] && [ "$NOPATCH" -eq 0 ] && [ -s "$ADDED_LINES_FILE" ]; then
521
+ if [ -n "$LINE" ] && [ "$NOSCOPE" -eq 0 ] && [ "$NOPATCH" -eq 0 ] && [ -s "$ADDED_LINES_FILE" ]; then
519
522
  LO=$((LINE > 5 ? LINE - 5 : 1)); INTRODUCED=0; L="$LO"
520
523
  while [ "$L" -le $((LINE + 5)) ]; do
521
524
  if grep -qxF -- "${FILE}:${L}" "$ADDED_LINES_FILE"; then INTRODUCED=1; break; fi
@@ -294,9 +294,9 @@ cm MEMBER User "$(agent_body security "$SHA" '### P-1 pre
294
294
  run "$GOOD" extract; want "unreadable diff keeps" '.findings[0].route' findings.json "file"
295
295
  want "unreadable diff: scope unknown" '.findings[0].scope' findings.json "unknown"
296
296
 
297
- # gate 2 route table (ported from the retired spec sketch test): severity normalisation, deleted/renamed/patch-less files, files absent from the diff
297
+ # gate 2 route table (ported from the retired spec sketch test): severity normalisation, deleted/renamed/patch-less files (scope unknown unless a patch shows an added line, #3985), files absent from the diff
298
298
  reset
299
- printf '%s\n' '[{"filename":"a.sh","status":"modified","patch":"@@ -9,2 +9,3 @@\n ctx\n+added\n ctx"},{"filename":"big.txt","status":"modified"},{"filename":"gone.txt","status":"removed"},{"filename":"new.sh","status":"renamed","previous_filename":"old.sh","patch":"@@ -1,1 +1,1 @@\n+x"}]' > "$FIX/pr-files.json"
299
+ printf '%s\n' '[{"filename":"a.sh","status":"modified","patch":"@@ -9,2 +9,3 @@\n ctx\n+added\n ctx"},{"filename":"big.txt","status":"modified"},{"filename":"gone.txt","status":"removed"},{"filename":"new.sh","status":"renamed","previous_filename":"old.sh","patch":"@@ -1,1 +1,1 @@\n+x"},{"filename":"mv.sh","status":"renamed","previous_filename":"was.sh"}]' > "$FIX/pr-files.json"
300
300
  G2_PROSE=""; G2_MARKS=""
301
301
  g2() { # g2 <id> <severity> <file:line> <title words>
302
302
  G2_PROSE="${G2_PROSE}### $1 finding
@@ -309,12 +309,14 @@ g2 G-1 MEDIUM a.sh:10 "alpha one"; g2 G-2 MEDIUM a.sh:100 "bravo two"; g2 G-3 CR
309
309
  g2 G-5 high a.sh:140 "echo five"; g2 G-6 "HIGH " a.sh:160 "foxtrot six"; g2 G-7 "" a.sh:180 "golf seven"; g2 G-8 SEVERE a.sh:200 "hotel eight"
310
310
  g2 G-9 medium a.sh:220 "india nine"; g2 G-10 INFO a.sh:240 "juliet ten"; g2 G-11 MEDIUM old.sh:3 "kilo eleven"; g2 G-12 LOW a.sh:260 "lima twelve"
311
311
  g2 G-13 LOW gone.txt:3 "mike thirteen"; g2 G-14 MEDIUM other.sh:5 "november fourteen"
312
+ # #3985: a pure rename (no patch) is unknown; a renamed file with a patch is scored by its added lines like any other
313
+ g2 G-15 MEDIUM mv.sh:3 "oscar fifteen"; g2 G-16 MEDIUM new.sh:1 "papa sixteen"; g2 G-17 MEDIUM new.sh:40 "quebec seventeen"
312
314
  cm MEMBER User "$(agent_body security "$SHA" "$G2_PROSE" "$G2_MARKS")"; flush
313
315
  run "$GOOD" extract; rc_is "gate2 table" "$RC" 0
314
- for pair in G-1:file G-2:pre-existing G-3:file G-4:file G-5:file G-6:file G-7:file G-8:file G-9:pre-existing G-10:pre-existing G-11:file G-12:pre-existing G-13:file; do
316
+ for pair in G-1:file G-2:pre-existing G-3:file G-4:file G-5:file G-6:file G-7:file G-8:file G-9:pre-existing G-10:pre-existing G-11:file G-12:pre-existing G-13:file G-15:file G-16:file G-17:pre-existing; do
315
317
  want "gate2 table: ${pair%%:*}" ".findings[] | select(.id == \"${pair%%:*}\") | .route" findings.json "${pair#*:}"
316
318
  done
317
- for pair in G-1:introduced G-2:pre-existing G-3:pre-existing G-4:unknown G-7:pre-existing G-13:introduced G-12:pre-existing; do
319
+ for pair in G-1:introduced G-2:pre-existing G-3:pre-existing G-4:unknown G-7:pre-existing G-12:pre-existing G-11:unknown G-13:unknown G-15:unknown G-16:introduced G-17:pre-existing; do
318
320
  want "scope table: ${pair%%:*}" ".findings[] | select(.id == \"${pair%%:*}\") | .scope" findings.json "${pair#*:}"
319
321
  done
320
322
  want "gate2 table: a file absent from the PR and from the head is stale (gate 1), not pre-existing" '[.findings[].id] | contains(["G-14"]) | not' findings.json "true"