forgedock 1.24.1 → 1.24.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -35,6 +35,8 @@ 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",
39
+ PATH_SWITCH_CONFLICT: "path.switch_conflict",
38
40
  ENGINE_ERROR: "engine.error",
39
41
  INFRA_DISK_QUOTA: "infra.disk_quota",
40
42
  INFRA_CLI_SPAWN_TRANSIENT: "infra.cli_spawn_transient",
@@ -56,7 +56,22 @@ export function readLog(dir, issue) {
56
56
  }
57
57
 
58
58
  /** Fold events into a RunState. The commit rule lives here. */
59
- export function deriveState(events) {
59
+ /**
60
+ * #4109: the run log is shared by the full and the core path. Keep only the events of runs whose `path` (from RUN_START;
61
+ * missing means "full", for historic logs) equals `path`, so one path never replays the other's commits or terminals.
62
+ */
63
+ export function scopeEvents(events, path = "full") {
64
+ let cur = "full";
65
+ const out = [];
66
+ for (const e of events) {
67
+ if (e.event === "RUN_START") cur = e.path ?? "full";
68
+ if (cur === path) out.push(e);
69
+ }
70
+ return out;
71
+ }
72
+
73
+ export function deriveState(allEvents, { path = "full" } = {}) {
74
+ const events = scopeEvents(allEvents, path);
60
75
  /** @type {import("./phases.mjs").RunState} */
61
76
  const s = { v: 0, run: null, issue: null, lane: "staging", committed: [],
62
77
  phase: null, branch: null, pr: null, terminal: false,
@@ -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;
@@ -495,7 +510,7 @@ export async function runFromCli(argv, deps = {}) {
495
510
  return res;
496
511
  }
497
512
  // forge#3932: refused before any state write (open or unreadable **Depends on**); nothing ran.
498
- if (res.terminalReason === "dependency-open" || res.terminalReason === "dependency-unreadable") {
513
+ if (res.terminalReason === "dependency-open" || res.terminalReason === "dependency-unreadable" || res.code === "path.switch_conflict") {
499
514
  console.log(res.detail ?? "run refused: dependency check");
500
515
  return res;
501
516
  }
@@ -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/engine.mjs CHANGED
@@ -8,7 +8,7 @@ import { readFileSync, rmSync, readdirSync } from "node:fs";
8
8
  import { execFileSync } from "node:child_process";
9
9
  import { createHash, scryptSync } from "node:crypto";
10
10
  import { hostname as osHostname, homedir } from "node:os";
11
- import { appendEvent, readLog, deriveState, rewriteLog } from "./engine/runlog.mjs";
11
+ import { appendEvent, readLog, deriveState, rewriteLog, scopeEvents } from "./engine/runlog.mjs";
12
12
  import { pickPhase, TERMINAL_REASONS, issueSnapshot, hasInvestigationExit, postCycleMarker, nextCycleNumber, newestCycle, cycleResetProvable, remediationHeadStale, decompositionRevoked, openDependency } from "./engine/phases.mjs";
13
13
  import { reconcileState } from "./engine/reconcile.mjs";
14
14
  import { makeProjector } from "./engine/projector.mjs";
@@ -440,7 +440,8 @@ export async function runIssue(opts) {
440
440
  const projector = makeProjector(io);
441
441
 
442
442
  // 1. Load + reconcile (GitHub wins).
443
- const local = readLog(dir, issue).length ? deriveState(readLog(dir, issue)) : null;
443
+ // #4109: only this (full) path's own events: core-path commits and terminals in the shared log are never replayed here.
444
+ const local = scopeEvents(readLog(dir, issue)).length ? deriveState(readLog(dir, issue)) : null;
444
445
  let remote = await projector.readState(issue);
445
446
  let { state, action } = reconcileState(local, remote);
446
447
  const leaseHeld = (r) => !!(r?.lease && r.lease.until > now() && r.lease.by !== agentId &&
@@ -495,6 +496,19 @@ export async function runIssue(opts) {
495
496
  };
496
497
  }
497
498
  }
499
+ // #4109: an open core-path PR means the work already exists; a second implementation from the full path would duplicate it.
500
+ {
501
+ let prs = [];
502
+ try { prs = JSON.parse(String(await io.gh(["pr", "list", ...(repo ? ["-R", String(repo)] : []), "--head", `feat/issue-${issue}-lean`, "--state", "open", "--json", "number,headRefName"])) || "[]"); } catch { prs = []; }
503
+ prs = Array.isArray(prs) ? prs.filter((x) => x?.headRefName === `feat/issue-${issue}-lean`) : [];
504
+ if (Array.isArray(prs) && prs.length && Number.isInteger(prs[0]?.number)) {
505
+ return {
506
+ terminalReason: "needs-human",
507
+ code: OUTCOME_CODES.PATH_SWITCH_CONFLICT,
508
+ detail: `issue ${issue} has an open core-path PR #${prs[0].number}; rerun on the default path (base-sync) or close that PR first.`,
509
+ };
510
+ }
511
+ }
498
512
  // forge#3824: hoisted so RUN_START can carry the repo (lazy, memoised; null when unresolved).
499
513
  let repoPromise = null;
500
514
  const resolveRepo = () => (repoPromise ??= (async () => {
@@ -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
+ }
@@ -4,7 +4,8 @@
4
4
  import { execFile } from "node:child_process";
5
5
  import { promisify } from "node:util";
6
6
  import { readFileSync, appendFileSync, mkdirSync, statSync, existsSync, rmSync, writeFileSync, readdirSync, lstatSync } from "node:fs";
7
- import { homedir, hostname } from "node:os";
7
+ import { homedir, hostname, freemem, loadavg, cpus, tmpdir } from "node:os";
8
+ import { promises as fsp } from "node:fs";
8
9
  import { dirname, join, resolve, sep } from "node:path";
9
10
  import { fileURLToPath } from "node:url";
10
11
  import { runCliBackendAsync, resolveClaudeCliBinary, getClaudeCliCapabilities, buildLeanSessionFlags, buildReadOnlyToolFlags } from "../runner.mjs";
@@ -70,11 +71,33 @@ export function makeRunTmp({ root = runTmpRoot(), now = () => Date.now(), alive
70
71
  };
71
72
  }
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
+
73
96
  export function makeLeanIo({ repo }) {
74
97
  const base = makeIo();
75
98
  const run = (bin) => async (args, opts = {}) => (await pexec(bin, args, { cwd: opts.cwd, maxBuffer: 100 * 1024 * 1024, timeout: 120000 })).stdout;
76
99
  return {
77
- gh: run("gh"), git: run("git"), runTmp: makeRunTmp(), 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,
78
101
  /** Names of the checks the base branch requires (contexts + checks). [] on 404 / no protection. */
79
102
  async requiredChecks(lane) {
80
103
  try {
package/bin/lean/lean.mjs CHANGED
@@ -11,6 +11,7 @@ import { appendEvent } from "../engine/runlog.mjs";
11
11
  import { codeForTerminalReason, OUTCOME_CODES } from "../engine/outcome-codes.mjs";
12
12
  import { ciPollDelayMs, ENGINE_VERSION, DEFAULT_MAX_SESSION_LIMIT_PAUSES, DEFAULT_SESSION_LIMIT_WAIT_MS, parseLeaseHolder, sanitizeLeaseHost } from "../engine.mjs";
13
13
  import { openDependency } from "../engine/phases.mjs";
14
+ import { checkAdmission, ADMISSION_RETRY_SECONDS } from "./admission.mjs";
14
15
  import { renderInvestigation, renderPlan, renderImplementation, renderReview, renderPrBody, renderTrajectory, capComment, sanitizeMd } from "./render.mjs";
15
16
  import { REVIEWER_SYSTEM, LEAN_DIR, RESULT_FILE, INVESTIGATION_FILE, PLAN_FILE, IMPLEMENTATION_FILE, builderPrompt, resumeBuilderPrompt, reviewerPrompt, parseVerdict } from "./prompts.mjs";
16
17
 
@@ -214,6 +215,11 @@ export async function runCore(o) {
214
215
  }
215
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; } };
216
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
+ }
217
223
  // ---- admission: Depends-on refusal, then lease ----
218
224
  {
219
225
  let dep = null;
@@ -10,6 +10,9 @@ import { REVIEWER_SYSTEM, approxTokens, builderPrompt, reviewerPrompt, parseVerd
10
10
  import { mapRecord } from "../engine/telemetry-seam.mjs";
11
11
  import { readLog } from "../engine/runlog.mjs";
12
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";
13
16
  import { runFromCli, orchestrateFromCli, ENGINE_PATHS } from "../engine-cli.mjs";
14
17
 
15
18
  const WT = "/wt/lean-7";
@@ -905,3 +908,74 @@ describe("usage limit, lease takeover, worktree setup, run tmp (#4101-#4104)", (
905
908
  assert.equal(seen.ANTHROPIC_API_KEY, undefined);
906
909
  });
907
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
+ });
@@ -81,6 +81,12 @@ lanes finish.
81
81
  When a lane finishes:
82
82
  - `deferred` (lease held by another or a dead session): put it back in the queue and retry after the
83
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.
84
90
  - `dependency-open`: the engine found a dependency you missed. Add the edge and requeue the issue behind it.
85
91
  - `merged` or `invalid`: start the next ready issue.
86
92
  - `needs-human`: read the issue's last comment for the reason, and mark its dependents skipped.
package/docs/CONFIG.md CHANGED
@@ -482,6 +482,7 @@ engine:
482
482
  | `<key>_max_turns` | integer | No | Positive integer passed as `--max-turns`. Invalid values are ignored. |
483
483
 
484
484
  | `path` | string | No | `core` (default), `full`, `lean` or `auto`. `core` is the lean work-on (`bin/lean/`): one plain builder session, one read-only two-question reviewer, GitHub as the register (see RELEASE-NOTES v1.22). `lean` and `auto` are aliases of `core`; `full` runs the legacy multi-phase pipeline and is required for `--retry` and `--wait-lease`. The `--path` flag overrides. `lean_reviewer_model` sets the reviewer model (default `claude-sonnet-5-5`); the builder uses `--model` or the CLI default. `forgedock orchestrate <issues...> [--parallel K] [--lane L]` runs the core per issue (K default 3, lane default `staging`) and honours `**Depends on**` ordering among the given issues. |
485
+ | `admission` | map | No | Host-resource admission for the core work-on (#4105), checked before the lease, worktree or any GitHub call. When a check fails the run ends `deferred` (code `host.busy`, exit 75) with a last line such as `issue #N → deferred (host busy: mem 1.2G < 2G; retry in 60s)`; no lease is taken and nothing is posted. Keys: `enabled` (default `true`; `false` disables admission), `min_free_mem_mb` (default 2048; `MemAvailable` from `/proc/meminfo`, else `os.freemem()`), `min_free_tmp_mb` (default 1024; free space of the run TMPDIR filesystem, `~/.forge/tmp`, and of `/tmp`, via `statfs`), `max_load_per_core` (default 4; 1-minute load average divided by cores). Write it nested (`engine:` then `admission:` with the keys indented) or flat as `admission_enabled`, `admission_min_free_mem_mb`, `admission_min_free_tmp_mb`, `admission_max_load_per_core`; the parser reads one nested level into `engine`. `/orchestrate` requeues a host-busy lane and lowers K by 1 for 5 minutes. |
485
486
  | `merge_wait` | string | No | Core work-on merge gate. `required` (default): wait only for the status checks the base branch requires under branch protection (`gh api repos/{o}/{r}/branches/{base}/protection/required_status_checks`, contexts and checks); with no protection or no required checks (404 or empty) merge right after review PASS. A failing required check is `needs-human`; a pending one is polled with backoff and never `needs-human`. `all`: wait for every check (`scripts/wait-ci-green.sh`). `none`: merge right after review. Always `--match-head-commit` at the reviewed head. The policy used is recorded in the `FORGE:WORK-ON` comment. |
486
487
  | `ci_pending_max_minutes` | integer | No | Minutes the engine waits for CI when the review phase ends BLOCKED with "checks still pending", or the remediate phase holds at its M7 re-gate on "CI pending" (the merge gate's wait window ended while CI was still running). Default: 90. Invalid values are ignored. This key is flat like the others but is not part of the `<key>_model` / `<key>_max_turns` pattern. |
487
488
 
@@ -33,6 +33,8 @@ 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
+ | `path.switch_conflict` | The full path was started on an issue that has an open core-path PR (`feat/issue-<n>-lean`); refused to avoid a second implementation. Rerun on the default path (base-sync) or close that PR first | engine | `bin/engine.mjs` admission |
37
+ | `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
38
  | `rate_limited` | Session or usage limit outlasted the pause budget | engine | `err.usageLimit` / `err.resetAtEpochMs` |
37
39
  | `engine.error` | Engine or tool failure (missing key/SDK, CLI crash, max turns, unresolved args, runner threw every attempt) | engine | `err.code` |
38
40
  | `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.1",
3
+ "version": "1.24.3",
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"