fix(engine): make global pause actually stop in-flight verification

Hitting Stop (globalPause) disposed the AI merge agent session but left
the spawned `pnpm test` / `pnpm build` child processes running until
they finished naturally. With recurring flaky-test loops at Step 5,
that meant Stop had no visible effect — new test runs kept piling up
across multiple worktrees.

Two gaps:
- project-engine.ts onGlobalPause never called mergeAbortController.abort(),
  so subsequent verification commands (gated by the signal) weren't cancelled.
- merger.ts execWithProcessGroup only listened to its own internal
  timeout — passing an AbortSignal had no effect on the in-flight
  child process group.

Fix: abort the controller on global pause, and have execWithProcessGroup
SIGTERM/SIGKILL the detached process group when its signal aborts.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-04-26 20:51:40 -07:00
parent 642d76a9ea
commit a2f23eae29
2 changed files with 49 additions and 10 deletions

View File

@@ -16,9 +16,17 @@ const execAsync = promisify(exec);
*/
async function execWithProcessGroup(
command: string,
options: { cwd: string; timeout: number; maxBuffer: number },
): Promise<{ stdout: string; stderr: string; bufferOverflow: boolean }> {
options: { cwd: string; timeout: number; maxBuffer: number; signal?: AbortSignal },
): Promise<{ stdout: string; stderr: string; bufferOverflow: boolean; aborted?: boolean }> {
return new Promise((resolve, reject) => {
if (options.signal?.aborted) {
reject(Object.assign(
new Error(`Command aborted before start: ${command}`),
{ code: "ABORT_ERR", aborted: true, stdout: "", stderr: "" },
));
return;
}
const child = spawn(command, {
cwd: options.cwd,
shell: true,
@@ -31,20 +39,34 @@ async function execWithProcessGroup(
let stdoutOverflow = false;
let stderrOverflow = false;
let timedOut = false;
let aborted = false;
let settled = false;
const killTree = (sig: NodeJS.Signals) => {
if (child.pid === undefined) return;
try { process.kill(-child.pid, sig); } catch { /* group may already be gone */ }
};
const timer = setTimeout(() => {
timedOut = true;
if (child.pid !== undefined) {
try { process.kill(-child.pid, "SIGTERM"); } catch { /* group may already be gone */ }
setTimeout(() => {
if (settled) return;
try { process.kill(-(child.pid as number), "SIGKILL"); } catch { /* ignore */ }
}, 5_000).unref();
}
killTree("SIGTERM");
setTimeout(() => {
if (settled) return;
killTree("SIGKILL");
}, 5_000).unref();
}, options.timeout);
timer.unref();
const onAbort = () => {
aborted = true;
killTree("SIGTERM");
setTimeout(() => {
if (settled) return;
killTree("SIGKILL");
}, 5_000).unref();
};
options.signal?.addEventListener("abort", onAbort, { once: true });
child.stdout?.on("data", (chunk: Buffer) => {
if (stdoutOverflow) return;
if (stdout.length + chunk.length > options.maxBuffer) {
@@ -68,6 +90,14 @@ async function execWithProcessGroup(
if (settled) return;
settled = true;
clearTimeout(timer);
options.signal?.removeEventListener("abort", onAbort);
if (aborted) {
reject(Object.assign(
new Error(`Command aborted: ${command}`),
{ code: "ABORT_ERR", aborted: true, stdout, stderr, killed: true },
));
return;
}
if (timedOut) {
reject(Object.assign(
new Error(`Command timed out after ${options.timeout}ms: ${command}`),
@@ -707,6 +737,7 @@ async function runVerificationCommand(
cwd: rootDir,
timeout: VERIFICATION_COMMAND_TIMEOUT_MS,
maxBuffer: VERIFICATION_COMMAND_MAX_BUFFER,
signal,
});
throwIfAborted(signal, taskId);

View File

@@ -1327,9 +1327,17 @@ export class ProjectEngine {
// ── Settings event listeners ──
private wireSettingsListeners(store: TaskStore): void {
// 1. Global pause — terminate active merge session
// 1. Global pause — terminate active merge session AND abort any running
// deterministic verification (pnpm test/build). The abort controller gates
// both the AI merge agent and the spawned child processes; without it,
// verification commands keep churning until they finish naturally.
const onGlobalPause = ({ settings, previous }: { settings: Settings; previous: Settings }) => {
if (settings.globalPause && !previous.globalPause) {
if (this.mergeAbortController) {
runtimeLog.log("Global pause — aborting in-flight merge verification");
this.mergeAbortController.abort();
this.mergeAbortController = null;
}
if (this.activeMergeSession) {
runtimeLog.log("Global pause — terminating active merge session");
this.activeMergeSession.dispose();