diff --git a/src/providers/classifyProviderFailure.test.ts b/src/providers/classifyProviderFailure.test.ts index 2c849625d7..2755c6a5af 100644 --- a/src/providers/classifyProviderFailure.test.ts +++ b/src/providers/classifyProviderFailure.test.ts @@ -7,6 +7,8 @@ import { withArtifactRegularFile, } from "../artifacts/ArtifactFileOperation.js"; import { ArtifactResourceScope } from "../artifacts/ArtifactResourceScope.js"; +import { ArtifactReaderFailure } from "../artifacts/ArtifactReader.js"; +import type { AnalysisPartialObservation } from "../domain/analysisErrorBase.js"; import { AnalysisOutputError, AnalysisTimeoutError, @@ -90,3 +92,59 @@ describe("classifyProviderFailure attribution", () => { expect(failure).toBeInstanceOf(AnalysisOutputError); }); }); + +describe("classifyProviderFailure cancelled reads", () => { + const partial: AnalysisPartialObservation = { + kind: "omitted-observation", + reason: "recovery-input-invalid", + }; + const unclassified = (cause: unknown) => + new AnalysisOutputError("read", String(cause), { cause }); + + it("keeps the read's partial observation and the provider's diagnostics", () => { + const reason = { caller: "stop" }; + const failure = classifyProviderFailure( + new ArtifactReaderFailure("cancelled", "read cancelled", { + cause: reason, + partialObservation: partial, + }), + { + providerId: "fixture", + operation: "read", + diagnostics: { path: "/selected" }, + unclassified, + }, + ); + expect(failure).toMatchObject({ + _tag: "AnalysisCancelledError", + partialObservation: partial, + diagnostics: { path: "/selected" }, + }); + }); + + it("keeps them on the primary of a cancelled read whose cleanup failed", () => { + let primary: unknown; + classifyProviderFailure( + new ArtifactReaderFailure("cancelled", "read cancelled", { + cause: { caller: "stop" }, + partialObservation: partial, + cleanup: { reason: "close failed", resources: ["/selected"] }, + }), + { + providerId: "fixture", + operation: "read", + diagnostics: { path: "/selected" }, + readCleanup: (read, reported) => { + primary = reported; + return new AnalysisOutputError("read", read.message); + }, + unclassified, + }, + ); + expect(primary).toMatchObject({ + _tag: "AnalysisCancelledError", + partialObservation: partial, + diagnostics: { path: "/selected" }, + }); + }); +}); diff --git a/src/providers/classifyProviderFailure.ts b/src/providers/classifyProviderFailure.ts index 190d27869a..e3d311249f 100644 --- a/src/providers/classifyProviderFailure.ts +++ b/src/providers/classifyProviderFailure.ts @@ -23,6 +23,7 @@ import { } from "../domain/analysisErrorBase.js"; import { analysisErrorWithCleanupFailure } from "../domain/analysisErrorCleanup.js"; import { cancelledBy, carriedReason } from "../domain/analysisErrorCore.js"; +import type { JsonValue } from "../domain/jsonValue.js"; import { ProviderCleanupError } from "../domain/providerCleanupError.js"; import { classifyProviderOutcome, @@ -107,6 +108,8 @@ export interface ProviderFailureDialect { read: CleanupFailedRead, primary: AnalysisError | undefined, ) => AnalysisError; + /** Facts this provider reports with a cancelled read, such as its path. */ + readonly diagnostics?: Readonly>; /** Output a finished command left before a later read was cancelled. */ readonly observedOutput?: AnalysisCapturedOutput | undefined; /** What the provider reports for a cause none of the above recognised. */ @@ -123,34 +126,50 @@ type CleanupFailedCommand = OwnedCommandFailure & { const cleanupFailedRead = (cause: unknown): cause is CleanupFailedRead => cause instanceof ArtifactReaderFailure && cause.cleanup !== undefined; -/** A read's cancellation: the producing phase's interruption, else a cancellation. */ +/** + * A read's cancellation: the producing phase's interruption, else a + * cancellation. Either keeps what the read had already established, the + * output a finished command left, and the provider's facts about the read. + */ const readCancellation = ( reason: unknown, + read: ArtifactReaderFailure, dialect: ProviderFailureDialect, - details: Omit, -): AnalysisError => - dialect.interruption?.(details) ?? - cancelledBy(dialect.operation, carriedReason(reason), details); + details: Omit = {}, +): AnalysisError => { + const reported = { + ...details, + ...(read.partialObservation === undefined + ? {} + : { partialObservation: read.partialObservation }), + ...(dialect.observedOutput === undefined + ? {} + : { capturedOutput: dialect.observedOutput }), + ...(dialect.diagnostics === undefined + ? {} + : { diagnostics: { ...details.diagnostics, ...dialect.diagnostics } }), + }; + return ( + dialect.interruption?.(reported) ?? + cancelledBy(dialect.operation, carriedReason(reason), reported) + ); +}; /** What a read whose cleanup failed reports apart from that cleanup. */ const readPrimary = ( read: CleanupFailedRead, dialect: ProviderFailureDialect, ): AnalysisError | undefined => { - const observed = - dialect.observedOutput === undefined - ? {} - : { capturedOutput: dialect.observedOutput }; if (read instanceof ArtifactFileCleanupFailure) { const outcome = read.fileCleanupFailure.outcome; if (outcome.kind === "failed") return classifyProviderFailure(outcome.cause, dialect); const cancellation = artifactFileCompletion(read)?.cancellation; if (cancellation !== undefined) - return readCancellation(cancellation.reason, dialect, observed); + return readCancellation(cancellation.reason, read, dialect); } return read.reason === "cancelled" - ? readCancellation(artifactFileCancellationCause(read), dialect, observed) + ? readCancellation(artifactFileCancellationCause(read), read, dialect) : undefined; }; @@ -202,17 +221,9 @@ const fileFailure = ( const { cause: _reader, ...cancellationDetails } = details; return readCancellation( artifactFileCancellationCause(physical), + physical, dialect, - { - ...cancellationDetails, - // What the reader had already established stays reported. - ...(physical.partialObservation === undefined - ? {} - : { partialObservation: physical.partialObservation }), - ...(dialect.observedOutput === undefined - ? {} - : { capturedOutput: dialect.observedOutput }), - }, + cancellationDetails, ); } if (file === undefined) return undefined;