faberun 0.26.3 → 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.
- package/README.md +2 -1
- package/package.json +1 -1
- package/src/cli/setup.mjs +1 -1
- package/src/cli.mjs +1 -1
- package/src/contract/index.mjs +1 -1
- package/src/contract/runtime.mjs +1 -1
- package/src/engine/live-preflight.mjs +1 -1
- package/src/engine/runtime-discovery.mjs +46 -4
- package/src/harnesses/catalogue.mjs +25 -2
- package/src/harnesses/claude/index.mjs +79 -1
- package/src/harnesses/dsh/index.mjs +4 -116
- package/src/harnesses/dsh/runner.mjs +1 -17
- package/src/harnesses/fx/home.mjs +57 -0
- package/src/harnesses/fx/index.mjs +124 -0
- package/src/harnesses/fx/native/build.zig +20 -0
- package/src/harnesses/fx/native/build.zig.zon +7 -0
- package/src/harnesses/fx/native/home.zig +85 -0
- package/src/harnesses/fx/native/main.zig +503 -0
- package/src/harnesses/fx/native/permissions.zig +51 -0
- package/src/harnesses/fx/native/relay.zig +253 -0
- package/src/harnesses/fx/native/usage.zig +112 -0
- package/src/harnesses/fx/permissions.mjs +35 -0
- package/src/harnesses/fx/runner.mjs +350 -0
- package/src/harnesses/fx/usage-proxy.mjs +176 -0
- package/src/harnesses/index.mjs +8 -4
- package/src/harnesses/runner-io.mjs +25 -0
- package/src/harnesses/runner-transcript.mjs +127 -0
- package/src/harnesses/session-metrics.mjs +38 -13
- package/src/host/preflight.mjs +2 -1
|
@@ -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
|
-
|
|
94
|
-
|
|
95
|
-
//
|
|
96
|
-
|
|
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 ===
|
|
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
|
|
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
|
-
|
|
457
|
-
|
|
458
|
-
//
|
|
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 ===
|
|
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 ===
|
|
492
|
+
} else if (record.type === transcript.tool) {
|
|
468
493
|
totals.toolCalls += 1;
|
|
469
|
-
} else if (record.type ===
|
|
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
|
}
|
package/src/host/preflight.mjs
CHANGED
|
@@ -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`
|
|
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]);
|