faberun 0.26.3 → 0.28.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 +11 -4
- package/package.json +1 -1
- package/skills/faberun/SKILL.md +3 -3
- package/skills/faberun/references/contract.md +9 -9
- package/skills/faberun/references/local-env.md +11 -0
- package/skills/faberun/references/operations.md +26 -33
- package/src/campaign/brief.mjs +41 -9
- package/src/campaign/campaign-brief.mjs +18 -3
- package/src/campaign/index.mjs +22 -9
- package/src/campaign/ledger.mjs +72 -4
- package/src/campaign/metrics.mjs +29 -2
- package/src/campaign/record.mjs +34 -0
- package/src/cli/campaign.mjs +35 -12
- package/src/cli/contract.mjs +11 -1
- package/src/cli/launch.mjs +12 -2
- package/src/cli/manual.mjs +24 -8
- package/src/cli/setup.mjs +6 -1
- package/src/cli/skills.mjs +18 -7
- package/src/cli/uninstall.mjs +542 -0
- package/src/cli/walkthrough.mjs +462 -0
- package/src/cli.mjs +68 -8
- package/src/contract/articles.mjs +30 -0
- package/src/contract/definition-of-done.mjs +109 -0
- package/src/contract/index.mjs +7 -4
- package/src/contract/judge-independence.mjs +18 -0
- package/src/contract/runtime.mjs +6 -2
- package/src/contract/scope-findings.mjs +120 -0
- package/src/contract/snapshot.mjs +19 -5
- package/src/contract/task-packet.mjs +49 -1
- package/src/contract/worker-result.mjs +10 -0
- package/src/engine/assignment.mjs +45 -0
- package/src/engine/detach.mjs +18 -0
- package/src/engine/gate.mjs +10 -2
- package/src/engine/judge-gate.mjs +24 -9
- package/src/engine/live-preflight.mjs +114 -18
- package/src/engine/prompts.mjs +41 -3
- package/src/engine/resume.mjs +119 -12
- package/src/engine/retry.mjs +11 -11
- package/src/engine/run-command.mjs +80 -4
- package/src/engine/run-identity.mjs +27 -14
- package/src/engine/runtime-discovery.mjs +61 -13
- package/src/engine/worker-env.mjs +179 -0
- package/src/harnesses/agy/index.mjs +11 -0
- package/src/harnesses/catalogue.mjs +54 -2
- package/src/harnesses/claude/index.mjs +177 -2
- package/src/harnesses/codex/index.mjs +15 -0
- package/src/harnesses/dsh/index.mjs +18 -116
- package/src/harnesses/dsh/runner.mjs +1 -17
- package/src/harnesses/exec-jsonl/index.mjs +12 -0
- package/src/harnesses/fx/home.mjs +57 -0
- package/src/harnesses/fx/index.mjs +137 -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 +29 -14
- package/src/harnesses/replay/index.mjs +12 -0
- 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/harnesses/zcode/index.mjs +19 -0
- package/src/host/config.mjs +8 -2
- package/src/host/environment-report.mjs +85 -0
- package/src/host/home.mjs +27 -0
- package/src/host/install-registry.mjs +511 -0
- package/src/host/preflight.mjs +34 -10
- package/src/host/uninstall-roots.mjs +196 -0
- package/src/plan/proof-check.mjs +15 -0
- package/src/plan/repo-facts.mjs +95 -11
- package/src/plan/rounds.mjs +27 -0
- package/src/repo/declared-paths.mjs +74 -6
- package/src/repo/integrate.mjs +1 -1
- package/src/repo/scope-closure.mjs +59 -0
- package/src/repo/signal-block.mjs +26 -0
- package/src/repo/signal.mjs +9 -24
- package/src/repo/workspace.mjs +189 -14
- package/src/report/campaign-brief.mjs +4 -0
- package/src/report/message.mjs +37 -6
- package/src/report/progress.mjs +13 -2
- package/src/run/paths.mjs +14 -1
- package/src/run/usage.mjs +66 -1
- package/src/util.mjs +18 -2
|
@@ -16,10 +16,35 @@ import { mkdirSync, writeFileSync } from "node:fs";
|
|
|
16
16
|
import { processStartToken } from "../run/lock.mjs";
|
|
17
17
|
import { randomUUID } from "node:crypto";
|
|
18
18
|
import { runMutation } from "./mutation.mjs";
|
|
19
|
+
import { SANDBOX_BLOCKED_WRITE } from "../contract/worker-result.mjs";
|
|
20
|
+
import { isAbsolute, resolve } from "node:path";
|
|
21
|
+
import { isContained } from "../util.mjs";
|
|
19
22
|
import { spawn } from "node:child_process";
|
|
20
23
|
import { killTarget, spawnInvocation } from "../host/platform.mjs";
|
|
21
24
|
/** @typedef {import("../contract/verification.mjs").VerificationOptions} VerificationOptions */
|
|
22
25
|
|
|
26
|
+
/**
|
|
27
|
+
* The run-level options plus the sandbox mode the caller knows. The mode is not
|
|
28
|
+
* part of the shared verification contract: it is controller environment, read
|
|
29
|
+
* here only to name a refusal the command itself cannot.
|
|
30
|
+
*
|
|
31
|
+
* @typedef {VerificationOptions & {sandboxMode?: string|null}} RunVerificationOptions
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* The named classification of a `workspace-write` refusal outside the worktree,
|
|
36
|
+
* carrying the mode that caused it and the path that was denied.
|
|
37
|
+
*
|
|
38
|
+
* @typedef {{classification: "sandbox_blocked_write", mode: string, path: string}} SandboxBlockedWrite
|
|
39
|
+
*/
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* A bounded attempt result plus the controller's sandbox classification, which
|
|
43
|
+
* rides beside the captured output only when a refusal was recognized.
|
|
44
|
+
*
|
|
45
|
+
* @typedef {VerificationAttemptResult & {attempt: number, sandboxBlockedWrite?: SandboxBlockedWrite}} SandboxAwareAttemptResult
|
|
46
|
+
*/
|
|
47
|
+
|
|
23
48
|
/** @typedef {import("node:child_process").ChildProcess} ChildProcess */
|
|
24
49
|
/** @typedef {import("../contract/verification.mjs").VerificationAttempt} VerificationAttempt */
|
|
25
50
|
/** @typedef {import("../contract/verification.mjs").VerificationAttemptResult} VerificationAttemptResult */
|
|
@@ -62,12 +87,51 @@ function verificationEnv(command) {
|
|
|
62
87
|
}
|
|
63
88
|
return env;
|
|
64
89
|
}
|
|
90
|
+
|
|
91
|
+
// `workspace-write` denies a toolchain's cache in `$HOME` and the tool reports
|
|
92
|
+
// a read-only filesystem: observed 2026-09-22 with Zig under `dsh`,
|
|
93
|
+
// `manifest_create ReadOnlyFileSystem` before its first source file. The
|
|
94
|
+
// sandbox, not the command, is the cause, so the controller names it. `EROFS`
|
|
95
|
+
// and the POSIX message cover the same refusal from other runners.
|
|
96
|
+
const SANDBOX_READ_ONLY_PATTERN = /ReadOnlyFileSystem|EROFS|read-only file system/iu;
|
|
97
|
+
// A denied path is usually quoted (`unable to load '/path': ReadOnlyFileSystem`)
|
|
98
|
+
// or bare after the operation name; either way it is absolute, since only an
|
|
99
|
+
// effect outside the worktree gets refused.
|
|
100
|
+
const ABSOLUTE_PATH_PATTERNS = Object.freeze([/['"]((?:[A-Za-z]:[\\/]|\/)[^'"]+)['"]/gu, /((?:[A-Za-z]:[\\/]|\/)[^\s'",;:)\]]+)/gu]);
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Classify a failed command whose output names a read-only-filesystem refusal
|
|
104
|
+
* outside the worktree, under `workspace-write`. Returns null for any other
|
|
105
|
+
* mode, a refusal inside the worktree, or output with no path to name.
|
|
106
|
+
*
|
|
107
|
+
* @param {{text?: string, workspace: string, mode?: string|null}} args
|
|
108
|
+
* @returns {SandboxBlockedWrite|null}
|
|
109
|
+
*/
|
|
110
|
+
export function classifySandboxBlockedWrite({ text, workspace, mode }) {
|
|
111
|
+
if (mode !== "workspace-write" || typeof text !== "string") return null;
|
|
112
|
+
const lines = text.split(/\r?\n/u);
|
|
113
|
+
const markerLines = lines.filter((line) => SANDBOX_READ_ONLY_PATTERN.test(line));
|
|
114
|
+
if (markerLines.length === 0) return null;
|
|
115
|
+
// Prefer a path the refusal line itself names; fall back to any path in the
|
|
116
|
+
// output, since `manifest_create ReadOnlyFileSystem` can put the path on a
|
|
117
|
+
// neighbouring line.
|
|
118
|
+
for (const line of [...markerLines, ...lines]) {
|
|
119
|
+
for (const pattern of ABSOLUTE_PATH_PATTERNS) {
|
|
120
|
+
for (const match of line.matchAll(pattern)) {
|
|
121
|
+
const candidate = match[1];
|
|
122
|
+
const path = isAbsolute(candidate) ? candidate : resolve(workspace, candidate);
|
|
123
|
+
if (!isContained(workspace, path)) return { classification: SANDBOX_BLOCKED_WRITE, mode, path };
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
return null;
|
|
128
|
+
}
|
|
65
129
|
/**
|
|
66
130
|
* Run every declared command `repeat` times inside the workspace.
|
|
67
131
|
*
|
|
68
132
|
* @param {unknown} commands
|
|
69
133
|
* @param {string} baseCwd
|
|
70
|
-
* @param {
|
|
134
|
+
* @param {RunVerificationOptions} options
|
|
71
135
|
* @returns {Promise<VerificationResult>}
|
|
72
136
|
*/
|
|
73
137
|
export async function runVerification(commands, baseCwd, options = {}) {
|
|
@@ -92,7 +156,7 @@ export async function runVerification(commands, baseCwd, options = {}) {
|
|
|
92
156
|
* @param {VerificationCommand} command
|
|
93
157
|
* @param {string} baseCwd
|
|
94
158
|
* @param {number} commandIndex
|
|
95
|
-
* @param {
|
|
159
|
+
* @param {RunVerificationOptions} options
|
|
96
160
|
* @returns {Promise<VerificationCommandResult>}
|
|
97
161
|
*/
|
|
98
162
|
async function runRepeatedCommand(command, baseCwd, commandIndex, options) {
|
|
@@ -132,7 +196,7 @@ function signalDeathRetry(result, signal) {
|
|
|
132
196
|
* @param {VerificationCommand} command
|
|
133
197
|
* @param {string} baseCwd
|
|
134
198
|
* @param {number} commandIndex
|
|
135
|
-
* @param {
|
|
199
|
+
* @param {RunVerificationOptions} options
|
|
136
200
|
* @returns {Promise<VerificationCommandResult>}
|
|
137
201
|
*/
|
|
138
202
|
async function runMutationCommand(command, baseCwd, commandIndex, options) {
|
|
@@ -149,7 +213,7 @@ async function runMutationCommand(command, baseCwd, commandIndex, options) {
|
|
|
149
213
|
* @param {string} commandCwd
|
|
150
214
|
* @param {number} attempt
|
|
151
215
|
* @param {AbortSignal|undefined} signal
|
|
152
|
-
* @param {
|
|
216
|
+
* @param {RunVerificationOptions} options
|
|
153
217
|
* @param {number} commandIndex
|
|
154
218
|
* @returns {Promise<VerificationAttemptResult>}
|
|
155
219
|
*/
|
|
@@ -181,6 +245,7 @@ function runCommand(command, baseCwd, commandCwd, attempt, signal, options, comm
|
|
|
181
245
|
if (abortHandler) signal?.removeEventListener("abort", abortHandler);
|
|
182
246
|
if (child?.pid && !error && !signalName && !timedOut) terminateGroup(child);
|
|
183
247
|
const durationMs = Number(process.hrtime.bigint() - started) / 1e6;
|
|
248
|
+
/** @type {SandboxAwareAttemptResult} */
|
|
184
249
|
const result = {
|
|
185
250
|
attempt,
|
|
186
251
|
stdout: stdout.value(),
|
|
@@ -192,6 +257,17 @@ function runCommand(command, baseCwd, commandCwd, attempt, signal, options, comm
|
|
|
192
257
|
error: error ? String(error.message ?? error) : null,
|
|
193
258
|
passed: !error && !timedOut && exitCode === 0 && !signalName,
|
|
194
259
|
};
|
|
260
|
+
// The controller's own sandbox mode bounds where a toolchain may write;
|
|
261
|
+
// a caller may name it, and a controller launched under `dsh` inherits it
|
|
262
|
+
// in `DSH_PERMISSION_MODE`. Only `workspace-write` classifies.
|
|
263
|
+
if (!result.passed) {
|
|
264
|
+
const sandboxBlockedWrite = classifySandboxBlockedWrite({
|
|
265
|
+
text: `${result.stdout}\n${result.stderr}`,
|
|
266
|
+
workspace: resolve(baseCwd, commandCwd),
|
|
267
|
+
mode: options?.sandboxMode ?? process.env.DSH_PERMISSION_MODE ?? null,
|
|
268
|
+
});
|
|
269
|
+
if (sandboxBlockedWrite) result.sandboxBlockedWrite = sandboxBlockedWrite;
|
|
270
|
+
}
|
|
195
271
|
if (!completionReported && identity) {
|
|
196
272
|
completionReported = true;
|
|
197
273
|
try { options?.onAttemptComplete?.({ ...identity, status: result.passed ? "closed" : "failed", completedAt: new Date().toISOString(), result }); } catch {
|
|
@@ -21,7 +21,7 @@ import { createHash } from "node:crypto";
|
|
|
21
21
|
import { lstatSync, readFileSync, readdirSync } from "node:fs";
|
|
22
22
|
import { join, relative, sep } from "node:path";
|
|
23
23
|
import { fileURLToPath } from "node:url";
|
|
24
|
-
import { stableJson } from "../util.mjs";
|
|
24
|
+
import { stableJson, errorCode } from "../util.mjs";
|
|
25
25
|
import { validateRunMetadata } from "../contract/snapshot.mjs";
|
|
26
26
|
import { contractDigest } from "../contract/index.mjs";
|
|
27
27
|
import { RUNS_DIR_NAME, runDirectory } from "../run/paths.mjs";
|
|
@@ -97,6 +97,26 @@ function runDirFor(sourceIdentity) {
|
|
|
97
97
|
return runDirectory(sourceIdentity.cwd, sourceIdentity.contractId);
|
|
98
98
|
}
|
|
99
99
|
|
|
100
|
+
/**
|
|
101
|
+
* Whether a run directory never materialized a node. The directory, its
|
|
102
|
+
* frozen contract and its empty `nodes/` folder are written before runtime
|
|
103
|
+
* assignment and identity capture; node states are written after. A launch
|
|
104
|
+
* refused in that window leaves a directory that never held a node, so
|
|
105
|
+
* nothing can resume it and it would refuse the next attempt as an existing
|
|
106
|
+
* run.
|
|
107
|
+
*
|
|
108
|
+
* @param {string} runDir
|
|
109
|
+
* @returns {boolean}
|
|
110
|
+
*/
|
|
111
|
+
export function runNeverHeldNode(runDir) {
|
|
112
|
+
try {
|
|
113
|
+
return readdirSync(join(runDir, "nodes")).every((name) => !name.endsWith(".json"));
|
|
114
|
+
} catch (error) {
|
|
115
|
+
if (errorCode(error) === "ENOENT") return true;
|
|
116
|
+
throw error;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
100
120
|
/**
|
|
101
121
|
* The records a prior run.json already froze, when one exists. On creation
|
|
102
122
|
* this is empty; on every later metadata rewrite it is the source of truth.
|
|
@@ -506,14 +526,10 @@ export async function assertEnvironmentReady(contract, runDir, sourceIdentity) {
|
|
|
506
526
|
// state reads done, this launch replays an accepted transaction, starts no
|
|
507
527
|
// worker and no judge, and can spend no availability.
|
|
508
528
|
if (blocking === null && launchMayDispatch(runDir)) {
|
|
509
|
-
//
|
|
510
|
-
//
|
|
511
|
-
//
|
|
512
|
-
|
|
513
|
-
// runtime; preflightContract validates it, so only a valid number is lifted.
|
|
514
|
-
const override = Number(process.env.FABERUN_PREFLIGHT_TIMEOUT_SEC);
|
|
515
|
-
const timeoutSec = process.env.FABERUN_PREFLIGHT_TIMEOUT_SEC !== undefined && Number.isFinite(override) && override > 0 ? override : undefined;
|
|
516
|
-
const probes = await livePreflightProbes(contract, runDir, sourceIdentity, timeoutSec);
|
|
529
|
+
// The live budget is `live-preflight.mjs`'s measured default (60s, 180s at xhigh;
|
|
530
|
+
// `FABERUN_PREFLIGHT_TIMEOUT_SEC` the operator override). It is not
|
|
531
|
+
// restated here: a second copy is a second default to keep honest.
|
|
532
|
+
const probes = await livePreflightProbes(contract, runDir, sourceIdentity);
|
|
517
533
|
const silent = probes.filter((probe) => liveSilenceCause(probe) !== null);
|
|
518
534
|
writeJsonAtomic(join(runDir, "env-preflight.json"), evidence(silent.length === 0, probes));
|
|
519
535
|
if (silent.length > 0) {
|
|
@@ -537,8 +553,6 @@ export async function assertEnvironmentReady(contract, runDir, sourceIdentity) {
|
|
|
537
553
|
throw Object.assign(new Error(`env_preflight_failed: ${blocking} · the run stays resumable: fix the environment and resume ${runDir}`), { code: "env_preflight_failed" });
|
|
538
554
|
}
|
|
539
555
|
|
|
540
|
-
/** No routed runtime is given less than this by the dispatch gate. */
|
|
541
|
-
const GATE_MINIMUM_PREFLIGHT_SEC = 60;
|
|
542
556
|
/**
|
|
543
557
|
* The live half of the gate for one launch. Every routed runtime either holds
|
|
544
558
|
* a verdict this machine recorded inside its freshness window -- reused, the
|
|
@@ -555,10 +569,9 @@ const GATE_MINIMUM_PREFLIGHT_SEC = 60;
|
|
|
555
569
|
* @param {ValidatedContract} contract
|
|
556
570
|
* @param {string} runDir
|
|
557
571
|
* @param {SourceIdentity|undefined} sourceIdentity
|
|
558
|
-
* @param {number|undefined} timeoutSec
|
|
559
572
|
* @returns {Promise<ProbeResult[]>}
|
|
560
573
|
*/
|
|
561
|
-
async function livePreflightProbes(contract, runDir, sourceIdentity
|
|
574
|
+
async function livePreflightProbes(contract, runDir, sourceIdentity) {
|
|
562
575
|
const routed = reachableRuntimes(contract);
|
|
563
576
|
/** @type {Map<string, RuntimeAvailability>} */
|
|
564
577
|
const fresh = new Map();
|
|
@@ -631,7 +644,7 @@ async function livePreflightProbes(contract, runDir, sourceIdentity, timeoutSec)
|
|
|
631
644
|
};
|
|
632
645
|
});
|
|
633
646
|
}
|
|
634
|
-
const probes = await preflightContract(join(runDir, "contract.json"), {
|
|
647
|
+
const probes = await preflightContract(join(runDir, "contract.json"), { persisted: true });
|
|
635
648
|
// What this launch bought is durable from here on: every ask that reached a
|
|
636
649
|
// provider -- a refusal included, an answer being an answer -- is recorded
|
|
637
650
|
// under the provider's own identity. Silence and a command that never
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { normalizeProviderAvailability, probeRuntime } from "../harnesses/index.mjs";
|
|
2
2
|
import { effectiveProvider } from "../contract/provider.mjs";
|
|
3
|
-
import { SAME_VENDOR_REVIEW_MODE, sameVendorTierRefusal } from "../contract/judge-independence.mjs";
|
|
3
|
+
import { SAME_VENDOR_REVIEW_MODE, effectiveJudgeIndependence, sameVendorTierRefusal } from "../contract/judge-independence.mjs";
|
|
4
4
|
|
|
5
5
|
// Availability normalization belongs to the adapter registry, which is where
|
|
6
6
|
// each provider's own exhaustion, balance, and authentication wording is
|
|
@@ -56,6 +56,18 @@ export { exhaustedUntilOf, normalizeProviderAvailability } from "../harnesses/in
|
|
|
56
56
|
* @type {Readonly<Record<string, DiscoveryRuntime>>}
|
|
57
57
|
*/
|
|
58
58
|
export const DISCOVERY_RUNTIME_DEFINITIONS = Object.freeze({
|
|
59
|
+
// Every vendor this harness reaches is declared ahead of the older harness
|
|
60
|
+
// for the same models, so fx wins each tie-break. Measured on the
|
|
61
|
+
// parseDuration benchmark (2026-09-25): 22-27 MB per fx worker against
|
|
62
|
+
// 362-422 MB for dsh, with the same scope and hidden-test results.
|
|
63
|
+
"fx-deepseek": {
|
|
64
|
+
harness: "fx",
|
|
65
|
+
model: "deepseek-flash",
|
|
66
|
+
vendor: "deepseek",
|
|
67
|
+
config: { "api_key.env_key": "DEEPSEEK_API_KEY" },
|
|
68
|
+
tier: 1,
|
|
69
|
+
costRank: 1,
|
|
70
|
+
},
|
|
59
71
|
// `dsh` defaults no vendor and no provider route, so both are declared here
|
|
60
72
|
// or nothing can build a command from this entry.
|
|
61
73
|
"dsh-deepseek": {
|
|
@@ -66,6 +78,32 @@ export const DISCOVERY_RUNTIME_DEFINITIONS = Object.freeze({
|
|
|
66
78
|
tier: 1,
|
|
67
79
|
costRank: 1,
|
|
68
80
|
},
|
|
81
|
+
// GLM 5.3 on fx through the Z.ai Coding Plan's Chat Completions endpoint.
|
|
82
|
+
// The operator chose this default knowing the plan's usage policy lists
|
|
83
|
+
// Claude Code and ZCode, not fx, and restricts or bans an account for
|
|
84
|
+
// detected third-party use; `claude-glm` below is the supported route.
|
|
85
|
+
// Measured 2026-09-26: the tiny-text campaign with a GLM worker and a
|
|
86
|
+
// DeepSeek judge, both on fx, peaked at 283 MB.
|
|
87
|
+
"fx-glm": {
|
|
88
|
+
harness: "fx",
|
|
89
|
+
model: "glm-5.3",
|
|
90
|
+
vendor: "zhipu",
|
|
91
|
+
config: { base_url: "https://api.z.ai/api/coding/paas/v4", "api_key.env_key": "ZAI_API_KEY", context_window: 200_000 },
|
|
92
|
+
tier: 1,
|
|
93
|
+
costRank: 1,
|
|
94
|
+
},
|
|
95
|
+
// GLM through Claude Code, which the Z.ai Coding Plan lists as a supported
|
|
96
|
+
// tool. Declared before zcode-glm so it wins the tie-break: measured
|
|
97
|
+
// 2026-09-25 on one review prompt, 384-392 MB per turn against 0.9-1.0 GB
|
|
98
|
+
// for zcode, and the tiny-text campaign peaked at 865 MB against 1,575 MB.
|
|
99
|
+
"claude-glm": {
|
|
100
|
+
harness: "claude",
|
|
101
|
+
model: "glm-5.3",
|
|
102
|
+
vendor: "zhipu",
|
|
103
|
+
config: { base_url: "https://api.z.ai/api/anthropic", "auth_token.env_key": "ZAI_API_KEY" },
|
|
104
|
+
tier: 1,
|
|
105
|
+
costRank: 1,
|
|
106
|
+
},
|
|
69
107
|
// The flash tier, not the unsuffixed pro: measured 2026-09-24 in the judge
|
|
70
108
|
// canary, glm-5.3 recalled 0.79 against glm-5.3-flash's 0.85 at about nine
|
|
71
109
|
// times the price, which the owner confirmed matches the public benchmarks.
|
|
@@ -78,15 +116,19 @@ export const DISCOVERY_RUNTIME_DEFINITIONS = Object.freeze({
|
|
|
78
116
|
costRank: 1,
|
|
79
117
|
},
|
|
80
118
|
"agy-gemini": { harness: "agy", model: "gemini-3.8-flash-low", vendor: "google", tier: 1, costRank: 1 },
|
|
119
|
+
// GPT on fx through fx's own ChatGPT login, which fx-faberun reads from the
|
|
120
|
+
// operator's profile (`FX_AUTH_HOME`). Measured 2026-09-26: one review turn
|
|
121
|
+
// peaked at 35-47 MB for the runner and fx together.
|
|
122
|
+
"fx-gpt": { harness: "fx", model: "gpt-5.6-sol", vendor: "openai", config: { provider: "codex" }, tier: 2, costRank: 2 },
|
|
81
123
|
// Three codex rows, because the harness declares three models and an
|
|
82
124
|
// account is entitled to only some of them: measured 2026-09-21 on the
|
|
83
125
|
// owner's ChatGPT account, plain `gpt-5.6` answers HTTP 400 while
|
|
84
126
|
// `gpt-5.6-sol` answers normally. One row meant `setup` could offer only the
|
|
85
127
|
// model that account cannot use, and the operator's only way out was to hand
|
|
86
|
-
// every contract its own catalogue through `--runtimes`.
|
|
87
|
-
//
|
|
88
|
-
// an account can reach is not something this file can know, and the
|
|
89
|
-
// preflight is what reports it per id.
|
|
128
|
+
// every contract its own catalogue through `--runtimes`. `fx-gpt` above is
|
|
129
|
+
// declared first, so it is the composed tier-2 default; among these three,
|
|
130
|
+
// which an account can reach is not something this file can know, and the
|
|
131
|
+
// live preflight is what reports it per id.
|
|
90
132
|
"codex-gpt": { harness: "codex", model: "gpt-5.6", vendor: "openai", tier: 2, costRank: 2 },
|
|
91
133
|
"codex-sol": { harness: "codex", model: "gpt-5.6-sol", vendor: "openai", tier: 2, costRank: 2 },
|
|
92
134
|
"codex-luna": { harness: "codex", model: "gpt-5.6-luna", vendor: "openai", tier: 2, costRank: 2 },
|
|
@@ -134,10 +176,12 @@ export function availableCandidates(runtimes, availability = {}) {
|
|
|
134
176
|
* copied exactly; callers persist the returned pair in run state.
|
|
135
177
|
*
|
|
136
178
|
* An optional `options.config` narrows the candidate set to the harnesses the
|
|
137
|
-
* operator enabled
|
|
138
|
-
*
|
|
139
|
-
* `
|
|
140
|
-
*
|
|
179
|
+
* operator enabled, prefers its named worker and judge, and -- when the
|
|
180
|
+
* contract declares none (R36) -- supplies the machine-wide
|
|
181
|
+
* `judgeIndependence` opt-in. An empty narrowed set falls back to the
|
|
182
|
+
* unrestricted candidates and is reported through `options.onWarning`; config
|
|
183
|
+
* never overrides an explicit node, gate or runtime-default declaration, and
|
|
184
|
+
* the cross-vendor judge rule still applies.
|
|
141
185
|
*
|
|
142
186
|
* `options.listJudge` (R18) is asked for an omitted judge before the single
|
|
143
187
|
* `config.judge` preference and the strongest-candidate default: it is the
|
|
@@ -152,7 +196,7 @@ export function availableCandidates(runtimes, availability = {}) {
|
|
|
152
196
|
* @param {RuntimeContract} contract
|
|
153
197
|
* @param {Record<string, RuntimeAvailability>} availability
|
|
154
198
|
* @param {ComposeOptions} [options]
|
|
155
|
-
* @returns {Record<string, {worker: string, judge: string}>}
|
|
199
|
+
* @returns {Record<string, {worker: string, judge: string, judgeIndependence?: "same-vendor"}>}
|
|
156
200
|
*/
|
|
157
201
|
export function composeAssignments(contract, availability = {}, options = {}) {
|
|
158
202
|
const allCandidates = availableCandidates(contract.runtimes, availability);
|
|
@@ -164,7 +208,7 @@ export function composeAssignments(contract, availability = {}, options = {}) {
|
|
|
164
208
|
if (restricted.length) candidates = restricted;
|
|
165
209
|
else if (allCandidates.length) options.onWarning?.(`config harnesses · none of ${config.harnesses.join(", ")} is available · using all available runtimes`);
|
|
166
210
|
}
|
|
167
|
-
/** @type {Record<string, {worker: string, judge: string}>} */
|
|
211
|
+
/** @type {Record<string, {worker: string, judge: string, judgeIndependence?: "same-vendor"}>} */
|
|
168
212
|
const assignments = {};
|
|
169
213
|
for (const node of contract.nodes ?? []) {
|
|
170
214
|
const workerOmitted = node.runtime === undefined && contract.runtimeDefaults?.worker === undefined;
|
|
@@ -193,7 +237,11 @@ export function composeAssignments(contract, availability = {}, options = {}) {
|
|
|
193
237
|
// R20: in same-vendor mode a judge of the worker's own provider is
|
|
194
238
|
// admissible when it is another model at an equal or higher declared tier;
|
|
195
239
|
// the strongest such candidate is the default only when no other vendor has one.
|
|
196
|
-
|
|
240
|
+
// R36: a contract that declares no mode inherits the machine config's, and
|
|
241
|
+
// the effective mode is recorded on the assignment so the launch decision
|
|
242
|
+
// survives in the run snapshot.
|
|
243
|
+
const judgeIndependence = effectiveJudgeIndependence(contract, config);
|
|
244
|
+
const sameVendorMode = judgeIndependence === SAME_VENDOR_REVIEW_MODE;
|
|
197
245
|
const judge = declaredJudge
|
|
198
246
|
?? judgeListPick?.chosen
|
|
199
247
|
?? (preferredJudge && effectiveProvider(preferredJudge.runtime) !== workerProvider ? preferredJudge.id : undefined)
|
|
@@ -209,7 +257,7 @@ export function composeAssignments(contract, availability = {}, options = {}) {
|
|
|
209
257
|
const refusal = sameVendorTierRefusal(/** @type {{harness: string, model: string}} */ (workerRuntime), /** @type {{harness: string, model: string}} */ (judgeRuntime));
|
|
210
258
|
if (refusal) throw new Error(`runtime_assignment_judge_unavailable: ${refusal.message} for node ${node.id}`);
|
|
211
259
|
}
|
|
212
|
-
assignments[node.id] = { worker, judge: judge ?? worker };
|
|
260
|
+
assignments[node.id] = { worker, judge: judge ?? worker, ...(judgeIndependence === undefined ? {} : { judgeIndependence }) };
|
|
213
261
|
}
|
|
214
262
|
return assignments;
|
|
215
263
|
}
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The environment a worker, a judge, or an availability probe may receive.
|
|
3
|
+
*
|
|
4
|
+
* A controller variable reaches a child only when something named it. The
|
|
5
|
+
* names a child may see are the union of:
|
|
6
|
+
*
|
|
7
|
+
* - the base operating-system names below,
|
|
8
|
+
* - the names the selected adapter declares in its `declaredEnvironment`,
|
|
9
|
+
* - the runtime's `*.env_key` names, and
|
|
10
|
+
* - the explicit `envPassthrough` names on the runtime or the contract.
|
|
11
|
+
*
|
|
12
|
+
* Everything else the controller holds -- an `AWS_*`, a `GITHUB_TOKEN`, a
|
|
13
|
+
* `DATABASE_URL` exported in a shell profile -- is dropped. Verification
|
|
14
|
+
* commands are not workers and keep the full controller environment
|
|
15
|
+
* (`judge-gate.mjs`); only worker, judge, and availability-probe spawns route
|
|
16
|
+
* through here.
|
|
17
|
+
*
|
|
18
|
+
* The adapter lists are imported from each adapter directly instead of through
|
|
19
|
+
* `harnesses/catalogue.mjs`: the registry imports this module for its probe, so
|
|
20
|
+
* reaching the catalogue from here would close a runtime import cycle the repo
|
|
21
|
+
* forbids. The catalogue keeps its own copy of the same names for R2, and both
|
|
22
|
+
* read the same adapter exports.
|
|
23
|
+
*/
|
|
24
|
+
import { declaredEnvironment as claudeEnvironment } from "../harnesses/claude/index.mjs";
|
|
25
|
+
import { declaredEnvironment as codexEnvironment } from "../harnesses/codex/index.mjs";
|
|
26
|
+
import { declaredEnvironment as agyEnvironment } from "../harnesses/agy/index.mjs";
|
|
27
|
+
import { declaredEnvironment as dshEnvironment } from "../harnesses/dsh/index.mjs";
|
|
28
|
+
import { declaredEnvironment as fxEnvironment } from "../harnesses/fx/index.mjs";
|
|
29
|
+
import { declaredEnvironment as zcodeEnvironment } from "../harnesses/zcode/index.mjs";
|
|
30
|
+
import { declaredEnvironment as execJsonlEnvironment } from "../harnesses/exec-jsonl/index.mjs";
|
|
31
|
+
import { declaredEnvironment as replayEnvironment } from "../harnesses/replay/index.mjs";
|
|
32
|
+
import { NOTIFY_ENV_NAMES } from "../notify/index.mjs";
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* The base operating-system names every child needs to resolve a binary, find
|
|
36
|
+
* its home, and speak the locale. `LC_*` is a family, matched by prefix; the
|
|
37
|
+
* Windows entries are listed on every platform because the machine that reads
|
|
38
|
+
* them is the machine that has them.
|
|
39
|
+
*
|
|
40
|
+
* @type {readonly string[]}
|
|
41
|
+
*/
|
|
42
|
+
export const BASE_ENV_NAMES = Object.freeze([
|
|
43
|
+
"PATH",
|
|
44
|
+
"HOME",
|
|
45
|
+
"USER",
|
|
46
|
+
"LOGNAME",
|
|
47
|
+
"SHELL",
|
|
48
|
+
"LANG",
|
|
49
|
+
"TERM",
|
|
50
|
+
"TMPDIR",
|
|
51
|
+
"TZ",
|
|
52
|
+
"SYSTEMROOT",
|
|
53
|
+
"USERPROFILE",
|
|
54
|
+
"APPDATA",
|
|
55
|
+
"LOCALAPPDATA",
|
|
56
|
+
"COMSPEC",
|
|
57
|
+
"PATHEXT",
|
|
58
|
+
]);
|
|
59
|
+
|
|
60
|
+
/** @type {ReadonlySet<string>} */
|
|
61
|
+
const BASE_NAME_SET = new Set(BASE_ENV_NAMES);
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* The adapter-declared names, keyed by harness. Values never travel here.
|
|
65
|
+
*
|
|
66
|
+
* A `Map` rather than the catalogue's frozen object on purpose: the repo
|
|
67
|
+
* forbids the same top-level body defined twice, and the registry owns the
|
|
68
|
+
* catalogue copy.
|
|
69
|
+
*
|
|
70
|
+
* @type {Map<string, readonly string[]>}
|
|
71
|
+
*/
|
|
72
|
+
const ADAPTER_ENVIRONMENTS = new Map([
|
|
73
|
+
["claude", claudeEnvironment],
|
|
74
|
+
["codex", codexEnvironment],
|
|
75
|
+
["agy", agyEnvironment],
|
|
76
|
+
["dsh", dshEnvironment],
|
|
77
|
+
["fx", fxEnvironment],
|
|
78
|
+
["zcode", zcodeEnvironment],
|
|
79
|
+
["exec-jsonl", execJsonlEnvironment],
|
|
80
|
+
["replay", replayEnvironment],
|
|
81
|
+
]);
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* @param {string} name
|
|
85
|
+
* @returns {boolean}
|
|
86
|
+
*/
|
|
87
|
+
function isBaseName(name) {
|
|
88
|
+
return BASE_NAME_SET.has(name) || name.startsWith("LC_");
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* The `*.env_key` names a runtime's config declares. A config value there is
|
|
93
|
+
* the environment-variable name, never the value, so this reads names alone.
|
|
94
|
+
*
|
|
95
|
+
* @param {{config?: Record<string, unknown>}|null|undefined} runtime
|
|
96
|
+
* @returns {string[]}
|
|
97
|
+
*/
|
|
98
|
+
function envKeyNames(runtime) {
|
|
99
|
+
/** @type {string[]} */
|
|
100
|
+
const names = [];
|
|
101
|
+
for (const [key, value] of Object.entries(runtime?.config ?? {})) {
|
|
102
|
+
if (key.endsWith(".env_key") && typeof value === "string" && value.length > 0) names.push(value);
|
|
103
|
+
}
|
|
104
|
+
return names;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* The non-base names one runtime may carry: what its adapter declares, what
|
|
109
|
+
* its `*.env_key` config entries name, and what the runtime or the contract
|
|
110
|
+
* explicitly passes through. The notification transport is never one of them,
|
|
111
|
+
* whatever an author writes.
|
|
112
|
+
*
|
|
113
|
+
* @param {{harness?: string, config?: Record<string, unknown>, envPassthrough?: string[]}|null|undefined} runtime
|
|
114
|
+
* @param {{envPassthrough?: string[]}|null|undefined} [contract]
|
|
115
|
+
* @returns {string[]}
|
|
116
|
+
*/
|
|
117
|
+
export function declaredEnvironmentNames(runtime, contract) {
|
|
118
|
+
const names = new Set(ADAPTER_ENVIRONMENTS.get(runtime?.harness ?? "") ?? []);
|
|
119
|
+
for (const name of envKeyNames(runtime)) names.add(name);
|
|
120
|
+
for (const name of Array.isArray(runtime?.envPassthrough) ? runtime.envPassthrough : []) names.add(name);
|
|
121
|
+
for (const name of Array.isArray(contract?.envPassthrough) ? contract.envPassthrough : []) names.add(name);
|
|
122
|
+
for (const name of NOTIFY_ENV_NAMES) names.delete(name);
|
|
123
|
+
return [...names];
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* The base names present in `source`, including every `LC_*` member.
|
|
128
|
+
*
|
|
129
|
+
* @param {Record<string, string|undefined>} [source]
|
|
130
|
+
* @returns {Record<string, string>}
|
|
131
|
+
*/
|
|
132
|
+
export function baseEnvironment(source = process.env) {
|
|
133
|
+
/** @type {Record<string, string>} */
|
|
134
|
+
const env = {};
|
|
135
|
+
// Look the base names up rather than filtering source keys: Windows exposes
|
|
136
|
+
// `Path`, not `PATH`, and `source[name]` is the case-insensitive read there.
|
|
137
|
+
for (const name of BASE_ENV_NAMES) {
|
|
138
|
+
const value = source[name];
|
|
139
|
+
if (value !== undefined) env[name] = value;
|
|
140
|
+
}
|
|
141
|
+
for (const [name, value] of Object.entries(source)) {
|
|
142
|
+
if (value !== undefined && name.startsWith("LC_")) env[name] = value;
|
|
143
|
+
}
|
|
144
|
+
return env;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* The non-base allowlisted names present in `source`, with their controller
|
|
149
|
+
* values. This is the overlay a caller hands the gate: the gate already holds
|
|
150
|
+
* the base set in its own environment, so it only needs what the runtime adds
|
|
151
|
+
* on top.
|
|
152
|
+
*
|
|
153
|
+
* @param {{harness?: string, config?: Record<string, unknown>, envPassthrough?: string[]}|null|undefined} runtime
|
|
154
|
+
* @param {{envPassthrough?: string[]}|null|undefined} [contract]
|
|
155
|
+
* @param {Record<string, string|undefined>} [source]
|
|
156
|
+
* @returns {Record<string, string>}
|
|
157
|
+
*/
|
|
158
|
+
export function passthroughEnvironment(runtime, contract, source = process.env) {
|
|
159
|
+
/** @type {Record<string, string>} */
|
|
160
|
+
const env = {};
|
|
161
|
+
for (const name of declaredEnvironmentNames(runtime, contract)) {
|
|
162
|
+
if (isBaseName(name)) continue;
|
|
163
|
+
const value = source[name];
|
|
164
|
+
if (value !== undefined) env[name] = value;
|
|
165
|
+
}
|
|
166
|
+
return env;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* The complete environment a worker, judge, or availability probe receives.
|
|
171
|
+
*
|
|
172
|
+
* @param {{harness?: string, config?: Record<string, unknown>, envPassthrough?: string[]}|null|undefined} runtime
|
|
173
|
+
* @param {{envPassthrough?: string[]}|null|undefined} [contract]
|
|
174
|
+
* @param {Record<string, string|undefined>} [source]
|
|
175
|
+
* @returns {Record<string, string>}
|
|
176
|
+
*/
|
|
177
|
+
export function workerEnvironment(runtime, contract, source = process.env) {
|
|
178
|
+
return { ...baseEnvironment(source), ...passthroughEnvironment(runtime, contract, source) };
|
|
179
|
+
}
|
|
@@ -1,5 +1,16 @@
|
|
|
1
1
|
import { normalizeAgyResult, parseVersion } from "../protocol.mjs";
|
|
2
2
|
|
|
3
|
+
/**
|
|
4
|
+
* The environment names the agy adapter reads to authenticate and configure the
|
|
5
|
+
* CLI. agy signs in through its own CLI configuration, so the only name read
|
|
6
|
+
* here is the binary override `executable()` honours. Values never travel here.
|
|
7
|
+
*
|
|
8
|
+
* @type {readonly string[]}
|
|
9
|
+
*/
|
|
10
|
+
export const declaredEnvironment = Object.freeze([
|
|
11
|
+
"FABERUN_AGY_BIN",
|
|
12
|
+
]);
|
|
13
|
+
|
|
3
14
|
/**
|
|
4
15
|
* @type {import("../index.mjs").HarnessAdapter}
|
|
5
16
|
*/
|