faberun 0.26.2 → 0.27.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,127 @@
1
+ /**
2
+ * The adapter half of Faberun's own harness clients. `dsh` and `fx` are
3
+ * driven by a runner this repository ships (a JSON-RPC client per harness),
4
+ * and both runners write the same transcript: `<prefix>.started`,
5
+ * `<prefix>.tool`, `<prefix>.message`, then exactly one `<prefix>.completed`
6
+ * or `<prefix>.failed` carrying the session usage. Folding that transcript
7
+ * into an envelope is one job whatever harness sits behind the runner, so it
8
+ * lives here instead of in either adapter.
9
+ */
10
+
11
+ import { canonicalUsage, extractJson, failed, isQuotaText, isVerdictCandidate, parseJsonLines } from "./protocol.mjs";
12
+
13
+ /**
14
+ * @param {string} prefix the runner's event namespace, `dsh` or `fx`
15
+ * @param {string} stdout
16
+ * @param {number|null} exitCode
17
+ * @param {string|null} signal
18
+ * @param {import("./index.mjs").NormalizeOptions} [options]
19
+ * @returns {import("./index.mjs").ProviderEnvelope}
20
+ */
21
+ export function normalizeRunnerTranscript(prefix, stdout, exitCode, signal, options = {}) {
22
+ if (signal) return failed("canceled", `provider ended after ${signal}`, "canceled");
23
+ let events;
24
+ try {
25
+ events = parseJsonLines(stdout, prefix);
26
+ } catch (error) {
27
+ return failed("invalid_protocol", error instanceof Error ? error.message : String(error));
28
+ }
29
+ const terminal = events.findLast((event) => event.type === `${prefix}.completed` || event.type === `${prefix}.failed`);
30
+ if (!terminal) {
31
+ const detail = options.stderr?.trim();
32
+ return failed(
33
+ "incomplete_stream",
34
+ `${prefix} emitted no terminal event${detail ? `: ${detail.slice(-512)}` : ""}${exitCode === null ? "" : ` (exit ${exitCode})`}`,
35
+ );
36
+ }
37
+ const usage = canonicalUsage(terminal.usage);
38
+ if (terminal.type === `${prefix}.completed`) {
39
+ const text = typeof terminal.result === "string" ? terminal.result : null;
40
+ const result = options.preferStructured ? extractJson(text) ?? text : text;
41
+ const verdicts = options.preferStructured
42
+ ? events.filter((event) => event.type === `${prefix}.message` && isVerdictCandidate(event.text)).length
43
+ : null;
44
+ return {
45
+ status: result?.trim() ? "done" : "no-op",
46
+ result,
47
+ continuationId: null,
48
+ usage,
49
+ costUsd: null,
50
+ error: null,
51
+ ...(verdicts === null ? {} : { judgeCandidates: verdicts }),
52
+ };
53
+ }
54
+ const error = terminal.error && typeof terminal.error === "object" ? /** @type {Record<string, unknown>} */ (terminal.error) : {};
55
+ const kind = typeof terminal.kind === "string" ? terminal.kind : "error";
56
+ const code = typeof error.code === "string" && error.code ? error.code : kind;
57
+ const message = typeof error.message === "string" && error.message ? error.message : code;
58
+ const resetAt = resetTimestamp(error.retryAfterMs);
59
+ return {
60
+ status: statusFor(kind, `${code} ${message}`),
61
+ result: null,
62
+ continuationId: null,
63
+ usage,
64
+ costUsd: null,
65
+ error: { code, message, ...(resetAt ? { resetAt } : {}) },
66
+ ...(resetAt ? { exhaustedUntil: resetAt } : {}),
67
+ };
68
+ }
69
+
70
+ /**
71
+ * Append the output schema the judge prompt refers to. Codex and Claude receive
72
+ * it through a native flag; neither runner-driven harness has one, so it
73
+ * travels in the prompt.
74
+ *
75
+ * @param {string} prompt
76
+ * @param {object|undefined} schema
77
+ * @returns {string}
78
+ */
79
+ export function withSchema(prompt, schema) {
80
+ if (!schema) return prompt;
81
+ return `${prompt}\n\nOutput schema (the JSON object must validate against it):\n${JSON.stringify(schema)}`;
82
+ }
83
+
84
+ /**
85
+ * The environment overlay every runner turn spawns with. The Claude Code shell
86
+ * exports `GIT_CONFIG_COUNT` with the VALUE half of each `GIT_CONFIG_{KEY,VALUE}_<n>`
87
+ * pair but not the key half, so `git init` inside the worker fails with status
88
+ * 128; the overlay removes the whole family. `GIT_TERMINAL_PROMPT=0` takes over
89
+ * the prompting that family was for. A null value removes the ambient variable
90
+ * when the gate merges the overlay over the runner environment.
91
+ *
92
+ * @param {NodeJS.ProcessEnv} env
93
+ * @returns {Record<string, string|null>}
94
+ */
95
+ export function runnerEnvironmentOverlay(env) {
96
+ /** @type {Record<string, string|null>} */
97
+ const overlay = { GIT_TERMINAL_PROMPT: "0" };
98
+ for (const key of Object.keys(env)) {
99
+ if (key === "GIT_CONFIG_COUNT" || /^GIT_CONFIG_(?:KEY|VALUE)_\d+$/u.test(key)) overlay[key] = null;
100
+ }
101
+ return overlay;
102
+ }
103
+
104
+ /**
105
+ * @param {string} kind
106
+ * @param {string} text
107
+ * @returns {"done"|"no-op"|"blocked"|"failed"|"exhausted"|"stalled"|"canceled"}
108
+ */
109
+ function statusFor(kind, text) {
110
+ if (kind === "aborted") return "canceled";
111
+ if (kind === "blocked") return "blocked";
112
+ if (isQuotaText(text) || /insufficient balance/iu.test(text)) return "exhausted";
113
+ if (/permission|approval|sandbox/iu.test(text)) return "blocked";
114
+ return "failed";
115
+ }
116
+
117
+ /**
118
+ * Turn the harness's relative retry hint into the absolute instant the
119
+ * controller needs; without one it takes the failover edge instead of waiting.
120
+ *
121
+ * @param {unknown} retryAfterMs
122
+ * @returns {string|null}
123
+ */
124
+ function resetTimestamp(retryAfterMs) {
125
+ if (typeof retryAfterMs !== "number" || !Number.isFinite(retryAfterMs) || retryAfterMs <= 0) return null;
126
+ return new Date(Date.now() + retryAfterMs).toISOString();
127
+ }
@@ -26,7 +26,29 @@ import { finite } from "../util.mjs";
26
26
  */
