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.
- package/bin/engine/outcome-codes.mjs +1 -0
- package/bin/engine-cli.mjs +17 -2
- package/bin/forgedock.mjs +10 -2
- package/bin/lean/admission.mjs +32 -0
- package/bin/lean/effects.mjs +74 -5
- package/bin/lean/lean.mjs +198 -45
- package/bin/lean/lean.test.mjs +359 -2
- package/bin/lean/render.mjs +1 -1
- package/bin/runner.mjs +5 -0
- package/commands/orchestrate.md +18 -4
- package/commands/review-pr.md +1 -1
- package/docs/CONFIG.md +1 -0
- package/docs/OUTCOME-CODES.md +1 -0
- package/package.json +1 -1
- package/scripts/review-finalize.sh +10 -7
- package/scripts/review-finalize.test.sh +6 -4
|
@@ -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",
|
package/bin/engine-cli.mjs
CHANGED
|
@@ -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
|
+
}
|
package/bin/lean/effects.mjs
CHANGED
|
@@ -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 {
|
|
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(
|
|
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(
|
|
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(
|
|
204
|
-
leaseTimer = setInterval(() => { writeLease(
|
|
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
|
|
301
|
+
await addWorktree(["worktree", "add", "-B", branch, wt, `origin/${branch}`]);
|
|
233
302
|
} else {
|
|
234
|
-
await
|
|
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
|
|
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
|
-
|
|
379
|
+
let localHead = String(await git(["rev-parse", "HEAD"])).trim();
|
|
311
380
|
|
|
312
|
-
// ---- 6.
|
|
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
|
-
// ----
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
let
|
|
329
|
-
|
|
330
|
-
await
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
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
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
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
|
-
|
|
350
|
-
await
|
|
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
|
-
|
|
353
|
-
|
|
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
|
}
|
package/bin/lean/lean.test.mjs
CHANGED
|
@@ -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
|
+
});
|
package/bin/lean/render.mjs
CHANGED
|
@@ -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
|
package/commands/orchestrate.md
CHANGED
|
@@ -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.
|
|
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`
|
|
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).**
|
|
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.
|
package/commands/review-pr.md
CHANGED
|
@@ -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;
|
|
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
|
|
package/docs/OUTCOME-CODES.md
CHANGED
|
@@ -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.
|
|
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
|
|
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
|
|
513
|
-
# a patch-less file, an empty map or a missing line is unknown
|
|
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 [ "$
|
|
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-
|
|
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"
|