forgedock 1.32.1 → 1.34.0

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.
Files changed (77) hide show
  1. package/bin/engine/outcome-codes.mjs +7 -3
  2. package/bin/engine/phases.mjs +3 -3
  3. package/bin/engine-cli.mjs +56 -11
  4. package/bin/forgedock.mjs +21 -1
  5. package/bin/lean/basesync.mjs +98 -0
  6. package/bin/lean/basesync.test.mjs +151 -0
  7. package/bin/lean/batch.test.mjs +103 -2
  8. package/bin/lean/budget.mjs +7 -1
  9. package/bin/lean/budget.test.mjs +9 -1
  10. package/bin/lean/conformance.mjs +46 -10
  11. package/bin/lean/conformance.test.mjs +50 -2
  12. package/bin/lean/depcache.mjs +23 -3
  13. package/bin/lean/depcache.test.mjs +42 -1
  14. package/bin/lean/effects.mjs +1 -1
  15. package/bin/lean/execution.mjs +102 -12
  16. package/bin/lean/execution.test.mjs +101 -1
  17. package/bin/lean/hostkill.mjs +8 -0
  18. package/bin/lean/lean.mjs +49 -21
  19. package/bin/lean/lean.test.mjs +133 -7
  20. package/bin/lean/lightlane.mjs +38 -13
  21. package/bin/lean/lightlane.test.mjs +35 -1
  22. package/bin/lean/orchestrate.mjs +63 -17
  23. package/bin/lean/prompts.mjs +19 -9
  24. package/bin/lean/recovery.test.mjs +26 -0
  25. package/bin/lean/sandbox-provider.contract.test.mjs +74 -1
  26. package/bin/lean/sandbox-remote.mjs +40 -7
  27. package/bin/lean/seqlease.mjs +49 -2
  28. package/bin/lean/seqlease.test.mjs +14 -0
  29. package/bin/lean/sequence.mjs +54 -6
  30. package/bin/lean/sequence.test.mjs +83 -1
  31. package/bin/pool/agent.mjs +435 -0
  32. package/bin/pool/cli.mjs +110 -0
  33. package/bin/pool/docker.mjs +56 -0
  34. package/bin/pool/http.mjs +55 -0
  35. package/bin/pool/router.mjs +299 -0
  36. package/bin/pool/store.mjs +25 -0
  37. package/bin/pool/version.mjs +44 -0
  38. package/bin/review-staging.mjs +14 -2
  39. package/bin/runner.mjs +1 -0
  40. package/bin/stats/aggregate.mjs +6 -0
  41. package/bin/stats/efficiency.mjs +103 -17
  42. package/bin/stats/efficiency.test.mjs +103 -4
  43. package/bin/stats/index.mjs +40 -6
  44. package/bin/stats/local-source.mjs +1 -1
  45. package/bin/stats/pool.mjs +51 -0
  46. package/bin/stats/pool.test.mjs +39 -0
  47. package/bin/stats/query.mjs +2 -2
  48. package/bin/stats/stats.test.mjs +15 -1
  49. package/commands/orchestrate.md +3 -3
  50. package/commands/review-pr-agents/protocols.md +1 -1
  51. package/commands/review-pr-staging.md +1 -1
  52. package/docs/CONFIG.md +23 -9
  53. package/docs/OUTCOME-CODES.md +5 -3
  54. package/docs/STATS.md +36 -1
  55. package/package.json +2 -1
  56. package/pool/README.md +13 -0
  57. package/pool/image/Dockerfile +46 -0
  58. package/pool/image/claude-shim +8 -0
  59. package/pool/image/cs-cred +12 -0
  60. package/pool/image/entrypoint.sh +61 -0
  61. package/pool/image/gh-shim +6 -0
  62. package/pool/install/install-agent-linux.sh +53 -0
  63. package/pool/install/install-agent.ps1 +225 -0
  64. package/pool/install/install-agent.sh +101 -0
  65. package/pool/install/install-router.sh +67 -0
  66. package/pool/install/keepalive.ps1 +36 -0
  67. package/pool/install/uninstall-agent.ps1 +20 -0
  68. package/scripts/hotspots.sh +33 -21
  69. package/scripts/hotspots.test.sh +22 -3
  70. package/scripts/review-finalize.sh +17 -1
  71. package/scripts/review-finalize.test.sh +41 -0
  72. package/scripts/review-pr-domain-map.test.sh +2 -2
  73. package/scripts/review-pr-staging-scope.test.sh +4 -2
  74. package/scripts/review-prepare.sh +62 -27
  75. package/scripts/review-prepare.test.sh +53 -0
  76. package/scripts/review-staging-pre.sh +29 -9
  77. package/scripts/review-staging-pre.test.sh +18 -0
