forgedock 1.26.0 → 1.28.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 (137) hide show
  1. package/README.md +4 -1
  2. package/bin/bootstrap.mjs +55 -0
  3. package/bin/engine/outcome-codes.mjs +13 -0
  4. package/bin/engine/phases.mjs +38 -2
  5. package/bin/engine/telemetry-seam.mjs +41 -6
  6. package/bin/engine/telemetry-seam.test.mjs +35 -0
  7. package/bin/engine-cli.mjs +616 -54
  8. package/bin/engine.mjs +13 -5
  9. package/bin/forgedock.mjs +169 -39
  10. package/bin/gc.mjs +580 -0
  11. package/bin/init-detect.mjs +68 -3
  12. package/bin/init-enrich.mjs +3 -0
  13. package/bin/init-repo.mjs +213 -0
  14. package/bin/journey.mjs +278 -43
  15. package/bin/lean/admission.mjs +33 -3
  16. package/bin/lean/batch.mjs +24 -5
  17. package/bin/lean/batch.test.mjs +54 -4
  18. package/bin/lean/checks.mjs +199 -0
  19. package/bin/lean/checks.test.mjs +183 -0
  20. package/bin/lean/conformance.mjs +235 -37
  21. package/bin/lean/conformance.test.mjs +143 -5
  22. package/bin/lean/credentials.mjs +29 -0
  23. package/bin/lean/depcache.mjs +312 -0
  24. package/bin/lean/depcache.test.mjs +298 -0
  25. package/bin/lean/drift.mjs +198 -0
  26. package/bin/lean/drift.test.mjs +223 -0
  27. package/bin/lean/effects.mjs +178 -16
  28. package/bin/lean/execution.e2e.test.mjs +81 -0
  29. package/bin/lean/execution.mjs +431 -0
  30. package/bin/lean/execution.test.mjs +583 -0
  31. package/bin/lean/heavy.mjs +289 -0
  32. package/bin/lean/heavy.test.mjs +207 -0
  33. package/bin/lean/lean.mjs +877 -151
  34. package/bin/lean/lean.test.mjs +1798 -28
  35. package/bin/lean/ledger.mjs +92 -0
  36. package/bin/lean/ledger.test.mjs +100 -0
  37. package/bin/lean/limits.mjs +58 -0
  38. package/bin/lean/limits.test.mjs +150 -0
  39. package/bin/lean/orchestrate.mjs +321 -19
  40. package/bin/lean/prompts.mjs +125 -17
  41. package/bin/lean/refactor.mjs +276 -0
  42. package/bin/lean/refactor.test.mjs +359 -0
  43. package/bin/lean/render.mjs +25 -6
  44. package/bin/lean/review-trigger.mjs +134 -0
  45. package/bin/lean/review-trigger.test.mjs +104 -0
  46. package/bin/lean/sandbox-provider.contract.test.mjs +257 -0
  47. package/bin/lean/sandbox-remote.mjs +142 -0
  48. package/bin/lean/sched.mjs +221 -0
  49. package/bin/lean/sched.test.mjs +310 -0
  50. package/bin/lean/schedd.mjs +247 -0
  51. package/bin/lean/schedd.test.mjs +180 -0
  52. package/bin/lean/toolchain.mjs +228 -0
  53. package/bin/lean/toolchain.test.mjs +154 -0
  54. package/bin/lean/verify.mjs +124 -0
  55. package/bin/lean/verify.test.mjs +118 -0
  56. package/bin/lean/worktree.mjs +69 -0
  57. package/bin/lean/worktree.test.mjs +141 -0
  58. package/bin/runner.mjs +53 -10
  59. package/bin/stats/github-source.mjs +1 -1
  60. package/bin/stats/index.mjs +14 -2
  61. package/bin/stats/query.mjs +72 -0
  62. package/bin/stats/stats.test.mjs +54 -0
  63. package/commands/adopt.md +3 -0
  64. package/commands/audit.md +5 -8
  65. package/commands/autopilot.md +2 -0
  66. package/commands/bootstrap.md +35 -0
  67. package/commands/cleanup.md +7 -22
  68. package/commands/forge-stats.md +2 -1
  69. package/commands/forgedock-init.md +125 -6
  70. package/commands/issue.md +89 -830
  71. package/commands/milestone.md +55 -6
  72. package/commands/orchestrate.md +10 -2
  73. package/commands/review-pr.md +2 -2
  74. package/commands/upgrade-deps.md +3 -3
  75. package/commands/work-on/build/validate.md +4 -0
  76. package/commands/work-on/close.md +2 -0
  77. package/commands/work-on/decompose.md +24 -6
  78. package/docs/CI.md +3 -3
  79. package/docs/CONFIG.md +227 -3
  80. package/docs/OUTCOME-CODES.md +11 -0
  81. package/docs/TELEMETRY.md +1 -1
  82. package/docs/site/command-reference.md +22 -0
  83. package/docs/site/getting-started.md +2 -0
  84. package/docs/site/start-a-new-project.md +327 -0
  85. package/package.json +1 -1
  86. package/scripts/affected-files-check.sh +91 -0
  87. package/scripts/affected-files-check.test.sh +80 -0
  88. package/scripts/bootstrap-create.mjs +393 -0
  89. package/scripts/bootstrap-validate.mjs +321 -0
  90. package/scripts/calibration.mjs +116 -0
  91. package/scripts/check-devdocs-freshness.sh +125 -0
  92. package/scripts/check-devdocs-freshness.test.sh +130 -0
  93. package/scripts/check-spec-markers.sh +3 -0
  94. package/scripts/check-spec-size.sh +29 -11
  95. package/scripts/check-spec-size.test.sh +27 -4
  96. package/scripts/ci-local/lib.mjs +8 -1
  97. package/scripts/ci-local/parallelism.test.mjs +27 -0
  98. package/scripts/ci-local/run.mjs +16 -5
  99. package/scripts/ci-local/scoping.mjs +82 -0
  100. package/scripts/ci-local.test.sh +14 -2
  101. package/scripts/fixtures/symbol-index/other/sample.rb +3 -0
  102. package/scripts/fixtures/symbol-index/py/pkg/__init__.py +1 -0
  103. package/scripts/fixtures/symbol-index/py/pkg/a.py +5 -0
  104. package/scripts/fixtures/symbol-index/py/pkg/b.py +14 -0
  105. package/scripts/fixtures/symbol-index/py/pkg/c.py +1 -0
  106. package/scripts/fixtures/symbol-index/py/sample.py +41 -0
  107. package/scripts/fixtures/symbol-index/ts/sample.ts +13 -0
  108. package/scripts/fixtures/symbol-move/gen_big.py +201 -0
  109. package/scripts/fixtures/symbol-move-ts/gen_big.mjs +56 -0
  110. package/scripts/forge-root.test.sh +1 -1
  111. package/scripts/hotspots.sh +100 -0
  112. package/scripts/hotspots.test.sh +101 -0
  113. package/scripts/issue-create.sh +197 -0
  114. package/scripts/issue-create.test.sh +166 -0
  115. package/scripts/maintenance-scan.sh +185 -0
  116. package/scripts/maintenance-scan.test.sh +89 -0
  117. package/scripts/milestone-drift.sh +206 -0
  118. package/scripts/milestone-drift.test.sh +120 -0
  119. package/scripts/northstar-docs.test.sh +68 -0
  120. package/scripts/refactor-verify.sh +368 -0
  121. package/scripts/refactor-verify.test.sh +202 -0
  122. package/scripts/review-prepare.sh +11 -9
  123. package/scripts/review-prepare.test.sh +14 -0
  124. package/scripts/structure-delta.sh +155 -0
  125. package/scripts/structure-delta.test.sh +136 -0
  126. package/scripts/symbol-index.mjs +253 -0
  127. package/scripts/symbol-index.py +354 -0
  128. package/scripts/symbol-index.test.sh +119 -0
  129. package/scripts/symbol-move-ts.test.sh +161 -0
  130. package/scripts/symbol-move.mjs +379 -0
  131. package/scripts/symbol-move.py +383 -0
  132. package/scripts/symbol-move.test.sh +168 -0
  133. package/templates/bootstrap/interview.md +34 -0
  134. package/templates/bootstrap/issue-body.md +42 -0
  135. package/templates/bootstrap/needs-human-issue.md +45 -0
  136. package/templates/bootstrap/stack-profiles.md +34 -0
  137. package/templates/bootstrap/technical-spike.md +75 -0
