faberun 0.19.3 → 0.20.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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "faberun",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.20.0",
|
|
4
4
|
"description": "Faberun is a development orchestration system that turns intent into verified software: harness- and model-agnostic, it keeps the intent, coordinates the work, verifies the result and decides what happens next.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
package/src/engine/dispatch.mjs
CHANGED
|
@@ -34,7 +34,7 @@ import { invocationCost, invocationUsage } from "../run/usage.mjs";
|
|
|
34
34
|
import { logPaths, startProcess } from "./process.mjs";
|
|
35
35
|
import { readBoundedTail } from "./transcript.mjs";
|
|
36
36
|
import { mkdirSync, statSync } from "node:fs";
|
|
37
|
-
import { READ_BYTE_LIMIT, READ_LINE_LIMIT, normalizeProviderResult } from "../harnesses/index.mjs";
|
|
37
|
+
import { READ_BYTE_LIMIT, READ_LINE_LIMIT, harnessCapabilities, normalizeProviderResult } from "../harnesses/index.mjs";
|
|
38
38
|
import { writeJsonAtomic } from "../run/store.mjs";
|
|
39
39
|
import { judgeReaskInstruction, reviewMode } from "../contract/review-modes.mjs";
|
|
40
40
|
import { routeRuntimeForState, runtimeSnapshot } from "./failover.mjs";
|
|
@@ -153,12 +153,19 @@ function workerToolPolicy(runtime, node, workspace) {
|
|
|
153
153
|
* @returns {import("../harnesses/index.mjs").CommandOptions}
|
|
154
154
|
*/
|
|
155
155
|
function invocationCommandOptions(contract, node, state, runtime, phasePlan, runDir, lock, extra = {}) {
|
|
156
|
+
// A harness that streams its stdout proves liveness through the event
|
|
157
|
+
// monitor; a buffered one (zcode's `--json` writes only at exit) has one
|
|
158
|
+
// live surface left, its own log stream, and the adapter decides whether
|
|
159
|
+
// this dir means anything to it. Streaming harnesses get none: their log
|
|
160
|
+
// dir would be dead weight the engine never watches.
|
|
161
|
+
const streaming = harnessCapabilities(runtime).streamsOutput === true;
|
|
156
162
|
return {
|
|
157
163
|
...extra,
|
|
158
164
|
continuationId: runtime.capabilities.continuation === true ? phasePlan.continuationId : null,
|
|
159
165
|
// The attempt's request ceiling. An adapter that can enforce it natively
|
|
160
166
|
// takes it as a flag; the monitor enforces it for every streaming harness.
|
|
161
167
|
maxTurns: node.maxTurns ?? contract.maxTurns,
|
|
168
|
+
logDir: streaming ? null : join(runDir, "logs", `${node.id}.${state.attempt}.provider`),
|
|
162
169
|
};
|
|
163
170
|
}
|
|
164
171
|
/**
|
package/src/engine/process.mjs
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
* a judge decides, or when a run is done. That separation is the point: a stuck
|
|
8
8
|
* provider is killed by the same code whatever it was asked to do.
|
|
9
9
|
*/
|
|
10
|
-
import { boundedRegion, monitorInvocation } from "./transcript.mjs";
|
|
10
|
+
import { boundedRegion, latestLogWriteMs, monitorInvocation } from "./transcript.mjs";
|
|
11
11
|
import { closeSync, existsSync, fsyncSync, openSync, unlinkSync, writeFileSync, writeSync } from "node:fs";
|
|
12
12
|
import { dirname, join } from "node:path";
|
|
13
13
|
import { errorCode, errorMessage } from "../util.mjs";
|
|
@@ -36,7 +36,7 @@ import { killTarget } from "../host/platform.mjs";
|
|
|
36
36
|
/** @typedef {{prompt: string|null, stdout: string, stderr: string}} PathSet */
|
|
37
37
|
/** @typedef {{id: string, pid: number, processGroupId: number|null, processStartToken: string|null, harness: string, runtimeId: string|null, runtimeFingerprint?: string, revision?: number, phase: string, promptPath: string|null, stdoutPath: string, stderrPath: string, startedAt: string, deadlineAt: string|null, updatedAt: string, closedAt: string|null, exitCode: number|null, signal: string|null, status: "active"|"closed"|"terminated", executable: string, snapshotPath?: string, usage?: Usage, usageEstimated?: boolean, costUsd?: number|null, costProvenance?: "priced", runId?: string, campaignId?: string, nodeId?: string, attempt?: number, workspace?: string, worktreeBranch?: string|null, worktreeBaseSha?: string|null, planPhase?: string, role?: "worker"|"judge", model?: string, reasoning?: string|null, sandbox?: string|null, continuationId?: string|null, continuationMode?: "fresh"|"reuse"|"rotate", session?: import("../harnesses/session-metrics.mjs").SessionLedger|null}} Invocation */
|
|
38
38
|
/** @typedef {{pid: number|null, processGroupId?: number|null, processStartToken?: string|null}} InvocationProbe */
|
|
39
|
-
/** @typedef {{child: ChildProcess, contract: ValidatedContract, node: ValidatedNode, state: NodeSnapshot, runtime: HarnessRuntime & {id: string|null}, cwd: string, paths: PathSet, phase: string, invocation: Invocation, startedAt: string, startedTicks: bigint, progressTicks: bigint, lastOutputAt: number, closed: boolean, exitCode: number|null, signal: string|null, spawnError: Error|null, terminating: Promise<void>|null, gateConfigPath: string, gateReleasePath: string, scopeBaseline?: unknown, scopeChecked?: boolean, scopeViolation?: boolean, resultMaterialization?: boolean, recoveryBaseline?: unknown, observeTimer?: ReturnType<typeof setInterval>, monitorOffset?: number, monitorParser?: import("../harnesses/session-metrics.mjs").SessionMetricsParser, lastEventCount?: number, lastMonitorOffset?: number, observedOnce?: boolean, turnCapWarned?: boolean, onClose?: (invocation: Invocation) => void, onInvocationUpdate?: (invocation: Invocation) => void, onProgress?: (state: NodeSnapshot) => void}} Job */
|
|
39
|
+
/** @typedef {{child: ChildProcess, contract: ValidatedContract, node: ValidatedNode, state: NodeSnapshot, runtime: HarnessRuntime & {id: string|null}, cwd: string, paths: PathSet, phase: string, invocation: Invocation, startedAt: string, startedTicks: bigint, progressTicks: bigint, lastOutputAt: number, closed: boolean, exitCode: number|null, signal: string|null, spawnError: Error|null, terminating: Promise<void>|null, gateConfigPath: string, gateReleasePath: string, logDir?: string|null, scopeBaseline?: unknown, scopeChecked?: boolean, scopeViolation?: boolean, resultMaterialization?: boolean, recoveryBaseline?: unknown, observeTimer?: ReturnType<typeof setInterval>, monitorOffset?: number, monitorParser?: import("../harnesses/session-metrics.mjs").SessionMetricsParser, lastEventCount?: number, lastMonitorOffset?: number, lastLogWriteMs?: number, observedOnce?: boolean, turnCapWarned?: boolean, onClose?: (invocation: Invocation) => void, onInvocationUpdate?: (invocation: Invocation) => void, onProgress?: (state: NodeSnapshot) => void}} Job */
|
|
40
40
|
/** @typedef {{graceMs?: number, killGraceMs?: number, escalate?: boolean, runDir?: string, kill?: (pid: number, signal: string|number) => unknown, child?: ChildProcess|null}} TerminateOptions */
|
|
41
41
|
|
|
42
42
|
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
@@ -48,6 +48,16 @@ const GATE_PATH = join(HERE, "gate.mjs");
|
|
|
48
48
|
*/
|
|
49
49
|
export function startProcess({ contract, node, state, runtime, prompt, paths, phase, workspace = contract.cwd, commandOptions = {}, onInvocation, onInvocationUpdate, onProgress }) {
|
|
50
50
|
const command = providerCommand(runtime, prompt, commandOptions);
|
|
51
|
+
// The engine offers a log dir to every non-streaming harness; only an
|
|
52
|
+
// adapter that wants one creates it (zcode does, inside `command()` above,
|
|
53
|
+
// which has already run). Taking the offer is therefore observable, and the
|
|
54
|
+
// stall detector must key on the adapter's answer rather than on the
|
|
55
|
+
// engine's offer: `exec-jsonl` and `replay` are non-streaming too, declare
|
|
56
|
+
// no `stallTimeoutSec`, and were deliberately not stall-tracked at all. A
|
|
57
|
+
// path they never write to would otherwise have made them tracked against a
|
|
58
|
+
// log that stays empty forever -- measured 2026-09-22 against the contract
|
|
59
|
+
// default of 300s, a healthy exec-jsonl worker was killed as stalled.
|
|
60
|
+
const logDir = commandOptions.logDir && existsSync(commandOptions.logDir) ? commandOptions.logDir : null;
|
|
51
61
|
if (paths.prompt) writeFileSync(paths.prompt, prompt, { flag: "wx", mode: 0o600 });
|
|
52
62
|
const gateConfigPath = `${paths.prompt}.gate.json`;
|
|
53
63
|
const gateReleasePath = `${paths.prompt}.gate.release`;
|
|
@@ -125,6 +135,7 @@ export function startProcess({ contract, node, state, runtime, prompt, paths, ph
|
|
|
125
135
|
terminating: null,
|
|
126
136
|
gateConfigPath,
|
|
127
137
|
gateReleasePath,
|
|
138
|
+
logDir,
|
|
128
139
|
onInvocationUpdate,
|
|
129
140
|
onProgress,
|
|
130
141
|
};
|
|
@@ -433,11 +444,13 @@ export async function detectStalls(contract, running, onTimeout, onProgress, onB
|
|
|
433
444
|
// calling tools is alive even when it writes no workspace file, and a
|
|
434
445
|
// buffered harness (zcode's `--json`) writes its whole transcript only at
|
|
435
446
|
// exit, so its mtime proves nothing. A harness that never streams is
|
|
436
|
-
// stall-tracked
|
|
437
|
-
//
|
|
447
|
+
// stall-tracked through its provider log dir when its adapter keeps one
|
|
448
|
+
// (zcode points the CLI's `ZCODE_LOG_DIR` at it), through a runtime that
|
|
449
|
+
// declares its own threshold, or by neither — the wall clock above is
|
|
450
|
+
// then the only budget it is held to.
|
|
438
451
|
const streaming = harnessCapabilities(job.runtime).streamsOutput;
|
|
439
452
|
const declaredStall = typeof (/** @type {{stallTimeoutSec?: unknown}} */ (job.runtime)?.stallTimeoutSec) === "number";
|
|
440
|
-
if (!streaming && !declaredStall) continue;
|
|
453
|
+
if (!streaming && !declaredStall && !job.logDir) continue;
|
|
441
454
|
const stallTimeoutSec = stallTimeoutSecFor(job.runtime, contract);
|
|
442
455
|
if (streaming) {
|
|
443
456
|
const monitored = monitorInvocation(job);
|
|
@@ -499,9 +512,24 @@ export async function detectStalls(contract, running, onTimeout, onProgress, onB
|
|
|
499
512
|
await onTimeout(job, "exhausted", limit);
|
|
500
513
|
continue;
|
|
501
514
|
}
|
|
502
|
-
} else
|
|
503
|
-
|
|
504
|
-
|
|
515
|
+
} else {
|
|
516
|
+
// The buffered-harness liveness signal: the CLI's log stream appending
|
|
517
|
+
// inside the attempt's log dir. An mtime that advances past the newest
|
|
518
|
+
// one this loop has seen is a write, and a write is progress — the same
|
|
519
|
+
// lower-bound discipline as the polling fixtures: it can only delay a
|
|
520
|
+
// stall verdict, never prove speed.
|
|
521
|
+
if (job.logDir) {
|
|
522
|
+
const written = latestLogWriteMs(job.logDir);
|
|
523
|
+
if (written > (job.lastLogWriteMs ?? 0)) {
|
|
524
|
+
job.lastLogWriteMs = written;
|
|
525
|
+
job.progressTicks = now;
|
|
526
|
+
job.lastOutputAt = Date.now();
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
if (job.observedOnce !== true) {
|
|
530
|
+
job.progressTicks = now;
|
|
531
|
+
job.lastOutputAt = Date.now();
|
|
532
|
+
}
|
|
505
533
|
}
|
|
506
534
|
job.observedOnce = true;
|
|
507
535
|
if (elapsedSeconds(job.progressTicks, now) < stallTimeoutSec) continue;
|
|
@@ -6,10 +6,11 @@
|
|
|
6
6
|
* because reading the log never touches the process, and because process.mjs
|
|
7
7
|
* crossed the 800-line ceiling carrying both jobs.
|
|
8
8
|
*/
|
|
9
|
-
import { closeSync, openSync, readFileSync, readSync, statSync } from "node:fs";
|
|
9
|
+
import { closeSync, openSync, readFileSync, readSync, readdirSync, statSync } from "node:fs";
|
|
10
10
|
import { Buffer } from "node:buffer";
|
|
11
11
|
import { SessionMetricsParser } from "../harnesses/session-metrics.mjs";
|
|
12
12
|
import { errorCode } from "../util.mjs";
|
|
13
|
+
import { join } from "node:path";
|
|
13
14
|
|
|
14
15
|
/** @typedef {import("./process.mjs").Job} Job */
|
|
15
16
|
|
|
@@ -143,3 +144,44 @@ function dropPartialLogLine(value) {
|
|
|
143
144
|
const newline = String(value).indexOf("\n");
|
|
144
145
|
return newline < 0 ? "" : String(value).slice(newline + 1);
|
|
145
146
|
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Newest write inside the attempt's provider log dir, 0 when nothing is there
|
|
150
|
+
* yet. The CLI lays its session logs out as files (it may nest a directory),
|
|
151
|
+
* so the scan walks three levels and reads only mtimes — never contents,
|
|
152
|
+
* which is the monitor's job for streaming harnesses and nobody else's.
|
|
153
|
+
* Every stat failure is an ordinary not-yet: a dir with nothing readable in
|
|
154
|
+
* it proves no liveness, which is exactly the right answer.
|
|
155
|
+
*
|
|
156
|
+
* Cheap enough to run on every tick: measured 2026-09-22 on macOS, a dir of
|
|
157
|
+
* 250 files across two levels scans in 0.80 ms, against the 1000 ms default
|
|
158
|
+
* `pollIntervalMs` — so the cost is under a tenth of a percent of one job's
|
|
159
|
+
* poll, and the scan reads no bytes.
|
|
160
|
+
*
|
|
161
|
+
* @param {string} dir
|
|
162
|
+
* @param {number} [depth]
|
|
163
|
+
* @returns {number}
|
|
164
|
+
*/
|
|
165
|
+
export function latestLogWriteMs(dir, depth = 0) {
|
|
166
|
+
let newest = 0;
|
|
167
|
+
let entries;
|
|
168
|
+
try {
|
|
169
|
+
entries = readdirSync(dir, { withFileTypes: true });
|
|
170
|
+
} catch {
|
|
171
|
+
return 0;
|
|
172
|
+
}
|
|
173
|
+
for (const entry of entries) {
|
|
174
|
+
const path = join(dir, entry.name);
|
|
175
|
+
if (entry.isDirectory()) {
|
|
176
|
+
if (depth < 3) newest = Math.max(newest, latestLogWriteMs(path, depth + 1));
|
|
177
|
+
continue;
|
|
178
|
+
}
|
|
179
|
+
try {
|
|
180
|
+
newest = Math.max(newest, statSync(path).mtimeMs);
|
|
181
|
+
} catch {
|
|
182
|
+
// Raced a rotation or a permission change: this entry proves nothing,
|
|
183
|
+
// the rest of the scan still does.
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
return newest;
|
|
187
|
+
}
|
package/src/harnesses/index.mjs
CHANGED
|
@@ -95,7 +95,7 @@ export const READ_LINE_LIMIT = 1500;
|
|
|
95
95
|
*/
|
|
96
96
|
export const READ_BYTE_LIMIT = 32 * 1024;
|
|
97
97
|
|
|
98
|
-
/** @typedef {{schema?: object, schemaPath?: string, continuationId?: string|null, toolPolicy?: ToolPolicy, env?: Record<string, string>, maxTurns?: number}} CommandOptions */
|
|
98
|
+
/** @typedef {{schema?: object, schemaPath?: string, continuationId?: string|null, toolPolicy?: ToolPolicy, env?: Record<string, string>, maxTurns?: number, logDir?: string|null}} CommandOptions */
|
|
99
99
|
|
|
100
100
|
/** @typedef {{preferStructured?: boolean, exitCode?: number|null, signal?: string|null, stderr?: string}} NormalizeOptions */
|
|
101
101
|
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { accessSync, chmodSync, constants, existsSync, lstatSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
1
|
+
import { accessSync, chmodSync, constants, existsSync, lstatSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
2
2
|
import { homedir } from "node:os";
|
|
3
3
|
import { delimiter, join } from "node:path";
|
|
4
4
|
import { normalizeZcodeResult, parseVersion } from "../protocol.mjs";
|
|
@@ -20,6 +20,20 @@ const ZCODE_MACOS_BUNDLE = Object.freeze({
|
|
|
20
20
|
cli: "/Applications/ZCode.app/Contents/Resources/glm/zcode.cjs",
|
|
21
21
|
});
|
|
22
22
|
|
|
23
|
+
/**
|
|
24
|
+
* Linux install layouts: an Electron `.deb`/`.rpm` unpacks the app under
|
|
25
|
+
* `/opt/<App>` (some packagers use `/usr/lib/<app>`) with the same
|
|
26
|
+
* `resources/` shape the macOS bundle has. None of these is documented by the
|
|
27
|
+
* vendor — `docs/harnesses/zcode-cli.md` records only the macOS layout — so the
|
|
28
|
+
* list is a probe, and `FABERUN_ZCODE_APP_DIR` names an install this list
|
|
29
|
+
* does not know.
|
|
30
|
+
*/
|
|
31
|
+
const ZCODE_LINUX_BUNDLES = Object.freeze([
|
|
32
|
+
{ electron: "/opt/ZCode/zcode", cli: "/opt/ZCode/resources/glm/zcode.cjs" },
|
|
33
|
+
{ electron: "/opt/zcode/zcode", cli: "/opt/zcode/resources/glm/zcode.cjs" },
|
|
34
|
+
{ electron: "/usr/lib/zcode/zcode", cli: "/usr/lib/zcode/resources/glm/zcode.cjs" },
|
|
35
|
+
]);
|
|
36
|
+
|
|
23
37
|
/** Provider id in the ZCODE_MODEL target; it also derives the auth env var name. */
|
|
24
38
|
const ZCODE_DEFAULT_PROVIDER = "glm";
|
|
25
39
|
|
|
@@ -68,7 +82,9 @@ export const zcodeHarness = {
|
|
|
68
82
|
// once at exit: a live worker node was killed at 420s stall_timeout with
|
|
69
83
|
// its stdout/stderr at zero bytes, while a completed 1m26s invocation's
|
|
70
84
|
// log held its full 26 lines only once the process exited. Stall
|
|
71
|
-
// detection must not watch this harness's stdout/stderr mtime.
|
|
85
|
+
// detection must not watch this harness's stdout/stderr mtime. Liveness
|
|
86
|
+
// comes from the CLI's own log stream instead — see `command()`'s
|
|
87
|
+
// ZCODE_LOG_DIR wiring, which the engine's stall clock watches.
|
|
72
88
|
streamsOutput: false,
|
|
73
89
|
// Unmeasured: no run has proven whether zcode's sandbox can signal child
|
|
74
90
|
// processes or read the process table.
|
|
@@ -134,6 +150,19 @@ export const zcodeHarness = {
|
|
|
134
150
|
// resolves (e.g. GLM_API_KEY); an unresolved token is omitted, not blanked.
|
|
135
151
|
const apiKeyVar = providerApiKeyVar(provider);
|
|
136
152
|
if (token !== null && apiKeyVar !== null) env[apiKeyVar] = token;
|
|
153
|
+
// The one live surface a buffered harness has: the CLI's own log stream.
|
|
154
|
+
// `--json` writes stdout only at exit, but `ZCODE_LOG_DIR` in `json`
|
|
155
|
+
// format receives session events as they happen, so the engine's stall
|
|
156
|
+
// clock can watch the log dir instead of holding the attempt to the wall
|
|
157
|
+
// clock alone. The engine supplies one dir per attempt (see
|
|
158
|
+
// `invocationCommandOptions`); the adapter creates it and points the CLI
|
|
159
|
+
// at it, and console logging stays off so stderr stays a pure error path.
|
|
160
|
+
if (options.logDir) {
|
|
161
|
+
mkdirSync(options.logDir, { recursive: true });
|
|
162
|
+
env.ZCODE_LOG_DIR = options.logDir;
|
|
163
|
+
env.ZCODE_LOG_FORMAT = "json";
|
|
164
|
+
env.ZCODE_LOG_CONSOLE = "false";
|
|
165
|
+
}
|
|
137
166
|
return { executable: this.executable(runtime), args, promptTransport: "argv", input: null, env };
|
|
138
167
|
},
|
|
139
168
|
|
|
@@ -187,8 +216,8 @@ export function ensureZcodeAvailable(options = {}) {
|
|
|
187
216
|
const env = options.env ?? process.env;
|
|
188
217
|
const pathDirs = options.pathDirs ?? (env.PATH ?? "").split(delimiter).filter(Boolean);
|
|
189
218
|
if (resolvesOnPath(pathDirs, ZCODE_BIN_NAME)) return;
|
|
190
|
-
const bundle = options.bundle ??
|
|
191
|
-
if (!existsSync(bundle.electron) || !existsSync(bundle.cli)) return;
|
|
219
|
+
const bundle = options.bundle ?? zcodeBundle(env);
|
|
220
|
+
if (!bundle || !existsSync(bundle.electron) || !existsSync(bundle.cli)) return;
|
|
192
221
|
const body = zcodeShim(bundle);
|
|
193
222
|
for (const dir of shimDirs(options.home ?? homedir())) {
|
|
194
223
|
if (!pathDirs.includes(dir)) continue;
|
|
@@ -200,6 +229,22 @@ export function ensureZcodeAvailable(options = {}) {
|
|
|
200
229
|
}
|
|
201
230
|
}
|
|
202
231
|
|
|
232
|
+
/**
|
|
233
|
+
* The bundle this host has, if any: an explicit `FABERUN_ZCODE_APP_DIR`
|
|
234
|
+
* override, the macOS app path on darwin, or the first probed Linux layout
|
|
235
|
+
* whose two paths both exist. A host with none gets null — the same "not
|
|
236
|
+
* installed" answer every probe below returns.
|
|
237
|
+
*
|
|
238
|
+
* @param {Record<string, string|undefined>} env
|
|
239
|
+
* @returns {{electron: string, cli: string}|null}
|
|
240
|
+
*/
|
|
241
|
+
function zcodeBundle(env) {
|
|
242
|
+
const appDir = env.FABERUN_ZCODE_APP_DIR;
|
|
243
|
+
if (appDir) return { electron: join(appDir, "zcode"), cli: join(appDir, "resources", "glm", "zcode.cjs") };
|
|
244
|
+
if (process.platform === "darwin") return ZCODE_MACOS_BUNDLE;
|
|
245
|
+
return ZCODE_LINUX_BUNDLES.find((bundle) => existsSync(bundle.electron) && existsSync(bundle.cli)) ?? null;
|
|
246
|
+
}
|
|
247
|
+
|
|
203
248
|
/**
|
|
204
249
|
* The shim body. It runs the bundle through the app's own Electron binary as
|
|
205
250
|
* node because the CLI mis-handles its response path under a system node
|