27
27
 
28
28
  /** The harnesses whose streams carry usage per provider request; the others meter turns and tool calls only. */
29
- const PER_REQUEST_USAGE = new Set(["claude", "dsh", "agy", "exec-jsonl", "replay"]);
29
+ const PER_REQUEST_USAGE = new Set(["claude", "dsh", "fx", "agy", "exec-jsonl", "replay"]);
30
+
31
+ /**
32
+ * The transcripts Faberun's own harness clients write (`runner-transcript.mjs`).
33
+ * `usage` names the event that carries one provider request's usage: dsh
34
+ * attaches it to each assistant message, while fx's relay reports it per HTTP
35
+ * request, apart from the message text.
36
+ */
37
+ const RUNNER_TRANSCRIPTS = Object.freeze({
38
+ dsh: Object.freeze({ usage: "dsh.message", tool: "dsh.tool", completed: "dsh.completed", failed: "dsh.failed" }),
39
+ fx: Object.freeze({ usage: "fx.request", tool: "fx.tool", completed: "fx.completed", failed: "fx.failed" }),
40
+ });
41
+
42
+ /** @param {string} harness */
43
+ function runnerTranscript(harness) {
44
+ return Object.hasOwn(RUNNER_TRANSCRIPTS, harness) ? RUNNER_TRANSCRIPTS[/** @type {"dsh"|"fx"} */ (harness)] : null;
45
+ }
46
+
47
+ /** @param {string} harness @param {unknown} type */
48
+ function isRunnerTerminal(harness, type) {
49
+ const transcript = runnerTranscript(harness);
50
+ return transcript !== null && (type === transcript.completed || type === transcript.failed);
51
+ }
30
52
 