@@ -0,0 +1,583 @@
1
+ // SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
2
+ // SPDX-License-Identifier: AGPL-3.0-or-later
3
+ // Lane execution targets (#4253): config, sandbox-provider seam, ledger-tracked teardown, gc reaping, max_remote admission.
4
+ import { describe, it } from "node:test";
5
+ import assert from "node:assert/strict";
6
+ import { mkdirSync, writeFileSync, existsSync, readFileSync } from "node:fs";
7
+ import { join } from "node:path";
8
+ import { tmpDir } from "../tests/_tmp.mjs";
9
+ import { parseExecution, placement, placeLane, validationWeight, hostHeadroom, scopedEnv, providerLanes, containerProvider, providerByName, openExecutionBudget } from "./execution.mjs";
10
+ import { makeLedger, countLiveLanes } from "./ledger.mjs";
11
+ import { openBatch } from "./batch.mjs";
12
+ import { orchestrate, formatTable, formatSandboxSummary } from "./orchestrate.mjs";
13
+ import { planGc, applyGc } from "../gc.mjs";
14
+ import { orchestrateFromCli, readExecution } from "../engine-cli.mjs";
15
+
16
+ const HOST = "testhost";
17
+ const quiet = async (fn) => { const l = console.log; console.log = () => {}; try { return await fn(); } finally { console.log = l; } };
18
+
19
+ /** In-memory provider: `results[issue]` is what the sandbox "writes"; `opts.startThrows`, `opts.teardownOk`. */
20
+ function fakeProvider(opts = {}) {
21
+ const p = {
22
+ name: "fake", calls: [], live: new Set(), results: {}, ledgerAtStart: null,
23
+ idFor: (n) => `fake-${n}`,
24
+ async start({ issue, env, secrets }) {
25
+ p.calls.push(["start", issue]);
26
+ p.ledgerAtStart = opts.ledger?.entries("sandbox").map((e) => e.value) ?? null;
27
+ if (opts.startThrows) throw new Error("boom: no capacity");
28
+ p.live.add(issue);
29
+ p.env = env; p.secrets = secrets;
30
+ if (opts.finish) { const r = opts.finish(issue); if (r !== undefined) { p.results[issue] = r; p.live.delete(issue); } }
31
+ return { provider: "fake", id: `fake-${issue}`, issue };
32
+ },
33
+ async status(h) { p.calls.push(["status", h.issue]); return p.live.has(h.issue) ? "running" : "exited"; },
34
+ async fetchResult(h) { return p.results[h.issue] ?? null; },
35
+ async teardown(h) { p.calls.push(["teardown", h.id]); p.live.delete(h.issue); return opts.teardownOk ?? true; },
36
+ ...(opts.usage ? { usage: opts.usage } : {}),
37
+ };
38
+ return p;
39
+ }
40
+
41
+ const NO_BUDGET = { batchMinutes: null, batchUsd: null, dayMinutes: null, dayUsd: null, onBudget: "stop", costPerMinute: 0 };
42
+ const ledgerIn = (root, { pid = process.pid, host = HOST } = {}) => (n) => makeLedger({ root, batch: "bt", lane: `sandbox-${n}`, owner: "orch_test", issue: n, host, pid });
43
+
44
+ describe("execution config (#4253)", () => {
45
+ it("absent, empty or invalid config is local; max_remote and secrets are validated", () => {
46
+ assert.deepEqual(parseExecution(undefined), { mode: "local", maxRemote: 0, provider: "local", image: null, secrets: [], budget: NO_BUDGET });
47
+ assert.deepEqual(parseExecution({ mode: "cloud", max_remote: "-1" }), { mode: "local", maxRemote: 0, provider: "local", image: null, secrets: [], budget: NO_BUDGET });
48
+ assert.deepEqual(parseExecution({ mode: "remote", max_remote: "4", image: "node:22-slim", secrets: "GH_TOKEN, bad-name,ANTHROPIC_API_KEY,GH_TOKEN" }),
49
+ { mode: "remote", maxRemote: 4, provider: "container", image: "node:22-slim", secrets: ["GH_TOKEN", "ANTHROPIC_API_KEY"], budget: NO_BUDGET });
50
+ assert.equal(parseExecution({ mode: "remote", provider: "My Provider" }).provider, "container");
51
+ });
52
+
53
+ it("placement: remote only for mode remote with max_remote >= 1; hybrid is placed per lane by placeLane (#4256)", () => {
54
+ assert.equal(placement(parseExecution({})), "local");
55
+ assert.equal(placement(parseExecution({ mode: "remote", max_remote: "0" })), "local");
56
+ assert.equal(placement(parseExecution({ mode: "hybrid", max_remote: "3" })), "local");
57
+ assert.equal(placement(parseExecution({ mode: "remote", max_remote: "3" })), "remote");
58
+ assert.equal(placement(undefined), "local");
59
+ });
60
+
61
+ it("readExecution reads the forge.yaml block; absent section is undefined", () => {
62
+ const dir = tmpDir("exec-cfg-");
63
+ writeFileSync(join(dir, "forge.yaml"), "project:\n name: x\n");
64
+ assert.equal(readExecution(dir), undefined);
65
+ writeFileSync(join(dir, "forge.yaml"), "execution:\n mode: remote\n max_remote: 5\n provider: container\n image: node:22\n secrets: GH_TOKEN\n");
66
+ assert.deepEqual(readExecution(dir), { mode: "remote", maxRemote: 5, provider: "container", image: "node:22", secrets: ["GH_TOKEN"], budget: NO_BUDGET });
67
+ });
68
+
69
+ it("scopedEnv forwards only the batch id, the target repo and allowlisted secret names", () => {
70
+ const env = { GH_TOKEN: "t0k", AWS_SECRET_ACCESS_KEY: "nope", HOME: "/home/x", EMPTY: "" };
71
+ assert.deepEqual(scopedEnv({ env, secrets: ["GH_TOKEN", "EMPTY", "MISSING"], batchId: "bt", repo: "o/r" }), { FORGEDOCK_BATCH_ID: "bt", GH_REPO: "o/r", GH_TOKEN: "t0k" });
72
+ });
73
+
74
+ it("an unknown provider is a typed error", () => {
75
+ assert.throws(() => providerByName("k8s", {}), (e) => e.code === "execution.provider.unknown" && /k8s/.test(e.message));
76
+ });
77
+ });
78
+
79
+ describe("providerLanes: sandbox ledger and teardown on every exit path (#4253)", () => {
80
+ const run = async ({ finish, startThrows, teardownOk, pollMs = 1 } = {}) => {
81
+ const root = tmpDir("exec-ledger-");
82
+ const ledgerFor = ledgerIn(root);
83
+ const prov = fakeProvider({ finish, startThrows, teardownOk, ledger: ledgerFor(7) });
84
+ let ticks = 0;
85
+ const lanes = providerLanes({ provider: prov, laneArgs: () => ["--repo", "o/r"], ledgerFor, pollMs, env: { GH_TOKEN: "x" }, secrets: ["GH_TOKEN"], now: () => 1000,
86
+ sleep: async () => { if (++ticks === 2 && !finish) { prov.results[7] = { terminalReason: "merged", pr: 9 }; prov.live.delete(7); } } });
87
+ const started = [];
88
+ const r = await lanes.runOne(7, "staging", { onStart: (i) => started.push(i) });
89
+ return { r, prov, started, led: ledgerFor(7) };
90
+ };
91
+
92
+ it("success: registered before start, result reaches the batch, torn down and released", async () => {
93
+ const { r, prov, started, led } = await run();
94
+ assert.deepEqual(prov.ledgerAtStart, ["fake-7"]); // registered BEFORE the sandbox was created
95
+ assert.deepEqual(r, { terminalReason: "merged", pr: 9, sandbox: { provider: "fake", id: "fake-7", minutes: 0, cost_usd: 0 } });
96
+ assert.deepEqual(started, [{ handle: { provider: "fake", id: "fake-7", issue: 7, startedAt: 1000 }, remote: true }]);
97
+ assert.ok(prov.calls.some((c) => c[0] === "teardown" && c[1] === "fake-7"));
98
+ assert.equal(led.read(), null); // released and the empty ledger removed
99
+ });
100
+
101
+ it("failure result: torn down and released", async () => {
102
+ const { r, prov, led } = await run({ finish: () => ({ terminalReason: "needs-human", detail: "ci.red" }) });
103
+ assert.equal(r.terminalReason, "needs-human");
104
+ assert.ok(prov.calls.some((c) => c[0] === "teardown"));
105
+ assert.equal(led.read(), null);
106
+ });
107
+
108
+ it("sandbox exited without a result: engine-error row, still torn down", async () => {
109
+ const { r, prov, led } = await run({ finish: () => null });
110
+ assert.equal(r.terminalReason, "engine-error");
111
+ assert.match(r.detail, /fake-7 exited without a result/);
112
+ assert.ok(prov.calls.some((c) => c[0] === "teardown"));
113
+ assert.equal(led.read(), null);
114
+ });
115
+
116
+ it("start throws: typed needs-human sandbox.start_failed row (#4254), teardown still runs", async () => {
117
+ const { r, prov, led } = await run({ startThrows: true });
118
+ assert.equal(r.terminalReason, "needs-human");
119
+ assert.equal(r.code, "sandbox.start_failed");
120
+ assert.match(r.detail, /boom/);
121
+ assert.ok(prov.calls.some((c) => c[0] === "teardown"));
122
+ assert.equal(led.read(), null);
123
+ });
124
+
125
+ it("a teardown that fails keeps the sandbox listed for the reaper", async () => {
126
+ const { led } = await run({ finish: () => ({ terminalReason: "merged" }), teardownOk: false });
127
+ assert.deepEqual(led.entries("sandbox").map((e) => [e.value, e.provider]), [["fake-7", "fake"]]);
128
+ });
129
+
130
+ it("adopt after a restart: re-registers under this dispatcher, waits, tears down", async () => {
131
+ const root = tmpDir("exec-adopt-");
132
+ makeLedger({ root, batch: "bt", lane: "sandbox-7", owner: "orch_dead", issue: 7, host: HOST, pid: 999999 }).register("sandbox", "fake-7", { provider: "fake" });
133
+ const ledgerFor = ledgerIn(root);
134
+ const prov = fakeProvider();
135
+ prov.live.add(7);
136
+ const lanes = providerLanes({ provider: prov, laneArgs: () => [], ledgerFor, pollMs: 1, sleep: async () => { prov.results[7] = { terminalReason: "merged", pr: 3 }; prov.live.delete(7); } });
137
+ const r = await lanes.adopt(7, { pid: undefined, remote: true, handle: { provider: "fake", id: "fake-7", issue: 7 } });
138
+ assert.deepEqual(r, { terminalReason: "merged", pr: 3, sandbox: { provider: "fake", id: "fake-7", minutes: 0, cost_usd: 0 } });
139
+ assert.ok(prov.calls.some((c) => c[0] === "teardown"));
140
+ assert.equal(ledgerFor(7).read(), null);
141
+ // Nothing to adopt (no handle): null, so orchestrate relaunches.
142
+ assert.equal(await lanes.adopt(8, { pid: 5 }), null);
143
+ });
144
+
145
+ it("a sandbox an earlier attempt left behind is torn down before the relaunch", async () => {
146
+ const root = tmpDir("exec-relaunch-");
147
+ const ledgerFor = ledgerIn(root);
148
+ ledgerFor(7).register("sandbox", "fake-7", { provider: "fake" });
149
+ const prov = fakeProvider({ finish: () => ({ terminalReason: "merged" }) });
150
+ await providerLanes({ provider: prov, laneArgs: () => [], ledgerFor, pollMs: 1 }).runOne(7, null);
151
+ assert.deepEqual(prov.calls.map((c) => c[0]).slice(0, 2), ["teardown", "start"]);
152
+ });
153
+ });
154
+
155
+ describe("runner kill: a sandbox left by a dead dispatcher is reaped by gc --apply (#4253)", () => {
156
+ it("lists the stale sandbox, removes the container, releases the ledger; a live one is only reported", async () => {
157
+ const home = tmpDir("exec-gc-");
158
+ const root = join(home, ".forge", "ledger");
159
+ mkdirSync(join(home, ".forge", "tmp"), { recursive: true });
160
+ const dead = makeLedger({ root, batch: "bt", lane: "sandbox-7", owner: "orch_1", issue: 7, host: HOST, pid: 424242 });
161
+ dead.register("sandbox", "fdsbx-bt-7", { provider: "container", handle: { provider: "container", id: "fdsbx-bt-7", issue: 7 } });
162
+ const live = makeLedger({ root, batch: "bt", lane: "sandbox-8", owner: "orch_2", issue: 8, host: HOST, pid: 111 });
163
+ live.register("sandbox", "fdsbx-bt-8", { provider: "container" });
164
+ const docker = [];
165
+ const probes = { cwd: home, host: HOST, ledgerRoot: root, forgeTmpRoot: join(home, ".forge", "tmp"), worktreeRoot: join(home, "wt"), fs: undefined,
166
+ alive: (pid) => pid === 111, ppid: () => 0, procsInDir: () => [], dirSize: () => 0,
167
+ docker: async (args) => { docker.push(args.join(" ")); if (args[0] === "ps") return [JSON.stringify({ Names: "fdsbx-bt-8", Labels: "forgedock.owner=1,forgedock.expires=1", CreatedAt: "2020-01-01 00:00:00 +0000 UTC", Size: "0B" })].join("\n"); return ""; },
168
+ git: async () => "", gh: async () => "[]" };
169
+ delete probes.fs;
170
+ const plan = await planGc({ probes });
171
+ const sb = plan.items.filter((i) => i.type === "sandbox");
172
+ assert.deepEqual(sb.map((i) => [i.id, i.action]).sort(), [["fdsbx-bt-7", "reclaim"], ["fdsbx-bt-8", "report"]]);
173
+ // The live sandbox's container is not reclaimed by the labelled-container pass even though its label expired.
174
+ assert.equal(plan.items.some((i) => i.type === "container" && i.id === "fdsbx-bt-8"), false);
175
+ const res = await applyGc(plan, probes);
176
+ assert.deepEqual(res.reclaimed.map((i) => i.id), ["fdsbx-bt-7"]);
177
+ assert.ok(docker.includes("rm -f fdsbx-bt-7"));
178
+ assert.equal(docker.some((d) => d.includes("fdsbx-bt-8") && d.startsWith("rm")), false);
179
+ assert.equal(existsSync(dead.path), false);
180
+ assert.equal(live.entries("sandbox").length, 1);
181
+ });
182
+
183
+ it("an unknown provider is reported, never reclaimed", async () => {
184
+ const home = tmpDir("exec-gc-unk-");
185
+ const root = join(home, ".forge", "ledger");
186
+ makeLedger({ root, batch: "bt", lane: "sandbox-9", owner: "o", issue: 9, host: HOST, pid: 424243 }).register("sandbox", "x-9", { provider: "k8s" });
187
+ const probes = { cwd: home, host: HOST, ledgerRoot: root, forgeTmpRoot: join(home, "t"), worktreeRoot: join(home, "wt"), alive: () => false, ppid: () => 0,
188
+ procsInDir: () => [], docker: async () => "", git: async () => "", gh: async () => "[]" };
189
+ const plan = await planGc({ probes, scopes: new Set(["sandboxes"]) });
190
+ assert.deepEqual(plan.items.map((i) => [i.type, i.action, i.reason]), [["sandbox", "report", "unknown sandbox provider k8s"]]);
191
+ });
192
+
193
+ it("a sandbox-only ledger file is not a live lane on this host", () => {
194
+ const root = tmpDir("exec-count-");
195
+ makeLedger({ root, batch: "bt", lane: "sandbox-1", owner: "o", issue: 1, host: HOST, pid: process.pid }).register("sandbox", "s-1", { provider: "container" });
196
+ makeLedger({ root, batch: "bt", lane: "lean-2", owner: "o", issue: 2, host: HOST, pid: process.pid }).register("worktree", "/tmp/wt-2");
197
+ assert.equal(countLiveLanes({ root, host: HOST }), 1);
198
+ });
199
+ });
200
+
201
+ describe("container provider (#4253)", () => {
202
+ it("docker run: --rm --pull never, ownership labels, read-only git and cache mounts, secret names only", async () => {
203
+ const work = tmpDir("exec-ctr-");
204
+ const cache = join(work, "cache"); mkdirSync(cache);
205
+ const batch = openBatch(join(work, "batches"), "bt");
206
+ const calls = [];
207
+ const docker = async (args, o = {}) => { calls.push({ args, env: o.env }); return "cid\n"; };
208
+ const p = containerProvider({ batch, repo: "o/r", image: "node:22", gitDir: "/repo/.git", cacheDir: cache, docker, user: "1000:1000", now: () => 5, ttlMs: 10 });
209
+ const h = await p.start({ issue: 7, args: ["--repo", "o/r"], env: { GH_TOKEN: "s3cret", AWS_KEY: "leak" }, secrets: ["GH_TOKEN"] });
210
+ assert.deepEqual(h, { provider: "container", id: "fdsbx-bt-7", issue: 7 });
211
+ const a = calls[0].args, s = a.join(" ");
212
+ assert.deepEqual(a.slice(0, 7), ["run", "-d", "--rm", "--pull", "never", "--name", "fdsbx-bt-7"]);
213
+ for (const l of ["forgedock.owner=1", "forgedock.batch=bt", "forgedock.lane=sandbox-7", "forgedock.issue=7", "forgedock.expires=15", "forgedock.sandbox=1"]) assert.ok(s.includes(`--label ${l}`), l);
214
+ assert.ok(s.includes("-v /repo/.git:/src:ro"));
215
+ assert.ok(s.includes(`-v ${cache}:/cache:ro`));
216
+ assert.ok(s.includes(`-v ${join(work, "batches", "bt", "lanes")}:/result`));
217
+ assert.ok(s.includes("-e GH_TOKEN") && s.includes("-e GH_REPO") && s.includes("-e FORGEDOCK_BATCH_ID"));
218
+ assert.equal(s.includes("s3cret"), false, "secret values never in argv");
219
+ assert.equal(s.includes("AWS_KEY") || s.includes("leak"), false, "non-allowlisted env never forwarded");
220
+ assert.equal(calls[0].env.GH_TOKEN, "s3cret");
221
+ assert.equal(calls[0].env.AWS_KEY, undefined);
222
+ assert.ok(a.includes("node:22") && a.includes("run-issue") && a.at(-1) === "/result/7.json");
223
+ assert.equal(JSON.stringify(h).includes("s3cret"), false);
224
+ });
225
+
226
+ it("status maps docker states; teardown treats a gone container as torn down", async () => {
227
+ const batch = openBatch(tmpDir("exec-ctr2-"), "bt");
228
+ let state = "running", rmErr = null;
229
+ const docker = async (args) => {
230
+ if (args[0] === "inspect") { if (state === null) throw new Error("Error: No such object"); return `${state}\n`; }
231
+ if (args[0] === "rm") { if (rmErr) { const e = new Error("x"); e.stderr = rmErr; throw e; } return ""; }
232
+ return "";
233
+ };
234
+ const p = containerProvider({ batch, image: "i", gitDir: "/g", docker });
235
+ const h = { provider: "container", id: "fdsbx-bt-1", issue: 1 };
236
+ assert.equal(await p.status(h), "running");
237
+ state = "exited"; assert.equal(await p.status(h), "exited");
238
+ state = null; assert.equal(await p.status(h), "gone");
239
+ assert.equal(await p.teardown(h), true);
240
+ rmErr = "Error response from daemon: No such container: fdsbx-bt-1"; assert.equal(await p.teardown(h), true);
241
+ rmErr = "removal of container fdsbx-bt-1 is already in progress"; state = null; assert.equal(await p.teardown(h), true);
242
+ rmErr = "Cannot connect to the Docker daemon"; state = "running"; assert.equal(await p.teardown(h), false);
243
+ await assert.rejects(containerProvider({ batch, gitDir: "/g", docker }).start({ issue: 1 }), /execution.image/);
244
+ });
245
+ });
246
+
247
+ describe("max_remote admission (#4253)", () => {
248
+ it("remote lanes run up to max_remote, never take a host registry slot, the rest wait", async () => {
249
+ let live = 0, peak = 0;
250
+ const gates = new Map();
251
+ const acquired = [];
252
+ const registry = { tryAcquire: (k) => { acquired.push(k); return { ok: false, reason: "host cap 1 reached" }; }, offer: () => {}, withdraw: () => {}, release: () => {}, setLanePid: () => {}, adoptSlot: () => {} };
253
+ const runOne = (n) => new Promise((res) => { live++; peak = Math.max(peak, live); gates.set(n, () => { live--; res({ terminalReason: "merged", pr: n }); }); });
254
+ const done = orchestrate({ issues: [1, 2, 3], parallel: 5, runOne, readBody: async () => "", sched: { registry, batch: "bt", repo: "o/r", cap: 1 }, priorityOf: async () => 2,
255
+ remote: { max: 2, isRemote: () => true }, pollMs: 1, sleep: (ms) => new Promise((r) => setTimeout(r, Math.min(ms ?? 1, 5))) });
256
+ const until = async (c) => { for (let i = 0; i < 400 && !c(); i++) await new Promise((r) => setTimeout(r, 5)); assert.ok(c()); };
257
+ await until(() => gates.size === 2);
258
+ assert.deepEqual([...gates.keys()], [1, 2]);
259
+ await new Promise((r) => setTimeout(r, 30));
260
+ assert.equal(gates.size, 2, "the third waits for a remote slot");
261
+ gates.get(1)();
262
+ await until(() => gates.has(3));
263
+ gates.get(2)(); gates.get(3)();
264
+ const rows = await done;
265
+ assert.deepEqual(rows.map((r) => r.outcome), ["merged", "merged", "merged"]);
266
+ assert.equal(peak, 2);
267
+ assert.deepEqual(acquired, [], "the host cap was never consulted");
268
+ });
269
+
270
+ it("a resumed batch re-counts adopted remote lanes against max_remote", async () => {
271
+ let state = { v: 1, order: [1, 2], deps: { 1: [], 2: [] }, lanes: {}, crossSeen: [], results: {}, running: { 1: { startedAt: 1, remote: true, handle: { provider: "fake", id: "fake-1", issue: 1 } } }, pending: [2], notBefore: {}, requeues: {}, deferrals: {}, engineErrors: {}, prefilter: {}, errorTimes: [], breaker: null };
272
+ const store = { load: () => state, save: (s) => { state = s; }, log: () => {} };
273
+ const order = [];
274
+ const rows = await orchestrate({ issues: [], parallel: 5, store, readBody: async () => "", remote: { max: 1, isRemote: () => true },
275
+ adopt: async (n) => { order.push(`adopt${n}`); return { terminalReason: "merged", pr: 10 + n }; },
276
+ runOne: async (n) => { order.push(`run${n}`); return { terminalReason: "merged", pr: 20 + n }; } });
277
+ assert.deepEqual(rows.map((r) => [r.issue, r.pr]), [[1, 11], [2, 22]]);
278
+ assert.deepEqual(order, ["adopt1", "run2"]);
279
+ });
280
+ });
281
+
282
+ describe("runBatch wiring (#4253)", () => {
283
+ const leanIo = { gh: async (a) => a.includes("milestone") ? JSON.stringify({ milestone: null }) : a[0] === "repo" ? "main\n" : JSON.stringify({ body: "" }),
284
+ git: async (a) => (a[0] === "ls-remote" ? "abc\trefs/heads/staging\n" : a[0] === "rev-parse" ? ".git\n" : "") };
285
+ const base = (work, extra) => ({ batchRoot: join(work, "batches"), allowTmpfs: true, pollMs: 5, orchestration: { breaker: null, maxDeferrals: 3 }, io: { gh: async () => "o/r\n" }, leanIo, sched: null, maintenance: null,
286
+ executionBudget: openExecutionBudget({ file: join(work, "execution-budget.json") }), emitTelemetry: () => {}, ...extra });
287
+
288
+ it("execution remote: lanes go through the provider with a sandbox ledger; result reaches the batch", async () => {
289
+ const work = tmpDir("exec-cli-");
290
+ const ledRoot = join(work, "ledger");
291
+ const prov = fakeProvider({ finish: (n) => ({ terminalReason: "merged", pr: 100 + n, cost_usd: 0 }) });
292
+ const res = await quiet(() => orchestrateFromCli(["1", "2", "--repo", "o/r", "--no-prefilter"], base(work, { batchId: "be1",
293
+ execution: parseExecution({ mode: "remote", max_remote: "2", secrets: "GH_TOKEN" }), sandboxProvider: prov,
294
+ sandboxLedger: (n) => makeLedger({ root: ledRoot, batch: "be1", lane: `sandbox-${n}`, owner: "t", issue: n }) })));
295
+ assert.deepEqual(res.rows.map((r) => [r.issue, r.outcome, r.pr]), [[1, "merged", 101], [2, "merged", 102]]);
296
+ assert.deepEqual(prov.calls.filter((c) => c[0] === "start").map((c) => c[1]), [1, 2]);
297
+ assert.deepEqual(prov.secrets, ["GH_TOKEN"]);
298
+ assert.equal(existsSync(join(ledRoot, "be1", "sandbox-1.json")), false); // torn down and released
299
+ });
300
+
301
+ it("execution absent or local: today's detached lanes (the provider is never used)", async () => {
302
+ const work = tmpDir("exec-cli-local-");
303
+ const stub = join(work, "stub.mjs");
304
+ writeFileSync(stub, `import { writeFileSync } from "node:fs"; const a = process.argv.slice(2); writeFileSync(a[a.indexOf("--result-file") + 1], JSON.stringify({ terminalReason: "merged", pr: 7 }));\n`);
305
+ for (const execution of [undefined, parseExecution({ mode: "local", max_remote: "4" }), parseExecution({ mode: "hybrid", max_remote: "0" })]) {
306
+ const prov = fakeProvider();
307
+ const res = await quiet(() => orchestrateFromCli(["1", "--repo", "o/r", "--no-prefilter"], base(work, { batchId: `bl-${execution?.mode ?? "absent"}`, laneScript: stub, execution, sandboxProvider: prov })));
308
+ assert.deepEqual(res.rows.map((r) => [r.issue, r.outcome, r.pr]), [[1, "merged", 7]]);
309
+ assert.deepEqual(prov.calls, []);
310
+ }
311
+ });
312
+ });
313
+
314
+ describe("execution budget and sandbox cost (#4255)", () => {
315
+ const leanIo = { gh: async (x) => x.includes("milestone") ? JSON.stringify({ milestone: null }) : x[0] === "repo" ? "main\n" : JSON.stringify({ body: "" }),
316
+ git: async (x) => (x[0] === "ls-remote" ? "abc\trefs/heads/staging\n" : x[0] === "rev-parse" ? ".git\n" : "") };
317
+ const base = (work, extra) => ({ batchRoot: join(work, "batches"), allowTmpfs: true, pollMs: 5, orchestration: { breaker: null, maxDeferrals: 3 }, io: { gh: async () => "o/r\n" }, leanIo, sched: null, maintenance: null, ...extra });
318
+ const sbx = (minutes, cost_usd = 0) => ({ provider: "fake", id: "x", minutes, cost_usd });
319
+ // Remote lanes that each report `minutes` of sandbox time; `ctxs` records ctx.remote per launch.
320
+ const budgetRun = ({ issues = [1, 2, 3, 4], budget, onBudget = "stop", file, store = null, minutes = 10, ctxs = [] }) => {
321
+ const b = openExecutionBudget({ budget, file, now: () => 0 });
322
+ return orchestrate({ issues, parallel: 1, store, readBody: async () => "", now: () => 0,
323
+ remote: { max: 1, isRemote: () => true, admit: (u) => b.admit(u), onBudget, onUsage: (n, sb) => b.record(`bt#${n}#${ctxs.length}`, sb) },
324
+ runOne: async (n, _l, ctx) => { ctxs.push([n, ctx.remote]); return { terminalReason: "merged", pr: n, cost_usd: 1, ...(ctx.remote ? { sandbox: sbx(minutes, 0.5) } : {}) }; } });
325
+ };
326
+
327
+ it("parseExecution reads the flat budget keys; invalid caps are no cap", () => {
328
+ assert.deepEqual(parseExecution({ mode: "remote", max_remote: "2", budget_minutes: "30", budget_usd: "2.5", day_budget_minutes: "x", day_budget_usd: "-1", on_budget: "local", cost_per_minute: "0.05" }).budget,
329
+ { batchMinutes: 30, batchUsd: 2.5, dayMinutes: null, dayUsd: null, onBudget: "local", costPerMinute: 0.05 });
330
+ assert.equal(parseExecution({ on_budget: "fallback" }).budget.onBudget, "stop");
331
+ });
332
+
333
+ it("providerLanes attaches sandbox usage: from the provider, else wall clock x cost_per_minute; adopt measures from the persisted start", async () => {
334
+ const root = tmpDir("exec-usage-");
335
+ let t = 0;
336
+ const now = () => t;
337
+ const finish = () => ({ terminalReason: "merged", pr: 1 });
338
+ const reported = fakeProvider({ finish, usage: async () => ({ minutes: 12.34, cost_usd: 0.5 }) });
339
+ const r1 = await providerLanes({ provider: reported, laneArgs: () => [], ledgerFor: ledgerIn(root), pollMs: 1, now }).runOne(1, null);
340
+ assert.deepEqual(r1.sandbox, { provider: "fake", id: "fake-1", minutes: 12.3, cost_usd: 0.5 });
341
+ const wall = fakeProvider({ finish: () => { t += 10 * 60000; return finish(); } });
342
+ const r2 = await providerLanes({ provider: wall, laneArgs: () => [], ledgerFor: ledgerIn(root), pollMs: 1, now, costPerMinute: 0.1 }).runOne(2, null);
343
+ assert.deepEqual(r2.sandbox, { provider: "fake", id: "fake-2", minutes: 10, cost_usd: 1 });
344
+ const throws = fakeProvider({ finish, usage: async () => { throw new Error("api down"); } });
345
+ assert.equal((await providerLanes({ provider: throws, laneArgs: () => [], ledgerFor: ledgerIn(root), pollMs: 1, now }).runOne(3, null)).sandbox.minutes, 0);
346
+ const adopted = fakeProvider();
347
+ adopted.results[4] = { terminalReason: "merged", pr: 4 };
348
+ t = 30 * 60000;
349
+ const r4 = await providerLanes({ provider: adopted, laneArgs: () => [], ledgerFor: ledgerIn(root), pollMs: 1, now, costPerMinute: 0.1 })
350
+ .adopt(4, { startedAt: 0, remote: true, handle: { provider: "fake", id: "fake-4", issue: 4, startedAt: 6 * 60000 } });
351
+ assert.deepEqual(r4.sandbox, { provider: "fake", id: "fake-4", minutes: 24, cost_usd: 2.4 });
352
+ });
353
+
354
+ it("billable minutes a provider puts on the handle (#4254) are the lane's minutes, priced by cost_per_minute; a sandbox that never started has no usage", async () => {
355
+ const root = tmpDir("exec-hmin-");
356
+ const prov = fakeProvider({ finish: () => ({ terminalReason: "merged" }) });
357
+ const start = prov.start;
358
+ prov.start = async (o) => ({ ...(await start(o)), minutes: 7.5 });
359
+ const r = await providerLanes({ provider: prov, laneArgs: () => [], ledgerFor: ledgerIn(root), pollMs: 1, costPerMinute: 0.2 }).runOne(1, null);
360
+ assert.deepEqual(r.sandbox, { provider: "fake", id: "fake-1", minutes: 7.5, cost_usd: 1.5 });
361
+ const failed = await providerLanes({ provider: fakeProvider({ startThrows: true }), laneArgs: () => [], ledgerFor: ledgerIn(root), pollMs: 1 }).runOne(2, null);
362
+ assert.equal(failed.sandbox, undefined);
363
+ });
364
+
365
+ it("a batch that hits the budget admits no further remote lanes; on_budget stop holds the rest (fake provider)", async () => {
366
+ const ctxs = [];
367
+ const rows = await budgetRun({ budget: { batchMinutes: 20 }, file: join(tmpDir("exec-bud-"), "b.json"), ctxs });
368
+ assert.deepEqual(ctxs, [[1, true], [2, true]], "the third lane is never started remote");
369
+ assert.deepEqual(rows.map((r) => r.outcome), ["merged", "merged", "held", "held"]);
370
+ assert.match(rows[2].detail, /^execution budget: batch sandbox minutes 20 >= budget_minutes 20/);
371
+ assert.deepEqual(rows[0].sandbox, { minutes: 10, cost_usd: 0.5, attempts: 1 });
372
+ });
373
+
374
+ it("the USD cap through providerLanes x orchestrate: no remote start past budget_usd", async () => {
375
+ const root = tmpDir("exec-bud-usd-");
376
+ const prov = fakeProvider({ finish: () => ({ terminalReason: "merged" }), usage: async () => ({ minutes: 1, cost_usd: 0.75 }) });
377
+ const lanes = providerLanes({ provider: prov, laneArgs: () => [], ledgerFor: ledgerIn(root), pollMs: 1 });
378
+ const b = openExecutionBudget({ budget: { batchUsd: 1.5 }, file: join(root, "b.json") });
379
+ const rows = await orchestrate({ issues: [1, 2, 3], parallel: 1, readBody: async () => "", ...lanes, remote: { max: 2, isRemote: () => true, admit: (u) => b.admit(u), onBudget: "stop" } });
380
+ assert.deepEqual(prov.calls.filter((c) => c[0] === "start").map((c) => c[1]), [1, 2]);
381
+ assert.deepEqual(rows.map((r) => r.outcome), ["merged", "merged", "held"]);
382
+ });
383
+
384
+ it("on_budget local: once the cap is hit the rest run as local lanes (ctx.remote false)", async () => {
385
+ const ctxs = [];
386
+ const rows = await budgetRun({ budget: { batchMinutes: 20 }, onBudget: "local", file: join(tmpDir("exec-bud-l-"), "b.json"), ctxs });
387
+ assert.deepEqual(ctxs, [[1, true], [2, true], [3, false], [4, false]]);
388
+ assert.deepEqual(rows.map((r) => r.outcome), ["merged", "merged", "merged", "merged"]);
389
+ assert.equal(rows[3].sandbox, undefined);
390
+ });
391
+
392
+ it("budget state survives --resume: a resumed batch at the cap starts no remote lane", async () => {
393
+ let state = null;
394
+ const store = { load: () => state, save: (s) => { state = JSON.parse(JSON.stringify(s)); }, log: () => {} };
395
+ const file = join(tmpDir("exec-bud-r-"), "b.json");
396
+ await budgetRun({ budget: { batchMinutes: 20 }, file, store });
397
+ assert.deepEqual(state.sandbox, { 1: { minutes: 10, cost_usd: 0.5, attempts: 1 }, 2: { minutes: 10, cost_usd: 0.5, attempts: 1 } });
398
+ const ctxs = [];
399
+ const rows = await budgetRun({ issues: [], budget: { batchMinutes: 20 }, file: join(tmpDir("exec-bud-r2-"), "fresh.json"), store, ctxs });
400
+ assert.deepEqual(ctxs, [], "the batch total came from the saved state, not the host file");
401
+ assert.deepEqual(rows.map((r) => r.outcome), ["merged", "merged", "held", "held"]);
402
+ // A pre-#4255 state (no sandbox key) resumes as zero usage.
403
+ const legacy = { v: 1, order: [5], deps: { 5: [] }, lanes: {}, crossSeen: [], results: {}, running: {}, pending: [5], notBefore: {}, requeues: {}, deferrals: {}, engineErrors: {}, prefilter: {}, errorTimes: [], breaker: null };
404
+ const ctx2 = [];
405
+ const r2 = await budgetRun({ issues: [], budget: { batchMinutes: 20 }, file, store: { load: () => legacy, save: () => {}, log: () => {} }, ctxs: ctx2 });
406
+ assert.deepEqual(ctx2, [[5, true]]);
407
+ assert.equal(r2[0].outcome, "merged");
408
+ });
409
+
410
+ it("host-day budget survives a schedd restart: totals reload from the file, a repeated record is not double-counted", () => {
411
+ const file = join(tmpDir("exec-day-"), "sched", "execution-budget.json");
412
+ const now = () => Date.parse("2026-10-20T10:00:00Z");
413
+ const a = openExecutionBudget({ budget: { dayMinutes: 15 }, file, now });
414
+ assert.equal(a.record("b1#fdsbx-1#100", { minutes: 10, cost_usd: 1 }), true);
415
+ assert.equal(a.record("b1#fdsbx-1#100", { minutes: 10, cost_usd: 1 }), false);
416
+ const b = openExecutionBudget({ budget: { dayMinutes: 15 }, file, now }); // a fresh daemon
417
+ assert.deepEqual(b.dayTotals(), { ok: true, day: "2026-10-20", minutes: 10, cost_usd: 1 });
418
+ assert.equal(b.admit({ minutes: 0, cost_usd: 0, running: { minutes: 0, cost_usd: 0 } }).ok, true);
419
+ assert.match(b.admit({ minutes: 5, cost_usd: 0, running: { minutes: 5, cost_usd: 0 } }).reason, /day_budget_minutes 15/);
420
+ // The next UTC day starts from zero.
421
+ assert.equal(openExecutionBudget({ budget: { dayMinutes: 15 }, file, now: () => Date.parse("2026-10-21T00:00:01Z") }).admit({}).ok, true);
422
+ // A corrupt store fails closed only while a day cap is set, and is never overwritten.
423
+ writeFileSync(file, "{not json");
424
+ assert.match(openExecutionBudget({ budget: { dayUsd: 5 }, file, now }).admit({}).reason, /unreadable/);
425
+ assert.equal(openExecutionBudget({ budget: { batchMinutes: 50 }, file, now }).admit({}).ok, true);
426
+ assert.equal(openExecutionBudget({ budget: {}, file, now }).record("k", { minutes: 1 }), false);
427
+ assert.equal(readFileSync(file, "utf8"), "{not json");
428
+ });
429
+
430
+ it("report: sandbox columns next to token cost only when a lane used a sandbox; summary line", () => {
431
+ const local = [{ issue: 1, outcome: "merged", pr: 2, cost_usd: 1.5, minutes: 3 }];
432
+ assert.doesNotMatch(formatTable(local), /sbx/);
433
+ assert.equal(formatSandboxSummary(local), null);
434
+ const rows = [{ issue: 1, outcome: "merged", pr: 2, cost_usd: 1.5, minutes: 3, sandbox: { minutes: 12.5, cost_usd: 0.25, attempts: 1 } },
435
+ { issue: 2, outcome: "held", pr: null, cost_usd: 0, minutes: 0, detail: "execution budget: batch sandbox minutes 12.5 >= budget_minutes 10" }];
436
+ const t = formatTable(rows);
437
+ assert.match(t.split("\n")[0], /cost\s+sbx-min\s+sbx-\$\s+min/);
438
+ assert.match(t, /#1\s+merged\s+#2\s+\$1\.50\s+12\.5\s+\$0\.25\s+3/);
439
+ assert.equal(formatSandboxSummary(rows, { budget: { batchMinutes: 10, batchUsd: null, dayMinutes: null, dayUsd: 20 }, day: { ok: true, minutes: 12.5, cost_usd: 0.25 } }),
440
+ "sandbox: 1 lane, 12.5 min, $0.25 (token cost $1.50); budget: batch 10 min, day $20; host today 12.5 min, $0.25; cap reached, 1 held");
441
+ });
442
+
443
+ it("runBatch: rows, report.json and FORGE:TELEMETRY carry each lane's sandbox usage; the host-day store records it", async () => {
444
+ const work = tmpDir("exec-cli-cost-");
445
+ const ledRoot = join(work, "ledger");
446
+ const prov = fakeProvider({ finish: (n) => ({ terminalReason: "merged", pr: 100 + n, cost_usd: 2 }), usage: async (h) => ({ minutes: 5 * h.issue, cost_usd: 0.1 * h.issue }) });
447
+ const events = [];
448
+ const file = join(work, "day.json");
449
+ const res = await quiet(() => orchestrateFromCli(["1", "2", "3", "--repo", "o/r", "--no-prefilter"], base(work, { batchId: "bc1",
450
+ execution: parseExecution({ mode: "remote", max_remote: "1", budget_minutes: "15" }), sandboxProvider: prov,
451
+ sandboxLedger: (n) => makeLedger({ root: ledRoot, batch: "bc1", lane: `sandbox-${n}`, owner: "t", issue: n }),
452
+ executionBudget: openExecutionBudget({ budget: parseExecution({ budget_minutes: "15" }).budget, file }), emitTelemetry: (rec) => { events.push(rec); } })));
453
+ assert.deepEqual(res.rows.map((r) => [r.issue, r.outcome, r.sandbox?.minutes ?? null]), [[1, "merged", 5], [2, "merged", 10], [3, "held", null]]);
454
+ const rep = openBatch(join(work, "batches"), "bc1").readReport();
455
+ assert.deepEqual(rep.rows[1].sandbox, { minutes: 10, cost_usd: 0.2, attempts: 1 });
456
+ assert.match(rep.report, /sandbox: 2 lanes, 15\.0 min, \$0\.30 \(token cost \$4\.00\); budget: batch 15 min/);
457
+ assert.match(rep.report, /sbx-min/);
458
+ assert.deepEqual(events.filter((e) => e.event === "SANDBOX_USAGE").map((e) => [e.issue, e.minutes, e.cost_usd, e.batch]), [[1, 5, 0.1, "bc1"], [2, 10, 0.2, "bc1"]]);
459
+ assert.equal(Object.keys(JSON.parse(readFileSync(file, "utf8")).days[new Date().toISOString().slice(0, 10)]).length, 2);
460
+ });
461
+ });
462
+
463
+ describe("hybrid placement (#4256)", () => {
464
+ const hy = (max = "2") => parseExecution({ mode: "hybrid", max_remote: max });
465
+ const HEAVY = { heavy: true, signals: ["tsc", "eslint"] }, LIGHT = { heavy: false, signals: [] };
466
+ const ROOMY = { ok: true, detail: "" }, BUSY = { ok: false, detail: "mem 900 MB < 4096 MB" };
467
+
468
+ it("placeLane is pure and decides from the facts in order", () => {
469
+ const cases = [
470
+ [{ exec: parseExecution({}) }, "local", "mode local"],
471
+ [{ exec: parseExecution({ mode: "remote", max_remote: "3" }), weight: LIGHT }, "remote", "mode remote"],
472
+ [{ exec: parseExecution({ mode: "remote", max_remote: "0" }) }, "local", "mode remote"],
473
+ [{ exec: hy("0"), weight: HEAVY }, "local", "max_remote 0"],
474
+ [{ exec: hy(), verifyMode: "ci", weight: HEAVY, headroom: BUSY }, "local", "verify.mode ci: validation runs on CI, not billed twice"],
475
+ [{ exec: hy(), labels: ["Documentation"], weight: HEAVY, headroom: BUSY }, "local", "docs-only lane"],
476
+ [{ exec: hy(), labels: ["type:docs"], weight: HEAVY }, "local", "docs-only lane"],
477
+ [{ exec: hy(), verifyMode: "hybrid", weight: HEAVY, headroom: ROOMY }, "remote", "heavy validation: tsc, eslint"],
478
+ [{ exec: hy(), labels: ["bug"], weight: LIGHT, headroom: BUSY }, "remote", "host without headroom: mem 900 MB < 4096 MB"],
479
+ [{ exec: hy(), weight: LIGHT, headroom: ROOMY }, "local", "light lane, host has headroom"],
480
+ [{ exec: hy(), weight: null, headroom: null }, "local", "light lane, host has headroom"],
481
+ [{}, "local", "mode local"],
482
+ [undefined, "local", "mode local"],
483
+ [{ exec: hy(), labels: "docs", weight: { heavy: true, signals: null } }, "remote", "heavy validation"],
484
+ ];
485
+ for (const [facts, where, reason] of cases) assert.deepEqual(placeLane(facts), { where, reason }, JSON.stringify(facts));
486
+ const facts = { exec: hy(), weight: HEAVY, headroom: ROOMY, labels: ["x"] };
487
+ const snap = JSON.stringify(facts);
488
+ assert.deepEqual(placeLane(facts), placeLane(facts));
489
+ assert.equal(JSON.stringify(facts), snap, "inputs are not mutated");
490
+ });
491
+
492
+ it("validationWeight reads the repo's own toolchain facts (multi-ecosystem)", async () => {
493
+ const repoWith = (files) => { const d = tmpDir("vw-"); for (const [f, t] of Object.entries(files)) writeFileSync(join(d, f), t); return d; };
494
+ assert.deepEqual(await validationWeight({ cwd: repoWith({ "README.md": "x" }) }), { heavy: false, signals: [] });
495
+ assert.deepEqual(await validationWeight({ cwd: repoWith({ "Cargo.toml": "[package]" }) }), { heavy: true, signals: ["cargo"] });
496
+ assert.deepEqual(await validationWeight({ cwd: repoWith({ "go.mod": "module x" }) }), { heavy: true, signals: ["go"] });
497
+ assert.deepEqual(await validationWeight({ cwd: repoWith({ "pyproject.toml": "[project]" }) }), { heavy: true, signals: ["python"] });
498
+ const js = await validationWeight({ cwd: repoWith({ "tsconfig.json": "{}", "package.json": JSON.stringify({ packageManager: "pnpm@10.4.1", scripts: { test: "vitest", start: "node ." } }) }) });
499
+ assert.deepEqual(js, { heavy: true, signals: ["tsc", "test script", "pnpm"] });
500
+ assert.deepEqual(await validationWeight({ cwd: repoWith({ "package.json": JSON.stringify({ scripts: { start: "node ." } }) }), precheckCommands: ["pnpm run test", "echo ok"] }),
501
+ { heavy: true, signals: ["pnpm"] });
502
+ assert.deepEqual(await validationWeight({ cwd: repoWith({}), precheckCommands: ["echo ok", "git diff --check"] }), { heavy: false, signals: [] });
503
+ });
504
+
505
+ it("hostHeadroom: memory below the admission floor plus one heavy step, or a configured load ceiling", () => {
506
+ const host = (mem, load = 1, cores = 4) => ({ memAvailableMb: () => mem, loadAvg1: () => load, cores: () => cores });
507
+ assert.deepEqual(hostHeadroom({ host: host(8000) }), { ok: true, detail: "" });
508
+ assert.deepEqual(hostHeadroom({ host: host(3000) }), { ok: false, detail: "mem 3000 MB < 4096 MB" });
509
+ assert.deepEqual(hostHeadroom({ cfg: { min_free_mem_mb: 512 }, heavyCfg: { step_mem_mb: 1024 }, host: host(3000) }), { ok: true, detail: "" });
510
+ assert.deepEqual(hostHeadroom({ cfg: { max_load_per_core: 2 }, host: host(8000, 12, 4) }), { ok: false, detail: "load 3.0/core > 2" });
511
+ assert.deepEqual(hostHeadroom({ host: host(8000, 99, 1) }), { ok: true, detail: "" }, "load is ignored without max_load_per_core");
512
+ assert.deepEqual(hostHeadroom({ host: { memAvailableMb: () => { throw new Error("x"); } } }), { ok: true, detail: "" });
513
+ assert.deepEqual(hostHeadroom({ cfg: { enabled: false }, host: host(10) }), { ok: true, detail: "" });
514
+ });
515
+
516
+ const run = (opts) => {
517
+ const ctxs = [];
518
+ const done = orchestrate({ issues: opts.issues ?? [1, 2, 3], parallel: opts.parallel ?? 5, readBody: async () => "", now: () => 0, pollMs: 1,
519
+ sleep: (ms) => new Promise((r) => setTimeout(r, Math.min(ms ?? 1, 5))), remote: opts.remote,
520
+ runOne: async (n, _l, ctx) => { ctxs.push([n, ctx.remote, ctx.placement]); if (opts.gate) await opts.gate(n); return { terminalReason: "merged", pr: n, ...(ctx.remote ? { sandbox: { provider: "fake", id: `f${n}`, minutes: 10, cost_usd: 0 } } : {}) }; } });
521
+ return { done, ctxs };
522
+ };
523
+
524
+ it("hybrid: a heavy lane goes remote, a light lane stays local; the placement reaches runOne and the row", async () => {
525
+ const place = (n) => (n === 1 ? { where: "remote", reason: "heavy validation: tsc" } : { where: "local", reason: "docs-only lane" });
526
+ const { done, ctxs } = run({ issues: [1, 2], remote: { max: 2, place, isRemote: () => true, fallbackLocal: true } });
527
+ const rows = await done;
528
+ assert.deepEqual(ctxs.sort((a, b) => a[0] - b[0]), [[1, true, { where: "remote", reason: "heavy validation: tsc" }], [2, false, { where: "local", reason: "docs-only lane" }]]);
529
+ assert.deepEqual(rows.map((r) => r.placement), [{ where: "remote", reason: "heavy validation: tsc" }, { where: "local", reason: "docs-only lane" }]);
530
+ });
531
+
532
+ it("hybrid: max_remote reached runs the next remote-placed lane locally instead of waiting", async () => {
533
+ const gates = new Map();
534
+ const gate = (n) => new Promise((r) => gates.set(n, r));
535
+ const { done, ctxs } = run({ issues: [1, 2, 3], gate, remote: { max: 1, place: () => ({ where: "remote", reason: "heavy validation: go" }), fallbackLocal: true } });
536
+ for (let i = 0; i < 200 && gates.size < 3; i++) await new Promise((r) => setTimeout(r, 5));
537
+ assert.equal(gates.size, 3, "all three run at once: one remote, two local");
538
+ for (const g of gates.values()) g();
539
+ const rows = await done;
540
+ assert.deepEqual(ctxs.map((c) => [c[0], c[1]]), [[1, true], [2, false], [3, false]]);
541
+ assert.equal(rows[1].placement.where, "local");
542
+ assert.equal(rows[1].placement.reason, "fallback to local: max_remote 1 reached (heavy validation: go)");
543
+ });
544
+
545
+ it("hybrid: an exhausted execution budget runs the rest locally even under on_budget stop", async () => {
546
+ const work = tmpDir("hy-budget-");
547
+ const b = openExecutionBudget({ budget: { batchMinutes: 10 }, file: join(work, "b.json"), now: () => 0 });
548
+ const { done, ctxs } = run({ issues: [1, 2, 3], parallel: 1, remote: { max: 1, place: () => ({ where: "remote", reason: "heavy validation" }), fallbackLocal: true, onBudget: "stop", admit: (u) => b.admit(u) } });
549
+ const rows = await done;
550
+ assert.deepEqual(rows.map((r) => r.outcome), ["merged", "merged", "merged"]);
551
+ assert.deepEqual(ctxs.map((c) => [c[0], c[1]]), [[1, true], [2, false], [3, false]]);
552
+ assert.match(rows[1].placement.reason, /^fallback to local: execution budget: batch sandbox minutes 10 >= budget_minutes 10/);
553
+ assert.match(rows[2].placement.reason, /^fallback to local: execution budget: /);
554
+ });
555
+
556
+ it("mode remote is unchanged: a full max_remote waits and a budget refusal stops", async () => {
557
+ const work = tmpDir("rm-budget-");
558
+ const b = openExecutionBudget({ budget: { batchMinutes: 10 }, file: join(work, "b.json"), now: () => 0 });
559
+ const { done, ctxs } = run({ issues: [1, 2], parallel: 1, remote: { max: 1, isRemote: () => true, onBudget: "stop", admit: (u) => b.admit(u) } });
560
+ const rows = await done;
561
+ assert.deepEqual(rows.map((r) => r.outcome), ["merged", "held"]);
562
+ assert.deepEqual(ctxs.map((c) => [c[0], c[1]]), [[1, true]]);
563
+ });
564
+
565
+ it("runBatch wiring: hybrid routes per lane and passes --placement to the local lane", async () => {
566
+ const work = tmpDir("exec-cli-hybrid-");
567
+ const stub = join(work, "stub.mjs");
568
+ writeFileSync(stub, `import { writeFileSync } from "node:fs"; const a = process.argv.slice(2); writeFileSync(a[a.indexOf("--result-file") + 1], JSON.stringify({ terminalReason: "merged", pr: 7, detail: a.slice(a.indexOf("--placement")).slice(0, 4).join("|") }));\n`);
569
+ const labels = { 1: [], 2: [{ name: "docs" }] };
570
+ const leanIo = { gh: async (a) => a.includes("milestone") ? JSON.stringify({ milestone: null }) : a[0] === "repo" ? "main\n" : JSON.stringify({ body: "", labels: labels[Number(a[2])] ?? [] }),
571
+ git: async (a) => (a[0] === "ls-remote" ? "abc\trefs/heads/staging\n" : a[0] === "rev-parse" ? ".git\n" : "") };
572
+ const prov = fakeProvider({ finish: (n) => ({ terminalReason: "merged", pr: 100 + n, cost_usd: 0 }) });
573
+ const res = await quiet(() => orchestrateFromCli(["1", "2", "--repo", "o/r", "--no-prefilter"], { batchRoot: join(work, "batches"), allowTmpfs: true, pollMs: 5,
574
+ orchestration: { breaker: null, maxDeferrals: 3 }, io: { gh: async () => "o/r\n" }, leanIo, sched: null, maintenance: null, batchId: "bh1", laneScript: stub,
575
+ executionBudget: openExecutionBudget({ file: join(work, "execution-budget.json") }), emitTelemetry: () => {},
576
+ execution: parseExecution({ mode: "hybrid", max_remote: "2" }), verify: { mode: "local" }, validationWeight: { heavy: true, signals: ["tsc"] }, hostHeadroom: () => ({ ok: true, detail: "" }),
577
+ sandboxProvider: prov, sandboxLedger: (n) => makeLedger({ root: join(work, "ledger"), batch: "bh1", lane: `sandbox-${n}`, owner: "t", issue: n }) }));
578
+ assert.deepEqual(res.rows.map((r) => [r.issue, r.outcome, r.pr]), [[1, "merged", 101], [2, "merged", 7]]);
579
+ assert.deepEqual(prov.calls.filter((c) => c[0] === "start").map((c) => c[1]), [1]);
580
+ assert.equal(res.rows[1].detail, "--placement|local|--placement-reason|docs-only lane");
581
+ assert.deepEqual(res.rows[0].placement, { where: "remote", reason: "heavy validation: tsc" });
582
+ });
583
+ });