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.
@@ -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
+ }
@@ -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.verdict ? pill(node.verdict) : ""}</td>
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>verdict</th><th>note</th></tr></thead>
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>
@@ -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
- verdict: node.gate?.verdict ?? null,
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 ?? [],