From bc35ef3cc095213ce8adeaf4cabcab3c945aee66 Mon Sep 17 00:00:00 2001 From: Edgar Arakelyan Date: Fri, 23 Oct 2026 17:27:43 -0700 Subject: [PATCH] fix(environment): dispatch a pending interrupt before each build stage check The manifest, state and coverage stages run synchronously, so a SIGINT that arrived during them was only dispatched once the bundle write had begun. Each check now yields first, so an interrupt before the bundle stage cancels the build with nothing written. Co-Authored-By: Claude Opus 5.5 --- .../environment/environmentBuild.ts | 20 +++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/src/application/environment/environmentBuild.ts b/src/application/environment/environmentBuild.ts index b2d9c877e9..2154789ccf 100644 --- a/src/application/environment/environmentBuild.ts +++ b/src/application/environment/environmentBuild.ts @@ -1,4 +1,5 @@ import { open } from "node:fs/promises"; +import { setImmediate } from "node:timers/promises"; import type { AnalysisError } from "../../domain/analysisErrorBase.js"; import { @@ -187,11 +188,13 @@ const repeatedRules = (): AnalysisInputError => ]); /** A cancellation, naming the bundle when it arrived after the bundle was written. */ -const cancelled = ( +const cancelled = async ( signal: AbortSignal | undefined, written?: string, -): AnalysisCancelledError | undefined => - signal?.aborted === true +): Promise => { + // The stages are synchronous; yield so a pending interrupt is dispatched before checking. + if (signal !== undefined) await setImmediate(); + return signal?.aborted === true ? new AnalysisCancelledError(ENVIRONMENT_BUILD_OPERATION, { cause: signal.reason, ...(written === undefined @@ -202,6 +205,7 @@ const cancelled = ( }), }) : undefined; +}; const notCaptureEvidence = (operation: string): AnalysisInputError => new AnalysisInputError( @@ -352,7 +356,7 @@ export const buildEnvironment = async ( (id) => (id === capture.evidence_id ? capture : undefined), ); if (!projected.ok) return projected; - const stopped = cancelled(input.signal); + const stopped = await cancelled(input.signal); if (stopped !== undefined) return err(stopped); const manifest = await environmentExportFile( "manifest", @@ -371,7 +375,7 @@ export const buildEnvironment = async ( const named = environmentName(input, source.value.manifest, warnings); if (!named.ok) return named; const name = named.value ?? source.value.manifest.name; - const beforeState = cancelled(input.signal); + const beforeState = await cancelled(input.signal); if (beforeState !== undefined) return err(beforeState); input.progress?.("state"); // Prepared as the export prepares it, so its report and the bundle's README match the three-step path. @@ -421,7 +425,7 @@ export const buildEnvironment = async ( warnings, }; if (input.dryRun) { - const beforePlan = cancelled(input.signal); + const beforePlan = await cancelled(input.signal); if (beforePlan !== undefined) return err(beforePlan); const plan = planEnvironmentBundle(source.value, { name, @@ -457,7 +461,7 @@ export const buildEnvironment = async ( } const directory = input.directory; if (directory === undefined) return err(missingDirectory()); - const beforeWrite = cancelled(input.signal); + const beforeWrite = await cancelled(input.signal); if (beforeWrite !== undefined) return err(beforeWrite); input.progress?.("bundle"); const exported = await exportEnvironmentBundle({ @@ -480,6 +484,6 @@ export const buildEnvironment = async ( next: [`agent-env run ${shellWord(directory)}`], }; // A cancellation that arrives during the write still names the bundle it wrote. - const afterWrite = cancelled(input.signal, directory); + const afterWrite = await cancelled(input.signal, directory); return afterWrite === undefined ? ok(report) : err(afterWrite); };