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:
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user