Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <id> --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/`:
Expand Down
25 changes: 23 additions & 2 deletions src/engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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), {
Expand Down Expand Up @@ -425,7 +446,7 @@ export function invokeEngine(engine: ResolvedEngine, prompt: string, opts: Invok
*/
export function invokeEngineAsync(engine: ResolvedEngine, prompt: string, opts: InvokeOptions = {}): Promise<string> {
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) => {
Expand Down
39 changes: 39 additions & 0 deletions tests/engine-invoke.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,3 +96,42 @@ test('invokeEngineAsync: captures multi-line stderr in the error message', async
},
);
});

function withEnv(key: string, value: string | undefined, fn: () => Promise<void>): Promise<void> {
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`,
);
});
}
});