diff --git a/docs/development/tool-design.md b/docs/development/tool-design.md index 30b029b0f9..3b77a73bde 100644 --- a/docs/development/tool-design.md +++ b/docs/development/tool-design.md @@ -459,7 +459,12 @@ failed request's physical, cancellation and cleanup facts. remain attached to their owning envelope while cleanup is pending and after confirmed release. CLI adapters should reuse [`finishAnalysisCommand`](https://github.com/morluto/rea/blob/main/src/cli/finishAnalysisCommand.ts) where its contract - applies rather than duplicate close/finally handling. + applies rather than duplicate close/finally handling. It projects + [`runOwned`](https://github.com/morluto/rea/blob/main/src/application/runOwned.ts), + which owns this precedence for owned work: a recorded failure stays primary, + cancellation before commit keeps the completed value as a partial + observation, cancellation after commit keeps the value, and unconfirmed + cleanup reports `cleanup_incomplete` while keeping the completed value. - Give descriptor-backed reads an owner before opening the file. A rejected close must retain the exact handle and preserve any earlier failure. Carry [`openRegularFile`](https://github.com/morluto/rea/blob/main/src/filesystem/RegularFile.ts)'s diff --git a/scripts/verify/architecture-guard-baselines.json b/scripts/verify/architecture-guard-baselines.json index 5c52c8650e..9f4b7fac61 100644 --- a/scripts/verify/architecture-guard-baselines.json +++ b/scripts/verify/architecture-guard-baselines.json @@ -113,7 +113,6 @@ "src/browser/modules/NativeModuleResolver.ts": 3, "src/captures/TsharkCaptureProvider.ts": 2, "src/cli/environmentCommands.ts": 2, - "src/cli/finishAnalysisCommand.ts": 3, "src/cliJsonInput.ts": 1, "src/desktop/BuildComputerLongRuns.ts": 5, "src/desktop/DesktopBuildDoctorFacts.ts": 5, diff --git a/src/application/runOwned.test.ts b/src/application/runOwned.test.ts new file mode 100644 index 0000000000..7b2ee3389e --- /dev/null +++ b/src/application/runOwned.test.ts @@ -0,0 +1,329 @@ +import { describe, expect, it } from "vitest"; + +import { + AnalysisCancelledError, + AnalysisProtocolError, +} from "../domain/analysisErrorCore.js"; +import { + primaryAnalysisError, + type AnalysisError, + type AnalysisPartialObservation, +} from "../domain/analysisErrorBase.js"; +import { analysisErrorWithCleanupFailure } from "../domain/analysisErrorCleanup.js"; +import { ProviderCleanupError } from "../domain/providerCleanupError.js"; +import { err, ok, type Result } from "../domain/result.js"; +import { finalizedResult, runOwned } from "./runOwned.js"; + +const value: AnalysisPartialObservation = { + kind: "retained-evidence", + evidence_id: "evidence-1", +}; +const reason = { caller: "stop" }; +const completedWork = async (): Promise< + Result +> => ok(value); + +/** A close that aborts the caller's signal, so cancellation lands during final cleanup. */ +const closeThenAbort = + (controller: AbortController, failure?: unknown) => async () => { + controller.abort(reason); + if (failure !== undefined) throw failure; + }; + +const options = (controller: AbortController, close: () => Promise) => ({ + providerId: "fixture", + operation: "fixture_operation", + signal: controller.signal, + close, +}); + +describe("runOwned precedence", () => { + it("completes when cleanup is released and no cancellation arrives", async () => { + const controller = new AbortController(); + const finalized = await runOwned( + completedWork, + options(controller, async () => undefined), + ); + expect(finalized).toEqual({ kind: "completed", value }); + }); + + it("cancels a completed value when cancellation arrives during final cleanup before commit", async () => { + const controller = new AbortController(); + const finalized = await runOwned( + completedWork, + options(controller, closeThenAbort(controller)), + ); + expect(finalized.kind).toBe("cancelled"); + const result = finalizedResult(finalized); + if (result.ok) throw new Error("expected cancellation"); + expect(result.error).toBeInstanceOf(AnalysisCancelledError); + expect(result.error.cause).toBe(reason); + expect(result.error.partialObservation).toBe(value); + }); + + it("keeps a committed value when cancellation arrives after the commit", async () => { + const controller = new AbortController(); + const commits: unknown[] = []; + const finalized = await runOwned(completedWork, { + ...options(controller, closeThenAbort(controller)), + commit: async (committed) => { + commits.push(committed); + }, + }); + expect(commits).toEqual([value]); + expect(finalized).toEqual({ + kind: "completed", + value, + lateCancellation: reason, + }); + }); + + it("does not commit after cancellation and reports it before commit", async () => { + const controller = new AbortController(); + controller.abort(reason); + let committed = false; + const finalized = await runOwned(completedWork, { + ...options(controller, async () => undefined), + commit: async () => { + committed = true; + }, + }); + expect(committed).toBe(false); + expect(finalized.kind).toBe("cancelled"); + }); + + it("keeps a recorded failure primary when cancellation arrives during final cleanup", async () => { + const controller = new AbortController(); + const failure = new AnalysisProtocolError("fixture failure"); + const finalized = await runOwned( + async () => err(failure), + options(controller, closeThenAbort(controller)), + ); + expect(finalized.kind).toBe("failed"); + const result = finalizedResult(finalized); + if (result.ok) throw new Error("expected failure"); + expect(primaryAnalysisError(result.error)).toBe(failure); + expect(failure.secondaryFailures).toEqual([ + expect.objectContaining({ + _tag: "AnalysisCancelledError", + cause: reason, + }), + ]); + }); + + it("reports a completed value with unconfirmed cleanup as cleanup_incomplete keeping the value", async () => { + const controller = new AbortController(); + const finalized = await runOwned( + completedWork, + options(controller, async () => { + throw new Error("close failed"); + }), + ); + expect(finalized.kind).toBe("completed-cleanup-retained"); + const result = finalizedResult(finalized); + if (result.ok) throw new Error("expected cleanup failure"); + expect(result.error).toBeInstanceOf(ProviderCleanupError); + expect(result.error.cleanupIncomplete).toBe(true); + expect(result.error.partialObservation).toBe(value); + }); + + it("keeps the primary failure's identity when cleanup also fails", async () => { + const controller = new AbortController(); + const failure = new AnalysisProtocolError("fixture failure"); + const cleanup = new ProviderCleanupError("fixture", ["fixture-run"], { + reason: "still running", + }); + const finalized = await runOwned( + async () => err(failure), + options(controller, async () => { + throw cleanup; + }), + ); + expect(finalized).toMatchObject({ + kind: "failed", + cleanup: { kind: "retained", failure: cleanup }, + }); + const result = finalizedResult(finalized); + if (result.ok) throw new Error("expected failure"); + expect(primaryAnalysisError(result.error)).toBe(failure); + expect(result.error.cleanupIncomplete).toBe(true); + }); + + it("types a thrown work failure as a provider adapter failure", async () => { + const controller = new AbortController(); + const finalized = await runOwned( + async () => { + throw new Error("unexpected"); + }, + options(controller, async () => undefined), + ); + const result = finalizedResult(finalized); + if (result.ok) throw new Error("expected failure"); + expect(result.error._tag).toBe("ProviderAdapterError"); + }); +}); + +it("classifies producer cancellation even when cleanup also fails", async () => { + const controller = new AbortController(); + const cancellation = new AnalysisCancelledError("fixture_operation", { + cause: reason, + }); + const finalized = await runOwned(async () => err(cancellation), { + ...options(controller, async () => { + throw new Error("still running"); + }), + }); + expect(finalized.kind).toBe("cancelled"); + if (finalized.kind !== "cancelled") throw new Error("expected cancellation"); + expect(finalized.cleanup.kind).toBe("retained"); + expect(primaryAnalysisError(finalized.error)).toBe(cancellation); +}); + +it("classifies a producer composite whose primary is a cancellation as cancelled", async () => { + const controller = new AbortController(); + const cancellation = new AnalysisCancelledError("fixture_operation", { + cause: reason, + }); + const cleanup = new ProviderCleanupError("fixture", ["owned-process"], { + reason: "pending", + }); + const composite = analysisErrorWithCleanupFailure( + cancellation, + cleanup, + "fixture_operation", + ).bindCleanupDependencies([cancellation, cleanup]); + const finalized = await runOwned( + async () => err(composite), + options(controller, async () => { + cleanup.resolveCleanup(); + }), + ); + expect(finalized).toMatchObject({ + kind: "cancelled", + error: composite, + cleanup: { kind: "released" }, + }); +}); + +it("keeps a failure with a later cancellation classified as failed", async () => { + const controller = new AbortController(); + const failure = new AnalysisProtocolError("fixture failure"); + const finalized = await runOwned( + async () => err(failure), + options(controller, closeThenAbort(controller)), + ); + expect(finalized.kind).toBe("failed"); + expect(failure.secondaryFailures).toHaveLength(1); +}); + +it("keeps producer cleanup retained when close does not resolve it", async () => { + const controller = new AbortController(); + const failure = new ProviderCleanupError("fixture", ["owned-process"], { + reason: "pending", + }); + const finalized = await runOwned( + async () => err(failure), + options(controller, async () => undefined), + ); + expect(finalized).toMatchObject({ + kind: "failed", + error: failure, + cleanup: { kind: "retained", failure }, + }); +}); + +it("retains completed work when publication fails", async () => { + const finalized = await runOwned(completedWork, { + ...options(new AbortController(), async () => undefined), + commit: async () => { + throw new Error("publication failed"); + }, + }); + const result = finalizedResult(finalized); + if (result.ok) throw new Error("expected publication failure"); + expect(result.error.partialObservation).toBe(value); +}); + +it("publishes recovered completion after cleanup resolves", async () => { + const controller = new AbortController(); + const failure = new ProviderCleanupError("fixture", ["owned-process"], { + reason: "pending", + }); + const published: AnalysisPartialObservation[] = []; + const finalized = await runOwned(async () => err(failure), { + ...options(controller, async () => { + failure.resolveCleanup(); + }), + recoverCompleted: (_error, observed) => + observed.cleanupResolvedByClose ? { value } : undefined, + commit: async (completed) => { + published.push(completed); + }, + }); + expect(published).toEqual([value]); + expect(finalized).toEqual({ kind: "completed", value }); +}); + +it("keeps a recovered value when cancellation arrives during its publication", async () => { + const controller = new AbortController(); + const failure = new ProviderCleanupError("fixture", ["owned-process"], { + reason: "pending", + }); + const finalized = await runOwned(async () => err(failure), { + ...options(controller, async () => { + failure.resolveCleanup(); + }), + recoverCompleted: () => ({ value }), + commit: async () => { + controller.abort(reason); + }, + }); + expect(finalized).toEqual({ + kind: "completed", + value, + lateCancellation: reason, + }); +}); + +it("keeps a cancellation that arrives while a recovered publication fails", async () => { + const controller = new AbortController(); + const failure = new ProviderCleanupError("fixture", ["owned-process"], { + reason: "pending", + }); + const publication = new AnalysisProtocolError("publication failed"); + const finalized = await runOwned(async () => err(failure), { + ...options(controller, async () => { + failure.resolveCleanup(); + }), + recoverCompleted: () => ({ value }), + commit: async () => { + controller.abort(reason); + throw publication; + }, + }); + expect(finalized.kind).toBe("failed"); + const result = finalizedResult(finalized); + if (result.ok) throw new Error("expected publication failure"); + expect(primaryAnalysisError(result.error)).toBe(publication); + expect(publication.secondaryFailures).toEqual([ + expect.objectContaining({ _tag: "AnalysisCancelledError", cause: reason }), + ]); +}); + +it("retains a recovered value when its publication fails", async () => { + const failure = new ProviderCleanupError("fixture", ["owned-process"], { + reason: "pending", + }); + const finalized = await runOwned(async () => err(failure), { + ...options(new AbortController(), async () => { + failure.resolveCleanup(); + }), + recoverCompleted: () => ({ value }), + commit: async () => { + throw new Error("publication failed"); + }, + }); + const result = finalizedResult(finalized); + if (result.ok) throw new Error("expected publication failure"); + expect(result.error.partialObservation).toBe(value); +}); diff --git a/src/application/runOwned.ts b/src/application/runOwned.ts new file mode 100644 index 0000000000..518cd62bd5 --- /dev/null +++ b/src/application/runOwned.ts @@ -0,0 +1,280 @@ +import { ArtifactReaderFailure } from "../artifacts/ArtifactReader.js"; +import { + AnalysisError, + primaryAnalysisError, + type AnalysisPartialObservation, +} from "../domain/analysisErrorBase.js"; +import { analysisErrorWithCleanupFailure } from "../domain/analysisErrorCleanup.js"; +import { analysisErrorWithCancellation } from "../domain/analysisErrorCancellation.js"; +import { + AnalysisCancelledError, + cancelledBy, + isAborted, +} from "../domain/analysisErrorCore.js"; +import { ProviderAdapterError } from "../domain/providerAdapterError.js"; +import { ProviderCleanupError } from "../domain/providerCleanupError.js"; +import type { JsonValue } from "../domain/jsonValue.js"; +import type { Result } from "../domain/result.js"; + +/** What closing the work's owner established. */ +export type CleanupOutcome = + | { readonly kind: "released" } + | { readonly kind: "retained"; readonly failure: AnalysisError }; + +/** + * Work, owner cleanup and cancellation composed by one precedence rule: + * - a recorded failure stays primary; a later cancellation is attached to it; + * - cancellation observed before commit turns a completed value into a + * cancellation that keeps the value as its partial observation; + * - cancellation observed after commit keeps the completed value; + * - unconfirmed cleanup never replaces the primary. A completed value whose + * cleanup is unconfirmed is a `cleanup_incomplete` error that keeps the + * value as its partial observation (the documented one-shot contract). + */ +export type Finalized = + | { + readonly kind: "completed"; + readonly value: Value; + /** A cancellation observed after the commit cutoff. */ + readonly lateCancellation?: unknown; + } + | { + readonly kind: "completed-cleanup-retained"; + readonly value: Value; + readonly error: AnalysisError; + } + | { + readonly kind: "cancelled"; + readonly error: AnalysisError; + readonly cleanup: CleanupOutcome; + } + | { + readonly kind: "failed"; + readonly error: AnalysisError; + readonly cleanup: CleanupOutcome; + }; + +/** A completed value recovered from a failure whose only cause was cleanup. */ +export interface RecoveredCompletion { + readonly value: Value; + /** Diagnostics a later pre-commit cancellation carries, such as the resolved cleanup history. */ + readonly cancellationDiagnostics?: Readonly>; +} + +export interface RunOwnedOptions { + readonly providerId: string; + readonly operation: string; + readonly signal: AbortSignal; + /** Releases everything the work acquired; awaited before the signal is read. */ + readonly close: () => Promise; + /** Names the owned resources when a non-REA close failure names none. */ + readonly fallbackResource?: string; + /** + * Publishes a completed value. Cancellation observed before it runs cancels + * the work; cancellation observed after it keeps the committed value. + */ + readonly commit?: (value: Value) => Promise; + /** Observe the primary outcome before close may change its cleanup status. */ + readonly inspectPrimary?: (primary: Result) => void; + /** Recover a completed value from a primary failure after close settled. */ + readonly recoverCompleted?: ( + primary: AnalysisError, + observed: { + /** The primary reported unconfirmed cleanup before close and not after. */ + readonly cleanupResolvedByClose: boolean; + readonly cleanup: CleanupOutcome; + }, + ) => RecoveredCompletion | undefined; +} + +const closeFailure = ( + cause: unknown, + options: Pick< + RunOwnedOptions, + "providerId" | "operation" | "fallbackResource" + >, +): AnalysisError => { + if (cause instanceof AnalysisError) return cause; + const observation = + cause instanceof ArtifactReaderFailure + ? ArtifactReaderFailure.cleanupObservation( + cause, + options.fallbackResource ?? `${options.operation} resources`, + ) + : { + reason: cause instanceof Error ? cause.message : String(cause), + resources: [], + }; + return new ProviderCleanupError( + options.providerId, + observation.resources, + { reason: observation.reason }, + { cause, operation: options.operation }, + ); +}; + +/** Run owned work, close its owner, and apply the finalization precedence once. */ +export const runOwned = async ( + work: () => Promise>, + options: RunOwnedOptions, +): Promise> => { + let primary: Result; + try { + primary = await work(); + } catch (cause) { + primary = { + ok: false, + error: + cause instanceof AnalysisError + ? cause + : new ProviderAdapterError(options.providerId, options.operation, { + cause, + }), + }; + } + let committed = false; + const commit = async ( + value: Value, + ): Promise> => { + try { + await options.commit!(value); + committed = true; + return { ok: true, value }; + } catch (cause) { + return { + ok: false, + error: + cause instanceof AnalysisError + ? cause.retainPartialObservation(value) + : new ProviderAdapterError(options.providerId, options.operation, { + cause, + partialObservation: value, + }), + }; + } + }; + if (primary.ok && options.commit !== undefined && !options.signal.aborted) + primary = await commit(primary.value); + options.inspectPrimary?.(primary); + const hadIncompleteCleanup = !primary.ok && primary.error.cleanupIncomplete; + let cleanup: CleanupOutcome = { kind: "released" }; + try { + // Owners may resolve the primary's retained cleanup during close; only + // they can confirm it, so its status is read again afterwards. + await options.close(); + } catch (cause) { + cleanup = { kind: "retained", failure: closeFailure(cause, options) }; + } + const signal = options.signal; + let outcome: + | { + readonly ok: true; + readonly value: Value; + readonly diagnostics?: Readonly>; + } + | { readonly ok: false; readonly error: AnalysisError } = primary; + if (!primary.ok) { + if (isAborted(signal)) + analysisErrorWithCancellation( + primary.error, + cancelledBy(options.operation, signal), + ); + const recovered = options.recoverCompleted?.(primary.error, { + cleanupResolvedByClose: + hadIncompleteCleanup && !primary.error.cleanupIncomplete, + cleanup, + }); + if (recovered !== undefined) + outcome = { + ok: true, + value: recovered.value, + ...(recovered.cancellationDiagnostics === undefined + ? {} + : { diagnostics: recovered.cancellationDiagnostics }), + }; + } + if ( + outcome.ok && + !primary.ok && + options.commit !== undefined && + !signal.aborted + ) { + const publication = await commit(outcome.value); + if (!publication.ok) { + // Publication is the last awaited step; a cancellation during it stays + // attached to the publication failure without replacing it. + outcome = publication; + if (isAborted(signal)) + analysisErrorWithCancellation( + publication.error, + cancelledBy(options.operation, signal), + ); + } + } + if ( + cleanup.kind === "released" && + !outcome.ok && + outcome.error.cleanupIncomplete + ) + cleanup = { kind: "retained", failure: outcome.error }; + // The kind follows the primary failure, so a producer's composite whose + // primary is a cancellation is cancelled, and a failure that only retains a + // later cancellation as a secondary stays failed. + let cancelled = + !outcome.ok && + primaryAnalysisError(outcome.error) instanceof AnalysisCancelledError; + if (outcome.ok && isAborted(signal)) { + if (committed && cleanup.kind === "released") + return { + kind: "completed", + value: outcome.value, + lateCancellation: signal.reason, + }; + if (!committed) { + cancelled = true; + outcome = { + ok: false, + error: cancelledBy(options.operation, signal, { + partialObservation: outcome.value, + ...(outcome.diagnostics === undefined + ? {} + : { diagnostics: outcome.diagnostics }), + }), + }; + } + } + if (cleanup.kind === "retained") { + if (outcome.ok) + return { + kind: "completed-cleanup-retained", + value: outcome.value, + error: cleanup.failure.retainPartialObservation(outcome.value), + }; + return { + kind: cancelled ? "cancelled" : "failed", + error: + cleanup.failure === outcome.error + ? outcome.error + : analysisErrorWithCleanupFailure( + outcome.error, + cleanup.failure, + options.operation, + ).bindCleanupDependencies([outcome.error, cleanup.failure]), + cleanup, + }; + } + if (outcome.ok) return { kind: "completed", value: outcome.value }; + return { + kind: cancelled ? "cancelled" : "failed", + error: outcome.error, + cleanup, + }; +}; + +/** The single Result a caller sees for a finalized outcome. */ +export const finalizedResult = ( + finalized: Finalized, +): Result => + finalized.kind === "completed" + ? { ok: true, value: finalized.value } + : { ok: false, error: finalized.error }; diff --git a/src/cli/finishAnalysisCommand.ts b/src/cli/finishAnalysisCommand.ts index cd7604a528..9dbf71c68d 100644 --- a/src/cli/finishAnalysisCommand.ts +++ b/src/cli/finishAnalysisCommand.ts @@ -1,16 +1,16 @@ -import { ArtifactReaderFailure } from "../artifacts/ArtifactReader.js"; import { + finalizedResult, + runOwned, + type RecoveredCompletion, +} from "../application/runOwned.js"; +import type { AnalysisError, - type AnalysisPartialObservation, + AnalysisPartialObservation, } from "../domain/analysisErrorBase.js"; -import { analysisErrorWithCleanupFailure } from "../domain/analysisErrorCleanup.js"; -import { analysisErrorWithCancellation } from "../domain/analysisErrorCancellation.js"; -import { AnalysisCancelledError } from "../domain/analysisErrorCore.js"; import { projectAnalysisError, projectAnalysisErrorWithoutCapturedOutput, } from "../domain/analysisErrorProjection.js"; -import { ProviderAdapterError } from "../domain/providerAdapterError.js"; import { ProviderCleanupError } from "../domain/providerCleanupError.js"; import { err, ok, type Result } from "../domain/result.js"; import { dispatchQueuedInterrupts } from "../process/queuedInterrupts.js"; @@ -43,69 +43,77 @@ export const finishAnalysisCommand = async < // reads this result, so guidance needing a continuing session is omitted. const projectFailure = (error: AnalysisError) => projectAnalysisError(options.redactFailure?.(error) ?? error, "invocation"); - let result: Result; - let cleanup: AnalysisError | undefined; - try { - result = await run(); - } catch (cause) { - result = { - ok: false, - error: - cause instanceof AnalysisError - ? cause - : new ProviderAdapterError(providerId, canonicalOperation, { cause }), - }; - } // Some one-shot commands promise the original failure snapshot even when // close retries and mutates the producer's live cleanup status. - const initialFailure = - !result.ok && options.failureProjection === "initial" - ? projectFailure(result.error) - : undefined; - const hadIncompleteCleanup = !result.ok && result.error.cleanupIncomplete; - const initialDiagnosticFailure = - initialFailure !== undefined && - !result.ok && - result.error.capturedOutput !== undefined - ? projectAnalysisErrorWithoutCapturedOutput( - options.redactFailure?.(result.error) ?? result.error, - "invocation", - ) - : initialFailure; - try { - // Resource owners may resolve their retained errors during close. This - // adapter cannot infer resolution of a producer's earlier cleanup failure. - await service.close(); - } catch (cause) { - if (cause instanceof AnalysisError) cleanup = cause; - else { - const observation = - cause instanceof ArtifactReaderFailure - ? ArtifactReaderFailure.cleanupObservation( - cause, - fallbackResource ?? `${canonicalOperation} resources`, - ) - : { - reason: cause instanceof Error ? cause.message : String(cause), - resources: [], - }; - cleanup = new ProviderCleanupError( - providerId, - observation.resources, - { reason: observation.reason }, - { cause, operation: canonicalOperation }, - ); - } - } - await dispatchQueuedInterrupts(); - if (!result.ok && signal.aborted) - analysisErrorWithCancellation( - result.error, - new AnalysisCancelledError(canonicalOperation, { cause: signal.reason }), - ); - if (initialFailure !== undefined && cleanup === undefined && !result.ok) { - // Close may mutate cleanup state; preserve the snapshot and add later failures. - const secondary = projectFailure(result.error).details?.secondary_failures; + let initialFailure: ReturnType | undefined; + let initialDiagnosticFailure: typeof initialFailure; + let closeFailed = false; + const finalized = await runOwned(run, { + providerId, + operation: canonicalOperation, + signal, + ...(fallbackResource === undefined ? {} : { fallbackResource }), + close: async () => { + try { + await service.close(); + } catch (cause) { + closeFailed = true; + throw cause; + } finally { + await dispatchQueuedInterrupts(); + } + }, + inspectPrimary: (primary) => { + if (primary.ok || options.failureProjection !== "initial") return; + initialFailure = projectFailure(primary.error); + initialDiagnosticFailure = + primary.error.capturedOutput === undefined + ? initialFailure + : projectAnalysisErrorWithoutCapturedOutput( + options.redactFailure?.(primary.error) ?? primary.error, + "invocation", + ); + }, + recoverCompleted: ( + primary, + observed, + ): RecoveredCompletion | undefined => { + if (initialFailure !== undefined) return undefined; + if ( + observed.cleanupResolvedByClose && + observed.cleanup.kind === "released" + ) { + const recovered = options.recoverResolvedCleanup?.(primary); + if (recovered !== undefined) return { value: recovered }; + } + if ( + primary instanceof ProviderCleanupError && + !primary.cleanupIncomplete && + primary.cleanupPrimary === undefined && + primary.completedObservation !== undefined + ) { + const completed = options.recoverCompletedObservation?.( + primary.completedObservation, + ); + if (completed !== undefined) + return { + value: completed, + cancellationDiagnostics: { + cleanup_history: projectFailure(primary), + }, + }; + } + return undefined; + }, + }); + const result = finalizedResult(finalized); + if (result.ok) return ok(result.value); + const failure = projectFailure(result.error); + if (initialFailure === undefined) return err(failure); + if (!closeFailed) { + // Close may mutate cleanup state, including resolving the cleanup of a + // cancelled primary; preserve the snapshot and add later failures. + const secondary = failure.details?.secondary_failures; return err( secondary === undefined ? initialFailure @@ -118,83 +126,19 @@ export const finishAnalysisCommand = async < }, ); } - if ( - initialFailure === undefined && - cleanup === undefined && - !result.ok && - hadIncompleteCleanup && - !result.error.cleanupIncomplete - ) { - const recovered = options.recoverResolvedCleanup?.(result.error); - if (recovered !== undefined) result = ok(recovered); - } - if ( - initialFailure === undefined && - !result.ok && - result.error instanceof ProviderCleanupError && - !result.error.cleanupIncomplete && - result.error.cleanupPrimary === undefined && - result.error.completedObservation !== undefined - ) { - const completed = options.recoverCompletedObservation?.( - result.error.completedObservation, - ); - if (completed !== undefined) { - const recoveredCleanup = result.error; - result = signal.aborted - ? { - ok: false, - error: new AnalysisCancelledError(canonicalOperation, { - cause: signal.reason, - partialObservation: completed, - diagnostics: { - cleanup_history: projectFailure(recoveredCleanup), - }, - }), - } - : { ok: true, value: completed }; - } - } - if (result.ok && signal.aborted) - result = { - ok: false, - error: new AnalysisCancelledError(canonicalOperation, { - cause: signal.reason, - partialObservation: result.value, - }), - }; - if (cleanup !== undefined) { - if (result.ok) cleanup.retainPartialObservation(result.value); - result = { - ok: false, - error: result.ok - ? cleanup - : analysisErrorWithCleanupFailure( - result.error, - cleanup, - canonicalOperation, - ).bindCleanupDependencies([result.error, cleanup]), - }; - } - if (result.ok) return ok(result.value); - const failure = projectFailure(result.error); const diagnostics = failure.details?.diagnostics; - return err( - initialFailure === undefined || cleanup === undefined - ? failure - : { - ...failure, - details: { - ...failure.details, - diagnostics: { - ...(typeof diagnostics === "object" && - diagnostics !== null && - !Array.isArray(diagnostics) - ? diagnostics - : {}), - primary_error: initialDiagnosticFailure ?? initialFailure, - }, - }, - }, - ); + return err({ + ...failure, + details: { + ...failure.details, + diagnostics: { + ...(typeof diagnostics === "object" && + diagnostics !== null && + !Array.isArray(diagnostics) + ? diagnostics + : {}), + primary_error: initialDiagnosticFailure ?? initialFailure, + }, + }, + }); };