faberun 0.8.0 → 0.10.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 +193 -31
- package/package.json +1 -1
- package/src/campaign/chain.mjs +19 -0
- package/src/campaign/journal.mjs +48 -0
- package/src/cli/brand.mjs +1 -0
- package/src/cli/campaign.mjs +15 -2
- package/src/cli/plan.mjs +142 -0
- package/src/cli.mjs +19 -3
- package/src/contract/final-verification.mjs +41 -10
- package/src/contract/task-packet.mjs +1 -1
- package/src/contract/worker-result.mjs +40 -8
- package/src/engine/lifecycle.mjs +11 -0
- package/src/engine/result-file.mjs +18 -4
- package/src/engine/scheduler.mjs +199 -26
- package/src/engine/settle.mjs +1 -1
- package/src/engine/verify.mjs +57 -4
- package/src/harnesses/protocol.mjs +91 -11
- package/src/plan/freeze.mjs +7 -3
- package/src/plan/pipeline.mjs +371 -0
- package/src/plan/template.mjs +278 -0
- package/src/repo/integrate.mjs +6 -1
- package/src/report/render.mjs +58 -58
- package/src/seat/allowance.mjs +177 -0
- package/src/web/index.html +3 -3
- package/src/web/server.mjs +2 -1
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Owns sampling the seat's allowance -- a coarse, harness-reported rate-limit
|
|
3
|
+
* signal -- from one minimal live invocation, and the delta between two
|
|
4
|
+
* samples. Separate from every other file in `seat/` because this is the one
|
|
5
|
+
* that spends a real call, however small; `seat/harnesses.mjs` only launches
|
|
6
|
+
* and detects, `seat/index.mjs` only composes tmux with that registry.
|
|
7
|
+
*
|
|
8
|
+
* Measured 2026-09-17 against `claude -p 'Reply with exactly OK and use no
|
|
9
|
+
* tools.' --output-format stream-json --verbose`, at a cost of
|
|
10
|
+
* `total_cost_usd: 0.27848` for that one probe (see `protocol.mjs`'s
|
|
11
|
+
* `extractClaudeAllowance` header for the raw measured line and the scale
|
|
12
|
+
* evidence): the stream carries one `rate_limit_event` line, not a field on
|
|
13
|
+
* the terminal `result` event, and no other operator harness (codex, zcode,
|
|
14
|
+
* dsh, agy) has been measured. `defaultInvoke` below only ever spawns a
|
|
15
|
+
* process for `claude`; every other harness name returns null without
|
|
16
|
+
* spending a call, which is also what happens once a claude call runs but the
|
|
17
|
+
* adapter finds no signal on it (`normalize(...).allowance` with every member
|
|
18
|
+
* null). Every sampling call site pays this cost again: `campaign init`
|
|
19
|
+
* (once) and `plan freeze` (once per phase) each spend one probe when the
|
|
20
|
+
* sampled harness is claude.
|
|
21
|
+
*/
|
|
22
|
+
import { spawn as nodeSpawn } from "node:child_process";
|
|
23
|
+
import { getHarness } from "../harnesses/index.mjs";
|
|
24
|
+
|
|
25
|
+
// `window` is optional on the type (not every construction site names one --
|
|
26
|
+
// `plan/pipeline.mjs` rebuilds a start sample from journal fields that predate
|
|
27
|
+
// this field) even though every live sample from `sampleAllowance` always
|
|
28
|
+
// carries it, `null` included.
|
|
29
|
+
/** @typedef {{remaining: number|null, limit: number|null, resetsAt: string|null, window?: string|null}} Allowance */
|
|
30
|
+
/** @typedef {{stdout: string, exitCode: number|null, signal: string|null}} InvokeResult */
|
|
31
|
+
|
|
32
|
+
const ALLOWANCE_PROBE_PROMPT = "Reply with exactly OK and use no tools.";
|
|
33
|
+
const ALLOWANCE_PROBE_TIMEOUT_MS = 30_000;
|
|
34
|
+
// The signal is account-wide (see protocol.mjs's extractClaudeAllowance
|
|
35
|
+
// header), not per-model, so the probe's own model choice does not affect
|
|
36
|
+
// what it measures; claude-sonnet-5 is the cheapest claude model this
|
|
37
|
+
// registry already names (src/engine/runtime-discovery.mjs), which keeps a
|
|
38
|
+
// spent probe call cheap without inventing an unlisted model string.
|
|
39
|
+
const ALLOWANCE_PROBE_MODEL = "claude-sonnet-5";
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* The argv/executable for one minimal claude probe, built through the claude
|
|
43
|
+
* adapter's own `command` so the probe pays the same preamble discipline
|
|
44
|
+
* (`claudePreambleArgs`: no skills, no MCP, no settings, a bare tool list)
|
|
45
|
+
* every worker invocation already pays instead of a hand-written argv that
|
|
46
|
+
* skips it -- measured 2026-09-01 on that discipline alone, ~4,300
|
|
47
|
+
* uncached input tokens per turn against 65,170 with the ambient
|
|
48
|
+
* configuration (see `harnesses/claude/index.mjs`).
|
|
49
|
+
*
|
|
50
|
+
* @returns {import("../harnesses/index.mjs").HarnessCommand}
|
|
51
|
+
*/
|
|
52
|
+
function claudeProbeCommand() {
|
|
53
|
+
return getHarness("claude").command({ harness: "claude", model: ALLOWANCE_PROBE_MODEL }, ALLOWANCE_PROBE_PROMPT, {});
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* One minimal claude call, its stdout handed back raw for the adapter's own
|
|
58
|
+
* `normalize` to parse. Every other harness resolves to null without
|
|
59
|
+
* spawning anything: the signal has only ever been measured on claude's
|
|
60
|
+
* stream, and probing a harness that is known to expose nothing would spend a
|
|
61
|
+
* call for no reading.
|
|
62
|
+
*
|
|
63
|
+
* @param {string} harness
|
|
64
|
+
* @param {{spawn?: typeof nodeSpawn}} [options]
|
|
65
|
+
* @returns {Promise<InvokeResult|null>}
|
|
66
|
+
*/
|
|
67
|
+
export function defaultInvoke(harness, { spawn = nodeSpawn } = {}) {
|
|
68
|
+
if (harness !== "claude") return Promise.resolve(null);
|
|
69
|
+
const command = claudeProbeCommand();
|
|
70
|
+
return new Promise((settle) => {
|
|
71
|
+
let child;
|
|
72
|
+
try {
|
|
73
|
+
child = spawn(command.executable, command.args, {
|
|
74
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
75
|
+
});
|
|
76
|
+
} catch {
|
|
77
|
+
settle(null);
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
let stdout = "";
|
|
81
|
+
let settled = false;
|
|
82
|
+
const timer = setTimeout(() => {
|
|
83
|
+
try {
|
|
84
|
+
child.kill("SIGTERM");
|
|
85
|
+
} catch {
|
|
86
|
+
// ESRCH: the child is already gone.
|
|
87
|
+
}
|
|
88
|
+
}, ALLOWANCE_PROBE_TIMEOUT_MS);
|
|
89
|
+
// A missing or early-dying binary can make the write below fail with
|
|
90
|
+
// EPIPE on the stdin stream itself, which is a different EventEmitter
|
|
91
|
+
// from the child process object below and needs its own listener (see
|
|
92
|
+
// `engine/process.mjs`'s stdin-transport branch for the same guard).
|
|
93
|
+
child.stdin.on("error", () => {});
|
|
94
|
+
child.stdin.end(command.input ?? "");
|
|
95
|
+
child.stdout.on("data", (chunk) => { stdout += chunk; });
|
|
96
|
+
child.stderr.on("data", () => {});
|
|
97
|
+
child.once("error", () => {
|
|
98
|
+
if (settled) return;
|
|
99
|
+
settled = true;
|
|
100
|
+
clearTimeout(timer);
|
|
101
|
+
settle(null);
|
|
102
|
+
});
|
|
103
|
+
child.once("close", (exitCode, signalName) => {
|
|
104
|
+
if (settled) return;
|
|
105
|
+
settled = true;
|
|
106
|
+
clearTimeout(timer);
|
|
107
|
+
settle({ stdout, exitCode, signal: signalName });
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* The current seat allowance, from one minimal invocation, or null when the
|
|
114
|
+
* harness is unset, the invocation fails, or the harness's adapter reports no
|
|
115
|
+
* signal. Never throws: an allowance sample is advisory, and a probe failure
|
|
116
|
+
* must not fail the campaign operation sampling it.
|
|
117
|
+
*
|
|
118
|
+
* @param {{harness: string|null, invoke?: (harness: string) => Promise<InvokeResult|null>|InvokeResult|null}} options
|
|
119
|
+
* @returns {Promise<Allowance|null>}
|
|
120
|
+
*/
|
|
121
|
+
export async function sampleAllowance({ harness, invoke = defaultInvoke }) {
|
|
122
|
+
if (!harness) return null;
|
|
123
|
+
/** @type {InvokeResult|null} */
|
|
124
|
+
let raw;
|
|
125
|
+
try {
|
|
126
|
+
raw = await invoke(harness);
|
|
127
|
+
} catch {
|
|
128
|
+
return null;
|
|
129
|
+
}
|
|
130
|
+
if (!raw || typeof raw.stdout !== "string") return null;
|
|
131
|
+
try {
|
|
132
|
+
const envelope = /** @type {{allowance?: Allowance|null}} */ (getHarness(harness).normalize(raw.stdout, raw.exitCode ?? null, raw.signal ?? null));
|
|
133
|
+
// The adapter always returns the shape (never omits it), with every
|
|
134
|
+
// member null when its stream carried no signal: that all-null shape and
|
|
135
|
+
// "no signal" are the same fact, so both collapse to null here.
|
|
136
|
+
return envelope.allowance && envelope.allowance.remaining !== null ? envelope.allowance : null;
|
|
137
|
+
} catch {
|
|
138
|
+
return null;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* The change in remaining allowance between two samples, or null when either
|
|
144
|
+
* sample is absent, itself carries no remaining figure, or the two samples
|
|
145
|
+
* name different rate-limit windows: a `five_hour` utilization minus a
|
|
146
|
+
* `seven_day` one is not a delta, even though both are 0..1 fractions that
|
|
147
|
+
* subtract without error.
|
|
148
|
+
*
|
|
149
|
+
* @param {Allowance|null} start
|
|
150
|
+
* @param {Allowance|null} freeze
|
|
151
|
+
* @returns {number|null}
|
|
152
|
+
*/
|
|
153
|
+
export function allowanceDelta(start, freeze) {
|
|
154
|
+
if (!start || !freeze || start.remaining === null || freeze.remaining === null) return null;
|
|
155
|
+
if (!start.window || !freeze.window || start.window !== freeze.window) return null;
|
|
156
|
+
return freeze.remaining - start.remaining;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* The four sampled fields a `seat.allowance` journal event carries, from a
|
|
161
|
+
* sample or its absence: every member null when there is no sample, so a
|
|
162
|
+
* call site building the event never repeats this null-coalescing itself. The
|
|
163
|
+
* one place both `campaign init`'s start write and `plan freeze`'s freeze
|
|
164
|
+
* write get these fields from, alongside `appendSeatAllowanceEvent`
|
|
165
|
+
* (`campaign/journal.mjs`) being the one place either write actually happens.
|
|
166
|
+
*
|
|
167
|
+
* @param {Allowance|null} sample
|
|
168
|
+
* @returns {{remaining: number|null, limit: number|null, resetsAt: string|null, window: string|null}}
|
|
169
|
+
*/
|
|
170
|
+
export function allowanceEventFields(sample) {
|
|
171
|
+
return {
|
|
172
|
+
remaining: sample?.remaining ?? null,
|
|
173
|
+
limit: sample?.limit ?? null,
|
|
174
|
+
resetsAt: sample?.resetsAt ?? null,
|
|
175
|
+
window: sample?.window ?? null,
|
|
176
|
+
};
|
|
177
|
+
}
|
package/src/web/index.html
CHANGED
|
@@ -185,18 +185,18 @@ function tabBodyHtml(tab, detail) {
|
|
|
185
185
|
function drawerHtml(drawer, tab) {
|
|
186
186
|
if (!drawer) return `<section id="drawer"><h2>Run drawer</h2><div class="card" style="padding:16px 18px"><p class="empty">select a run above</p></div></section>`;
|
|
187
187
|
const rows = drawer.nodes.map((node) => `<tr class="clickable ${node.id === drawer.selectedNodeId ? "selected" : ""}" data-node="${esc(node.id)}">
|
|
188
|
-
<td>${pill(node.status)}</td>
|
|
188
|
+
<td>${pill(node.status)}${node.executionPhase === "candidate" ? ` <span class="dim">· candidate verification</span>` : node.executionPhase === "verification" ? ` <span class="dim">· verification</span>` : ""}</td>
|
|
189
189
|
<td class="mono">${esc(node.id)}</td>
|
|
190
190
|
<td class="num">${node.attempt}</td>
|
|
191
191
|
<td class="mono">${esc(node.model ?? "–")}</td>
|
|
192
192
|
<td class="num">${fmtDur(node.elapsedMs)}</td>
|
|
193
193
|
<td class="num">${fmtUsd(node.costUsd)}</td>
|
|
194
|
-
<td>${node.
|
|
194
|
+
<td>${node.gateOutcome ? pill(node.gateOutcome) : ""}</td>
|
|
195
195
|
<td class="goal" title="${esc(node.note ?? "")}">${esc(node.note ?? "")}</td>
|
|
196
196
|
</tr>`).join("");
|
|
197
197
|
const tabs = TABS.map(([key, label]) => `<button data-tab="${key}" class="${tab === key ? "active" : ""}">${esc(label)}</button>`).join("");
|
|
198
198
|
return `<section id="drawer"><h2>Run drawer · ${esc(drawer.runId)}</h2><div class="card" style="padding:16px 18px">
|
|
199
|
-
<table><thead><tr><th>state</th><th>node</th><th>try</th><th>model</th><th>elapsed</th><th>USD</th><th>
|
|
199
|
+
<table><thead><tr><th>state</th><th>node</th><th>try</th><th>model</th><th>elapsed</th><th>USD</th><th>gate</th><th>note</th></tr></thead>
|
|
200
200
|
<tbody>${rows}</tbody></table>
|
|
201
201
|
<div class="tabs">${tabs}</div>
|
|
202
202
|
<div id="tabbody">${tabBodyHtml(tab, drawer.detail)}</div>
|
package/src/web/server.mjs
CHANGED
|
@@ -294,7 +294,8 @@ function nodeRow(runDir, statusNode) {
|
|
|
294
294
|
model: node.runtime?.model ?? null,
|
|
295
295
|
elapsedMs: elapsedMsBetween(node.startedAt, closed ? node.updatedAt : null),
|
|
296
296
|
costUsd: typeof node.costUsd === "number" ? node.costUsd : null,
|
|
297
|
-
|
|
297
|
+
gateOutcome: statusNode.gateOutcome ?? null,
|
|
298
|
+
executionPhase: statusNode.executionPhase ?? null,
|
|
298
299
|
note: statusNode.note ?? null,
|
|
299
300
|
errorCode: statusNode.errorCode ?? null,
|
|
300
301
|
blockedBy: statusNode.blockedBy ?? [],
|