@gethmy/harness 1.0.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.
- package/README.md +66 -0
- package/dist/cli.js +2936 -0
- package/dist/index.js +3734 -0
- package/package.json +65 -0
- package/src/artifact-judge.ts +410 -0
- package/src/cli.ts +272 -0
- package/src/command-metric.ts +594 -0
- package/src/error-classifier.ts +95 -0
- package/src/exec-types.ts +109 -0
- package/src/gate-collectors.ts +431 -0
- package/src/gate-config-error.ts +73 -0
- package/src/git-diff-stat.ts +148 -0
- package/src/git-pr.ts +839 -0
- package/src/harmony-client.ts +197 -0
- package/src/index.ts +37 -0
- package/src/log.ts +129 -0
- package/src/model-tier.test.ts +169 -0
- package/src/model-tier.ts +108 -0
- package/src/oracle-collector.ts +148 -0
- package/src/oracle.ts +434 -0
- package/src/pm.ts +73 -0
- package/src/process-group.ts +149 -0
- package/src/project-type.ts +303 -0
- package/src/revert-guard.ts +99 -0
- package/src/review-types.ts +52 -0
- package/src/runner.ts +184 -0
- package/src/sdk-agent-runner.ts +575 -0
- package/src/stage-cli.ts +302 -0
- package/src/stage-run.ts +91 -0
- package/src/verification.ts +711 -0
- package/src/worktree.ts +639 -0
|
@@ -0,0 +1,594 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Command evidence collector (card #690, from the #616 battle report P3.7).
|
|
3
|
+
*
|
|
4
|
+
* The gap this closes: the gate runner had four collectors for seven kinds, so a
|
|
5
|
+
* `custom` gate reported `blocked` forever (`gate-collectors.ts`) and conditions
|
|
6
|
+
* could only address what those four produced. A goal phrased as a NUMBER —
|
|
7
|
+
* "homepage Lighthouse score ≥ 90, stop after 5 tries" — had no evidence source
|
|
8
|
+
* and was simply not expressible.
|
|
9
|
+
*
|
|
10
|
+
* This module supplies the missing source: run an operator-allowlisted command in
|
|
11
|
+
* the worktree, parse one value out of its stdout, and put it in the evidence doc
|
|
12
|
+
* as `structured.value`. The existing closed-enum predicate then does the gating
|
|
13
|
+
* (`{ path: "value", op: "gte", value: 90 }`), and the existing converge-loop
|
|
14
|
+
* machinery (`max_iterations` + `resolveLoopExitGate` + `decideLoopContinuation`)
|
|
15
|
+
* turns that into the /goal loop: re-run this stage until the number clears the
|
|
16
|
+
* bar or the try budget is spent. No new gate kind, no new operator, no new loop —
|
|
17
|
+
* one new *measurement*, and the parts already in the tree compose into the goal.
|
|
18
|
+
*
|
|
19
|
+
* ## Trust boundary (the reason this is an allowlist and not a command field)
|
|
20
|
+
*
|
|
21
|
+
* A playbook is workspace data: any member can edit it, and it is fetched by a
|
|
22
|
+
* daemon that executes on someone's machine. A gate carrying its own command
|
|
23
|
+
* string would therefore be remote code execution on that machine, dressed as a
|
|
24
|
+
* board edit. So the two halves are split:
|
|
25
|
+
*
|
|
26
|
+
* - the playbook gate names a metric → `{ kind: "custom", metric: "lh_perf" }`
|
|
27
|
+
* - the operator's own config declares it → `agent.playbooks.metrics.lh_perf`
|
|
28
|
+
*
|
|
29
|
+
* A name that is not declared runs nothing and HOLDS with a legible reason. There
|
|
30
|
+
* is deliberately NO path for gate-supplied arguments to reach the process, and
|
|
31
|
+
* the command runs in argv form (never a shell), so no value is ever word-split,
|
|
32
|
+
* glob-expanded, or interpolated. This mirrors `entryActionAllowlist`: a vetted
|
|
33
|
+
* name maps to a vetted capability, and anything else is refused.
|
|
34
|
+
*
|
|
35
|
+
* ## Fail-closed contract
|
|
36
|
+
*
|
|
37
|
+
* Every path that does not end in a clean measurement yields `blocked` with a
|
|
38
|
+
* human-legible `structured.reason` — no metric name, an undeclared name, a
|
|
39
|
+
* malformed declaration, a non-zero exit, a timeout, unparsable output, or a
|
|
40
|
+
* missing JSON path. `blocked` evidence can never satisfy a gate (`gateEvaluate`
|
|
41
|
+
* short-circuits on it even when conditions would otherwise hold), so a broken
|
|
42
|
+
* measurement can never read as a passing goal. Nothing here throws: the collector
|
|
43
|
+
* is total, so a flaky tool can't crash the worker.
|
|
44
|
+
*
|
|
45
|
+
* The *config-defect* subset of those blocks (no metric name, an undeclared name,
|
|
46
|
+
* a malformed declaration) additionally carries the `GATE_CONFIG_ERROR_MARK`, so
|
|
47
|
+
* the advancement engine holds the card instead of re-running the stage against
|
|
48
|
+
* inputs that cannot change (#823 — see `gate-config-error.ts`).
|
|
49
|
+
*
|
|
50
|
+
* ## Process supervision (#823)
|
|
51
|
+
*
|
|
52
|
+
* The measurement runs as a **process-group leader** (`spawnInGroup`), never as a
|
|
53
|
+
* bare child. The headline use case forks: `lighthouse` starts Chrome, so the
|
|
54
|
+
* plain `execFileSync` this collector shipped with (#690) SIGTERM'd only the
|
|
55
|
+
* direct child on a timeout and left the browser holding its debug port and CPU —
|
|
56
|
+
* once per converge-loop iteration, with no reaper. Every exit path here now
|
|
57
|
+
* escalates over the whole group (`terminateGroup`) and sweeps the group by its
|
|
58
|
+
* recorded pgid afterwards (`reapGroup`), so a timed-out measurement leaves no
|
|
59
|
+
* descendants behind.
|
|
60
|
+
*
|
|
61
|
+
* The run is also genuinely **asynchronous**. The daemon is one Node process: the
|
|
62
|
+
* realtime watcher, the `/health` endpoint, the reconcile heartbeat, the merge
|
|
63
|
+
* monitor and every pool worker share its event loop. A synchronous
|
|
64
|
+
* `execFileSync` froze all of them for the length of the measurement — up to
|
|
65
|
+
* fifteen minutes for a Lighthouse run. `collect()` was already `async` and every
|
|
66
|
+
* caller up to the worker already awaited it, so awaiting a spawned child here
|
|
67
|
+
* keeps the daemon responsive for free.
|
|
68
|
+
*/
|
|
69
|
+
|
|
70
|
+
import type { ChildProcess } from "node:child_process";
|
|
71
|
+
import type {
|
|
72
|
+
GateEvidence,
|
|
73
|
+
GateEvidenceCollector,
|
|
74
|
+
GateEvidenceContext,
|
|
75
|
+
GateKind,
|
|
76
|
+
} from "@harmony/shared";
|
|
77
|
+
import { resolvePath } from "@harmony/shared";
|
|
78
|
+
import {
|
|
79
|
+
DEFAULT_METRIC_TIMEOUT_MS,
|
|
80
|
+
type PlaybookMetricDef,
|
|
81
|
+
} from "./exec-types.js";
|
|
82
|
+
import { GATE_CONFIG_ERROR_MARK } from "./gate-config-error.js";
|
|
83
|
+
import { log } from "./log.js";
|
|
84
|
+
import { reapGroup, spawnInGroup, terminateGroup } from "./process-group.js";
|
|
85
|
+
|
|
86
|
+
const TAG = "command-metric";
|
|
87
|
+
|
|
88
|
+
/** Cap on captured stdout kept in the evidence row — the doc is a jsonb column,
|
|
89
|
+
* and a real tool (a full Lighthouse report) prints far more than is useful as a
|
|
90
|
+
* diagnostic. The parsed `value` is the signal; `raw` is only for reading later. */
|
|
91
|
+
const MAX_RAW_CHARS = 2000;
|
|
92
|
+
|
|
93
|
+
/** Same cap the other verification primitives use for a child process's output. */
|
|
94
|
+
const MAX_OUTPUT_BUFFER = 10 * 1024 * 1024;
|
|
95
|
+
|
|
96
|
+
/** Cap on captured stderr. It only ever feeds a truncated failure reason, so
|
|
97
|
+
* buffering a runaway error stream would cost memory for no diagnostic gain. */
|
|
98
|
+
const MAX_STDERR_CHARS = 64 * 1024;
|
|
99
|
+
|
|
100
|
+
/** Hard ceiling on a metric's timeout, whatever the operator declared. The run no
|
|
101
|
+
* longer blocks the daemon's event loop (#823), but a stage still waits on it: an
|
|
102
|
+
* unbounded value strands the card in its column with a live session and a held
|
|
103
|
+
* worker slot, and no board-facing reason. 15 min is well past any real measurement
|
|
104
|
+
* tool and still bounded. */
|
|
105
|
+
const MAX_METRIC_TIMEOUT_MS = 900_000;
|
|
106
|
+
|
|
107
|
+
/** Grace windows for the escalating group termination on a timeout / overflow.
|
|
108
|
+
* A measurement tool gets a moment to shut down cleanly (Chrome flushes its
|
|
109
|
+
* profile) before the group is SIGKILLed — the same SIGINT → SIGTERM → SIGKILL
|
|
110
|
+
* ladder the Claude subprocess uses, just with shorter waits: nothing here has
|
|
111
|
+
* uncommitted work worth a long drain. */
|
|
112
|
+
const METRIC_SIGINT_GRACE_MS = 2_000;
|
|
113
|
+
const METRIC_SIGTERM_GRACE_MS = 3_000;
|
|
114
|
+
|
|
115
|
+
/** How long to wait after the leader exits for its stdio pipes to deliver the
|
|
116
|
+
* tail of the output. Bounded on purpose: a grandchild that left the group with
|
|
117
|
+
* its own `setsid()` keeps the pipe open forever, and a measurement that already
|
|
118
|
+
* produced its exit code must not wait on it. */
|
|
119
|
+
const STDIO_DRAIN_GRACE_MS = 500;
|
|
120
|
+
|
|
121
|
+
/** The value kinds a metric may resolve to. Non-primitives are refused — a
|
|
122
|
+
* condition can only compare primitives, so an object here is a config error
|
|
123
|
+
* worth naming rather than a silently-failing gate. */
|
|
124
|
+
export type MetricValue = string | number | boolean | null;
|
|
125
|
+
|
|
126
|
+
/** A parsed, validated `parse` directive. */
|
|
127
|
+
export type ParseMode =
|
|
128
|
+
| { kind: "number" }
|
|
129
|
+
| { kind: "json"; path: string }
|
|
130
|
+
| { kind: "invalid"; reason: string };
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Parse the `parse` directive of a metric declaration. Pure + total.
|
|
134
|
+
*
|
|
135
|
+
* `"number"` reads the whole trimmed stdout as a number; `"json:<dot.path>"`
|
|
136
|
+
* reads stdout as JSON and resolves the path. An unrecognized directive is
|
|
137
|
+
* `invalid` — never silently treated as one of the two, because guessing here
|
|
138
|
+
* would turn a typo into a gate that measures the wrong thing.
|
|
139
|
+
*/
|
|
140
|
+
export function parseParseMode(parse: unknown): ParseMode {
|
|
141
|
+
if (typeof parse !== "string" || parse.length === 0) {
|
|
142
|
+
return { kind: "invalid", reason: "`parse` must be a non-empty string" };
|
|
143
|
+
}
|
|
144
|
+
if (parse === "number") return { kind: "number" };
|
|
145
|
+
if (parse.startsWith("json:")) {
|
|
146
|
+
const path = parse.slice("json:".length).trim();
|
|
147
|
+
if (!path) {
|
|
148
|
+
return { kind: "invalid", reason: '`parse` "json:" is missing a path' };
|
|
149
|
+
}
|
|
150
|
+
return { kind: "json", path };
|
|
151
|
+
}
|
|
152
|
+
return {
|
|
153
|
+
kind: "invalid",
|
|
154
|
+
reason: `unknown \`parse\` mode "${parse}" (expected "number" or "json:<path>")`,
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** Outcome of reading a value out of a command's stdout. */
|
|
159
|
+
export type MetricParseResult =
|
|
160
|
+
| { ok: true; value: MetricValue }
|
|
161
|
+
| { ok: false; reason: string };
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Extract the metric value from captured stdout. Pure + total — the one place
|
|
165
|
+
* output becomes a number, so the collector body stays about process control.
|
|
166
|
+
*
|
|
167
|
+
* A `number` parse rejects empty/NaN output rather than coercing it: `Number("")`
|
|
168
|
+
* is 0, and a metric that silently reads 0 would fail a `gte` gate for a reason
|
|
169
|
+
* no one could see. `json` rejects a resolved value that is `undefined` (path
|
|
170
|
+
* absent) or non-primitive (an object can't be compared by any operator).
|
|
171
|
+
*/
|
|
172
|
+
export function parseMetricValue(
|
|
173
|
+
mode: ParseMode,
|
|
174
|
+
stdout: string,
|
|
175
|
+
): MetricParseResult {
|
|
176
|
+
if (mode.kind === "invalid") return { ok: false, reason: mode.reason };
|
|
177
|
+
|
|
178
|
+
if (mode.kind === "number") {
|
|
179
|
+
const trimmed = stdout.trim();
|
|
180
|
+
if (!trimmed) {
|
|
181
|
+
return {
|
|
182
|
+
ok: false,
|
|
183
|
+
reason: "command produced no output to read a number from",
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
const value = Number(trimmed);
|
|
187
|
+
if (!Number.isFinite(value)) {
|
|
188
|
+
return {
|
|
189
|
+
ok: false,
|
|
190
|
+
reason: `command output is not a finite number: ${JSON.stringify(truncate(trimmed, 120))}`,
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
return { ok: true, value };
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
let doc: unknown;
|
|
197
|
+
try {
|
|
198
|
+
doc = JSON.parse(stdout);
|
|
199
|
+
} catch (err) {
|
|
200
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
201
|
+
return { ok: false, reason: `command output is not valid JSON: ${msg}` };
|
|
202
|
+
}
|
|
203
|
+
// Reuse the evaluator's own resolver: same dot-path semantics a condition uses,
|
|
204
|
+
// and it never descends into the prototype chain.
|
|
205
|
+
const resolved = resolvePath(doc, mode.path);
|
|
206
|
+
if (resolved === undefined) {
|
|
207
|
+
return {
|
|
208
|
+
ok: false,
|
|
209
|
+
reason: `JSON path "${mode.path}" is absent in the command output`,
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
if (
|
|
213
|
+
resolved !== null &&
|
|
214
|
+
typeof resolved !== "number" &&
|
|
215
|
+
typeof resolved !== "string" &&
|
|
216
|
+
typeof resolved !== "boolean"
|
|
217
|
+
) {
|
|
218
|
+
return {
|
|
219
|
+
ok: false,
|
|
220
|
+
reason: `JSON path "${mode.path}" resolved to a ${Array.isArray(resolved) ? "array" : typeof resolved}, which no gate operator can compare`,
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
return { ok: true, value: resolved };
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/** The one argument shape a metric runner receives. */
|
|
227
|
+
export interface MetricCommandRun {
|
|
228
|
+
command: string;
|
|
229
|
+
args: string[];
|
|
230
|
+
cwd: string;
|
|
231
|
+
timeoutMs: number;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/** Deps the command collector needs. Injected so the collector is unit-testable
|
|
235
|
+
* with a fake runner instead of a real subprocess. */
|
|
236
|
+
export interface CommandMetricDeps {
|
|
237
|
+
/** Where the command runs — the card's isolated checkout, never the user's tree. */
|
|
238
|
+
worktreePath: string;
|
|
239
|
+
/** The operator's allowlist, keyed by metric name (`agent.playbooks.metrics`). */
|
|
240
|
+
metrics: Record<string, PlaybookMetricDef>;
|
|
241
|
+
/**
|
|
242
|
+
* Inject for tests; defaults to {@link runMetricCommand}. May be sync or async —
|
|
243
|
+
* the collector awaits it either way, so the many fakes that just return a string
|
|
244
|
+
* stay valid while the real runner is a spawned child.
|
|
245
|
+
*/
|
|
246
|
+
runCommand?: (args: MetricCommandRun) => string | Promise<string>;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* Run the measurement for real and resolve its stdout. Rejects on a non-zero exit,
|
|
251
|
+
* a timeout, an output-cap overflow, or a missing binary — the collector turns any
|
|
252
|
+
* of those into `blocked` via {@link describeRunFailure}.
|
|
253
|
+
*
|
|
254
|
+
* Three properties matter here (#823):
|
|
255
|
+
*
|
|
256
|
+
* 1. **Group leader.** `spawnInGroup` gives the child its own process group, so
|
|
257
|
+
* a timeout signals the whole tree rather than just the direct child. A
|
|
258
|
+
* `lighthouse` that forked Chrome dies with its browser.
|
|
259
|
+
* 2. **Reaped on every path.** The pgid is recorded at spawn and swept with
|
|
260
|
+
* `reapGroup` when the promise settles — clean exit included, because a tool
|
|
261
|
+
* that backgrounded a helper leaves it reparented but still in the group.
|
|
262
|
+
* 3. **Non-blocking.** Nothing here occupies the event loop, so the daemon's
|
|
263
|
+
* watcher, health endpoint and other workers keep running during a long
|
|
264
|
+
* measurement.
|
|
265
|
+
*
|
|
266
|
+
* The rejection objects deliberately mimic `execFileSync`'s error shape
|
|
267
|
+
* (`code: "ETIMEDOUT" | "ENOBUFS" | "ENOENT"`, `status`, `stderr`) so the failure
|
|
268
|
+
* description stays one pure function shared with any injected runner.
|
|
269
|
+
*/
|
|
270
|
+
export function runMetricCommand(args: MetricCommandRun): Promise<string> {
|
|
271
|
+
return new Promise<string>((resolve, reject) => {
|
|
272
|
+
let child: ChildProcess;
|
|
273
|
+
try {
|
|
274
|
+
child = spawnInGroup(args.command, args.args, {
|
|
275
|
+
cwd: args.cwd,
|
|
276
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
277
|
+
});
|
|
278
|
+
} catch (err) {
|
|
279
|
+
// A synchronous spawn throw (unusable cwd, bad argv) — no group exists yet.
|
|
280
|
+
reject(err);
|
|
281
|
+
return;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// Record the pgid AT SPAWN: `reapGroup` addresses the group by number, so it
|
|
285
|
+
// still sweeps stragglers once the leader handle is spent.
|
|
286
|
+
const pgid = child.pid;
|
|
287
|
+
const chunks: Buffer[] = [];
|
|
288
|
+
let stdoutBytes = 0;
|
|
289
|
+
let stderr = "";
|
|
290
|
+
let settled = false;
|
|
291
|
+
let killReason: "timeout" | "overflow" | null = null;
|
|
292
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
293
|
+
let drainTimer: ReturnType<typeof setTimeout> | undefined;
|
|
294
|
+
|
|
295
|
+
/** Settle once, and never without sweeping the group. */
|
|
296
|
+
const settle = (failure: Error | null): void => {
|
|
297
|
+
if (settled) return;
|
|
298
|
+
settled = true;
|
|
299
|
+
if (timer) clearTimeout(timer);
|
|
300
|
+
if (drainTimer) clearTimeout(drainTimer);
|
|
301
|
+
reapGroup(pgid);
|
|
302
|
+
if (failure) reject(failure);
|
|
303
|
+
else resolve(Buffer.concat(chunks).toString("utf8"));
|
|
304
|
+
};
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* Escalate SIGINT → SIGTERM → SIGKILL across the GROUP, then settle. The
|
|
308
|
+
* settle is deferred until termination returns so the collector's caller can
|
|
309
|
+
* rely on the tree being gone once `collect()` resolves — the acceptance
|
|
310
|
+
* property of this card, and the reason the `exit` handler below yields to
|
|
311
|
+
* this path once a kill is in flight.
|
|
312
|
+
*/
|
|
313
|
+
const killTree = (reason: "timeout" | "overflow"): void => {
|
|
314
|
+
if (settled || killReason) return;
|
|
315
|
+
killReason = reason;
|
|
316
|
+
terminateGroup(child, {
|
|
317
|
+
sigintTimeoutMs: METRIC_SIGINT_GRACE_MS,
|
|
318
|
+
sigtermTimeoutMs: METRIC_SIGTERM_GRACE_MS,
|
|
319
|
+
})
|
|
320
|
+
.catch(() => {
|
|
321
|
+
// terminateGroup swallows its own signal errors; guard anyway so a
|
|
322
|
+
// rejection can never strand the promise unsettled.
|
|
323
|
+
})
|
|
324
|
+
.then(() => {
|
|
325
|
+
settle(
|
|
326
|
+
reason === "timeout"
|
|
327
|
+
? Object.assign(
|
|
328
|
+
new Error(`command timed out after ${args.timeoutMs}ms`),
|
|
329
|
+
{ code: "ETIMEDOUT" },
|
|
330
|
+
)
|
|
331
|
+
: Object.assign(new Error("stdout maxBuffer exceeded"), {
|
|
332
|
+
code: "ENOBUFS",
|
|
333
|
+
}),
|
|
334
|
+
);
|
|
335
|
+
});
|
|
336
|
+
};
|
|
337
|
+
|
|
338
|
+
child.stdout?.on("data", (chunk: Buffer) => {
|
|
339
|
+
stdoutBytes += chunk.length;
|
|
340
|
+
if (stdoutBytes > MAX_OUTPUT_BUFFER) {
|
|
341
|
+
// Match `execFileSync`'s maxBuffer contract: stop buffering and kill.
|
|
342
|
+
killTree("overflow");
|
|
343
|
+
return;
|
|
344
|
+
}
|
|
345
|
+
chunks.push(chunk);
|
|
346
|
+
});
|
|
347
|
+
child.stderr?.on("data", (chunk: Buffer) => {
|
|
348
|
+
if (stderr.length >= MAX_STDERR_CHARS) return;
|
|
349
|
+
stderr += chunk.toString("utf8");
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
// A missing binary surfaces as an async `error` event here (an async spawn
|
|
353
|
+
// never throws it), carrying the same `ENOENT` code execFileSync threw.
|
|
354
|
+
child.once("error", (err) => settle(err));
|
|
355
|
+
|
|
356
|
+
/** Settle from a recorded exit — the leader is gone, the outcome is known. */
|
|
357
|
+
const settleFromExit = (
|
|
358
|
+
code: number | null,
|
|
359
|
+
signal: NodeJS.Signals | null,
|
|
360
|
+
): void => {
|
|
361
|
+
if (drainTimer) clearTimeout(drainTimer);
|
|
362
|
+
if (code === 0) {
|
|
363
|
+
settle(null);
|
|
364
|
+
return;
|
|
365
|
+
}
|
|
366
|
+
// A signal we did NOT send (an operator's `kill`, an OOM killer). Reported
|
|
367
|
+
// as a plain failure, never as a timeout: `signal` is left off the error so
|
|
368
|
+
// it can't trip `describeRunFailure`'s legacy SIGTERM heuristic and blame a
|
|
369
|
+
// clock that had nothing to do with it.
|
|
370
|
+
const detail = signal
|
|
371
|
+
? `terminated by signal ${signal}`
|
|
372
|
+
: `exited ${code}`;
|
|
373
|
+
settle(
|
|
374
|
+
Object.assign(new Error(`command ${detail}`), {
|
|
375
|
+
status: typeof code === "number" ? code : null,
|
|
376
|
+
stderr,
|
|
377
|
+
}),
|
|
378
|
+
);
|
|
379
|
+
};
|
|
380
|
+
|
|
381
|
+
child.once("exit", (code, signal) => {
|
|
382
|
+
if (killReason) return; // the kill path owns this outcome
|
|
383
|
+
// The timeout governs the command's RUNTIME, which is now over. Disarm it so
|
|
384
|
+
// a slow pipe drain can't turn a finished measurement into a reported
|
|
385
|
+
// timeout; `drainTimer` below bounds what remains.
|
|
386
|
+
if (timer) clearTimeout(timer);
|
|
387
|
+
// Reap HERE, before waiting on the pipes. A grandchild the command
|
|
388
|
+
// backgrounded inherits the stdout pipe, so `close` cannot fire while it
|
|
389
|
+
// lives — waiting for it first would hang the measurement until the timeout
|
|
390
|
+
// (which is what a plain `execFileSync` did). Killing the group both stops
|
|
391
|
+
// that leak and releases the pipe.
|
|
392
|
+
reapGroup(pgid);
|
|
393
|
+
// Then give the drained pipes a moment to deliver the tail, so a fast
|
|
394
|
+
// command can't lose output between `exit` and the last `data` event. The
|
|
395
|
+
// grace is bounded because a grandchild that escaped the group with its own
|
|
396
|
+
// `setsid()` would hold the pipe open forever.
|
|
397
|
+
drainTimer = setTimeout(
|
|
398
|
+
() => settleFromExit(code, signal),
|
|
399
|
+
STDIO_DRAIN_GRACE_MS,
|
|
400
|
+
);
|
|
401
|
+
child.once("close", () => settleFromExit(code, signal));
|
|
402
|
+
});
|
|
403
|
+
|
|
404
|
+
timer = setTimeout(() => killTree("timeout"), args.timeoutMs);
|
|
405
|
+
});
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
/**
|
|
409
|
+
* `custom` — measure an operator-allowlisted command and expose its value for the
|
|
410
|
+
* gate's conditions to judge (#690).
|
|
411
|
+
*
|
|
412
|
+
* structured shape (predicate paths address into this):
|
|
413
|
+
* passed: { metric: string, value: number|string|boolean|null, raw: string }
|
|
414
|
+
* blocked: { metric: string|null, reason: string }
|
|
415
|
+
*
|
|
416
|
+
* `result` is about whether a MEASUREMENT was obtained, not whether it is good
|
|
417
|
+
* enough — the conditions own the bar. So a clean run is `passed` even for a bad
|
|
418
|
+
* number, and `{ path: "value", op: "gte", value: 90 }` is what fails the gate.
|
|
419
|
+
* A `custom` gate with no conditions therefore means "this command exits 0",
|
|
420
|
+
* which is a useful smoke-test gate in its own right.
|
|
421
|
+
*/
|
|
422
|
+
export class CommandMetricCollector implements GateEvidenceCollector {
|
|
423
|
+
readonly kind: GateKind = "custom";
|
|
424
|
+
constructor(private readonly deps: CommandMetricDeps) {}
|
|
425
|
+
|
|
426
|
+
async collect(context: GateEvidenceContext): Promise<GateEvidence> {
|
|
427
|
+
// ---- Configuration defects (#823) --------------------------------------
|
|
428
|
+
// Everything down to the timeout clamp is decided by static config alone: the
|
|
429
|
+
// gate's own text and the operator's declaration. Re-running the stage cannot
|
|
430
|
+
// change any of it, so these blocks are marked non-retryable and the
|
|
431
|
+
// advancement engine holds the card instead of burning its attempt budget.
|
|
432
|
+
const name = context.gate.metric;
|
|
433
|
+
if (typeof name !== "string" || name.length === 0) {
|
|
434
|
+
return configError(
|
|
435
|
+
null,
|
|
436
|
+
'Gate kind "custom" needs a `metric` name naming an allowlisted command (e.g. { "kind": "custom", "metric": "lighthouse_performance" }).',
|
|
437
|
+
);
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
// The allowlist check. An undeclared name never runs anything — this is the
|
|
441
|
+
// fail-closed boundary, so the reason names the config key an operator edits.
|
|
442
|
+
const def = Object.hasOwn(this.deps.metrics, name)
|
|
443
|
+
? this.deps.metrics[name]
|
|
444
|
+
: undefined;
|
|
445
|
+
if (!def) {
|
|
446
|
+
return configError(
|
|
447
|
+
name,
|
|
448
|
+
`Metric "${name}" is not declared in this daemon's allowlist — add it under \`agent.playbooks.metrics\` to permit it.`,
|
|
449
|
+
);
|
|
450
|
+
}
|
|
451
|
+
if (typeof def.command !== "string" || def.command.length === 0) {
|
|
452
|
+
return configError(name, `Metric "${name}" declares no \`command\`.`);
|
|
453
|
+
}
|
|
454
|
+
const mode = parseParseMode(def.parse);
|
|
455
|
+
if (mode.kind === "invalid") {
|
|
456
|
+
return configError(name, `Metric "${name}": ${mode.reason}.`);
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
// Clamped, not merely validated. The measurement no longer blocks the daemon's
|
|
460
|
+
// event loop, but the card's stage waits on it with a live session and a held
|
|
461
|
+
// worker slot, so the operator's number is honoured only up to a ceiling.
|
|
462
|
+
const requested =
|
|
463
|
+
typeof def.timeoutMs === "number" && def.timeoutMs > 0
|
|
464
|
+
? Math.floor(def.timeoutMs)
|
|
465
|
+
: DEFAULT_METRIC_TIMEOUT_MS;
|
|
466
|
+
const timeoutMs = Math.min(requested, MAX_METRIC_TIMEOUT_MS);
|
|
467
|
+
if (timeoutMs < requested) {
|
|
468
|
+
// Say so: otherwise the blocked reason quotes a duration that appears
|
|
469
|
+
// nowhere in the operator's config, and they go looking for a typo.
|
|
470
|
+
log.warn(
|
|
471
|
+
TAG,
|
|
472
|
+
`Metric "${name}" declares timeoutMs=${requested}, clamped to the ${MAX_METRIC_TIMEOUT_MS}ms ceiling`,
|
|
473
|
+
);
|
|
474
|
+
}
|
|
475
|
+
const run = this.deps.runCommand ?? runMetricCommand;
|
|
476
|
+
|
|
477
|
+
let stdout: string;
|
|
478
|
+
try {
|
|
479
|
+
stdout = await run({
|
|
480
|
+
command: def.command,
|
|
481
|
+
args: def.args ?? [],
|
|
482
|
+
cwd: this.deps.worktreePath,
|
|
483
|
+
timeoutMs,
|
|
484
|
+
});
|
|
485
|
+
} catch (err) {
|
|
486
|
+
// Non-zero exit, timeout, or a missing binary: no measurement exists, so we
|
|
487
|
+
// cannot judge the goal. `blocked` (not `failed`) is the honest signal, and
|
|
488
|
+
// it can never satisfy the gate. NOT marked as a config error: unlike an
|
|
489
|
+
// undeclared metric, a run failure can genuinely differ on the next attempt
|
|
490
|
+
// (the tool is flaky, the branch under measurement changes), so it keeps the
|
|
491
|
+
// ordinary gate-unmet retry path.
|
|
492
|
+
const reason = describeRunFailure(err, timeoutMs);
|
|
493
|
+
log.warn(
|
|
494
|
+
TAG,
|
|
495
|
+
`Metric "${name}" did not produce a measurement: ${reason}`,
|
|
496
|
+
);
|
|
497
|
+
return blocked(name, reason);
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
const parsed = parseMetricValue(mode, stdout);
|
|
501
|
+
if (!parsed.ok) {
|
|
502
|
+
log.warn(TAG, `Metric "${name}" output unusable: ${parsed.reason}`);
|
|
503
|
+
return blocked(name, `Metric "${name}": ${parsed.reason}.`, stdout);
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
log.info(TAG, `Metric "${name}" measured: ${JSON.stringify(parsed.value)}`);
|
|
507
|
+
return {
|
|
508
|
+
result: "passed",
|
|
509
|
+
structured: {
|
|
510
|
+
metric: name,
|
|
511
|
+
value: parsed.value,
|
|
512
|
+
raw: truncate(stdout, MAX_RAW_CHARS),
|
|
513
|
+
},
|
|
514
|
+
};
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
/** Build the `blocked` evidence shape, with the reason a human will actually read. */
|
|
519
|
+
function blocked(
|
|
520
|
+
metric: string | null,
|
|
521
|
+
reason: string,
|
|
522
|
+
raw?: string,
|
|
523
|
+
): GateEvidence {
|
|
524
|
+
return {
|
|
525
|
+
result: "blocked",
|
|
526
|
+
structured: {
|
|
527
|
+
metric,
|
|
528
|
+
reason,
|
|
529
|
+
...(raw === undefined ? {} : { raw: truncate(raw, MAX_RAW_CHARS) }),
|
|
530
|
+
},
|
|
531
|
+
};
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
/**
|
|
535
|
+
* A `blocked` that also says "re-running cannot help" (#823). Reserved for defects
|
|
536
|
+
* decided entirely by static configuration — an absent metric name, an undeclared
|
|
537
|
+
* one, a declaration with no `command` or an unknown `parse` mode. The advancement
|
|
538
|
+
* engine holds the card on these instead of spending a full stage re-run (and a
|
|
539
|
+
* give-up attempt) to compute the identical answer.
|
|
540
|
+
*/
|
|
541
|
+
function configError(metric: string | null, reason: string): GateEvidence {
|
|
542
|
+
const evidence = blocked(metric, reason);
|
|
543
|
+
return {
|
|
544
|
+
...evidence,
|
|
545
|
+
structured: { ...evidence.structured, ...GATE_CONFIG_ERROR_MARK },
|
|
546
|
+
};
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
/**
|
|
550
|
+
* Turn an `execFileSync` throw into a legible reason. A timeout surfaces as an
|
|
551
|
+
* ETIMEDOUT/SIGTERM child rather than an exit code, so it is named explicitly —
|
|
552
|
+
* "timed out after 5m" is actionable where "command failed" is not.
|
|
553
|
+
*/
|
|
554
|
+
function describeRunFailure(err: unknown, timeoutMs: number): string {
|
|
555
|
+
const e = err as {
|
|
556
|
+
code?: unknown;
|
|
557
|
+
signal?: unknown;
|
|
558
|
+
status?: unknown;
|
|
559
|
+
stderr?: unknown;
|
|
560
|
+
message?: unknown;
|
|
561
|
+
};
|
|
562
|
+
// ORDER MATTERS: a `maxBuffer` overflow is also SIGTERM'd (`{ code: "ENOBUFS",
|
|
563
|
+
// signal: "SIGTERM" }`), so it must be named before the timeout branch or a tool
|
|
564
|
+
// that simply prints too much reads as a slow one. That is not hypothetical for
|
|
565
|
+
// this card: a full Lighthouse JSON report is the headline use case and the
|
|
566
|
+
// biggest output any of these commands produces, so the misreport would land
|
|
567
|
+
// exactly where it costs the most — sending an operator after a performance
|
|
568
|
+
// problem that does not exist.
|
|
569
|
+
if (e?.code === "ENOBUFS") {
|
|
570
|
+
return `command produced more than ${MAX_OUTPUT_BUFFER} bytes of output (use a flag that prints only the metric, or parse a smaller report)`;
|
|
571
|
+
}
|
|
572
|
+
if (e?.code === "ETIMEDOUT" || e?.signal === "SIGTERM") {
|
|
573
|
+
return `command timed out after ${timeoutMs}ms`;
|
|
574
|
+
}
|
|
575
|
+
if (e?.code === "ENOENT") {
|
|
576
|
+
return "command not found on PATH";
|
|
577
|
+
}
|
|
578
|
+
const stderr =
|
|
579
|
+
typeof e?.stderr === "string"
|
|
580
|
+
? e.stderr
|
|
581
|
+
: e?.stderr instanceof Buffer
|
|
582
|
+
? e.stderr.toString("utf8")
|
|
583
|
+
: "";
|
|
584
|
+
const detail = stderr.trim() || String(e?.message ?? err);
|
|
585
|
+
const status = typeof e?.status === "number" ? e.status : null;
|
|
586
|
+
return status === null
|
|
587
|
+
? `command failed: ${truncate(detail, 400)}`
|
|
588
|
+
: `command exited ${status}: ${truncate(detail, 400)}`;
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
/** Trim a string to `max` chars, marking that it was cut. */
|
|
592
|
+
function truncate(value: string, max: number): string {
|
|
593
|
+
return value.length <= max ? value : `${value.slice(0, max)}…[truncated]`;
|
|
594
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Classify a failed Claude CLI run into an actionable error class.
|
|
3
|
+
*
|
|
4
|
+
* The implement worker captures the CLI's stderr and folds it into the
|
|
5
|
+
* rejection message (`claude exited with code N: <stderr>`). When a run dies
|
|
6
|
+
* from an Anthropic API condition — auth, billing, rate limit, usage cap — the
|
|
7
|
+
* CLI exits non-zero with a recognisable message. Without classification the
|
|
8
|
+
* worker treats every such failure as a generic crash, counts it against the
|
|
9
|
+
* card's give-up budget, and (historically) stranded the card. Classifying
|
|
10
|
+
* lets the worker requeue WITHOUT burning an attempt and lets the pool back off
|
|
11
|
+
* so it doesn't hot-loop the same failing call across every in-flight card.
|
|
12
|
+
*
|
|
13
|
+
* `kind: null` means "not a recognised API/quota error" — treat it as a generic
|
|
14
|
+
* crash (requeue + count the attempt, same as a timeout).
|
|
15
|
+
*/
|
|
16
|
+
export type ApiErrorKind =
|
|
17
|
+
| "rate_limit"
|
|
18
|
+
| "out_of_credits"
|
|
19
|
+
| "usage_limit"
|
|
20
|
+
| "auth";
|
|
21
|
+
|
|
22
|
+
export interface RunErrorClass {
|
|
23
|
+
kind: ApiErrorKind | null;
|
|
24
|
+
/** Parsed from a `Retry-After` hint when present (milliseconds). */
|
|
25
|
+
retryAfterMs?: number;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// Order matters: "usage limit" and "credit balance" both contain tokens that
|
|
29
|
+
// the rate-limit patterns would otherwise swallow, so the more specific
|
|
30
|
+
// billing/usage checks run before the generic rate-limit check.
|
|
31
|
+
const AUTH =
|
|
32
|
+
/\b401\b|invalid x-api-key|authentication_error|unauthorized|oauth token (?:has )?expired|please run .*login|invalid bearer token/i;
|
|
33
|
+
const OUT_OF_CREDITS =
|
|
34
|
+
/\b402\b|credit balance is too low|insufficient (?:funds|credit|balance)|billing|payment required|purchase more credits/i;
|
|
35
|
+
const USAGE_LIMIT =
|
|
36
|
+
/usage limit|daily limit|monthly limit|quota (?:exceeded|reached)|reached your .{0,20}limit|usage_limit_reached|limit will reset/i;
|
|
37
|
+
const RATE_LIMIT =
|
|
38
|
+
/\b429\b|\b529\b|rate[ _-]?limit|too many requests|overloaded_error|"type"\s*:\s*"overloaded"/i;
|
|
39
|
+
|
|
40
|
+
/** Best-effort `Retry-After: <seconds>` extraction. */
|
|
41
|
+
function parseRetryAfterMs(message: string): number | undefined {
|
|
42
|
+
const match = message.match(/retry[- ]?after["':\s]+(\d+)/i);
|
|
43
|
+
if (!match) return undefined;
|
|
44
|
+
const seconds = Number(match[1]);
|
|
45
|
+
if (!Number.isFinite(seconds) || seconds <= 0) return undefined;
|
|
46
|
+
return seconds * 1000;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function classifyRunError(message: string): RunErrorClass {
|
|
50
|
+
if (!message) return { kind: null };
|
|
51
|
+
const retryAfterMs = parseRetryAfterMs(message);
|
|
52
|
+
|
|
53
|
+
// Auth first: a bad/expired key fails every card identically, so the pool
|
|
54
|
+
// pauses wholesale rather than churning. Check before billing because some
|
|
55
|
+
// 401 bodies also mention "billing".
|
|
56
|
+
if (AUTH.test(message)) return { kind: "auth", retryAfterMs };
|
|
57
|
+
if (OUT_OF_CREDITS.test(message))
|
|
58
|
+
return { kind: "out_of_credits", retryAfterMs };
|
|
59
|
+
if (USAGE_LIMIT.test(message)) return { kind: "usage_limit", retryAfterMs };
|
|
60
|
+
if (RATE_LIMIT.test(message)) return { kind: "rate_limit", retryAfterMs };
|
|
61
|
+
|
|
62
|
+
return { kind: null };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Human-readable, board-facing reason for an API error class. */
|
|
66
|
+
export function describeApiError(kind: ApiErrorKind): string {
|
|
67
|
+
switch (kind) {
|
|
68
|
+
case "auth":
|
|
69
|
+
return "Anthropic auth error — agent paused, check API credentials";
|
|
70
|
+
case "out_of_credits":
|
|
71
|
+
return "Anthropic credit balance too low — retrying after top-up";
|
|
72
|
+
case "usage_limit":
|
|
73
|
+
return "Anthropic usage limit reached — retrying after reset";
|
|
74
|
+
case "rate_limit":
|
|
75
|
+
return "Anthropic rate limit hit — retrying shortly";
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Default daemon-wide cooldown for an API error class, used when the API gave
|
|
81
|
+
* no explicit `Retry-After`. Billing/usage conditions reset slowly (or need a
|
|
82
|
+
* human), so they back off longer; transient rate limits clear fast.
|
|
83
|
+
*/
|
|
84
|
+
export function cooldownMsFor(kind: ApiErrorKind): number {
|
|
85
|
+
switch (kind) {
|
|
86
|
+
case "rate_limit":
|
|
87
|
+
return 60_000; // 1 min — transient, clears fast
|
|
88
|
+
case "usage_limit":
|
|
89
|
+
return 15 * 60_000; // 15 min — resets on a schedule
|
|
90
|
+
case "out_of_credits":
|
|
91
|
+
return 30 * 60_000; // 30 min — usually needs a human top-up
|
|
92
|
+
case "auth":
|
|
93
|
+
return 30 * 60_000; // fallback; pool pauses wholesale on auth anyway
|
|
94
|
+
}
|
|
95
|
+
}
|