forgedock 1.31.0 → 1.32.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/engine/phases.mjs +28 -7
- package/bin/engine-cli.mjs +79 -22
- package/bin/hooks/reread-nudge.mjs +65 -0
- package/bin/lean/batch.mjs +9 -3
- package/bin/lean/batch.test.mjs +75 -2
- package/bin/lean/context.mjs +12 -1
- package/bin/lean/context.test.mjs +18 -1
- package/bin/lean/effects.mjs +41 -4
- package/bin/lean/execution.mjs +4 -2
- package/bin/lean/lean.mjs +83 -18
- package/bin/lean/lean.test.mjs +265 -4
- package/bin/lean/lightlane.mjs +219 -0
- package/bin/lean/lightlane.test.mjs +237 -0
- package/bin/lean/orchestrate.mjs +75 -15
- package/bin/lean/precheck.test.mjs +5 -0
- package/bin/lean/prefilter.mjs +69 -8
- package/bin/lean/prefilter.test.mjs +109 -2
- package/bin/lean/prompts.mjs +3 -0
- package/bin/lean/retry.mjs +15 -1
- package/bin/lean/sched.mjs +16 -1
- package/bin/lean/sched.test.mjs +48 -0
- package/bin/lean/schedd.mjs +50 -14
- package/bin/lean/schedd.test.mjs +153 -20
- package/bin/stats/efficiency.mjs +22 -6
- package/bin/stats/efficiency.test.mjs +35 -2
- package/bin/stats/index.mjs +25 -3
- package/bin/stats/local-source.mjs +59 -5
- package/bin/stats/query.mjs +133 -3
- package/bin/stats/stats.test.mjs +181 -5
- package/docs/CONFIG.md +3 -3
- package/docs/STATS.md +13 -0
- package/docs/eval/corpus/lightlane-v1.json +55 -0
- package/docs/eval/lightlane-bench.md +91 -0
- package/package.json +1 -1
- package/scripts/bench-lightlane.mjs +286 -0
- package/scripts/refresh-bot-token.test.sh +56 -0
package/bin/lean/schedd.mjs
CHANGED
|
@@ -24,6 +24,9 @@
|
|
|
24
24
|
* Shutdown (#4454): `close()` aborts every hosted batch's `signal` (its loop admits nothing more and leaves, lanes keep
|
|
25
25
|
* running, no report is written), waits for them up to `stopMs`, and only then releases `schedd.lock` and the socket, so a
|
|
26
26
|
* daemon started next never resumes a batch this one still dispatches. `exitOnSignals` exits 128+signo after that.
|
|
27
|
+
* Exit reasons (#4503): every exit logs a line: `idle, exiting`, `exiting (<signal>|uncaught: …)`, `lost lock|socket …,
|
|
28
|
+
* exiting` (checked every `lockCheckMs`: the lock no longer holds this pid, or the socket is gone; nothing it does not own
|
|
29
|
+
* is removed), then `exited (code N)`. Taking over a dead owner's lock is logged, so a SIGKILLed daemon leaves a trace.
|
|
27
30
|
* `runBatch({argv, cwd, env, sink, keepBreaker, signal})` is injected (engine-cli `runBatch`); `sink` gets `batch(b)`, `row(r)`, `line(text)`.
|
|
28
31
|
*/
|
|
29
32
|
import { closeSync, existsSync, mkdirSync, openSync, readFileSync, rmSync, writeSync } from "node:fs";
|
|
@@ -35,6 +38,7 @@ import { batchRoot, listBatches, openBatch } from "./batch.mjs";
|
|
|
35
38
|
|
|
36
39
|
export const IDLE_MS = 10 * 60 * 1000;
|
|
37
40
|
export const STOP_MS = 10 * 1000;
|
|
41
|
+
export const LOCK_CHECK_MS = 5 * 1000;
|
|
38
42
|
|
|
39
43
|
export function scheddPaths(dir = schedDir()) {
|
|
40
44
|
return { dir, sock: join(dir, "schedd.sock"), lock: join(dir, "schedd.lock"), log: join(dir, "schedd.log") };
|
|
@@ -54,17 +58,19 @@ export function mergeEnv(base, kept) {
|
|
|
54
58
|
return { ...out, ...kept };
|
|
55
59
|
}
|
|
56
60
|
|
|
57
|
-
/** Take the daemon lock; null when another live daemon holds it. */
|
|
61
|
+
/** Take the daemon lock: `{took}` (the dead owner's pid it replaced, or null); null when another live daemon holds it. */
|
|
58
62
|
function takeLock(lock, pid, alive) {
|
|
63
|
+
let took = null;
|
|
59
64
|
for (let i = 0; i < 5; i++) {
|
|
60
65
|
try {
|
|
61
66
|
const fd = openSync(lock, "wx");
|
|
62
67
|
try { writeSync(fd, String(pid)); } finally { closeSync(fd); }
|
|
63
|
-
return
|
|
68
|
+
return { took };
|
|
64
69
|
} catch (e) { if (e?.code !== "EEXIST") throw e; }
|
|
65
70
|
let owner = NaN;
|
|
66
71
|
try { owner = parseInt(readFileSync(lock, "utf8"), 10); } catch { continue; }
|
|
67
72
|
if (Number.isInteger(owner) && owner !== pid && alive(owner)) return null;
|
|
73
|
+
if (Number.isInteger(owner) && owner !== pid) took = owner;
|
|
68
74
|
rmSync(lock, { force: true });
|
|
69
75
|
}
|
|
70
76
|
return null;
|
|
@@ -74,10 +80,13 @@ function takeLock(lock, pid, alive) {
|
|
|
74
80
|
* Start the daemon. @returns {Promise<{running:true, pid, close, closed:Promise} | {running:false, owner:number}>}
|
|
75
81
|
*/
|
|
76
82
|
export async function startDaemon({ dir = schedDir(), runBatch, idleMs = Number(process.env.FORGEDOCK_SCHEDD_IDLE_MS) || IDLE_MS,
|
|
77
|
-
root = batchRoot(), log = (m) => console.log(m), pid = process.pid, alive = pidAlive, resumeOnStart = true, stopMs = STOP_MS
|
|
83
|
+
root = batchRoot(), log = (m) => console.log(m), pid = process.pid, alive = pidAlive, resumeOnStart = true, stopMs = STOP_MS,
|
|
84
|
+
lockCheckMs = LOCK_CHECK_MS } = {}) {
|
|
78
85
|
const p = scheddPaths(dir);
|
|
79
86
|
mkdirSync(dir, { recursive: true });
|
|
80
|
-
|
|
87
|
+
const lock = takeLock(p.lock, pid, alive);
|
|
88
|
+
if (lock?.took) log(`schedd: took over the lock of dead pid ${lock.took} (it exited without a clean shutdown)`);
|
|
89
|
+
if (!lock) {
|
|
81
90
|
let owner = NaN;
|
|
82
91
|
try { owner = parseInt(readFileSync(p.lock, "utf8"), 10); } catch { /* gone */ }
|
|
83
92
|
log(`schedd: already running (pid ${owner})`);
|
|
@@ -88,7 +97,8 @@ export async function startDaemon({ dir = schedDir(), runBatch, idleMs = Number(
|
|
|
88
97
|
const hosted = new Map(); // batch id -> {id, dir, subs:Set<fn>}
|
|
89
98
|
const conns = new Set();
|
|
90
99
|
const runs = new Map(); // every runBatch in flight (hosted or not yet started) -> its AbortController (#4454)
|
|
91
|
-
|
|
100
|
+
const starting = new Map(); // batch id -> host() promise of a start-up resume not yet hosted (#4503: an attach waits for it)
|
|
101
|
+
let closing = null, resolveClosed, keepSock = false;
|
|
92
102
|
let lastActive = Date.now();
|
|
93
103
|
const touch = () => { lastActive = Date.now(); };
|
|
94
104
|
|
|
@@ -143,6 +153,7 @@ export async function startDaemon({ dir = schedDir(), runBatch, idleMs = Number(
|
|
|
143
153
|
return send({ type: "batch", id: ent.id, dir: ent.dir });
|
|
144
154
|
}
|
|
145
155
|
if (req.op === "attach") {
|
|
156
|
+
if (!hosted.has(req.id) && starting.has(req.id)) await starting.get(req.id).catch(() => {});
|
|
146
157
|
const live = hosted.get(req.id);
|
|
147
158
|
if (live) {
|
|
148
159
|
// Synchronous read + subscribe: no row is lost or doubled between the replay and the live stream.
|
|
@@ -191,6 +202,7 @@ export async function startDaemon({ dir = schedDir(), runBatch, idleMs = Number(
|
|
|
191
202
|
const close = () => {
|
|
192
203
|
if (closing) return closing;
|
|
193
204
|
clearInterval(idleTimer);
|
|
205
|
+
clearInterval(lockTimer);
|
|
194
206
|
for (const c of conns) c.destroy();
|
|
195
207
|
const stopping = [...runs.keys()];
|
|
196
208
|
for (const ac of runs.values()) ac.abort();
|
|
@@ -198,9 +210,11 @@ export async function startDaemon({ dir = schedDir(), runBatch, idleMs = Number(
|
|
|
198
210
|
// #4454: the lock goes only once every hosted batch loop has stopped (or `stopMs` passed: the caller exits regardless)
|
|
199
211
|
const stopped = stopping.length ? Promise.race([Promise.allSettled(stopping).then(() => true), new Promise((r) => { timer = setTimeout(() => r(false), stopMs); })])
|
|
200
212
|
.then((ok) => { clearTimeout(timer); if (!ok) log(`schedd: ${runs.size} batch(es) did not stop within ${stopMs} ms`); }) : Promise.resolve();
|
|
201
|
-
|
|
213
|
+
// A lost lock: the socket path may be the successor's, and closing the server unlinks it (libuv), so leave it unref'd.
|
|
214
|
+
const serverClosed = keepSock ? Promise.resolve(server.unref()) : new Promise((r) => server.close(() => r()));
|
|
215
|
+
closing = Promise.all([serverClosed, stopped]).then(() => {
|
|
202
216
|
try { if (parseInt(readFileSync(p.lock, "utf8"), 10) === pid) rmSync(p.lock, { force: true }); } catch { /* gone */ }
|
|
203
|
-
rmSync(p.sock, { force: true });
|
|
217
|
+
if (!keepSock) rmSync(p.sock, { force: true });
|
|
204
218
|
resolveClosed();
|
|
205
219
|
});
|
|
206
220
|
return closing;
|
|
@@ -209,6 +223,15 @@ export async function startDaemon({ dir = schedDir(), runBatch, idleMs = Number(
|
|
|
209
223
|
if (!hosted.size && !conns.size && Date.now() - lastActive >= idleMs) { log("schedd: idle, exiting"); close(); }
|
|
210
224
|
}, Math.min(idleMs, 30_000));
|
|
211
225
|
idleTimer.unref?.();
|
|
226
|
+
// #4503: a daemon whose lock or socket was taken stops (its batches stay resumable) instead of running unreachable.
|
|
227
|
+
const lockTimer = setInterval(() => {
|
|
228
|
+
if (closing) return;
|
|
229
|
+
let owner = NaN;
|
|
230
|
+
try { owner = parseInt(readFileSync(p.lock, "utf8"), 10); } catch { /* gone */ }
|
|
231
|
+
if (owner !== pid) { keepSock = true; log(`schedd: lost lock (owner ${Number.isInteger(owner) ? `pid ${owner}` : "none"}), exiting`); close(); }
|
|
232
|
+
else if (!existsSync(p.sock)) { log(`schedd: lost socket ${p.sock}, exiting`); close(); }
|
|
233
|
+
}, lockCheckMs);
|
|
234
|
+
lockTimer.unref?.();
|
|
212
235
|
|
|
213
236
|
// Daemon restart: resume every daemon-hosted batch that never wrote its report; live lanes are re-adopted.
|
|
214
237
|
if (resumeOnStart) {
|
|
@@ -216,22 +239,35 @@ export async function startDaemon({ dir = schedDir(), runBatch, idleMs = Number(
|
|
|
216
239
|
const b = openBatch(root, id);
|
|
217
240
|
if (!b.readPlan()?.daemon || b.readReport()) continue;
|
|
218
241
|
log(`schedd: resuming batch ${id}`);
|
|
219
|
-
host({ argv: ["--resume", id], cwd: process.cwd(), env: {}, keepBreaker: true })
|
|
242
|
+
const started = host({ argv: ["--resume", id], cwd: process.cwd(), env: {}, keepBreaker: true });
|
|
243
|
+
starting.set(id, started);
|
|
244
|
+
started.catch((e) => log(`schedd: resume ${id} failed: ${e?.message ?? e}`)).finally(() => starting.delete(id));
|
|
220
245
|
}
|
|
221
246
|
}
|
|
222
247
|
return { running: true, pid, sock: p.sock, close, closed };
|
|
223
248
|
}
|
|
224
249
|
|
|
225
|
-
/**
|
|
226
|
-
|
|
227
|
-
|
|
250
|
+
/**
|
|
251
|
+
* #4454: on SIGTERM/SIGINT/SIGHUP stop the daemon (`close()`: batch loops first, then lock and socket), then exit 128+signo,
|
|
252
|
+
* once; an uncaught exception or unhandled rejection does the same and exits 1. #4503: each logs its reason first, and the
|
|
253
|
+
* process `exit` logs `schedd: exited (code N)`.
|
|
254
|
+
*/
|
|
255
|
+
export function exitOnSignals(d, { on = (s, f) => (s.startsWith("SIG") || s === "exit" ? process.once(s, f) : process.on(s, f)),
|
|
256
|
+
exit = (c) => process.exit(c), log = (m) => console.log(m), signals = ["SIGTERM", "SIGINT", "SIGHUP"] } = {}) {
|
|
257
|
+
const SIGNO = { SIGHUP: 1, SIGINT: 2, SIGTERM: 15 };
|
|
228
258
|
let fired = false;
|
|
229
|
-
|
|
259
|
+
const stop = async (reason, code, detail = null) => {
|
|
230
260
|
if (fired) return;
|
|
231
261
|
fired = true;
|
|
262
|
+
log(`schedd: exiting (${reason})`);
|
|
263
|
+
if (detail) log(detail);
|
|
232
264
|
try { await d.close(); } catch { /* exit anyway */ }
|
|
233
|
-
exit(
|
|
234
|
-
}
|
|
265
|
+
exit(code);
|
|
266
|
+
};
|
|
267
|
+
for (const sig of signals) on(sig, () => stop(sig, 128 + (SIGNO[sig] ?? 0)));
|
|
268
|
+
on("uncaughtException", (e) => stop(`uncaught: ${String(e?.message ?? e).split("\n")[0]}`, 1, e?.stack ?? null));
|
|
269
|
+
on("unhandledRejection", (e) => stop(`unhandled rejection: ${String(e?.message ?? e).split("\n")[0]}`, 1, e?.stack ?? null));
|
|
270
|
+
on("exit", (code) => log(`schedd: exited (code ${code})`));
|
|
235
271
|
}
|
|
236
272
|
|
|
237
273
|
/** Connect to the daemon, spawning it (`spawnDaemon()`) when nobody listens. @returns {Promise<import("node:net").Socket>} */
|
package/bin/lean/schedd.test.mjs
CHANGED
|
@@ -3,14 +3,23 @@
|
|
|
3
3
|
import { describe, it } from "node:test";
|
|
4
4
|
import assert from "node:assert/strict";
|
|
5
5
|
import { spawn } from "node:child_process";
|
|
6
|
-
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
6
|
+
import { closeSync, existsSync, openSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
7
|
+
import { homedir, tmpdir } from "node:os";
|
|
7
8
|
import { join } from "node:path";
|
|
8
9
|
import { tmpDir } from "../tests/_tmp.mjs";
|
|
9
10
|
import { openBatch, isLaneAlive, listBatches } from "./batch.mjs";
|
|
10
|
-
import { startDaemon, scheddPaths, connectDaemon, request, mergeEnv, batchEnv } from "./schedd.mjs";
|
|
11
|
+
import { startDaemon, scheddPaths, connectDaemon, request, mergeEnv, batchEnv, exitOnSignals } from "./schedd.mjs";
|
|
12
|
+
import { schedDir } from "./sched.mjs";
|
|
11
13
|
import { formatTable, orchestrate } from "./orchestrate.mjs";
|
|
12
14
|
import { orchestrateFromCli } from "../engine-cli.mjs";
|
|
13
15
|
|
|
16
|
+
// #4503: this process, and the env its clients submit for their lanes, never resolve the host's `~/.forge/sched`.
|
|
17
|
+
process.env.FORGEDOCK_SCHED_DIR = tmpDir("schedd-home-");
|
|
18
|
+
// Stub lanes run detached: a host whose test never reached its `finally` still has them reaped when the file ends.
|
|
19
|
+
const hosts = [];
|
|
20
|
+
process.on("exit", () => { for (const h of hosts) h.reap(); });
|
|
21
|
+
const LANE_MAX_MS = 120_000;
|
|
22
|
+
|
|
14
23
|
const until = async (cond, ms = 15000) => {
|
|
15
24
|
const end = Date.now() + ms;
|
|
16
25
|
while (!cond()) { if (Date.now() > end) throw new Error("timed out waiting"); await new Promise((r) => setTimeout(r, 25)); }
|
|
@@ -24,13 +33,20 @@ const capture = async (fn) => {
|
|
|
24
33
|
/** A host: sched + batch dirs, a stub lane that waits for `release`, and a daemon script with injected lanes. */
|
|
25
34
|
function host() {
|
|
26
35
|
const work = tmpDir("schedd-");
|
|
27
|
-
const h = { work, sched: join(work, "s"), root: join(work, "batches"), launches: join(work, "launches"), release: join(work, "release"), procs: []
|
|
36
|
+
const h = { work, sched: join(work, "s"), root: join(work, "batches"), launches: join(work, "launches"), release: join(work, "release"), procs: [],
|
|
37
|
+
lanePidFile: join(work, "lane-pids"), log: join(work, "schedd.log") };
|
|
28
38
|
writeFileSync(h.launches, "");
|
|
39
|
+
writeFileSync(h.lanePidFile, "");
|
|
40
|
+
hosts.push(h);
|
|
29
41
|
const stub = join(work, "stub-lane.mjs");
|
|
30
42
|
writeFileSync(stub, `import { appendFileSync, existsSync, writeFileSync, renameSync } from "node:fs";
|
|
31
43
|
const a = process.argv.slice(2); const n = Number(a[1]); const rf = a[a.indexOf("--result-file") + 1];
|
|
32
44
|
appendFileSync(${JSON.stringify(h.launches)}, n + "\\n");
|
|
33
|
-
|
|
45
|
+
appendFileSync(${JSON.stringify(h.lanePidFile)}, process.pid + "\\n");
|
|
46
|
+
// #4503: a stub whose test run is gone (work dir removed) or that outlived its bound exits instead of polling forever.
|
|
47
|
+
const born = Date.now();
|
|
48
|
+
const t = setInterval(() => { if (!existsSync(${JSON.stringify(work)}) || Date.now() - born > ${LANE_MAX_MS}) process.exit(0);
|
|
49
|
+
if (!existsSync(${JSON.stringify(h.release)})) return; clearInterval(t);
|
|
34
50
|
writeFileSync(rf + ".tmp", JSON.stringify({ terminalReason: "merged", pr: 100 + n, cost_usd: 0 })); renameSync(rf + ".tmp", rf); }, 20);
|
|
35
51
|
// Like run-issue's #4179 teardown: SIGTERM reports deferred run.interrupted.
|
|
36
52
|
process.once("SIGTERM", () => { writeFileSync(rf + ".tmp", JSON.stringify({ terminalReason: "deferred", code: "run.interrupted", detail: "interrupted by SIGTERM" })); renameSync(rf + ".tmp", rf); process.exit(143); });
|
|
@@ -49,7 +65,9 @@ await d.closed;
|
|
|
49
65
|
h.env = { ...process.env, FORGEDOCK_SCHED_DIR: h.sched, FORGEDOCK_BATCH_DIR: h.root, FORGEDOCK_LIMITS_FILE: join(work, "limits.json") };
|
|
50
66
|
h.daemon = async () => {
|
|
51
67
|
const before = existsSync(scheddPaths(h.sched).lock) ? readFileSync(scheddPaths(h.sched).lock, "utf8") : null;
|
|
52
|
-
const
|
|
68
|
+
const out = openSync(h.log, "a");
|
|
69
|
+
let child;
|
|
70
|
+
try { child = spawn(process.execPath, [h.daemonScript], { env: h.env, stdio: ["ignore", out, out] }); } finally { closeSync(out); }
|
|
53
71
|
h.procs.push(child);
|
|
54
72
|
await until(() => existsSync(scheddPaths(h.sched).sock) && existsSync(scheddPaths(h.sched).lock) && readFileSync(scheddPaths(h.sched).lock, "utf8") === String(child.pid) && readFileSync(scheddPaths(h.sched).lock, "utf8") !== before);
|
|
55
73
|
// Wait for the socket to accept.
|
|
@@ -59,9 +77,15 @@ await d.closed;
|
|
|
59
77
|
h.client = { scheddDir: h.sched, spawnDaemon: null };
|
|
60
78
|
h.launched = () => readFileSync(h.launches, "utf8").trim().split("\n").filter(Boolean).map(Number).sort();
|
|
61
79
|
h.lanePids = () => listBatches(h.root).flatMap((id) => Object.values(openBatch(h.root, id).store.load()?.running ?? {}).map((r) => r.pid).filter(Boolean));
|
|
62
|
-
h.
|
|
63
|
-
|
|
80
|
+
h.stubPids = () => (existsSync(h.lanePidFile) ? readFileSync(h.lanePidFile, "utf8").trim().split("\n").filter(Boolean).map(Number) : []);
|
|
81
|
+
h.reap = () => {
|
|
64
82
|
for (const p of h.procs) { try { p.kill("SIGKILL"); } catch { /* gone */ } }
|
|
83
|
+
for (const pid of h.stubPids()) { try { process.kill(pid, "SIGKILL"); } catch { /* gone */ } }
|
|
84
|
+
};
|
|
85
|
+
h.cleanup = async () => {
|
|
86
|
+
if (existsSync(h.work)) writeFileSync(h.release, "");
|
|
87
|
+
h.reap();
|
|
88
|
+
await until(() => h.stubPids().every((pid) => !isLaneAlive(pid, null)), 5000).catch(() => {});
|
|
65
89
|
};
|
|
66
90
|
return h;
|
|
67
91
|
}
|
|
@@ -98,7 +122,7 @@ describe("schedd host scheduler (#4192)", () => {
|
|
|
98
122
|
const id = done[0].batch;
|
|
99
123
|
await withRoot(h, () => capture(() => orchestrateFromCli(["--set-parallel", id, "5"], h.client)));
|
|
100
124
|
assert.equal(openBatch(h.root, id).readParallel(), 5);
|
|
101
|
-
} finally { h.cleanup(); }
|
|
125
|
+
} finally { await h.cleanup(); }
|
|
102
126
|
});
|
|
103
127
|
|
|
104
128
|
it("a client that disconnects does not stop the batch; --resume reattaches, and replays the report once done", async () => {
|
|
@@ -127,7 +151,7 @@ await orchestrateFromCli(["1", "2", "--parallel", "2", "--repo", "o/r", "--no-pr
|
|
|
127
151
|
assert.deepEqual(again.res.rows, res.rows);
|
|
128
152
|
assert.ok(again.lines.join("\n").includes(formatTable(res.rows)));
|
|
129
153
|
assert.deepEqual(h.launched(), [1, 2]);
|
|
130
|
-
} finally { h.cleanup(); }
|
|
154
|
+
} finally { await h.cleanup(); }
|
|
131
155
|
});
|
|
132
156
|
|
|
133
157
|
it("a daemon restart resumes the batch from state and re-adopts its live lanes without duplicate launches", async () => {
|
|
@@ -150,7 +174,7 @@ await orchestrateFromCli(["1", "2", "--parallel", "2", "--repo", "o/r", "--no-pr
|
|
|
150
174
|
assert.deepEqual(res.rows.map((r) => [r.issue, r.outcome, r.pr]), [[1, "merged", 101], [2, "merged", 102], [3, "merged", 103]]);
|
|
151
175
|
assert.deepEqual(h.launched(), [1, 2, 3], "adopted, not relaunched");
|
|
152
176
|
assert.deepEqual(openBatch(h.root, id).store.load().running, {});
|
|
153
|
-
} finally { h.cleanup(); }
|
|
177
|
+
} finally { await h.cleanup(); }
|
|
154
178
|
});
|
|
155
179
|
|
|
156
180
|
it("#4408: --add and --resume <id> N add issues to the live batch (no second batch); an unhosted finished batch runs its adds on --resume", async () => {
|
|
@@ -181,7 +205,7 @@ await orchestrateFromCli(["1", "2", "--parallel", "2", "--repo", "o/r", "--no-pr
|
|
|
181
205
|
}));
|
|
182
206
|
assert.equal(res.rows.length, 3);
|
|
183
207
|
assert.deepEqual(h.launched(), [1, 2, 4, 6], "each issue launched once");
|
|
184
|
-
} finally { h.cleanup(); }
|
|
208
|
+
} finally { await h.cleanup(); }
|
|
185
209
|
});
|
|
186
210
|
|
|
187
211
|
it("#4376: --cancel stops one batch (its lane torn down, the rest held, report written); the other batch and the daemon run on", async () => {
|
|
@@ -211,7 +235,28 @@ await orchestrateFromCli(["1", "2", "--parallel", "2", "--repo", "o/r", "--no-pr
|
|
|
211
235
|
assert.equal(again.cancelled, false, "a finished batch is left alone");
|
|
212
236
|
await assert.rejects(orchestrateFromCli(["--cancel", "nope"], h.client), /no batch nope/);
|
|
213
237
|
}));
|
|
214
|
-
} finally { h.cleanup(); }
|
|
238
|
+
} finally { await h.cleanup(); }
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
it("#4481: a batch cancelled through the daemon is not resumed by a schedd restart", async () => {
|
|
242
|
+
const h = host();
|
|
243
|
+
try {
|
|
244
|
+
const statusIds = async () => (await request(await connectDaemon({ dir: h.sched, spawnDaemon: null }), { op: "status" })).batches.map((x) => x.id);
|
|
245
|
+
const kill = async (d) => { const gone = new Promise((r) => d.on("exit", r)); d.kill("SIGKILL"); await gone; };
|
|
246
|
+
let d = await h.daemon();
|
|
247
|
+
await withRoot(h, () => capture(async () => {
|
|
248
|
+
const a = orchestrateFromCli(["1", "2", "--parallel", "1", "--repo", "o/r", "--no-prefilter"], h.client);
|
|
249
|
+
await until(() => h.launched().length === 1 && h.lanePids().length === 1);
|
|
250
|
+
const [idA] = listBatches(h.root);
|
|
251
|
+
assert.equal((await orchestrateFromCli(["--cancel", idA], h.client)).cancelled, true);
|
|
252
|
+
await a;
|
|
253
|
+
await kill(d);
|
|
254
|
+
d = await h.daemon();
|
|
255
|
+
await new Promise((r) => setTimeout(r, 300));
|
|
256
|
+
assert.deepEqual(await statusIds(), [], "the restarted daemon hosts no cancelled batch");
|
|
257
|
+
assert.deepEqual(h.launched(), [1]);
|
|
258
|
+
}));
|
|
259
|
+
} finally { await h.cleanup(); }
|
|
215
260
|
});
|
|
216
261
|
|
|
217
262
|
it("single instance: a live daemon keeps its lock; a dead owner's lock and socket are taken over", async () => {
|
|
@@ -231,7 +276,7 @@ await orchestrateFromCli(["1", "2", "--parallel", "2", "--repo", "o/r", "--no-pr
|
|
|
231
276
|
assert.equal(st.pid, process.pid);
|
|
232
277
|
} finally { await third.close(); }
|
|
233
278
|
assert.equal(existsSync(scheddPaths(h.sched).lock), false, "lock released on close");
|
|
234
|
-
} finally { h.cleanup(); }
|
|
279
|
+
} finally { await h.cleanup(); }
|
|
235
280
|
});
|
|
236
281
|
|
|
237
282
|
it("#4454: close() stops every hosted batch loop before releasing the lock; no batch admits a lane after close()", async () => {
|
|
@@ -288,11 +333,12 @@ await orchestrateFromCli(["1", "2", "--parallel", "2", "--repo", "o/r", "--no-pr
|
|
|
288
333
|
const h = host();
|
|
289
334
|
try {
|
|
290
335
|
const d1 = await h.daemon();
|
|
291
|
-
await withRoot(h, () => capture(async () => {
|
|
292
|
-
|
|
336
|
+
const { lines } = await withRoot(h, () => capture(async () => {
|
|
337
|
+
// #4503: a gives up at once (reconnectMs 0) with the resume hint; b re-attaches to the next daemon on its own.
|
|
338
|
+
const a = orchestrateFromCli(["1", "2", "--parallel", "1", "--repo", "o/r", "--no-prefilter"], { ...h.client, reconnectMs: 0 }).then(() => null, (e) => e);
|
|
293
339
|
await until(() => h.launched().length === 1 && h.lanePids().length === 1);
|
|
294
340
|
const [idA] = listBatches(h.root);
|
|
295
|
-
const b = orchestrateFromCli(["5", "--parallel", "1", "--repo", "o/r", "--no-prefilter"], h.client)
|
|
341
|
+
const b = orchestrateFromCli(["5", "--parallel", "1", "--repo", "o/r", "--no-prefilter"], h.client);
|
|
296
342
|
await until(() => h.launched().includes(5) && h.lanePids().length === 2);
|
|
297
343
|
const idB = listBatches(h.root).find((x) => x !== idA);
|
|
298
344
|
const lanes = h.lanePids();
|
|
@@ -306,15 +352,102 @@ await orchestrateFromCli(["1", "2", "--parallel", "2", "--repo", "o/r", "--no-pr
|
|
|
306
352
|
assert.equal(openBatch(h.root, idA).readReport(), null, "a stopped batch writes no report");
|
|
307
353
|
assert.equal(openBatch(h.root, idB).readReport(), null);
|
|
308
354
|
assert.match(String((await a)?.message), /--resume/);
|
|
309
|
-
await b;
|
|
310
355
|
await h.daemon(); // resumes both batches
|
|
311
|
-
const ra = orchestrateFromCli(["--resume", idA], h.client)
|
|
356
|
+
const ra = orchestrateFromCli(["--resume", idA], h.client);
|
|
312
357
|
setTimeout(() => writeFileSync(h.release, ""), 200);
|
|
313
358
|
assert.deepEqual((await ra).rows.map((r) => [r.issue, r.outcome]), [[1, "merged"], [2, "merged"]]);
|
|
314
|
-
assert.deepEqual((await
|
|
359
|
+
assert.deepEqual((await b).rows.map((r) => [r.issue, r.outcome]), [[5, "merged"]], "b re-attached and finished");
|
|
315
360
|
assert.deepEqual(h.launched(), [1, 2, 5], "adopted, not relaunched");
|
|
316
361
|
}));
|
|
317
|
-
|
|
362
|
+
assert.ok(lines.some((l) => l.startsWith("schedd connection lost; reconnecting to batch ")), "the client said it reconnects");
|
|
363
|
+
const log = readFileSync(h.log, "utf8");
|
|
364
|
+
assert.match(log, /^schedd: exiting \(SIGTERM\)$/m);
|
|
365
|
+
assert.match(log, /^schedd: exited \(code 143\)$/m);
|
|
366
|
+
} finally { await h.cleanup(); }
|
|
367
|
+
});
|
|
368
|
+
|
|
369
|
+
it("#4503: every schedd exit writes a reason line", async () => {
|
|
370
|
+
for (const [ev, arg, code, re] of [["SIGTERM", undefined, 143, /^schedd: exiting \(SIGTERM\)$/], ["SIGINT", undefined, 130, /^schedd: exiting \(SIGINT\)$/],
|
|
371
|
+
["SIGHUP", undefined, 129, /^schedd: exiting \(SIGHUP\)$/], ["uncaughtException", new Error("boom\nmore"), 1, /^schedd: exiting \(uncaught: boom\)$/],
|
|
372
|
+
["unhandledRejection", new Error("nope"), 1, /^schedd: exiting \(unhandled rejection: nope\)$/]]) {
|
|
373
|
+
const handlers = {}, lines = [], exits = [];
|
|
374
|
+
exitOnSignals({ close: async () => { lines.push("closed"); } }, { on: (s, f) => { handlers[s] = f; }, exit: (c) => exits.push(c), log: (m) => lines.push(m) });
|
|
375
|
+
await handlers[ev](arg);
|
|
376
|
+
assert.match(lines[0], re, `${ev}: the reason is logged before close()`);
|
|
377
|
+
assert.equal(lines.at(-1), "closed");
|
|
378
|
+
assert.deepEqual(exits, [code]);
|
|
379
|
+
handlers.exit(code);
|
|
380
|
+
assert.equal(lines.at(-1), `schedd: exited (code ${code})`);
|
|
381
|
+
}
|
|
382
|
+
const dir = tmpDir("schedd-idle-"), lines = [];
|
|
383
|
+
const d = await startDaemon({ dir, root: join(dir, "batches"), runBatch: async () => ({}), log: (m) => lines.push(m), resumeOnStart: false, idleMs: 50 });
|
|
384
|
+
await d.closed;
|
|
385
|
+
assert.ok(lines.includes("schedd: idle, exiting"));
|
|
386
|
+
});
|
|
387
|
+
|
|
388
|
+
it("#4503: a daemon that lost its lock or socket logs it and stops its batches resumable, never removing the successor's lock or socket; a dead owner's takeover is logged", async () => {
|
|
389
|
+
const dir = tmpDir("schedd-lost-"), p = scheddPaths(dir), root = join(dir, "batches"), lines = [];
|
|
390
|
+
let stopped = false;
|
|
391
|
+
const d = await startDaemon({ dir, root, log: (m) => lines.push(m), resumeOnStart: false, lockCheckMs: 30,
|
|
392
|
+
runBatch: ({ sink, signal }) => { sink.batch({ id: "x", dir }); return new Promise((_, rej) => signal.addEventListener("abort", () => { stopped = true; rej(Object.assign(new Error("stopped"), { code: "batch.stopped" })); })); } });
|
|
393
|
+
request(await connectDaemon({ dir, spawnDaemon: null }), { op: "submit", argv: ["x"], cwd: dir, env: {} }).catch(() => {});
|
|
394
|
+
await until(() => lines.some((l) => l.includes("listening")));
|
|
395
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
396
|
+
writeFileSync(p.lock, "999999999"); // a successor (now dead) took the lock
|
|
397
|
+
await d.closed;
|
|
398
|
+
assert.ok(lines.includes("schedd: lost lock (owner pid 999999999), exiting"), lines.join("\n"));
|
|
399
|
+
assert.equal(stopped, true, "the hosted batch loop was stopped (no report: the next daemon resumes it)");
|
|
400
|
+
assert.equal(readFileSync(p.lock, "utf8"), "999999999", "the successor's lock is left alone");
|
|
401
|
+
assert.equal(existsSync(p.sock), true, "the successor's socket is left alone");
|
|
402
|
+
|
|
403
|
+
const lines2 = [];
|
|
404
|
+
const d2 = await startDaemon({ dir, root, runBatch: async () => ({}), log: (m) => lines2.push(m), resumeOnStart: false, lockCheckMs: 30 });
|
|
405
|
+
assert.ok(lines2.includes("schedd: took over the lock of dead pid 999999999 (it exited without a clean shutdown)"), lines2.join("\n"));
|
|
406
|
+
rmSync(p.sock);
|
|
407
|
+
await d2.closed;
|
|
408
|
+
assert.ok(lines2.includes(`schedd: lost socket ${p.sock}, exiting`), lines2.join("\n"));
|
|
409
|
+
assert.equal(existsSync(p.lock), false, "its own lock is released");
|
|
410
|
+
});
|
|
411
|
+
|
|
412
|
+
it("#4503: a client whose daemon is SIGKILLed re-attaches to the next daemon and finishes, without relaunching a lane", async () => {
|
|
413
|
+
const h = host();
|
|
414
|
+
try {
|
|
415
|
+
const d1 = await h.daemon();
|
|
416
|
+
await withRoot(h, () => capture(async () => {
|
|
417
|
+
const a = orchestrateFromCli(["1", "--parallel", "1", "--repo", "o/r", "--no-prefilter"], h.client);
|
|
418
|
+
await until(() => h.launched().length === 1 && h.lanePids().length === 1);
|
|
419
|
+
const gone = new Promise((r) => d1.on("exit", r));
|
|
420
|
+
d1.kill("SIGKILL");
|
|
421
|
+
await gone;
|
|
422
|
+
await new Promise((r) => setTimeout(r, 300)); // the client is retrying against the dead socket
|
|
423
|
+
await h.daemon();
|
|
424
|
+
setTimeout(() => writeFileSync(h.release, ""), 200);
|
|
425
|
+
assert.deepEqual((await a).rows.map((r) => [r.issue, r.outcome]), [[1, "merged"]]);
|
|
426
|
+
assert.deepEqual(h.launched(), [1], "adopted, not relaunched");
|
|
427
|
+
}));
|
|
428
|
+
assert.match(readFileSync(h.log, "utf8"), /^schedd: took over the lock of dead pid \d+ \(it exited without a clean shutdown\)$/m);
|
|
429
|
+
} finally { await h.cleanup(); }
|
|
430
|
+
});
|
|
431
|
+
|
|
432
|
+
it("#4503: test runs never resolve ~/.forge/sched, and teardown leaves no live stub lane", async () => {
|
|
433
|
+
assert.notEqual(schedDir(), join(homedir(), ".forge", "sched"));
|
|
434
|
+
assert.ok(schedDir().startsWith(tmpdir()), schedDir());
|
|
435
|
+
const h = host();
|
|
436
|
+
let pids = [], a;
|
|
437
|
+
try {
|
|
438
|
+
await h.daemon();
|
|
439
|
+
await withRoot(h, async () => {
|
|
440
|
+
a = capture(() => orchestrateFromCli(["1", "2", "--parallel", "2", "--repo", "o/r", "--no-prefilter"], { ...h.client, reconnectMs: 0 })).catch((e) => e);
|
|
441
|
+
await until(() => h.stubPids().length === 2 && h.lanePids().length === 2);
|
|
442
|
+
});
|
|
443
|
+
pids = h.stubPids();
|
|
444
|
+
for (const pid of pids) assert.equal(isLaneAlive(pid, null), true);
|
|
445
|
+
const [id] = listBatches(h.root);
|
|
446
|
+
assert.equal(openBatch(h.root, id).readPlan().env.FORGEDOCK_SCHED_DIR, schedDir(), "the lanes' env pins the test sched dir");
|
|
447
|
+
} finally { await h.cleanup(); }
|
|
448
|
+
await a;
|
|
449
|
+
assert.equal(pids.length, 2);
|
|
450
|
+
for (const pid of pids) assert.equal(isLaneAlive(pid, null), false, `stub lane ${pid} reaped`);
|
|
318
451
|
});
|
|
319
452
|
|
|
320
453
|
it("lane env: allowlisted keys come from the client only, the rest from the daemon", () => {
|
package/bin/stats/efficiency.mjs
CHANGED
|
@@ -59,14 +59,18 @@ function parseLog(text) {
|
|
|
59
59
|
return out;
|
|
60
60
|
}
|
|
61
61
|
|
|
62
|
-
|
|
62
|
+
export const LANE_SHAPES = Object.freeze(["light", "upgraded", "full"]);
|
|
63
|
+
|
|
64
|
+
/** Every RUN_START..RUN_TERMINAL slice of one run log (all of them, not only the last). `shape` (#4452): light (LIGHT_DONE), upgraded (LIGHT_UPGRADE) or full. */
|
|
63
65
|
export function issueSlices(events) {
|
|
64
66
|
const out = [];
|
|
65
67
|
let cur = null;
|
|
66
68
|
for (const e of Array.isArray(events) ? events : []) {
|
|
67
69
|
if (!e || typeof e !== "object") continue;
|
|
68
|
-
if (e.event === "RUN_START") { cur = { startTs: e.ts ?? null, repo: e.repo ?? null, terminal: null, attempts: [] }; out.push(cur); continue; }
|
|
70
|
+
if (e.event === "RUN_START") { cur = { startTs: e.ts ?? null, repo: e.repo ?? null, terminal: null, shape: "full", attempts: [] }; out.push(cur); continue; }
|
|
69
71
|
if (!cur) continue;
|
|
72
|
+
if (e.event === "LIGHT_DONE") cur.shape = "light";
|
|
73
|
+
else if (e.event === "LIGHT_UPGRADE") cur.shape = "upgraded";
|
|
70
74
|
if (e.event === "PHASE_ATTEMPT_USAGE") {
|
|
71
75
|
cur.attempts.push({ phase: e.phase ?? "other", trigger: e.trigger ?? null, restart_of: e.restart_of ?? null, model: e.model ?? null,
|
|
72
76
|
turns: num(e.turns), cacheRead: num(e.usage?.cache_read_input_tokens), cost: costOf(e),
|
|
@@ -145,9 +149,18 @@ const group = (xs) => {
|
|
|
145
149
|
*/
|
|
146
150
|
export function efficiencyMetrics({ entries = [], logs = new Map(), issues = null, repo = null, now = 0, notes = [] } = {}) {
|
|
147
151
|
const merged = [];
|
|
152
|
+
const inScope = (row, settleAt) => issueSlices(logs.get(row.issue) ?? []).filter((s) => (!repo || !s.repo || s.repo === repo) && (!settleAt || !s.startTs || s.startTs <= settleAt));
|
|
153
|
+
// #4452: light/full split and upgrade rate over every lane of every row (all outcomes); cost is the #4407 delta sum
|
|
154
|
+
const shapes = Object.fromEntries(LANE_SHAPES.map((k) => [k, { n: 0, cost_usd: 0 }]));
|
|
155
|
+
for (const { row, settleAt } of entries) {
|
|
156
|
+
if (!Number.isSafeInteger(row?.issue)) continue;
|
|
157
|
+
for (const s of inScope(row, settleAt)) { const g = shapes[s.shape] ?? shapes.full; g.n++; g.cost_usd += sum(s.attempts.map((a) => a.cost)); }
|
|
158
|
+
}
|
|
159
|
+
for (const g of Object.values(shapes)) g.cost_usd = r(g.cost_usd, 1e6);
|
|
160
|
+
const lanes = { ...shapes, upgrade_rate: r(per(shapes.upgraded.n, shapes.light.n + shapes.upgraded.n)) };
|
|
148
161
|
for (const { row, settleAt } of entries) {
|
|
149
162
|
if (row?.outcome !== "merged") continue;
|
|
150
|
-
const slices =
|
|
163
|
+
const slices = inScope(row, settleAt);
|
|
151
164
|
const atts = slices.flatMap((s) => s.attempts), impl = atts.filter((a) => a.phase === "implement");
|
|
152
165
|
const retries = Object.fromEntries(RETRY_CAUSES.map((c) => [c, 0]));
|
|
153
166
|
for (const s of slices) for (const [c, k] of Object.entries(sliceRetries(s))) retries[c] += k;
|
|
@@ -156,7 +169,7 @@ export function efficiencyMetrics({ entries = [], logs = new Map(), issues = nul
|
|
|
156
169
|
const term = [...slices].reverse().find((s) => s.terminal?.reason === "merged")?.terminal;
|
|
157
170
|
merged.push({ issue: row.issue, slices: slices.length, cost: sum(atts.map((a) => a.cost)), implCost: sum(impl.map((a) => a.cost)),
|
|
158
171
|
reviewCost: sum(atts.filter((a) => a.phase === "review").map((a) => a.cost)), implTurns: sum(impl.map((a) => a.turns)), implCache: sum(impl.map((a) => a.cacheRead)),
|
|
159
|
-
size: term?.size ?? laneSize({ labels: info?.labels ?? [] }) ?? "unknown", impl, retries, info, tools: laneTools(impl) });
|
|
172
|
+
size: term?.size ?? laneSize({ labels: info?.labels ?? [] }) ?? "unknown", impl, retries, info, tools: laneTools(impl), shape: slices.at(-1)?.shape ?? null });
|
|
160
173
|
}
|
|
161
174
|
const all = group(merged);
|
|
162
175
|
const bySize = {};
|
|
@@ -202,10 +215,10 @@ export function efficiencyMetrics({ entries = [], logs = new Map(), issues = nul
|
|
|
202
215
|
retries: { total, per_merge: r(per(total, merged.length), 100), by_cause: retries, lane_reruns: sum(merged.map((m) => Math.max(0, m.slices - 1))) },
|
|
203
216
|
no_action: { lanes: sum(Object.values(noAction).map((g) => g.n)), cost_usd: r(sum(Object.values(noAction).map((g) => g.cost_usd)), 1e6),
|
|
204
217
|
by_kind: Object.fromEntries(Object.entries(noAction).map(([k, g]) => [k, { ...g, cost_usd: r(g.cost_usd, 1e6) }])) },
|
|
205
|
-
quality, acceptance,
|
|
218
|
+
quality, acceptance, lanes,
|
|
206
219
|
tool_calls_per_turn_p50: median(withTools.map((m) => m.tools.per_turn)), rereads_per_merge: withTools.length ? r(per(sum(withTools.map((m) => m.tools.rereads)), withTools.length), 100) : null,
|
|
207
220
|
per_issue: merged.map((m) => ({ issue: m.issue, slices: m.slices, cost_usd: r(m.cost, 1e6), implement_usd: r(m.implCost, 1e6), implement_turns: m.implTurns,
|
|
208
|
-
implement_cache_read: m.implCache, size: m.size, retries: m.retries, ...(m.info ? { quality: issueQuality(m.info.body).tag } : {}),
|
|
221
|
+
implement_cache_read: m.implCache, size: m.size, retries: m.retries, ...(m.shape ? { shape: m.shape } : {}), ...(m.info ? { quality: issueQuality(m.info.body).tag } : {}),
|
|
209
222
|
...(m.tools ? { implement_tool_calls_per_turn: m.tools.per_turn, implement_rereads: m.tools.rereads } : {}) })),
|
|
210
223
|
notes,
|
|
211
224
|
};
|
|
@@ -315,5 +328,8 @@ export function formatEfficiencyLines(m, label = "") {
|
|
|
315
328
|
lines.push(a ? `acceptance (7d after promotion): pass ${a.pass}/${a.pass + a.fail}${a.pass_rate == null ? "" : ` (${Math.round(a.pass_rate * 100)}%)`}, pending ${a.pending}, unprobed ${a.unprobed}`
|
|
316
329
|
: "acceptance (7d after promotion): n/a");
|
|
317
330
|
lines.push(`tools: calls/turn p50 ${f(m.tool_calls_per_turn_p50)}, re-reads (>=3x same path)/merge ${f(m.rereads_per_merge)}`); // #4410
|
|
331
|
+
const ln = m.lanes; // #4452
|
|
332
|
+
lines.push(ln ? `lanes: ${LANE_SHAPES.map((k) => `${k} ${ln[k]?.n ?? 0} (${usd(ln[k]?.cost_usd ?? 0)})`).join(", ")}; upgrade rate ${ln.upgrade_rate == null ? "n/a"
|
|
333
|
+
: `${Math.round(ln.upgrade_rate * 100)}% (${ln.upgraded?.n ?? 0}/${(ln.light?.n ?? 0) + (ln.upgraded?.n ?? 0)})`}` : "lanes: n/a");
|
|
318
334
|
return lines;
|
|
319
335
|
}
|
|
@@ -115,11 +115,44 @@ describe("#4402 efficiencyMetrics", () => {
|
|
|
115
115
|
});
|
|
116
116
|
it("renders n/a, never NaN, for an empty cohort", () => {
|
|
117
117
|
const lines = formatEfficiencyLines(efficiencyMetrics({}));
|
|
118
|
-
assert.equal(lines.length,
|
|
118
|
+
assert.equal(lines.length, 9);
|
|
119
119
|
assert.ok(!lines.some((l) => /NaN|undefined/.test(l)), lines.join("\n"));
|
|
120
120
|
assert.match(lines[0], /^efficiency: merged 0, n\/a\/merge/);
|
|
121
121
|
assert.equal(lines[6], "acceptance (7d after promotion): n/a");
|
|
122
122
|
assert.equal(lines[7], "tools: calls/turn p50 n/a, re-reads (>=3x same path)/merge n/a");
|
|
123
|
+
assert.equal(lines[8], "lanes: light 0 ($0.00), upgraded 0 ($0.00), full 0 ($0.00); upgrade rate n/a");
|
|
124
|
+
});
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
describe("#4452 light/full split and upgrade rate", () => {
|
|
128
|
+
// issue 1: light, merged; 2: light then upgraded in the same run, merged; 3: full, merged; 4: light, failed; 5: upgraded, failed
|
|
129
|
+
const logs = new Map([
|
|
130
|
+
[1, [start("2026-10-22T10:00:00Z"), { event: "LIGHT_ELIGIBILITY", light: true }, att("implement", { trigger: "build", cost_usd: 0.9, cost_usd_delta: 0.4 }), { event: "LIGHT_DONE" }, att("review", { cost_usd: 0.05 }), term("merged", "merged")]],
|
|
131
|
+
[2, [start("2026-10-22T10:00:00Z"), att("implement", { trigger: "build", cost_usd_delta: 0.3 }), { event: "LIGHT_UPGRADE", reasons: [{ code: "diff_lines" }] }, att("implement", { trigger: "plan", cost_usd_delta: 0.5 }), term("merged", "merged")]],
|
|
132
|
+
[3, [start("2026-10-22T10:00:00Z"), att("implement", { trigger: "plan", cost_usd_delta: 0.6 }), att("implement", { trigger: "build", cost_usd: 1.2, cost_usd_delta: 0.4 }), term("merged", "merged")]],
|
|
133
|
+
[4, [start("2026-10-22T10:00:00Z"), att("implement", { trigger: "build", cost_usd_delta: 0.2 }), { event: "LIGHT_DONE" }, term("failed", "ci.red")]],
|
|
134
|
+
[5, [start("2026-10-22T10:00:00Z"), att("implement", { trigger: "build", cost_usd_delta: 0.1 }), { event: "LIGHT_UPGRADE" }, term("failed", "conformance.unimplemented")]],
|
|
135
|
+
]);
|
|
136
|
+
const entries = [1, 2, 3, 4, 5].map((issue) => ({ row: { issue, outcome: issue <= 3 ? "merged" : "failed" }, settleAt: null }));
|
|
137
|
+
const m = efficiencyMetrics({ entries, logs, repo: "o/r" });
|
|
138
|
+
|
|
139
|
+
it("tags each lane slice by shape from LIGHT_DONE / LIGHT_UPGRADE", () => {
|
|
140
|
+
assert.deepEqual(issueSlices(logs.get(1)).map((s) => s.shape), ["light"]);
|
|
141
|
+
assert.deepEqual(issueSlices(logs.get(2)).map((s) => s.shape), ["upgraded"]);
|
|
142
|
+
assert.deepEqual(issueSlices(logs.get(3)).map((s) => s.shape), ["full"]);
|
|
143
|
+
});
|
|
144
|
+
it("splits every lane (all outcomes) by shape with true #4407 delta costs and computes the upgrade rate", () => {
|
|
145
|
+
assert.deepEqual(m.lanes, {
|
|
146
|
+
light: { n: 2, cost_usd: 0.65 }, // 0.4 delta (not the 0.9 running total) + 0.05 review + 0.2
|
|
147
|
+
upgraded: { n: 2, cost_usd: 0.9 },
|
|
148
|
+
full: { n: 1, cost_usd: 1 }, // 0.6 + 0.4 delta, not 1.2
|
|
149
|
+
upgrade_rate: 0.5,
|
|
150
|
+
});
|
|
151
|
+
assert.deepEqual(m.per_issue.map((x) => [x.issue, x.shape]), [[1, "light"], [2, "upgraded"], [3, "full"]]);
|
|
152
|
+
});
|
|
153
|
+
it("the batch report prints the split and the upgrade rate", () => {
|
|
154
|
+
const lines = formatEfficiencyLines(m);
|
|
155
|
+
assert.equal(lines.at(-1), "lanes: light 2 ($0.65), upgraded 2 ($0.90), full 1 ($1.00); upgrade rate 50% (2/4)");
|
|
123
156
|
});
|
|
124
157
|
});
|
|
125
158
|
|
|
@@ -142,7 +175,7 @@ describe("#4410 tool calls per turn and re-reads", () => {
|
|
|
142
175
|
const p = Object.fromEntries(m.per_issue.map((x) => [x.issue, x]));
|
|
143
176
|
assert.equal(p[1].implement_tool_calls_per_turn, 1.5); assert.equal(p[1].implement_rereads, 2);
|
|
144
177
|
assert.equal("implement_tool_calls_per_turn" in p[4], false);
|
|
145
|
-
assert.equal(formatEfficiencyLines(m).
|
|
178
|
+
assert.equal(formatEfficiencyLines(m).find((l) => l.startsWith("tools:")), "tools: calls/turn p50 1.50, re-reads (>=3x same path)/merge 1.00");
|
|
146
179
|
});
|
|
147
180
|
it("null without tool data (older logs)", () => {
|
|
148
181
|
const e = efficiencyMetrics({ entries: [entries[3]], logs, repo: "o/r", now: NOW });
|