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(
|
async function execWithProcessGroup(
|
||||||
command: string,
|
command: string,
|
||||||
options: { cwd: string; timeout: number; maxBuffer: number },
|
options: { cwd: string; timeout: number; maxBuffer: number; signal?: AbortSignal },
|
||||||
): Promise<{ stdout: string; stderr: string; bufferOverflow: boolean }> {
|
): Promise<{ stdout: string; stderr: string; bufferOverflow: boolean; aborted?: boolean }> {
|
||||||
return new Promise((resolve, reject) => {
|
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, {
|
const child = spawn(command, {
|
||||||
cwd: options.cwd,
|
cwd: options.cwd,
|
||||||
shell: true,
|
shell: true,
|
||||||
@@ -31,20 +39,34 @@ async function execWithProcessGroup(
|
|||||||
let stdoutOverflow = false;
|
let stdoutOverflow = false;
|
||||||
let stderrOverflow = false;
|
let stderrOverflow = false;
|
||||||
let timedOut = false;
|
let timedOut = false;
|
||||||
|
let aborted = false;
|
||||||
let settled = 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(() => {
|
const timer = setTimeout(() => {
|
||||||
timedOut = true;
|
timedOut = true;
|
||||||
if (child.pid !== undefined) {
|
killTree("SIGTERM");
|
||||||
try { process.kill(-child.pid, "SIGTERM"); } catch { /* group may already be gone */ }
|
setTimeout(() => {
|
||||||
setTimeout(() => {
|
if (settled) return;
|
||||||
if (settled) return;
|
killTree("SIGKILL");
|
||||||
try { process.kill(-(child.pid as number), "SIGKILL"); } catch { /* ignore */ }
|
}, 5_000).unref();
|
||||||
}, 5_000).unref();
|
|
||||||
}
|
|
||||||
}, options.timeout);
|
}, options.timeout);
|
||||||
timer.unref();
|
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) => {
|
child.stdout?.on("data", (chunk: Buffer) => {
|
||||||
if (stdoutOverflow) return;
|
if (stdoutOverflow) return;
|
||||||
if (stdout.length + chunk.length > options.maxBuffer) {
|
if (stdout.length + chunk.length > options.maxBuffer) {
|
||||||
@@ -68,6 +90,14 @@ async function execWithProcessGroup(
|
|||||||
if (settled) return;
|
if (settled) return;
|
||||||
settled = true;
|
settled = true;
|
||||||
clearTimeout(timer);
|
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) {
|
if (timedOut) {
|
||||||
reject(Object.assign(
|
reject(Object.assign(
|
||||||
new Error(`Command timed out after ${options.timeout}ms: ${command}`),
|
new Error(`Command timed out after ${options.timeout}ms: ${command}`),
|
||||||
@@ -707,6 +737,7 @@ async function runVerificationCommand(
|
|||||||
cwd: rootDir,
|
cwd: rootDir,
|
||||||
timeout: VERIFICATION_COMMAND_TIMEOUT_MS,
|
timeout: VERIFICATION_COMMAND_TIMEOUT_MS,
|
||||||
maxBuffer: VERIFICATION_COMMAND_MAX_BUFFER,
|
maxBuffer: VERIFICATION_COMMAND_MAX_BUFFER,
|
||||||
|
signal,
|
||||||
});
|
});
|
||||||
|
|
||||||
throwIfAborted(signal, taskId);
|
throwIfAborted(signal, taskId);
|
||||||
|
|||||||
@@ -1327,9 +1327,17 @@ export class ProjectEngine {
|
|||||||
// ── Settings event listeners ──
|
// ── Settings event listeners ──
|
||||||
|
|
||||||
private wireSettingsListeners(store: TaskStore): void {
|
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 }) => {
|
const onGlobalPause = ({ settings, previous }: { settings: Settings; previous: Settings }) => {
|
||||||
if (settings.globalPause && !previous.globalPause) {
|
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) {
|
if (this.activeMergeSession) {
|
||||||
runtimeLog.log("Global pause — terminating active merge session");
|
runtimeLog.log("Global pause — terminating active merge session");
|
||||||
this.activeMergeSession.dispose();
|
this.activeMergeSession.dispose();
|
||||||
|
|||||||
Reference in New Issue
Block a user