From 1243b01b5d545b2ed86e6ef7b09d61b889c87af0 Mon Sep 17 00:00:00 2001 From: aaronwestphal Date: Wed, 27 May 2026 15:32:17 -0600 Subject: [PATCH] Add FT_TIMEOUT_MULTIPLIER env var to scale engine call timeouts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Engine call timeouts (DEFAULT_TIMEOUT in src/engine.ts, the per-depth timeoutMs values in adjacent/prompts.ts, the wiki-compile timeout in md.ts) are sized for a baseline local CLI with no startup overhead. In environments where the configured engine has heavy startup cost — MCP servers, SessionStart hooks, high reasoning effort — the deadlines fire before the child has responded, producing non-deterministic "claude/ codex timed out after Ns" failures even on small prompts. The fix is a single uniform multiplier applied at the engine layer so every caller (classify, wiki compile, Possible runs) inherits it without changes. Read on every call so tests can scope the override. Non-numeric or non-positive values fall back to 1 silently. No default change — opt-in only. export FT_TIMEOUT_MULTIPLIER=2 # doubles every timeout ft possible run --seed --repo . Two new tests pin the scaling behavior and the input-validation fallback. All 545 tests pass. --- README.md | 11 +++++++++++ src/engine.ts | 25 ++++++++++++++++++++++-- tests/engine-invoke.test.ts | 39 +++++++++++++++++++++++++++++++++++++ 3 files changed, 73 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index f7ddb85..87a35ec 100644 --- a/README.md +++ b/README.md @@ -189,6 +189,17 @@ Nightly schedules are stored under `~/.fieldtheory/ideas/nightly/`. Each tick st `ft` respects standard proxy environment variables for network requests: `HTTPS_PROXY`, `HTTP_PROXY`, `ALL_PROXY`, and `NO_PROXY`. +### Engine timeouts + +Set `FT_TIMEOUT_MULTIPLIER` to scale every engine-call deadline uniformly. Useful when the configured agent CLI has heavy startup overhead (MCP servers, session hooks, high reasoning effort) and the default per-call budgets fire before the child has responded: + +```bash +export FT_TIMEOUT_MULTIPLIER=2 # doubles every timeout +ft possible run --seed --repo . +``` + +Applies to every `invokeEngine` / `invokeEngineAsync` call (classification, wiki compile, Possible runs). Default is `1` (no change). Non-numeric or non-positive values are ignored. + ## Data Data is stored locally under `~/.fieldtheory/`: diff --git a/src/engine.ts b/src/engine.ts index 62a30e2..3f0909f 100644 --- a/src/engine.ts +++ b/src/engine.ts @@ -289,6 +289,27 @@ const STDERR_TAIL_BYTES = 4096; // clipped tail shown in errors/logs const STDERR_HARD_CAP = 64 * 1024; // hard ceiling on in-memory stderr buffering const SIGKILL_GRACE_MS = 2_000; // grace period between SIGTERM and SIGKILL +/** Optional global multiplier applied to every engine-call timeout. + * Set FT_TIMEOUT_MULTIPLIER to scale the per-call deadlines uniformly when + * the configured engine has heavy startup overhead (MCP servers, session + * hooks, high reasoning effort) and the default budgets fire before the + * child has responded. Applies to caller-supplied opts.timeout and to the + * DEFAULT_TIMEOUT fallback. Non-numeric or non-positive values are ignored. + * Read on every call so tests and one-off env overrides take effect without + * re-importing the module. */ +function timeoutMultiplier(): number { + const raw = process.env.FT_TIMEOUT_MULTIPLIER; + if (raw === undefined || raw === '') return 1; + const n = Number(raw); + if (!Number.isFinite(n) || n <= 0) return 1; + return n; +} + +function resolveTimeout(callerTimeout: number | undefined): number { + const base = callerTimeout ?? DEFAULT_TIMEOUT; + return Math.round(base * timeoutMultiplier()); +} + /** Clip the tail of a buffer to a byte budget — engines put the "what went * wrong" line at the end of stderr. */ function tailString(buf: Buffer, bytes: number): string { @@ -358,7 +379,7 @@ function buildMessage( */ export function invokeEngine(engine: ResolvedEngine, prompt: string, opts: InvokeOptions = {}): string { const { bin, args } = engine.config; - const timeout = opts.timeout ?? DEFAULT_TIMEOUT; + const timeout = resolveTimeout(opts.timeout); const maxBuffer = opts.maxBuffer ?? DEFAULT_MAXBUF; const result = spawnSync(bin, args(prompt, engine), { @@ -425,7 +446,7 @@ export function invokeEngine(engine: ResolvedEngine, prompt: string, opts: Invok */ export function invokeEngineAsync(engine: ResolvedEngine, prompt: string, opts: InvokeOptions = {}): Promise { const { bin, args } = engine.config; - const timeout = opts.timeout ?? DEFAULT_TIMEOUT; + const timeout = resolveTimeout(opts.timeout); const maxBuffer = opts.maxBuffer ?? DEFAULT_MAXBUF; return new Promise((resolve, reject) => { diff --git a/tests/engine-invoke.test.ts b/tests/engine-invoke.test.ts index 44c7cb7..5834d4f 100644 --- a/tests/engine-invoke.test.ts +++ b/tests/engine-invoke.test.ts @@ -96,3 +96,42 @@ test('invokeEngineAsync: captures multi-line stderr in the error message', async }, ); }); + +function withEnv(key: string, value: string | undefined, fn: () => Promise): Promise { + const prior = process.env[key]; + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + return fn().finally(() => { + if (prior === undefined) delete process.env[key]; + else process.env[key] = prior; + }); +} + +test('invokeEngineAsync: FT_TIMEOUT_MULTIPLIER scales the per-call deadline', async () => { + const { invokeEngineAsync } = await import('../src/engine.js'); + // Caller asks for 200ms; multiplier 5 → effective 1000ms. A 500ms sleep + // would have timed out under the raw 200ms deadline, but completes when + // the multiplier widens it. + await withEnv('FT_TIMEOUT_MULTIPLIER', '5', async () => { + const out = await invokeEngineAsync( + shEngine('fake-fits-under-multiplier', 'sleep 0.5; printf "ok\\n"'), + 'ignored', + { timeout: 200 }, + ); + assert.equal(out, 'ok'); + }); +}); + +test('invokeEngineAsync: invalid FT_TIMEOUT_MULTIPLIER values fall back to 1', async () => { + const { invokeEngineAsync } = await import('../src/engine.js'); + // Garbage values must not crash and must not silently change the deadline. + for (const bad of ['not-a-number', '0', '-2', '']) { + await withEnv('FT_TIMEOUT_MULTIPLIER', bad, async () => { + await assert.rejects( + () => invokeEngineAsync(shEngine('fake-slow', 'sleep 5'), 'ignored', { timeout: 200 }), + /timed out after 200ms/, + `bad value ${JSON.stringify(bad)} should fall back to multiplier=1`, + ); + }); + } +});