diff --git a/docs/tool-design.md b/docs/tool-design.md index a6f9caa88e..cb78da95a5 100644 --- a/docs/tool-design.md +++ b/docs/tool-design.md @@ -195,7 +195,9 @@ providers need not expose identical state machines. owner's recovery instructions. While cleanup remains unconfirmed, canonical errors keep `cleanup_incomplete` and live resources while taking category, operation and failure details from the retained typed primary. Nested cleanup - composition does not replace that semantic identity. CLI adapters should reuse + composition does not replace that semantic identity. Finalization reports + 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. - Give descriptor-backed reads an owner before opening the file. A rejected diff --git a/src/domain/analysisErrorCleanup.test.ts b/src/domain/analysisErrorCleanup.test.ts index 51e324d4a3..c14f73ab3f 100644 --- a/src/domain/analysisErrorCleanup.test.ts +++ b/src/domain/analysisErrorCleanup.test.ts @@ -318,3 +318,43 @@ it("retains an observation attached to the composite after the primary failed", details: { partial_observation: observation }, }); }); + +it("retains an owner's finalization report independently of pending primary semantics", () => { + const report = { + owned_process_group: { state: "not_required" as const, reason: null }, + terminal_renderer: { state: "not_required" as const, reason: null }, + temporary_root: { state: "cleaned" as const, reason: null }, + }; + class ReportedCleanup extends ProviderCleanupError { + override readonly cleanupReport = report; + } + const primary = new AnalysisInputError("inspect"); + const finalized = new ReportedCleanup( + "fixture", + ["input"], + {}, + { + cleanupPrimary: primary, + }, + ); + const later = new ProviderCleanupError("fixture", ["workspace"], {}); + const outer = analysisErrorWithCleanupFailure( + finalized, + later, + ).bindCleanupDependencies([finalized, later]); + const assertReport = (error: typeof outer) => { + expect(projectAnalysisError(error)).toMatchObject({ + category: "invalid_input", + details: { operation: "inspect", cleanup_report: report }, + }); + }; + assertReport(finalized); + assertReport(outer); + expect(outer.cleanupIncomplete).toBe(true); + later.resolveCleanup(); + assertReport(outer); + expect(outer.cleanupIncomplete).toBe(true); + finalized.resolveCleanup(); + assertReport(outer); + expect(outer.cleanupIncomplete).toBe(false); +}); diff --git a/src/domain/analysisErrorProjection.ts b/src/domain/analysisErrorProjection.ts index a924d29a63..1ac9c51f46 100644 --- a/src/domain/analysisErrorProjection.ts +++ b/src/domain/analysisErrorProjection.ts @@ -75,6 +75,15 @@ const projectError = ( const capturedOutput = composed.capturedOutput ?? error.capturedOutput; const partialObservation = composed.partialObservation ?? error.partialObservation; + // A finalizer can add a report above the semantic primary, including while + // its cleanup is pending or beneath another cleanup composition. + let cleanupReport = composed.cleanupReport; + for ( + let owner = composed.cleanupPrimary; + cleanupReport === undefined && owner !== undefined; + owner = owner.cleanupPrimary + ) + cleanupReport = owner.cleanupReport; assertKnownAnalysisErrorTag(error._tag); const primary = primaryAnalysisError(error); assertKnownAnalysisErrorTag(primary._tag); @@ -83,9 +92,9 @@ const projectError = ( const primaryDetails = errorDetails(primary); const details = { ...primaryDetails, - ...(composed === error || composed.cleanupReport === undefined + ...(cleanupReport === undefined ? {} - : { cleanup_report: jsonValueSchema.parse(composed.cleanupReport) }), + : { cleanup_report: jsonValueSchema.parse(cleanupReport) }), ...(diagnostics === undefined ? {} : {