@@ -0,0 +1,39 @@
1
+ // SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
2
+ // SPDX-License-Identifier: AGPL-3.0-or-later
3
+ import { test } from "node:test";
4
+ import assert from "node:assert/strict";
5
+ import { mkdirSync, writeFileSync } from "node:fs";
6
+ import { join } from "node:path";
7
+ import { tmpDir } from "../tests/_tmp.mjs";
8
+ import { poolUtilisation, formatPoolLines } from "./pool.mjs";
9
+ import { parseArgs } from "./index.mjs";
10
+
11
+ const NOW = Date.parse("2026-10-24T00:00:00Z"), DAY = 86400000;
12
+ function batch(root, id, created, rows, repo = "o/r") {
13
+ const d = join(root, id); mkdirSync(d, { recursive: true });
14
+ writeFileSync(join(d, "plan.json"), JSON.stringify({ repo, created: new Date(created).toISOString() }));
15
+ writeFileSync(join(d, "report.json"), JSON.stringify({ rows }));
16
+ }
17
+ const sbx = (issue, by) => ({ issue, outcome: "merged", sandbox: { minutes: Object.values(by).reduce((a, b) => a + b, 0), by_agent: by } });
18
+
19
+ test("poolUtilisation sums minutes, lanes and share per agent per window", () => {
20
+ const root = tmpDir("pool-stats-");
21
+ batch(root, "b1", NOW - 2 * DAY, [sbx(1, { legion: 30 }), sbx(2, { legion: 10, box2: 10 }), { issue: 3, outcome: "merged" }]);
22
+ batch(root, "b2", NOW - 20 * DAY, [sbx(4, { box2: 50 })]);
23
+ batch(root, "b3", NOW - DAY, [sbx(9, { other: 99 })], "x/y");
24
+ const w = poolUtilisation({ batchRoot: root, repo: "o/r", windows: [7 * DAY, 30 * DAY], now: NOW });
25
+ assert.equal(w["7d"].remote_lanes, 2);
26
+ assert.deepEqual(w["7d"].agents.legion, { minutes: 40, lanes: 2, share: 0.8 });
27
+ assert.equal(w["30d"].agents.box2.minutes, 60);
28
+ assert.equal(w["30d"].agents.other, undefined);
29
+ assert.match(formatPoolLines(w).join("\n"), /legion: 40\.0 min, 2 lanes, 80% of pool/);
30
+ });
31
+
32
+ test("poolUtilisation with no agent data prints n/a; --window works with --pool", () => {
33
+ const root = tmpDir("pool-stats-");
34
+ batch(root, "b1", NOW - DAY, [{ issue: 1, outcome: "merged", sandbox: { minutes: 3 } }]);
35
+ const w = poolUtilisation({ batchRoot: root, repo: "o/r", windows: [7 * DAY], now: NOW });
36
+ assert.match(formatPoolLines(w).join("\n"), /per-agent minutes: n\/a/);
37
+ assert.equal(poolUtilisation({ batchRoot: join(root, "none"), windows: [DAY], now: NOW })["1d"].batches, 0);
38
+ assert.equal(parseArgs(["--pool", "--window", "7d"]).pool, true);
39
+ });
@@ -74,7 +74,7 @@ export function verdictMix(records, items = [], milestone = null) {
74
74
  /**
75
75
  * forge#4275: the context-pack A/B over local run summaries (local-source.mjs summariseLog), per lane type (rerun,
76
76
  * child, first) and mode (on, off, shadow), never one blended average. First-pass yield: merged with one plan + one
77
- * implement builder call and at most one review. Runs without a recorded lane type (older logs) are left out.
77
+ * implement builder call and at most one review. Runs without a recorded lane type (older logs) and light lanes (#4502: they skip the pack) are left out.
78
78
  */
79
79
  export const CONTEXT_LANE_TYPES = Object.freeze(["rerun", "child", "first"]);
80
80
  export function contextPackAb(runs) {
@@ -83,7 +83,7 @@ export function contextPackAb(runs) {
83
83
  for (const lt of CONTEXT_LANE_TYPES) {
84
84
  out[lt] = {};
85
85
  for (const mode of ["on", "off", "shadow"]) {
86
- const rs = runs.filter((r) => r?.finished && r.contextPack?.laneType === lt && r.contextPack?.mode === mode);
86
+ const rs = runs.filter((r) => r?.finished && !r.contextPack?.light && r.contextPack?.laneType === lt && r.contextPack?.mode === mode);
87
87
  out[lt][mode] = { n: rs.length, first_pass_yield: mean(rs.map((r) => (r.firstPass ? 1 : 0))), builder_turns: mean(rs.map((r) => r.builderTurns ?? 0)),
88
88
  cache_tokens: mean(rs.map((r) => r.cacheTokens ?? 0)), cost_usd: mean(rs.map((r) => r.costUsd ?? 0)), ...perMerge(rs, lt) };
89
89
  }
@@ -538,7 +538,7 @@ describe("context-pack A/B over run logs (forge#4275)", () => {
538
538
 
539
539
  it("summariseLog carries mode, lane type, builder turns, cache tokens, cost and first-pass", () => {
540
540
  const s = summariseLog(log("on", "child"));
541
- assert.deepEqual(s.contextPack, { mode: "on", laneType: "child", ab: false });
541
+ assert.deepEqual(s.contextPack, { mode: "on", laneType: "child", ab: false, light: false });
542
542
  assert.deepEqual([s.builderTurns, s.cacheTokens, s.costUsd, s.firstPass], [10, 2130, 1.1, true]);
543
543
  assert.equal(summariseLog(log("on", "child", { builds: 4 })).firstPass, false);
544
544
  assert.equal(summariseLog(log("on", "child", { reason: "needs-human" })).firstPass, false);
@@ -549,6 +549,13 @@ describe("context-pack A/B over run logs (forge#4275)", () => {
549
549
  assert.equal(summariseLog([{ event: "RUN_START", issue: 3 }, { event: "RUN_TERMINAL", reason: "merged", turns: { size: "L", budget: 100, build: 9 } }]).size, "L"); // #4409
550
550
  });
551
551
 
552
+ it("#4502 light lanes are flagged from RUN_TERMINAL and left out of the A/B", () => {
553
+ const l = log("on", "first"); l[l.length - 1] = { ...l[l.length - 1], light: true };
554
+ const s = summariseLog(l);
555
+ assert.equal(s.contextPack.light, true);
556
+ assert.equal(contextPackAb([s, summariseLog(log("on", "first"))]).first.on.n, 1);
557
+ });
558
+
552
559
  it("reports per lane type and mode, never one blended average", () => {
553
560
  const runs = [log("on", "first"), log("on", "first", { builds: 4, reviews: 2 }), log("off", "first"), log("shadow", "rerun"),
554
561
  [{ event: "RUN_START", issue: 2 }, { event: "RUN_TERMINAL", reason: "merged" }]].map((l) => summariseLog(l));
@@ -877,6 +884,13 @@ describe("fail-soft alerts (#4354)", () => {
877
884
  assert.match(line, /^DEPS_PREPARED failed \(pnpm\): 30% \(3\/10 lanes, threshold 20%\), e\.g\. "tool not found: spawn pnpm ENOENT"$/);
878
885
  });
879
886
 
887
+ it("#4522 DEPS_PREPARED materialize-fallback is its own alert above the same threshold, apart from failed", () => {
888
+ const why = "materialize failed: ERR_PNPM_TEST_REPAIR some cause";
889
+ const a = computeAlerts(lanes(10, (i) => [deps("pnpm", i < 3 ? "materialize-fallback" : "hit", why)]), { repo: "o/r" });
890
+ assert.deepEqual(a.map((x) => [x.event, x.count, x.sample, x.example]), [["DEPS_PREPARED materialize-fallback (pnpm)", 3, 10, why]]);
891
+ assert.deepEqual(computeAlerts(lanes(10, (i) => [deps("pnpm", i < 2 ? "materialize-fallback" : "hit", why)]), { repo: "o/r" }), [], "exactly 20% is not above");
892
+ });
893
+
880
894
  it("deferred-to-ci above 30% of lanes and a TOOLCHAIN_WARNING repeated on more than 50% of lanes", () => {
881
895
  const pre = (deferred) => ({ event: "PRECHECK_COMMANDS", round: 0, results: [{ command: "npm test", rc: 0, ms: 5 }, ...(deferred ? [{ command: "npx tsc", status: "deferred-to-ci" }] : [])] });
882
896
  const warn = { event: "TOOLCHAIN_WARNING", warning: "host node v26 does not satisfy the .nvmrc pin \"22\"" };
@@ -102,9 +102,9 @@ remaining maintenance once no feature is pending; otherwise the rest ends `held
102
102
  `--resume`; a batch started without the daemon still needs `forgedock orchestrate --resume <id>`. Change K of a running batch with
103
103
  `forgedock orchestrate --set-parallel <id> K`. Issues filed while a batch runs join it with
104
104
  `forgedock orchestrate --add <id> N...` (or `--resume <id> N...`), under its K and DAG; never start a second batch
105
- for them. Its pre-filter still closes already-fixed ones. The runner requeues `deferred` lanes by code (host busy after its
106
- retry-in, a usage limit holds admissions for that account (backend identity) until the reset, across every batch on
107
- the host; `forgedock limits show|clear <id>|--all` lists or drops holds; transient merge/push/idle failures back off; non-host
105
+ for them. Its pre-filter still closes already-fixed ones. The runner requeues `deferred` lanes by code (host busy and a full sandbox pool after their
106
+ retry-in, uncapped, a usage limit holds admissions for that account (backend identity) until the reset, across every batch on
107
+ the host; `forgedock limits show|clear <id>|--all` lists or drops holds; transient merge/push/idle failures back off; other
108
108
  deferrals stop after `orchestration.max_deferrals`), relaunches an `engine-error` once, and stops admitting after
109
109
  `orchestration.breaker` engine-errors (`errors` within `window_min`; held issues continue on `--resume`). Lanes are
110
110
  capped host-wide (`orchestration.global_parallel`, `forgedock sched cap N|show`), highest priority first; within a
@@ -18,7 +18,7 @@ Canonical source: `docs/spec/review-protocol.md` — sync changes there first.
18
18
 
19
19
  **Rationale**: Passing the full changeset to every agent inflates per-child input cost on large PRs. A billing agent reviewing auth code produces noise, not signal. Scoped inputs reduce cost and improve focus.
20
20
 
21
- **Fallback rule**: If a domain's file pattern matches nothing in the PR (slice is empty), the orchestrator falls back to the full diff — ensuring no agent is launched with an empty context. This preserves review coverage on PRs where domain boundaries are blurry.
21
+ **Empty slice rule** (forge#4573): if a domain's file pattern matches nothing in the PR, its slice is a one-line note, never a copy of the full diff (that duplicated the whole diff across domains). Slices hold whole file blocks only: a file too large for the slice is listed as `# OMITTED` at the top and read from the head checkout. A staging brief may carry a pointer to the slice file instead of the text when the slice is large; read that file with bounded `grep`/`sed -n`.
22
22
 
23
23
  **Security agent exception**: The General Security agent ALWAYS receives the full diff — security vulnerabilities are cross-cutting and cannot be safely scoped to a file-path filter.
24
24
 
@@ -129,7 +129,7 @@ node "$FD_ROOT/bin/forgedock.mjs" review-staging ${PR_ARG_NUM:+"$PR_ARG_NUM"} ${
129
129
 
130
130
  Exit 0 prints the manifest. `REVIEW_STAGING: PR #N head <sha>` gives the PR number and the full head SHA. `BUNDLE_PRS:` lists the bundle member PRs. `SCOPE_MODE=` gives the 0B.1 mode. `ROSTER:` gives the tier and reason (record them in the final summary). `CONTEXT_FILE=` names the 0C context. There is one `DISPATCH <domain> <brief file>` line per roster domain. Exit 1 is a stop: the gate printed `RESULT: BLOCK DEPLOY` (report the blocking finding list), or a `REVIEW_RESULT: status: BLOCKED` line names the blocker. Report it and do not continue. A PR with no gate comment at all indicates a gate bypass, not a clean pass.
131
131
 
132
- **Delta pass** (forge#4360): after a fix lands on staging mid-review, re-run with `--delta <previous full head SHA>`. Only that range is reviewed, by the domains it touches (a `DELTA:` manifest line), and the earlier pass is credited. Dispatch only the DISPATCH lines.
132
+ **Delta pass** (forge#4360): after a fix lands on staging mid-review, re-run with `--delta <previous full head SHA>`. Only that range is reviewed, by the domains it touches (a `DELTA:` manifest line), and the earlier pass is credited. The earlier pass counts as complete when it was finalized, or when every launched domain (the PR-keyed launched-agent file) delivered findings at that head with no degraded marker (forge#4573). Dispatch only the DISPATCH lines.
133
133
 
134
134
  ---
135
135
 
package/docs/CONFIG.md CHANGED
@@ -408,15 +408,23 @@ orchestration:
408
408
  # Default 1; 0 disables in-batch recovery.
409
409
  recovery:
410
410
  max_attempts: 1
411
+
412
+ # Parked-PR base sync (#4525): while a batch runs, a needs-human PR whose base moved
413
+ # is merged with the base. Default on; `enabled: false` opts out.
414
+ base_sync:
415
+ enabled: true
416
+ union_globs: "CHANGELOG.md, src/registry/*.ts"
411
417
  ```
412
418
 
413
419
  | Field | Type | Required | Description |
414
420
  |-------|------|----------|-------------|
415
421
  | `max_concurrent` | integer | No | Max concurrent top-level `/work-on` dispatches. It does not bound cascade growth; under `policy: all` it raises drain and spawn throughput together. Default: 12. |
416
422
  | `breaker.errors` / `breaker.window_min` | integer | No | `forgedock orchestrate` circuit breaker (#4121): after `errors` engine-errors within `window_min` minutes the batch stops admitting lanes, lets running lanes finish, and keeps the rest pending for `--resume`. `errors: 0` turns it off. Default: 3 in 30 |
417
- | `max_deferrals` | integer | No | `forgedock orchestrate`: requeues of one issue after a non-host `deferred` (usage limit, merge/push transient, CLI idle, lease held, gh unavailable) before its row is final. `host.busy` deferrals are not capped. Default: 3 |
423
+ | `max_deferrals` | integer | No | `forgedock orchestrate`: requeues of one issue after a non-host `deferred` (usage limit, merge/push transient, CLI idle, lease held, gh unavailable) before its row is final. `host.busy` and `sandbox.capacity` (sandbox pool full) deferrals are not capped. Default: 3 |
418
424
  | `global_parallel` | integer | No | `forgedock orchestrate` (#4191): host-wide cap on running lanes across every batch and repo on the host, enforced through the host lane registry (`~/.forge/sched/`, or `FORGEDOCK_SCHED_DIR`). A freed slot goes to the highest-priority queued issue (`priority:P<n>` / `P<n>` labels, unlabelled P2); within a priority, slots are shared across repos by weight (#4193: `forgedock sched weight <owner/repo> N`, `--clear` resets; default equal, FIFO inside a repo), and priority always dominates weights; running lanes are never preempted and `--parallel` K still bounds each batch. `forgedock sched cap N` overrides it live for the whole host (`--clear` reverts); `forgedock sched show` lists the weights, slots and the queue. Default: unset (no global cap) |
419
425
  | `recovery.max_attempts` | integer | No | `forgedock orchestrate` in-batch recovery (#4267): how many recovery lanes the batch launches per issue for a `needs-human` with a mechanical code (`ci.red`, `base.conflict`, `ci-pending` on a conflicting PR, `contract.invalid` after a mid-run issue edit, `builder.block_untyped`). A recovery lane (`run-issue --recover <code>`) removes `needs-human`, posts a `FORGE:DECISION`, resumes the open PR and re-queues the issue with its dependents. If it ends anything but `merged`, the issue is labelled `needs-human` again with a recovery-evidence `FORGE:NEEDS_HUMAN` comment. Human codes are never recovered. Flat form: `recovery_max_attempts`. Default: 1; `0` disables in-batch recovery (a mechanical `needs-human` is final, as before). |
426
+ | `base_sync.enabled` | boolean | No | `forgedock orchestrate` parked-PR base sync (#4525): every 10 min while the batch runs, an open PR of a `needs-human` row is merged with its moved base in a throwaway worktree. A clean merge, or one whose only conflicts are hunks where both sides purely added lines in a `union_globs` file, is pushed as a plain merge commit (never forced). Any other conflict is aborted, the branch left alone, and its paths are posted once per base sha as a `FORGE:DECISION` on the issue. Sequence/`_journal.json` files are never resolved here. Flat form: `base_sync_enabled`. Default: `true`; `false` opts out. |
427
+ | `base_sync.union_globs` | string | No | Comma-separated globs (`*` within a segment, `**` across; no `/` matches the file name anywhere) of well-known append-only files (registries, changelogs) whose two-sided line additions the base sync keeps both of. Flat form: `base_sync_union_globs`. Default: none (every conflict is reported). |
420
428
  | `cascade.policy` | string | No | `all` \| `balanced` \| `conservative`. Default: `balanced` |
421
429
  | `cascade.max_generation` | integer or `"unlimited"` | No | Max cascade generation admitted at Phase 1 resolve time. Default: 1 |
422
430
  | `cascade.batch_max_generation` | integer | No | Finite maximum member generation that an automated P3 batch may aggregate. Default: 2 |
@@ -559,7 +567,7 @@ engine:
559
567
  | `cli_idle_minutes` | number | No | Core builder idle timeout (#4112): the builder CLI runs with `--output-format stream-json` and is killed (code `cli.idle`) when it shows no stream activity for this many minutes. The run log records the last tool call and the live child processes. Default 15. |
560
568
  | `cli_busy_minutes` | number | No | Extended idle window for a core builder (#4405). When the idle timeout is reached but the session has a live heavy step (a heavy-shim step, test runner, linter or type checker child) or a queued or running heavy-queue entry of its own processes, the kill waits until `cli_idle_minutes` + this many minutes of silence. The run log records `CLI_IDLE_EXTENDED` once per silent stretch, and a later `CLI_IDLE_KILL` carries `heavyQueue` ({queued, running}) and `extended: true`. A silent session with no such child is still killed at `cli_idle_minutes`. Default 45 (the heavy queue's 30-minute maximum wait plus the 15-minute idle default). |
561
569
  | `cli_max_minutes` | number | No | Absolute ceiling for a core builder session (code `cli.timeout`), replacing the fixed 60-minute kill. A session that keeps making progress is never idle-killed. Default 180. On either kill, uncommitted work is committed as `wip(forge): checkpoint after <reason>` and the lane branch is pushed (with the push retry), the builder retries once from the checkpoint, and a second kill ends `deferred`; a rerun resumes from the branch. |
562
- | `turn_budget_s` / `turn_budget_m` / `turn_budget_l` | number | No | Core implement turn budget per lane size (#4392). Size comes from a `size:S|M|L` issue label, else the frozen contract's distinct deliverable paths (S ≤ 2, M ≤ 5, else L). The build call and its context/timeout restarts share the budget; at the limit the session is stopped, checkpointed, and one fresh wrap-up session of max(5, budget/5) turns either finishes or writes `status: budget-exceeded` with a digest. A stop or overrun ends `needs-human` with code `budget.exceeded`, which `forgedock orchestrate` recovers once (continue from the checkpoint or split). Lane results carry `turns: {size, budget, build, total, exceeded}`, and the batch report prints `turns by size`. Defaults 25 / 60 / 100; 0 disables that size. |
570
+ | `turn_budget_s` / `turn_budget_m` / `turn_budget_l` | number | No | Core implement turn budget per lane size (#4392). Size comes from a `size:S|M|L` issue label, else the frozen contract's distinct deliverable paths (S ≤ 2, M ≤ 5, else L). The build call and its context/timeout restarts share the budget; at the limit the session is stopped, checkpointed, and one fresh wrap-up session of max(5, budget/5) turns either finishes or writes `status: budget-exceeded` with a digest. A stop or overrun ends with the non-human `budget-exceeded` reason and code `budget.exceeded` (no `needs-human` label, forge#4505), which `forgedock orchestrate` recovers once (continue from the checkpoint or split); a batch that does not recover it labels it `needs-human` once. Lane results carry `turns: {size, budget, build, total, exceeded}`, and the batch report prints `turns by size`. Defaults 25 / 60 / 100; 0 disables that size. |
563
571
  | `lane_limits` | map | No | Cap each core lane's whole process tree (#4115), under `pipeline:` or `engine:`: `memory_max` (for example `3G`) and `cpu_quota` (for example `150%`). When set and `systemd-run --user --scope` works (probed once), the builder CLI is spawned as `systemd-run --user --scope -q -p MemoryMax=... -p CPUQuota=... -- claude ...`; elsewhere the caps are ignored. Write it nested or flat (`lane_limits_memory_max`, `lane_limits_cpu_quota`). While a builder runs the engine polls host load and memory every 15 s against the `admission` thresholds: over them it SIGSTOPs the lane's process group, under them SIGCONT, and never pauses longer than 10 minutes. |
564
572
  | `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. |
565
573
  | `merge_wait` | string | No | Core work-on merge gate. `required` (default): wait only for the status checks the base branch requires: the union of classic branch protection (`gh api repos/{o}/{r}/branches/{base}/protection/required_status_checks`, contexts and checks) and repository rulesets (`gh api repos/{o}/{r}/rules/branches/{base}`, `required_status_checks` rules); with neither (404 or empty) merge right after review PASS. A merge rejected by base branch policy (PR `MERGEABLE` but `BLOCKED`) is waited on and retried within the CI budget, then left `ci-pending`; it is never base-synced and never `base.conflict`. 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. |
@@ -616,7 +624,7 @@ lean:
616
624
  | `light.mode` | string | No | `off`, `shadow` (default) or `on` (#4448). `bin/lean/light.mjs` decides whether an issue qualifies for the light lane: a repro, steps to reproduce, expected or actual behaviour or a code block in the body; 1 to `max_files` predicted non-test files; no `size:M`/`size:L` label; no predicted path with a built-in risk word or matching `review.risk_paths`; not an `investigate(` issue, a post-deploy issue (`post-deploy`/`awaiting-promotion` label, or only post-deploy checklist items) or a decision issue (`decision`, `needs-decision`, `product-decision`, `rfc` or `adr` label, or a `decision(`/`rfc(`/`adr(` title). A missing repro or file prediction is `ambiguous`, left to a cheap classifier. `off` is never eligible; `shadow` reports the verdict without changing the lane; `on` lets an eligible issue run light. The lane wiring is #4450. A light lane posts a `FORGE:LITE` receipt on the issue (outcome light or upgraded, diff stat, red/green repro, gates, true cost from the #4407 per-session deltas, eligibility reason, upgrade reasons), and the batch efficiency report ends with a `lanes:` line: the light/upgraded/full split with cost and the upgrade rate, upgraded ÷ (light + upgraded) (#4452). The flat form `lean.light_mode` also works and wins. |
617
625
  | `light.max_lines` | number | No | Upgrade trigger (#4448): a light lane whose non-test diff (added + deleted lines) exceeds this moves to the full lane. Default 40. The other triggers: more than `max_files` non-test changed files, a repro that is not red on the base, two failed test runs, a touched risk path, or a public-API-shape change (#4449). Flat `lean.light_max_lines` wins. |
618
626
  | `light.max_files` | number | No | Max predicted non-test files for eligibility and max changed non-test files before an upgrade (#4448). Default 2. Flat `lean.light_max_files` wins. |
619
- | `sequence.regenerate` | string | No | Command run in the lane worktree (`sh -c`) after the engine renumbers a schema-changing sequence-numbered migration (#4386), to regenerate its snapshot, e.g. `pnpm --filter @x/db db:generate`. The flat form `lean.sequence_regenerate` also works and wins. When a lane-added numbered file (`NNNN_*.sql`, a drizzle `meta/_journal.json` + `meta/NNNN_snapshot.json`, a numbered ADR) shares its number with the base, the engine renumbers it without a builder session: `git mv` to max(base)+1…, the document's own number in its heading, references to the old name repo-wide, the base journal plus the lane's entries, and a data-only migration's snapshot cloned from the base tip with a new `id`/`prevId`. A schema-changing migration without this command, or a migration in another language (`.py`, `.ts`), falls back to the builder renumber, and the trajectory records why. A base-sync whose conflicts are only in such files is resolved the same way, with no re-review. At the merge gate a lane with numbered files holds a host sequence lease per (repo, lane branch, directory) from its last renumber check through the merge, so only one numbered-file PR is in the renumber→CI→merge window at a time. |
627
+ | `sequence.regenerate` | string | No | Command run in the lane worktree (`sh -c`) after the engine renumbers a schema-changing sequence-numbered migration (#4386), to regenerate its snapshot, e.g. `pnpm --filter @x/db db:generate`. The flat form `lean.sequence_regenerate` also works and wins. When a lane-added numbered file (`NNNN_*.sql`, a drizzle `meta/_journal.json` + `meta/NNNN_snapshot.json`, a numbered ADR) shares its number with the base, the engine renumbers it without a builder session: `git mv` to max(base)+1…, the document's own number in its heading, references to the old name repo-wide, the base journal plus the lane's entries, and a data-only migration's snapshot cloned from the base tip with a new `id`/`prevId`. A schema-changing migration without this command, or a migration in another language (`.py`, `.ts`), falls back to the builder renumber, and the trajectory records why. When the generator writes no snapshot for a hand-written migration (no schema diff), the engine reruns it with ` --custom` appended (drizzle-kit's empty migration), keeps its snapshot and restores the lane's SQL byte-identical, so the command must forward trailing args (a pnpm/yarn script, or `npm run x --`). A base-sync whose conflicts are only in such files is resolved the same way, with no re-review. At the merge gate a lane with numbered files holds a host sequence lease per (repo, lane branch, directory) from its last renumber check through the merge, so only one numbered-file PR is in the renumber→CI→merge window at a time. |
620
628
 
621
629
  ## `stats` (OPTIONAL)
622
630
 
@@ -627,7 +635,7 @@ stats:
627
635
  alerts:
628
636
  window: 50 # last N lanes per repo
629
637
  min_lanes: 10 # fewer lanes in the window: nothing is printed
630
- deps_failed_pct: 20 # DEPS_PREPARED status failed, per ecosystem
638
+ deps_failed_pct: 20 # DEPS_PREPARED status failed, and status materialize-fallback, per ecosystem
631
639
  deferred_to_ci_pct: 30 # lanes with a deferred-to-ci pre-PR check
632
640
  toolchain_warning_pct: 50 # lanes repeating the same TOOLCHAIN_WARNING
633
641
  outcome_share_factor: 2 # outcome code share vs the previous window
@@ -637,7 +645,7 @@ stats:
637
645
  |-------|------|----------|-------------|
638
646
  | `alerts.window` | number | No | Rolling window: the last N lanes of the repo, ordered by `RUN_START`. Each run in a run log is one lane. Default 50. |
639
647
  | `alerts.min_lanes` | number | No | With fewer lanes in the window, or no run logs, nothing is printed. Default 10. |
640
- | `alerts.deps_failed_pct` | number | No | Alert when `DEPS_PREPARED` status `failed` is above this percentage of the lanes that prepared that ecosystem. Default 20. |
648
+ | `alerts.deps_failed_pct` | number | No | Alert when `DEPS_PREPARED` status `failed` is above this percentage of the lanes that prepared that ecosystem. A separate alert uses the same threshold for status `materialize-fallback`: a cached snapshot failed to link or repair, and the lane installed fresh. Default 20. |
641
649
  | `alerts.deferred_to_ci_pct` | number | No | Alert when more than this percentage of lanes left a pre-PR check `deferred-to-ci`. Default 30. |
642
650
  | `alerts.toolchain_warning_pct` | number | No | Alert when one `TOOLCHAIN_WARNING` text is repeated on more than this percentage of lanes. Default 50. |
643
651
  | `alerts.outcome_share_factor` | number | No | Alert when an outcome code other than `completed` has at least this factor of its share in the previous window. The code must be on at least 2 lanes, and the previous window must have at least `min_lanes` lanes. A code new in this window counts. Default 2. |
@@ -729,6 +737,7 @@ execution:
729
737
  provider: container # default container when mode is not local
730
738
  image: my/lane-image:1 # container provider: an image with git and forgedock; never pulled
731
739
  secrets: GH_TOKEN,ANTHROPIC_API_KEY # env var NAMES forwarded to the sandbox; nothing else is
740
+ # provider: pool (forge#4546): remote with endpoint and token defaulted to the local router (below); no keys needed
732
741
  # provider: remote (forge#4254), only when named explicitly:
733
742
  # endpoint: https://runner-pool.example/v1 # sandbox HTTP API (protocol below)
734
743
  # token_env: FORGEDOCK_SANDBOX_TOKEN # credential NAME: read from the env, else the OS keychain
@@ -739,6 +748,7 @@ execution:
739
748
  budget_usd: 25 # hard cap on estimated sandbox USD per batch
740
749
  day_budget_minutes: 2000 # hard cap on sandbox-minutes per host UTC day (all batches, schedd or not)
741
750
  day_budget_usd: 80 # hard cap on estimated sandbox USD per host UTC day
751
+ # heavy: true # hybrid placement only: true = lanes go remote, false = ignore detected weight; adds no commands, no admission change
742
752
  on_budget: stop # stop (default) | local: what the queue does once a cap is reached
743
753
  cost_per_minute: 0.04 # USD per sandbox-minute when the provider reports no cost (default 0)
744
754
  ```
@@ -747,16 +757,20 @@ execution:
747
757
  |-----|------|----------|-------------|
748
758
  | `mode` | string | No | `local` (default): lanes are detached host processes, as before. `remote`: lanes run in sandboxes of `provider`. `hybrid`: each lane is placed from facts (forge#4256, below). |
749
759
  | `max_remote` | integer | No | Remote lanes of the batch that may run at once. They take no slot of the host lane cap (`orchestration.global_parallel`, `forgedock sched cap`); the batch `--parallel` still applies. `0` (default) keeps lanes local. |
750
- | `provider` | string | No | `container` (reference: an ephemeral local container), `remote` (a runner pool or remote session over HTTP, forge#4254) or `local`. The default is `container` off `local` mode; `remote` is used only when named. Unknown names fail the batch start. |
760
+ | `heavy` | boolean | No | Hybrid placement override (forge#4523). `true`: every lane that passes rules 1-3 is placed remote. `false`: the detected validation weight is ignored (rule 5 still applies). Unset: detect. It changes placement only: no pre-PR commands run, and host admission (`max_load_per_core`) is unchanged. |
761
+ | `provider` | string | No | `container` (reference: an ephemeral local container), `remote` (a runner pool or remote session over HTTP, forge#4254), `pool` (the built-in pool router on this host, forge#4546: `remote` with `endpoint` and `token_env` defaulted from the router config, `COMPUTESHARE_CONFIG` or `~/.config/computeshare/router.json`, and its `forgedock.token`; the repo's `forge.yaml` is sent to every sandbox, so no router `repos` map is needed; missing router config and no `endpoint` fails with `execution.provider.misconfigured`) or `local`. The default is `container` off `local` mode; `remote` and `pool` are used only when named. Unknown names fail the batch start. |
751
762
  | `image` | string | No | Container provider image; it must provide `git` and `forgedock`. Started with `--pull never`. |
752
763
  | `secrets` | string | No | Comma-separated env var names copied from the dispatcher's env into the sandbox. The sandbox also gets `GH_REPO` (the target repo) and `FORGEDOCK_BATCH_ID`, and nothing else from the host. |
753
- | `endpoint` | string | `remote` | Base URL of the sandbox API. A GitHub Actions API (`api.github.com`, or any `/actions/` path) is refused unless `allow_actions: true`, so pre-review Actions minutes are never spent implicitly. Missing: the batch start fails (`execution.provider.misconfigured`). |
764
+ | `endpoint` | string | `remote` | Base URL of the sandbox API (`pool`: defaults to the local router). A GitHub Actions API (`api.github.com`, or any `/actions/` path) is refused unless `allow_actions: true`, so pre-review Actions minutes are never spent implicitly. Missing: the batch start fails (`execution.provider.misconfigured`). |
754
765
  | `token_env` | string | No | Name of the API credential, sent as `Authorization: Bearer`. Read from the env, else the OS keychain (service `forgedock`, account = the name: `secret-tool store --label forgedock service forgedock account NAME` on Linux, `security add-generic-password -s forgedock -a NAME -w` on macOS). Never written to the ledger, the batch state, logs or GitHub. |
755
766
  | `cache_store` | string | No | Shared cache store the remote sandbox mounts (sent as `cache.store` with `cache.key` = the repo). |
756
767
  | `allow_actions` | boolean | No | `true` accepts a GitHub Actions endpoint. Default `false`. |
757
768
  | `budget_minutes`, `budget_usd` | number | No | The `execution.budget` per batch: once the batch's sandbox minutes / estimated USD (settled lanes plus the running ones' elapsed time) reach the cap, no new remote lane is admitted. Missing, non-numeric or negative means no cap. |
758
769
  | `day_budget_minutes`, `day_budget_usd` | number | No | The same cap per host UTC day, summed over every batch on the host from the host-day store (`execution-budget.json` in `~/.forge/sched/`, or `FORGEDOCK_EXECUTION_BUDGET_FILE`). A corrupt or unreadable store refuses remote lanes while a day cap is set; remove the file to reset it. |
770
+ | `fallback_local` | boolean | No | `true`: a remote-placed lane runs on the host while the provider reports no agent online (`GET {endpoint}/status -> {"online": N}`, the built-in router answers it with `online`, `total` and per-agent `ok`/`paused`), so a batch completes locally with every pool agent offline or asleep (forge#4547). Default `false`: such lanes defer (`sandbox.capacity`). A missing or failing `/status` is unknown and never triggers it. |
759
771
  | `on_budget` | string | No | `stop` (default): running lanes finish and the rest end `held` (`execution budget: ...`) for a later `--resume`. `local`: the rest run as detached host lanes. |
772
+ | `host_capabilities` | list | No | Host-only capabilities the dispatching host has and a sandbox does not relay (forge#4542): names such as `sops`, `prod_monitor`, `ops_credentials`. A lane whose issue text names one (built-in signals: `.secrets/*.enc.yaml`, `sops`, `ssh *-monitor`, `obs-psql`, `credentials.yaml`; a declared name written out; `host_capability_patterns`) is placed `local` in `remote` and `hybrid` mode with the reason `host capability: <name>`, since remote providers (computeshare included) relay none of them. A remote lane that still blocks as `credentials` naming only a declared capability ends `env.capability_remote` (recoverable) and is rerun locally once. |
773
+ | `host_capability_patterns` | list | No | `name:regex` entries (case-insensitive) that also mean the lane needs capability `name`, e.g. `vault:vault\s+kv`. An invalid entry is ignored. |
760
774
  | `cost_per_minute` | number | No | USD per sandbox-minute, used when the provider reports no cost of its own (the `container` provider never does). Default `0`. |
761
775
 
762
776
  **Lifecycle:** each sandbox is listed in the lane ledger (`~/.forge/ledger/<batch>/sandbox-<n>.json`, kind `sandbox`) before it starts and removed from it only after teardown succeeded: on success, failure, a lane that exits without a result, and adopt after `--resume`. A sandbox left by a killed dispatcher is reaped by `forgedock gc --apply` (scope `--sandboxes`). The container provider mounts the checkout's git dir read-only at `/src` (cloned to the container's own `/work`), `~/.forge/verify-cache` read-only at `/cache`, and the batch `lanes/` dir at `/result`.
@@ -772,14 +786,14 @@ execution:
772
786
  | `GET /sandboxes/:id/usage` | `{minutes}`: billable sandbox minutes, set on the handle and the lane result (`sandbox.minutes`); without it, wall-clock minutes |
773
787
  | `DELETE /sandboxes/:id` | Teardown; `404` counts as torn down |
774
788
 
775
- **Start failures** (every provider): a sandbox that cannot start ends the lane `needs-human` with code `sandbox.start_failed`, not relaunched and not counted by the engine-error breaker; a capacity error ends it `deferred` (`sandbox.capacity`, backoff, bounded by `orchestration.max_deferrals`). See docs/OUTCOME-CODES.md. `forgedock gc --apply --sandboxes` tears a stale remote sandbox down through the same API (the checkout's forge.yaml `execution`).
789
+ **Start failures** (every provider): a sandbox that cannot start ends the lane `needs-human` with code `sandbox.start_failed`, not relaunched and not counted by the engine-error breaker; a capacity error ends it `deferred` (`sandbox.capacity`), requeued after the provider's retry-in (else 60s) and not counted toward `orchestration.max_deferrals`, so a queue deeper than the pool waits instead of being skipped (forge#4528). A pool that serves `GET {endpoint}/status -> {"free": N}` (integer, optional) also caps remote admissions to N per sample, so the extra lanes queue inside the batch instead of bouncing on a 503 (forge#4540); a missing, failing or malformed `/status` is unknown and never blocks admission. See docs/OUTCOME-CODES.md. `forgedock gc --apply --sandboxes` tears a stale remote sandbox down through the same API (the checkout's forge.yaml `execution`).
776
790
 
777
791
  **Hybrid placement** (`mode: hybrid`, forge#4256, `bin/lean/execution.mjs` `placeLane`, no model call). Each lane is placed when it is admitted. The first matching rule wins:
778
792
 
779
793
  1. `max_remote: 0` → local.
780
794
  2. `verify.mode: ci` → local. Heavy validation already runs, and is billed, on CI, so the lane is not billed twice.
781
795
  3. A docs-only lane (issue label `docs`, `documentation` or `type:docs`) → local.
782
- 4. Heavy expected validation → remote. This means a declared check command (`learned.test_commands`) is a type check, linter, test runner or build, or the repo has a heavy toolchain marker: `tsconfig.json`, an eslint config, a package.json `test`/`lint`/`typecheck`/`build` script, `Cargo.toml`, `go.mod`, `pyproject.toml`/`setup.cfg`/`pytest.ini`, `pom.xml` or `build.gradle`.
796
+ 4. Heavy expected validation → remote (`execution.heavy: true` forces this, `false` ignores the detected weight). This means a declared check command (`learned.test_commands`) is a type check, linter, test runner or build, or the repo has a heavy toolchain marker, at the root or one or two directory levels down (`web/tsconfig.json`, `services/x/pyproject.toml`; `node_modules`, `vendor`, `dist`, `build`, `target` and dot-directories are skipped): `tsconfig.json`, an eslint config, a package.json `test`/`lint`/`typecheck`/`build` script (also in those subdirectories), `Cargo.toml`, `go.mod`, `pyproject.toml`/`setup.cfg`/`pytest.ini`, `pom.xml` or `build.gradle`.
783
797
  5. A host without headroom → remote. "Without headroom" means MemAvailable is below `engine.min_free_mem_mb` + `engine.heavy_step_mem_mb`, or the load per core is above `engine.max_load_per_core` when that is set.
784
798
  6. Otherwise → local.
785
799
 
@@ -37,6 +37,7 @@ An absent or unknown code is recorded as `unclassified`, with the raw reason kep
37
37
  | `merge.transient` | `gh pr merge` kept failing with a transient error (`Base branch was modified`, GraphQL or 5xx) after the retry budget; the run is `deferred` and a rerun resumes at the merge | lean | `bin/lean/lean.mjs` |
38
38
  | `push.transient` | `git push` kept failing with a transient error (`remote unpack failed`, `index-pack failed`, `RPC failed`, 5xx, reset, timeout) after 4 attempts; `deferred`, rerun resumes | lean | `bin/lean/lean.mjs` |
39
39
  | `cli.idle` | The builder CLI showed no stream activity for `engine.cli_idle_minutes` and was killed; uncommitted work was checkpointed to the lane branch; `deferred` after one automatic retry | lean | `bin/runner.mjs` |
40
+ | `cli.host_killed` | The builder CLI was killed from outside (SIGTERM/SIGKILL, status 143/137) with none of our own timers firing; checkpointed and retried once, then `deferred`; never counts toward the batch breaker (#4518) | lean | `bin/lean/hostkill.mjs` |
40
41
  | `run.interrupted` | A core run got SIGTERM/SIGINT; the lane tore down from its resource ledger (unpushed work checkpointed to the lane branch, worktree removed, lease released) and ended `deferred`, so a batch requeues it | lean | `bin/engine-cli.mjs` |
41
42
  | `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` |
42
43
  | `gh.unavailable` | `run-issue --repo` could not verify the cwd repo because `gh repo view` kept failing after 3 attempts with backoff (typically a timeout under host load); the core or full run is `deferred` (exit 75, retry in 60s) before any lease, nothing posted | engine CLI | `bin/engine-cli.mjs` |
@@ -49,8 +50,9 @@ An absent or unknown code is recorded as `unclassified`, with the raw reason kep
49
50
  | `env.setup_failed` | forge.yaml `env.setup` (run once per fresh lane worktree, before the builder) exited non-zero, timed out or could not start. An environment error, not a build failure: the builder never ran. Core work-on ends `needs-human` with this code instead of `engine-error`, so `forgedock orchestrate` does not relaunch it; fix the setup command or the environment and rerun | lean | `io.runSetup` exit code / spawn error |
50
51
  | `env.tool_missing` | Core work-on (forge#4323): a validate command the builder recorded as failed was rerun by the engine and could not run because the host lacks a tool or module (`command not found` / exit 127, Python `No module named x`, Node `Cannot find module|package 'x'` for a bare name the repo does not track). An environment error, not a conformance failure: no builder resume, no reviewer. Ends `needs-human` naming the tool; recoverable by `forgedock orchestrate` (the recovery lane runs the check through the repo's own toolchain). Skipped on a CI-verified head | lean | `gate()` rerun via `io.runCommand`, `{rc, output}` classified by `conformance.mjs` `missingTool` |
51
52
  | `env.repro_unrunnable` | Core work-on (forge#4334): on a bug lane the frozen `repro_test` could not be run on head by `scripts/acceptance-red-green.sh` (`HEAD=UNRUNNABLE`: timeout, exit 126/127, unclassifiable output) or the harness returned no result. An environment error, not a conformance failure: no builder resume, no reviewer. Ends `needs-human`; recoverable by `forgedock orchestrate` (the recovery lane reruns the repro command through the repo's own toolchain and installed dependencies). Other non-proofs (passes on base, fails on head, base unrunnable) stay `conformance.unimplemented` | lean | `gate()` `io.redGreen` words, classified by `conformance.mjs` `reproFailures` |
53
+ | `env.capability_remote` | Core work-on (forge#4542): a lane placed in a remote sandbox ended `builder.blocked (credentials)` and its summary names only host capabilities declared in `execution.host_capabilities` (SOPS/age key, ops credentials, prod monitor SSH), which the sandbox does not relay but the dispatching host has. Recoverable by `forgedock orchestrate`: the recovery lane is placed local, once, under the same lease. A credential need the host lacks too stays `builder.blocked` | lean | `endBlocked` via `execution.mjs` `hostCapabilityBlock` |
52
54
  | `sandbox.start_failed` | A `forgedock orchestrate` lane sandbox (forge.yaml `execution`, provider `container` or `remote`) could not start: bad image, missing endpoint or credential, refused launch. The lane never ran. Final `needs-human` row: not relaunched and not counted by the engine-error circuit breaker | orchestrate | `bin/lean/execution.mjs` `providerLanes`: `provider.start` threw without `retryable` |
53
- | `sandbox.capacity` | The sandbox provider had no capacity for the lane (HTTP 429/503 from the remote transport). `deferred` with backoff (`Retry-After` when sent), bounded by `orchestration.max_deferrals` | orchestrate | `provider.start` threw with `err.retryable: true` |
55
+ | `sandbox.capacity` | The sandbox provider had no capacity for the lane (HTTP 429/503 from the remote transport). `deferred`, requeued after `Retry-After` when sent, else 60s; not counted toward `orchestration.max_deferrals`, like `host.busy` (forge#4528) | orchestrate | `provider.start` threw with `err.retryable: true` |
54
56
  | `lease.held` | Another live agent holds the issue lease | engine | Foreign live lease on acquire |
55
57
  | `dependency.open` | A dependency is still open: a trusted `**Depends on**: #N` line refused the run before any state write, or the core builder reported `block.kind: "dependency"` naming an issue that is open. Carries `dependencies`; never `needs-human`. `forgedock orchestrate` adds the edge and requeues the issue behind it, without skipping dependents | engine, lean | Trusted `**Depends on**` line, or `result.json` `block.issues`, + the dependency issue's live open state |
56
58
  | `dependency.unreadable` | The issue body or a declared dependency could not be read, so the run was refused (fail closed) | engine, lean | Failed or unparsable `gh api` / `gh issue view` read |
@@ -65,7 +67,7 @@ An absent or unknown code is recorded as `unclassified`, with the raw reason kep
65
67
  | `builder.block_untyped` | Core work-on: the builder stayed `blocked` without a valid typed reason (missing `block`, a non-human `why` such as prod data, or a dependency that is not open) after one corrective resume | lean | `result.json` status `blocked`, twice |
66
68
  | `builder.no_evidence` | Core work-on: the builder reported invalid/already-fixed without file:line evidence, so the issue was not closed | lean | `result.json` with empty `evidence` |
67
69
  | `builder.no_commit` | Core work-on: the builder made no commit for a fix | lean | No changed files after the builder session |
68
- | `budget.exceeded` | Core work-on (forge#4392): the implement turn budget for the lane's size (forge.yaml `engine.turn_budget_s|m|l`, defaults 25/60/100) was spent and the one bounded wrap-up session did not finish (it wrote `status: budget-exceeded` or overran its grant). Work is checkpointed on the lane branch; the detail carries the builder's digest (else the diff stat). Ends `needs-human`; recoverable once by `forgedock orchestrate` (the recovery lane continues from the checkpoint or ends with a typed split-required block) | lean | Stream turn count (`CLI_TURNS` from `runCliBackendAsync` `turnMax`), wrap-up result.json status |
70
+ | `budget.exceeded` | Core work-on (forge#4392): the implement turn budget for the lane's size (forge.yaml `engine.turn_budget_s|m|l`, defaults 25/60/100) was spent and the one bounded wrap-up session did not finish (it wrote `status: budget-exceeded` or overran its grant). Work is checkpointed on the lane branch; the detail carries the builder's digest (else the diff stat). Ends with the non-human `budget-exceeded` reason (forge#4505): no `needs-human` label and the issue stays open. `forgedock orchestrate` recovers it once (the recovery lane continues from the checkpoint or ends with a typed split-required block); a first-run overrun the batch does not recover is labelled `needs-human` by the batch, once | lean | Stream turn count (`CLI_TURNS` from `runCliBackendAsync` `turnMax`), wrap-up result.json status |
69
71
  | `review.failed` | Core work-on: the fix/regression review failed twice (or could not resume the builder) | lean | Reviewer verdict FAIL |
70
72
  | `review.no_verdict` | Core work-on: the reviewer returned no parseable verdict twice | lean | Unparsable reviewer output |
71
73
  | `contract.invalid` | Core work-on: the builder's contract.json stayed invalid after one correction (missing requirement rows, a quote not verbatim from the issue, a defer without a why, no validate commands) | lean | `validateContract` problems after the correction resume |
@@ -96,7 +98,7 @@ An absent or unknown code is recorded as `unclassified`, with the raw reason kep
96
98
  `forgedock orchestrate` recovers mechanical outcomes inside the batch:
97
99
 
98
100
  - **recoverable**:
99
- - needs-human with `ci.red`, `base.conflict`, `builder.block_untyped`, `env.tool_missing`, `env.repro_unrunnable` or `budget.exceeded`;
101
+ - needs-human with `ci.red`, `base.conflict`, `builder.block_untyped`, `env.tool_missing`, `env.repro_unrunnable`, `env.capability_remote` or `budget.exceeded`;
100
102
  - `contract.invalid` when the issue was edited mid-run (`issueEdited`);
101
103
  - `ci-pending` on a conflicting PR, recovered as `base.conflict`.
102
104
 
package/docs/STATS.md CHANGED
@@ -37,7 +37,7 @@ With no local state at all, `forgedock stats --repo <r>` reproduces the report f
37
37
  ## Efficiency (`--efficiency`, forge#4402)
38
38
 
39
39
  ```bash
40
- npx forgedock stats --efficiency [--batch <id>] [--window 7d,30d] [--repo <owner/repo>] [--json]
40
+ npx forgedock stats --efficiency [--batch <id>] [--window 7d,30d] [--compare <date|version>] [--offline] [--repo <owner/repo>] [--json]
41
41
  ```
42
42
 
43
43
  Per batch (`--batch`, a dir under `~/.forge/batches`, `FORGEDOCK_BATCH_DIR`) and over trailing windows (`--window`,
@@ -78,6 +78,41 @@ Limits: logs written before #4362 carry no trigger, so implement attempts beyond
78
78
  `unknown`. Logs written before #4407 sum running session totals. Lanes run outside a batch are in no window. `gh` (for
79
79
  merged issues only) is the one network call; when it fails, quality and acceptance print `n/a` with the reason.
80
80
 
81
+ Every scope also prints, as its last line (forge#4491):
82
+
83
+ - **Implement wall p50**: the median over merged lanes of the summed `duration_ms` of their implement attempts, in
84
+ minutes.
85
+ - **Merge rate**: merged ÷ the cohort's issue rows.
86
+ - **Conformance pass**: the share of rows whose lanes logged a `CONFORMANCE` verdict and whose last verdict is
87
+ `conforms`.
88
+
89
+ `--repo` matches batch plans case-insensitively, as GitHub slugs are. Run logs are read under the plans' spelling.
90
+ `--offline` skips the GitHub crawl, so issue quality and acceptance print `n/a`. The crawl is two `gh` calls per merged
91
+ issue, which can take minutes over a large window.
92
+
93
+ ### Before/after A/B (`--compare`, forge#4491)
94
+
95
+ `--compare <date|version>` compares two cohorts of the repo's batches around a deploy. The pivot is a date or ISO time,
96
+ or a release version resolved to its exact tag time (`--release-times`, else the checkout's `v*` tags).
97
+
98
+ - **post**: batches created from the pivot to now.
99
+ - **pre**: batches created in the same span before the pivot. Both sides are equally long.
100
+
101
+ It prints for each cohort turns/merge, implement wall p50, implement calls/turn p50, re-reads/merge, merge rate and
102
+ conformance pass, and then the post − pre delta.
103
+
104
+ `matched` repeats the comparison on full-shape merged lanes only, per size bucket present on both sides. Light lanes
105
+ (#4452) cut turns on their own, so the unmatched delta mixes the lane mix into any prompt or tool change. With `--json`
106
+ the result is under `query.efficiency.compare`. Cohorts are split by batch `plan.created`, as `--window` is; a lane run
107
+ outside a batch is in neither.
108
+
109
+ ## Pool utilisation (`--pool`, forge#4549)
110
+
111
+ `forgedock stats --pool [--window 7d,30d] [--json]` reads the repo's local batch `report.json` rows and prints, per window,
112
+ remote lane minutes, lanes and share of the pool per agent (`sandbox.by_agent`). Rows without agent data print `n/a`.
113
+ The same per-agent minutes appear in the batch report's `sandbox:` line as `by agent: <name> <min> min, ...`.
114
+ Live capacity is `forgedock pool status`.
115
+
81
116
  ## Explorer A/B (`--explore-ab`, forge#4418)
82
117
 
83
118
  Compares lanes run with forge.yaml `lean.explore: on` against `off` (#4391), from the local run logs only (no GitHub
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "forgedock",
3
- "version": "1.32.1",
3
+ "version": "1.34.0",
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"
@@ -36,6 +36,7 @@
36
36
  "bin/",
37
37
  "!bin/tests/",
38
38
  "!bin/stats/fixtures/",
39
+ "pool/",
39
40
  "commands/",
40
41
  "docs/",
41
42
  "!docs/site/.vitepress/",
package/pool/README.md ADDED
@@ -0,0 +1,13 @@
1
+ # pool
2
+
3
+ ForgeDock's built-in remote compute pool (imported from computeshare; AGPL-3.0-or-later).
4
+
5
+ - `bin/pool/` router, agent, docker/http/store helpers and `cli.mjs` (`node bin/pool/cli.mjs router|agent|install-command ...`).
6
+ - `pool/image/` lane image recipe (Dockerfile, shims, entrypoint, `cs-cred` credential relay).
7
+ - `pool/install/` agent installers (Windows/WSL, native Linux, router).
8
+ Native Linux agent (root, systemd): `pool/install/install-agent-linux.sh --router URL --token CODE [--name N --cpus N --mem GB --dry-run]`.
9
+ - Tests: `bin/tests/computeshare.test.mjs`.
10
+
11
+ The module boundary is the ForgeDock HTTP sandbox protocol: the router speaks it to the dispatcher and relays to agents over HTTP. Sandboxes receive only short-lived GitHub and Claude access tokens; refresh tokens stay on the router host.
12
+
13
+ The `forgedock pool` CLI wiring is tracked separately (#4544).
@@ -0,0 +1,46 @@
1
+ # SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
2
+ # SPDX-License-Identifier: AGPL-3.0-or-later
3
+ # Lane image: everything `forgedock run-issue` needs. Versions are pinned; bump them together with the router host.
4
+ # IS_SANDBOX=1: lanes run as root in a throwaway container; Claude Code refuses --dangerously-skip-permissions as root without it.
5
+ FROM ubuntu:24.04
6
+
7
+ ARG NODE_VERSION=22.23.3 # lanes need >=22.19 (undici 8 via vitest)
8
+ # Node 22.12 bundles corepack 0.29.4, whose npm signing keys are stale: `corepack pnpm` fails to verify
9
+ # pnpm, so lanes get no dependencies. A current corepack carries the rotated keys.
10
+ ARG COREPACK_VERSION=0.34.7
11
+ # No default: the lane image is built from the installed ForgeDock version (docker build --build-arg FORGEDOCK_VERSION=...).
12
+ ARG FORGEDOCK_VERSION
13
+ ARG CLAUDE_CODE_VERSION=2.1.309
14
+ ARG UV_VERSION=0.12.5
15
+ ARG POETRY_VERSION=2.4.3
16
+
17
+ LABEL org.opencontainers.image.version=$FORGEDOCK_VERSION
18
+ RUN test -n "$FORGEDOCK_VERSION" || { echo "FORGEDOCK_VERSION build arg is required" >&2; exit 1; }
19
+
20
+ ENV IS_SANDBOX=1 DEBIAN_FRONTEND=noninteractive LANG=C.UTF-8 PATH=/opt/cs/bin:/opt/node/bin:/root/.local/bin:$PATH
21
+
22
+ RUN apt-get update && apt-get install -y --no-install-recommends \
23
+ ca-certificates curl git jq xz-utils build-essential python3 python3-venv python3-dev pkg-config libpq-dev \
24
+ && install -d -m 0755 /etc/apt/keyrings \
25
+ && curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg -o /etc/apt/keyrings/githubcli.gpg \
26
+ && echo "deb [arch=amd64 signed-by=/etc/apt/keyrings/githubcli.gpg] https://cli.github.com/packages stable main" > /etc/apt/sources.list.d/github-cli.list \
27
+ && curl -fsSL https://download.docker.com/linux/ubuntu/gpg -o /etc/apt/keyrings/docker.asc \
28
+ && echo "deb [arch=amd64 signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/ubuntu noble stable" > /etc/apt/sources.list.d/docker.list \
29
+ && apt-get update && apt-get install -y --no-install-recommends gh docker-ce-cli docker-compose-plugin \
30
+ && rm -rf /var/lib/apt/lists/*
31
+
32
+ RUN curl -fsSL "https://nodejs.org/dist/v${NODE_VERSION}/node-v${NODE_VERSION}-linux-x64.tar.xz" | tar -xJ -C /opt \
33
+ && mv "/opt/node-v${NODE_VERSION}-linux-x64" /opt/node \
34
+ && npm install -g "corepack@${COREPACK_VERSION}" "forgedock@${FORGEDOCK_VERSION}" "@anthropic-ai/claude-code@${CLAUDE_CODE_VERSION}" \
35
+ && npm cache clean --force
36
+
37
+ RUN curl -LsSf "https://astral.sh/uv/${UV_VERSION}/install.sh" | env UV_INSTALL_DIR=/usr/local/bin sh \
38
+ && uv tool install "poetry==${POETRY_VERSION}"
39
+
40
+ COPY entrypoint.sh /usr/local/bin/cs-entrypoint
41
+ COPY cs-cred /usr/local/bin/cs-cred
42
+ COPY gh-shim /opt/cs/bin/gh
43
+ COPY claude-shim /opt/cs/bin/claude
44
+ RUN chmod 0755 /usr/local/bin/cs-entrypoint /usr/local/bin/cs-cred /opt/cs/bin/gh /opt/cs/bin/claude
45
+ WORKDIR /work
46
+ ENTRYPOINT ["/usr/local/bin/cs-entrypoint"]
@@ -0,0 +1,8 @@
1
+ #!/usr/bin/env bash
2
+ # SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
3
+ # SPDX-License-Identifier: AGPL-3.0-or-later
4
+ # claude with a current access token from the router host's logins, fetched per process (see cs-cred).
5
+ if [ -n "${CS_CRED_BASE:-}" ] && [ -z "${CLAUDE_CODE_OAUTH_TOKEN:-}" ]; then
6
+ CLAUDE_CODE_OAUTH_TOKEN="$(cs-cred claude)" || exit 1; export CLAUDE_CODE_OAUTH_TOKEN
7
+ fi
8
+ exec /opt/node/bin/claude "$@"
@@ -0,0 +1,12 @@
1
+ #!/usr/bin/env bash
2
+ # SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
3
+ # SPDX-License-Identifier: AGPL-3.0-or-later
4
+ # cs-cred <github|claude>: prints a current credential, fetched through the agent and cached briefly.
5
+ set -euo pipefail
6
+ kind="${1:?kind}"; ttl=240; [ "$kind" = claude ] && ttl=600
7
+ cache="/tmp/.cs-cred-$kind"
8
+ if [ -s "$cache" ] && [ $(( $(date +%s) - $(stat -c %Y "$cache") )) -lt $ttl ]; then cat "$cache"; exit 0; fi
9
+ [ -n "${CS_CRED_BASE:-}" ] || { echo "no credential relay (CS_CRED_BASE)" >&2; exit 1; }
10
+ tok=$(curl -fsS --max-time 30 -H "authorization: Bearer ${CS_CRED_TOKEN}" "$CS_CRED_BASE/$kind" | jq -r .token)
11
+ [ -n "$tok" ] && [ "$tok" != null ] || { echo "credential fetch failed ($kind)" >&2; exit 1; }
12
+ umask 077; printf '%s' "$tok" > "$cache"; printf '%s' "$tok"
@@ -0,0 +1,61 @@
1
+ #!/usr/bin/env bash
2
+ # SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
3
+ # SPDX-License-Identifier: AGPL-3.0-or-later
4
+ # Prepares the checkout ForgeDock expects, then execs the lane command (`forgedock run-issue ...`).
5
+ # All output goes to /csout/lane.log, which the agent streams back to ForgeDock.
6
+ set -euo pipefail
7
+ mkdir -p /csout /result /cache
8
+ exec > >(tee -a /csout/lane.log) 2>&1
9
+
10
+ export HOME=/root
11
+ export UV_CACHE_DIR=/cache/uv npm_config_cache=/cache/npm POETRY_CACHE_DIR=/cache/poetry PIP_CACHE_DIR=/cache/pip
12
+ export TMPDIR=/tmp
13
+ # ForgeDock's dependency cache (its pnpm store and snapshots) and pnpm's own store live in the persistent per-repo
14
+ # cache volume, so a lane installs from a warm store instead of downloading every package (a cold monorepo install
15
+ # took ~15 min and hit ForgeDock's install timeout). /cache and /work are different volumes, so pnpm imports by copy.
16
+ export FORGE_DEPCACHE_ROOT=/cache/forge-depcache npm_config_store_dir=/cache/pnpm-store
17
+ # Testcontainers' Ryuk reaper never logs "Started" on the sandbox's private dockerd, so every DB test setup failed.
18
+ # The sandbox and its dockerd are discarded after the lane, so the reaper has nothing to add.
19
+ export TESTCONTAINERS_RYUK_DISABLED=true
20
+
21
+ echo "[computeshare] sandbox ${CS_SANDBOX_ID:-?} repo ${CS_REPO_SLUG:-?} ref ${CS_REPO_REF:-default} on $(hostname)"
22
+
23
+ # GitHub auth: the token is fetched on demand through the agent (installation tokens expire hourly), so a long lane
24
+ # never runs on a stale token. `gh` is a shim that exports a fresh GH_TOKEN; git uses the same source as a helper.
25
+ if [ -n "${CS_CRED_BASE:-}" ]; then
26
+ # Scoped to github.com: the installation token is never offered to another HTTPS git host (#4565).
27
+ git config --global credential.https://github.com.helper '!f() { echo username=x-access-token; echo "password=$(cs-cred github)"; }; f'
28
+ elif [ -n "${GH_TOKEN:-}" ]; then
29
+ gh auth setup-git >/dev/null 2>&1 || true
30
+ fi
31
+ git config --global user.name "${GIT_AUTHOR_NAME:-forgedock}"
32
+ git config --global user.email "${GIT_AUTHOR_EMAIL:-forgedock@localhost}"
33
+
34
+ if [ -n "${CS_REPO_URL:-}" ]; then
35
+ # Shared bare mirror in the cache volume: later lanes fetch a delta instead of a full clone.
36
+ mirror="/cache/git/$(echo "${CS_REPO_SLUG:-repo}" | tr '/' '_').git"
37
+ if [ -d "$mirror" ]; then
38
+ git -C "$mirror" fetch --prune --quiet origin '+refs/heads/*:refs/heads/*' || true
39
+ else
40
+ mkdir -p /cache/git
41
+ git clone --mirror --quiet "$CS_REPO_URL" "$mirror"
42
+ fi
43
+ rm -rf /work/repo
44
+ git clone --quiet --reference "$mirror" ${CS_REPO_REF:+--branch "$CS_REPO_REF"} "$CS_REPO_URL" /work/repo
45
+ cd /work/repo
46
+ fi
47
+
48
+ if [ -n "${CS_FORGE_YAML_B64:-}" ]; then
49
+ echo "$CS_FORGE_YAML_B64" | base64 -d > forge.yaml
50
+ unset CS_FORGE_YAML_B64
51
+ fi
52
+
53
+ if [ -n "${DOCKER_HOST:-}" ]; then
54
+ # Docker-enabled sandbox: wait for the private daemon sidecar before the lane starts its stack.
55
+ for i in $(seq 1 60); do docker info >/dev/null 2>&1 && break; sleep 1; done
56
+ docker info >/dev/null 2>&1 && echo "[computeshare] private docker daemon ready ($(docker version --format '{{.Server.Version}}'))" \
57
+ || echo "[computeshare] WARNING: private docker daemon not reachable at $DOCKER_HOST"
58
+ fi
59
+
60
+ echo "[computeshare] running: $*"
61
+ exec "$@"
@@ -0,0 +1,6 @@
1
+ #!/usr/bin/env bash
2
+ # SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
3
+ # SPDX-License-Identifier: AGPL-3.0-or-later
4
+ # gh with a GitHub token that is always current (see cs-cred).
5
+ if [ -n "${CS_CRED_BASE:-}" ]; then GH_TOKEN="$(cs-cred github)" || exit 1; export GH_TOKEN; fi
6
+ exec /usr/bin/gh "$@"