faberun 0.26.0 → 0.26.1

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.26.0",
3
+ "version": "0.26.1",
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": {
@@ -24,6 +24,7 @@ import { readRunNodes } from "../run/node-store.mjs";
24
24
  import { spawn } from "node:child_process";
25
25
  import { validateContract } from "../contract/index.mjs";
26
26
  import { runDirectory } from "../run/paths.mjs";
27
+ import { livePreflightCeilingSec } from "../engine/live-preflight.mjs";
27
28
 
28
29
  /**
29
30
  * The file a detached child is spawned as. It must be the CLI and not this
@@ -94,7 +95,7 @@ export function detachArgv(argv, options = {}) {
94
95
  * @param {number} [timeoutMs]
95
96
  * @returns {Promise<BootstrapRecord>}
96
97
  */
97
- export async function waitForBootstrap(runDir, pid, child = null, timeoutMs = 30_000) {
98
+ export async function waitForBootstrap(runDir, pid, child = null, timeoutMs = bootstrapReadyTimeoutMs()) {
98
99
  const deadline = Date.now() + timeoutMs;
99
100
  const nonce = child?.bootstrapNonce;
100
101
  if (!nonce) throw new Error(`detached bootstrap has no start nonce for pid ${pid}`);
@@ -157,6 +158,18 @@ export async function waitForBootstrap(runDir, pid, child = null, timeoutMs = 30
157
158
  cleanupBootstrapAttempts(runDir);
158
159
  throw new Error(`detached bootstrap did not become ready for pid ${pid}`);
159
160
  }
161
+ /**
162
+ * The controller answers ready only after the dispatch gate's live preflight,
163
+ * so the launcher waits for that ask on top of its own 30s start-up margin.
164
+ * Measured 2026-09-27: with a fixed 30s wait, a gate asking Opus at `xhigh`
165
+ * made `run --detach` and `plan --detach` report "did not become ready" for
166
+ * controllers that then came up and ran.
167
+ *
168
+ * @returns {number}
169
+ */
170
+ export function bootstrapReadyTimeoutMs() {
171
+ return 30_000 + livePreflightCeilingSec() * 1000;
172
+ }
160
173
  /**
161
174
  * @param {string} runDir
162
175
  * @param {BootstrapRecord} bootstrap
@@ -46,7 +46,7 @@ const LIVE_PREFLIGHT_OUTPUT_LIMIT_BYTES = 512 * 1024;
46
46
  * whole is part of the answer.
47
47
  *
48
48
  * @param {string} contractPath
49
- * @param {{static?: boolean, liveTimeoutSec?: number, persisted?: boolean}} [options]
49
+ * @param {{static?: boolean, liveTimeoutSec?: number, minimumTimeoutSec?: number, persisted?: boolean}} [options]
50
50
  * @returns {Promise<ProbeResult[]>}
51
51
  */
52
52
  export async function preflightContract(contractPath, options = {}) {
@@ -70,7 +70,7 @@ export async function preflightContract(contractPath, options = {}) {
70
70
  * once, so a caller cannot accidentally ask a different question.
71
71
  *
72
72
  * @param {{runtime: RuntimeSnapshot, requiredCapabilitySets?: import("../harnesses/index.mjs").CapabilityRequirements[]}[]} entries
73
- * @param {{static?: boolean, liveTimeoutSec?: number, cwd?: string}} [options]
73
+ * @param {{static?: boolean, liveTimeoutSec?: number, minimumTimeoutSec?: number, cwd?: string}} [options]
74
74
  * @returns {Promise<ProbeResult[]>}
75
75
  */
76
76
  export async function preflightRuntimes(entries, options = {}) {
@@ -80,7 +80,7 @@ export async function preflightRuntimes(entries, options = {}) {
80
80
  ));
81
81
  if (options.static === true) return staticChecks;
82
82
 
83
- const timeoutSec = livePreflightTimeout(options.liveTimeoutSec);
83
+ const configuredTimeoutSec = configuredLivePreflightTimeout(options.liveTimeoutSec);
84
84
  let liveRepo;
85
85
  try {
86
86
  liveRepo = createLivePreflightRepo();
@@ -95,7 +95,7 @@ export async function preflightRuntimes(entries, options = {}) {
95
95
  }
96
96
  try {
97
97
  return await Promise.all(staticChecks.map(async (check, index) => {
98
- const live = await livePreflight(runtimes[index], liveRepo, timeoutSec);
98
+ const live = await livePreflight(runtimes[index], liveRepo, configuredTimeoutSec ?? Math.max(options.minimumTimeoutSec ?? 0, defaultLivePreflightTimeout(runtimes[index])));
99
99
  const liveDetail = live.status === "done"
100
100
  ? `live done · usage ${formatUsage(live.usage)} · cost ${formatCost(live.costUsd)}`
101
101
  : `live ${live.status} · ${live.error?.code ?? "provider_error"}: ${redactProviderText(live.error?.message ?? "generation failed")} · usage ${formatUsage(live.usage)} · cost ${formatCost(live.costUsd)}`;
@@ -116,16 +116,54 @@ export async function preflightRuntimes(entries, options = {}) {
116
116
  rmSync(liveRepo, { recursive: true, force: true });
117
117
  }
118
118
  }
119
- /** @param {number|undefined} configured */
120
- function livePreflightTimeout(configured) {
119
+ /**
120
+ * An explicit budget (option, then environment) applies to every runtime.
121
+ *
122
+ * @param {number|undefined} configured
123
+ * @returns {number|null}
124
+ */
125
+ function configuredLivePreflightTimeout(configured) {
121
126
  const raw = configured ?? (process.env.FABERUN_PREFLIGHT_TIMEOUT_SEC === undefined
122
- ? 15
127
+ ? null
123
128
  : Number(process.env.FABERUN_PREFLIGHT_TIMEOUT_SEC));
129
+ if (raw === null) return null;
124
130
  if (typeof raw !== "number" || !Number.isFinite(raw) || raw <= 0) {
125
131
  throw new TypeError("preflight live timeout must be a positive number of seconds");
126
132
  }
127
133
  return raw;
128
134
  }
135
+ /**
136
+ * Without an explicit budget, the wait follows the runtime's reasoning effort:
137
+ * a model at high effort thinks before answering even a one-word prompt.
138
+ * Measured 2026-09-26: Opus 5.5 at `xhigh` failed every `faberun plan`
139
+ * preflight at 15 s and passed once the budget was raised, so the planning
140
+ * pipeline was refusing a runtime that was healthy.
141
+ *
142
+ * @param {RuntimeSnapshot} runtime
143
+ * @returns {number}
144
+ */
145
+ export function defaultLivePreflightTimeout(runtime) {
146
+ // Keep LIVE_PREFLIGHT_CEILING_SEC equal to the largest value returned here.
147
+ const reasoning = /** @type {{reasoning?: unknown}} */ (runtime).reasoning;
148
+ if (reasoning === "xhigh" || reasoning === "max") return 180;
149
+ if (reasoning === "high") return 60;
150
+ return 15;
151
+ }
152
+ /**
153
+ * The longest a live preflight can take without an explicit budget: what a
154
+ * detached launcher must be willing to wait before the controller it spawned
155
+ * can report ready, because the dispatch gate asks before it does.
156
+ */
157
+ export const LIVE_PREFLIGHT_CEILING_SEC = 180;
158
+ /**
159
+ * The longest one live preflight ask may take under the current environment.
160
+ *
161
+ * @returns {number}
162
+ */
163
+ export function livePreflightCeilingSec() {
164
+ const override = Number(process.env.FABERUN_PREFLIGHT_TIMEOUT_SEC);
165
+ return process.env.FABERUN_PREFLIGHT_TIMEOUT_SEC !== undefined && Number.isFinite(override) && override > 0 ? override : LIVE_PREFLIGHT_CEILING_SEC;
166
+ }
129
167
  /** @returns {string} */
130
168
  function createLivePreflightRepo() {
131
169
  const directory = mkdtempSync(join(tmpdir(), "faberun-preflight-"));
@@ -507,11 +507,12 @@ export async function assertEnvironmentReady(contract, runDir, sourceIdentity) {
507
507
  // worker and no judge, and can spend no availability.
508
508
  if (blocking === null && launchMayDispatch(runDir)) {
509
509
  // measured 2026-09-22: asking four routed runtimes in parallel took about
510
- // 18s, so the default budget is 60s. FABERUN_PREFLIGHT_TIMEOUT_SEC stays
511
- // the operator override; preflightContract validates it, so only a valid
512
- // number is lifted here.
510
+ // 18s, so no runtime is given less than 60s here; a runtime at high
511
+ // reasoning effort gets its longer default (defaultLivePreflightTimeout).
512
+ // FABERUN_PREFLIGHT_TIMEOUT_SEC stays the operator override for every
513
+ // runtime; preflightContract validates it, so only a valid number is lifted.
513
514
  const override = Number(process.env.FABERUN_PREFLIGHT_TIMEOUT_SEC);
514
- const timeoutSec = process.env.FABERUN_PREFLIGHT_TIMEOUT_SEC !== undefined && Number.isFinite(override) && override > 0 ? override : 60;
515
+ const timeoutSec = process.env.FABERUN_PREFLIGHT_TIMEOUT_SEC !== undefined && Number.isFinite(override) && override > 0 ? override : undefined;
515
516
  const probes = await livePreflightProbes(contract, runDir, sourceIdentity, timeoutSec);
516
517
  const silent = probes.filter((probe) => liveSilenceCause(probe) !== null);
517
518
  writeJsonAtomic(join(runDir, "env-preflight.json"), evidence(silent.length === 0, probes));
@@ -536,6 +537,8 @@ export async function assertEnvironmentReady(contract, runDir, sourceIdentity) {
536
537
  throw Object.assign(new Error(`env_preflight_failed: ${blocking} · the run stays resumable: fix the environment and resume ${runDir}`), { code: "env_preflight_failed" });
537
538
  }
538
539
 
540
+ /** No routed runtime is given less than this by the dispatch gate. */
541
+ const GATE_MINIMUM_PREFLIGHT_SEC = 60;
539
542
  /**
540
543
  * The live half of the gate for one launch. Every routed runtime either holds
541
544
  * a verdict this machine recorded inside its freshness window -- reused, the
@@ -552,7 +555,7 @@ export async function assertEnvironmentReady(contract, runDir, sourceIdentity) {
552
555
  * @param {ValidatedContract} contract
553
556
  * @param {string} runDir
554
557
  * @param {SourceIdentity|undefined} sourceIdentity
555
- * @param {number} timeoutSec
558
+ * @param {number|undefined} timeoutSec
556
559
  * @returns {Promise<ProbeResult[]>}
557
560
  */
558
561
  async function livePreflightProbes(contract, runDir, sourceIdentity, timeoutSec) {
@@ -628,7 +631,7 @@ async function livePreflightProbes(contract, runDir, sourceIdentity, timeoutSec)
628
631
  };
629
632
  });
630
633
  }
631
- const probes = await preflightContract(join(runDir, "contract.json"), { liveTimeoutSec: timeoutSec, persisted: true });
634
+ const probes = await preflightContract(join(runDir, "contract.json"), { ...(timeoutSec === undefined ? {} : { liveTimeoutSec: timeoutSec }), minimumTimeoutSec: GATE_MINIMUM_PREFLIGHT_SEC, persisted: true });
632
635
  // What this launch bought is durable from here on: every ask that reached a
633
636
  // provider -- a refusal included, an answer being an answer -- is recorded
634
637
  // under the provider's own identity. Silence and a command that never