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`, + ); + }); + } +});