31
53
  /**
32
54
  * Best-effort input-token meter over a still-growing transcript. The
@@ -90,15 +112,16 @@ export function liveUsage(harness, stdout) {
90
112
  cacheReadInputTokens: null,
91
113
  };
92
114
  }
93
- if (harness === "dsh") {
94
- // The terminal event carries the harness's own session total; before it
95
- // lands, sum the per-message usage the runner forwards.
96
- const terminal = events.findLast((event) => event?.type === "dsh.completed" || event?.type === "dsh.failed");
115
+ const transcript = runnerTranscript(harness);
116
+ if (transcript) {
117
+ // The terminal event carries the session total; before it lands, sum the
118
+ // per-request usage the runner forwards.
119
+ const terminal = events.findLast((event) => event?.type === transcript.completed || event?.type === transcript.failed);
97
120
  const terminalUsage = terminal ? canonicalUsage(terminal.usage) : null;
98
121
  if (terminalUsage && terminalUsage.inputTokens !== null) {
99
122
  return { inputTokens: terminalUsage.inputTokens, cacheReadInputTokens: terminalUsage.cacheReadInputTokens };
100
123
  }
101
- return sumRequestUsage(events.filter((event) => event?.type === "dsh.message").map((event) => event.usage));
124
+ return sumRequestUsage(events.filter((event) => event?.type === transcript.usage).map((event) => event.usage));
102
125
  }
103
126
  if (harness === "agy") {
104
127
  const resultRecord = events.map((event) => agyResult(event)).findLast((result) => result !== null);
@@ -394,7 +417,7 @@ export class SessionMetricsParser {
394
417
  }
395
418
  } else if (this.harness === "claude" && record.type === "result") {
396
419
  totals.completed = true;
397
- } else if (this.harness === "dsh" && (record.type === "dsh.completed" || record.type === "dsh.failed")) {
420
+ } else if (isRunnerTerminal(this.harness, record.type)) {
398
421
  totals.completed = true;
399
422
  } else if (this.harness === "agy" && record.event === "result") {
400
423
  totals.completed = true;
@@ -453,20 +476,22 @@ function foldRecord(harness, totals, record) {
453
476
  }
454
477
  return;
455
478
  }
456
- if (harness === "dsh") {
457
- // The runner folds the sdk firehose to one `dsh.message` per assistant
458
- // message (with that message's usage) and one `dsh.tool` per tool call.
479
+ const transcript = runnerTranscript(harness);
480
+ if (transcript) {
481
+ // The dsh runner folds the sdk firehose to one `dsh.message` per assistant
482
+ // message (with that message's usage) and one `dsh.tool` per tool call;
483
+ // the fx runner reports usage per relayed request as `fx.request`.
459
484
  // measured 2026-09-20: before this branch a dsh turn metered zero events,
460
485
  // so once progress became event-only (668f6c1) the stall detector cut
461
486
  // every dsh worker at the contract's stallTimeoutSec regardless of
462
487
  // activity -- three kills on 2026-09-16 at 903s against a 900s limit.
463
- if (record.type === "dsh.message") {
488
+ if (record.type === transcript.usage) {
464
489
  totals.turns += 1;
465
490
  totals.cacheReadInputTokens += canonicalUsage(record.usage).cacheReadInputTokens ?? 0;
466
491
  foldRequestUsage(totals, canonicalUsage(record.usage));
467
- } else if (record.type === "dsh.tool") {
492
+ } else if (record.type === transcript.tool) {
468
493
  totals.toolCalls += 1;
469
- } else if (record.type === "dsh.completed" || record.type === "dsh.failed") {
494
+ } else if (record.type === transcript.completed || record.type === transcript.failed) {
470
495
  const sessionTotal = canonicalUsage(record.usage).cacheReadInputTokens;
471
496
  if (sessionTotal !== null) totals.cacheReadInputTokens = sessionTotal;
472
497
  }
@@ -344,6 +344,7 @@ const SIDE_EFFECT_ENV_KEYS = [
344
344
  "FABERUN_CLAUDE_BIN", // same, for the claude harness
345
345
  "FABERUN_AGY_BIN", // same, for the agy harness
346
346
  "FABERUN_DSH_BIN", // same, for the dsh harness
347
+ "FABERUN_FX_BIN", // same, for the fx harness
347
348
  "FABERUN_ZCODE_BIN", // same, for the zcode harness
348
349
  "FABERUN_EXEC_JSONL_BIN", // same, for the exec-jsonl harness
349
350
  ];
@@ -672,7 +673,7 @@ export async function doctorCommand(contractPath, values) {
672
673
  // already validated whatever the runtime actually resolves to. `zcode` is
673
674
  // absent on purpose — its binary is a shim the harness writes on first use, so
674
675
  // a PATH miss here is the normal state of a fresh machine, not a missing
675
- // dependency; `dsh` runs through its own SDK client, not a PATH binary.
676
+ // dependency; `dsh` and `fx` run through Faberun's own clients, not a PATH binary.
676
677
  for (const binary of ["codex", "claude", "agy", "exec-jsonl"]) {
677
678
  const overrideName = /** @type {Record<string, string>} */ (HARNESS_BIN_OVERRIDES)[binary];
