fix(engine): run user commands via async exec to avoid blocking event loop

The merger's test/build verification, executor's worktreeInitCommand,
setupScript, and script-mode workflow steps all used execSync, which
blocks Node's event loop until the child process exits. A hanging
pnpm test could freeze the entire engine (no logs, heartbeats, or
other task progress) for the full 5-minute timeout.

Switch these call sites to promisify(exec) with awaited calls so the
engine keeps running while user-configured commands execute. Short
internal git plumbing (rev-parse, branch -d, worktree remove) still
uses execSync since those commands are bounded and measured in ms.

Document the rule in AGENTS.md under a new "Engine process rules"
section so future agents don't reintroduce blocking behavior.

Tests: update child_process mocks in merger.test, executor.test, and
restart.integration.test to route the new async exec through the
existing execSync mock and expose promisify.custom so destructuring
{ stdout, stderr } matches real child_process.exec semantics.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-04-11 18:28:05 -07:00
parent 46fff0d33f
commit bc3688badf
6 changed files with 123 additions and 22 deletions

View File

@@ -166,6 +166,32 @@ pnpm build # build all packages
Tests are required. Typechecks and manual verification are not substitutes for real tests with assertions.
## Engine process rules
The engine (`packages/engine`) runs the executor, merger, scheduler, IPC host, and dashboard-facing activity loop on a single Node event loop. **Blocking that loop stalls every task concurrently in-flight.**
### Never use `execSync` for user-configured or long-running commands
`execSync` blocks the entire event loop until the child process exits (or hits its timeout). If a user's test/build/setup command hangs for 5 minutes, the engine stops responding — no logging, no heartbeats, no scheduler ticks, no other merges/executions progress. We've been burned by this: the engine appeared hung while waiting for `pnpm test`.
**Rule:** any command that comes from project settings or user configuration — `testCommand`, `buildCommand`, `worktreeInitCommand`, `setupScript`, `settings.scripts[...]`, workflow step scripts — **must** run via `promisify(exec)` (or `spawn`) and be `await`ed. Always pass a `timeout`.
```ts
import { exec } from "node:child_process";
import { promisify } from "node:util";
const execAsync = promisify(exec);
const { stdout, stderr } = await execAsync(command, {
cwd: worktreePath,
timeout: 120_000,
maxBuffer: 10 * 1024 * 1024,
});
```
`execSync` is only acceptable for short, deterministic git plumbing (`git rev-parse`, `git branch -d`, `git worktree remove`, etc.) where the command is internal, bounded, and measured in milliseconds. When in doubt, use async.
Same reasoning applies to any new helper added to the engine: if you find yourself reaching for `execSync` and the command is not a trivial git/fs call, stop and use the async form.
## Multi-Project Architecture / Central Core
fn supports multi-project coordination through a central infrastructure that provides:
@@ -2414,7 +2440,7 @@ The order of IDs in `enabledWorkflowSteps` determines execution order — the en
### Engine Behavior
- **Prompt mode** steps use readonly agent tools (file reading only, no modifications); **script mode** steps execute a named command from project settings (`settings.scripts`) in the task worktree
- Each prompt-mode step runs as a separate agent session; script-mode steps run via `execSync` with a 2-minute timeout
- Each prompt-mode step runs as a separate agent session; script-mode steps run via async `exec` (promisified) with a 2-minute timeout — **never use `execSync` here** (see "Engine process rules" below)
- **Model override:** Prompt-mode steps can specify a `modelProvider` + `modelId` pair. When both are set, the executor uses that model instead of global defaults. When either is missing, the executor falls back to `defaultProvider`/`defaultModelId`
- Steps execute sequentially within their phase (pre-merge steps first, then post-merge steps after merge)
- Pre-merge steps run in the executor; post-merge steps run in the merger after successful merge