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.19.3",
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": {
@@ -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
  /**
@@ -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 only when its runtime declares its own threshold; otherwise
437
- // the wall clock above is the only budget it is held to.
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 if (job.observedOnce !== true) {
503
- job.progressTicks = now;
504
- job.lastOutputAt = Date.now();
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
+ }
@@ -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 ?? ZCODE_MACOS_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