678
679
  const overridden = overriddenHarnesses.has(binary) || Boolean(process.env[overrideName]);
@@ -236,6 +236,25 @@ function readFilesForKind(kind, inputs) {
236
236
  return [/** @type {string} */ (inputs.specPath)];
237
237
  }
238
238
 
239
+ /**
240
+ * The wall clock one planning stage gets, from the reasoning effort of the
241
+ * runtime that runs it. A stage is a single long turn over the whole plan, so
242
+ * the contract default (2400 s) fits a draft at ordinary effort but not a
243
+ * revise at high effort. Measured 2026-09-27: Opus 5.5 at `xhigh` revising
244
+ * a 20-node plan against 13 findings was still reasoning when the 2400 s wall
245
+ * clock ended the stage, and the pipeline, which cannot resume a stage, lost
246
+ * the draft and review it had already paid for.
247
+ *
248
+ * @param {Record<string, unknown>|undefined} runtime
249
+ * @returns {number}
250
+ */
251
+ export function planningStageTimeoutSec(runtime) {
252
+ const reasoning = runtime?.reasoning;
253
+ if (reasoning === "xhigh" || reasoning === "max") return 7200;
254
+ if (reasoning === "high") return 3600;
255
+ return 2400;
256
+ }
257
+
239
258
  /**
240
259
  * Build one of the planning pipeline's one-node discovery contracts. Pure:
241
260
  * no file is read or written, and no model is invoked. The returned object is
@@ -273,10 +292,13 @@ export function buildPlanningContract(kind, inputs) {
273
292
  verification: [],
274
293
  };
275
294
 
295
+ const stageRuntime = runtimeId === undefined ? undefined : /** @type {Record<string, unknown>|undefined} */ (inputs.runtimes[runtimeId]);
296
+
276
297
  return {
277
298
  schemaVersion: PROTOCOL_SCHEMA_VERSION,
278
299
  contractVersion: CONTRACT_VERSION,
279
300
  id: `${inputs.campaignId}-plan-${inputs.phase}-${kind}-${inputs.n}`,
301
+ timeoutSec: planningStageTimeoutSec(stageRuntime),
280
302
  campaignId: inputs.campaignId,
281
303
  goal: inputs.goal ?? `Plan ${kind} for phase ${inputs.phase}`,
282
304
  cwd: inputs.cwd ?? ".",