fix(kuna): preserve live cleanup in caller failures

Reuse canonical typed failure attribution instead of copying cleanup markers. Preserve captured facts, typed primary outcomes and opaque cancellation reasons; bind caller projections to the exact producer cleanup owner.

Refs: #4693
This commit is contained in:
morluto committed 2026-10-17 14:13:16 +08:00
1 parent 5717ad24aa
commit 6eb55be4e8
4 files changed
+236 -97

No files matched your search

+3 -9
View File
@@ -1,6 +1,6 @@
import type { JsonValue } from "./jsonValue.js";
import { ProviderAdapterError } from "./providerAdapterError.js";
import type { AnalysisPartialObservation } from "./analysisErrorBase.js";
import type { AnalysisErrorOptions } from "./analysisErrorBase.js";
/** Provider resources could not be proven closed after bounded cleanup. */
export class ProviderCleanupError extends ProviderAdapterError {
@@ -13,20 +13,14 @@ export class ProviderCleanupError extends ProviderAdapterError {
resources: readonly string[],
diagnostics: Readonly<Record<string, JsonValue>>,
/** Identify the operation owning these resources; binary callers retain their default. */
options?: ErrorOptions & {
options?: Omit<AnalysisErrorOptions, "diagnostics"> & {
readonly operation?: string;
readonly partialObservation?: AnalysisPartialObservation;
readonly remediationAction?: string;
},
) {
const {
operation = "close_binary",
partialObservation,
...errorOptions
} = options ?? {};
const { operation = "close_binary", ...errorOptions } = options ?? {};
super(providerId, operation, {
...errorOptions,
...(partialObservation === undefined ? {} : { partialObservation }),
diagnostics,
});
this.setCleanupResources(resources);
+11 -86
View File
@@ -1,16 +1,6 @@
import type {
AnalysisError,
AnalysisErrorOptions,
} from "../domain/analysisErrorBase.js";
import {
AnalysisArtifactChangedError,
AnalysisCancelledError,
AnalysisOutputError,
AnalysisResourceConstraintError,
AnalysisTimeoutError,
} from "../domain/analysisErrorCore.js";
import { ProviderAdapterError } from "../domain/providerAdapterError.js";
import { ProviderCleanupError } from "../domain/providerCleanupError.js";
import type { AnalysisError } from "../domain/analysisErrorBase.js";
import { analysisClientFailureForOperation } from "../providers/AnalysisClientFailure.js";
import { KUNA_PROVIDER_ID } from "./KunaRelease.js";
/** Keep one producer failure immutable while each waiter receives its context. */
export const kunaQueryFailureForCaller = (
@@ -23,77 +13,12 @@ export const kunaQueryFailureForCaller = (
source.operation === operation
)
return source;
const diagnostics = {
...source.diagnostics,
producer_operation: source.operation,
};
const options: AnalysisErrorOptions & {
userMessage?: string;
remediationAction?: string;
} = {
cause: source,
diagnostics,
...(source.capturedOutput === undefined
? {}
: { capturedOutput: source.capturedOutput }),
...(source.cleanup === undefined ? {} : { cleanup: source.cleanup }),
...(source.partialObservation === undefined
? {}
: { partialObservation: source.partialObservation }),
...(source.userMessage === undefined
? {}
: { userMessage: source.userMessage }),
...("remediationAction" in source &&
typeof source.remediationAction === "string"
? { remediationAction: source.remediationAction }
: {}),
};
// The command owner keeps the original cleanup failure and its supervisor.
// This contextual diagnostic retains that source as its cause and every resource.
if (source instanceof ProviderCleanupError)
return new ProviderCleanupError(
source.providerId,
source.cleanupResources,
diagnostics,
{
operation,
cause: source,
...(source.partialObservation === undefined
? {}
: { partialObservation: source.partialObservation }),
},
);
if (source instanceof AnalysisTimeoutError)
return new AnalysisTimeoutError(operation, source.timeoutMs, options);
if (source instanceof AnalysisResourceConstraintError)
return new AnalysisResourceConstraintError(
operation,
source.resource,
source.reason,
source.reportedLimits,
{
...options,
...(source.retryable === undefined
? {}
: { retryable: source.retryable }),
},
);
if (source instanceof AnalysisOutputError)
return new AnalysisOutputError(operation, source.reason, {
...options,
cleanupOnly: source.cleanupOnly,
});
if (source instanceof ProviderAdapterError)
return new ProviderAdapterError(source.providerId, operation, options);
if (source instanceof AnalysisCancelledError)
return new AnalysisCancelledError(operation, options);
if (source instanceof AnalysisArtifactChangedError)
return new AnalysisArtifactChangedError(
operation,
source.path,
source.reason,
options,
);
// Preserve unfamiliar error variants rather than flattening their semantics.
return source;
return analysisClientFailureForOperation(
source,
KUNA_PROVIDER_ID,
operation,
{
producer_operation: source.operation,
},
);
};
+59 -2
View File
@@ -13,8 +13,12 @@ import {
AnalysisAccessDeniedError,
AnalysisArtifactChangedError,
AnalysisResourceConstraintError,
AnalysisOutputError,
AnalysisTimeoutError,
} from "../domain/analysisErrorCore.js";
import { ProviderAdapterError } from "../domain/providerAdapterError.js";
import { ProviderCleanupError } from "../domain/providerCleanupError.js";
import type { JsonValue } from "../domain/jsonValue.js";
import { ArtifactReaderFailure } from "../artifacts/ArtifactReader.js";
import { bindArtifactFileCleanup } from "../artifacts/ArtifactFileOperation.js";
@@ -86,10 +90,14 @@ export const analysisClientFailureForOperation = (
cause: unknown,
providerId: string,
operation: string,
callerDiagnostics?: Readonly<Record<string, JsonValue>>,
): AnalysisError => {
if (!(cause instanceof AnalysisError)) {
const failure = new ProviderAdapterError(providerId, operation, {
cause,
...(callerDiagnostics === undefined
? {}
: { diagnostics: callerDiagnostics }),
...(cause instanceof ArtifactReaderFailure && cause.cleanup !== undefined
? { cleanup: cause.cleanup }
: {}),
@@ -106,10 +114,24 @@ export const analysisClientFailureForOperation = (
!(cause instanceof AnalysisCapabilityUnavailableError) &&
!(cause instanceof AnalysisAccessDeniedError) &&
!(cause instanceof AnalysisArtifactChangedError) &&
!(cause instanceof AnalysisTimeoutError) &&
!(cause instanceof AnalysisOutputError) &&
!(cause instanceof AnalysisResourceConstraintError)
)
return cause;
const context = analysisClientFailureContext(cause, providerId, operation);
const sourceContext = analysisClientFailureContext(
cause,
providerId,
operation,
);
const context = {
...sourceContext,
...(callerDiagnostics === undefined
? {}
: {
diagnostics: { ...sourceContext.diagnostics, ...callerDiagnostics },
}),
};
let result: AnalysisError;
if (cause instanceof HopperRemoteError)
result = new HopperRemoteError(cause.code, cause.safeMessage, {
@@ -142,6 +164,41 @@ export const analysisClientFailureForOperation = (
? {}
: { launcherFailure: cause.launcherFailure }),
});
else if (cause instanceof ProviderCleanupError) {
const { diagnostics, ...outcomeContext } = context;
result = new ProviderCleanupError(
cause.providerId,
cause.cleanupResources,
diagnostics ?? {},
{
...outcomeContext,
operation,
...(cause.remediationAction === undefined
? {}
: { remediationAction: cause.remediationAction }),
},
);
} else if (cause instanceof AnalysisTimeoutError)
result = new AnalysisTimeoutError(operation, cause.timeoutMs, {
...context,
...(cause.userMessage === undefined
? {}
: { userMessage: cause.userMessage }),
...(cause.remediationAction === undefined
? {}
: { remediationAction: cause.remediationAction }),
});
else if (cause instanceof AnalysisOutputError)
result = new AnalysisOutputError(operation, cause.reason, {
...context,
cleanupOnly: cause.cleanupOnly,
...(cause.userMessage === undefined
? {}
: { userMessage: cause.userMessage }),
...(cause.remediationAction === undefined
? {}
: { remediationAction: cause.remediationAction }),
});
else if (cause instanceof AnalysisCancelledError)
result = new AnalysisCancelledError(operation, {
...context,
@@ -202,5 +259,5 @@ export const analysisClientFailureForOperation = (
});
return cause.cleanupIncomplete
? result.bindCleanupDependencies([cause])
: result;
: result.resolveCleanup();
};
@@ -0,0 +1,163 @@
import { expect, it } from "vitest";
import { kunaQueryFailureForCaller } from "../../../../src/kuna/KunaQueryErrors.js";
import {
AnalysisCancelledError,
AnalysisInputError,
AnalysisOutputError,
AnalysisTimeoutError,
} from "../../../../src/domain/analysisErrorCore.js";
import { ProviderAdapterError } from "../../../../src/domain/providerAdapterError.js";
import { ProviderCleanupError } from "../../../../src/domain/providerCleanupError.js";
import { analysisErrorWithCleanupFailure } from "../../../../src/domain/analysisErrorCleanup.js";
import { projectAnalysisError } from "../../../../src/domain/analysisErrorProjection.js";
import { createEvidence } from "../../../../src/domain/evidence.js";
const capturedOutput = {
stdout: "selected output",
stderr: "selected warning",
truncated: false,
};
const partialObservation = createEvidence(
undefined,
{ id: "kuna", name: "Kuna", version: null },
{
operation: "kuna:strings",
parameters: {},
result: [],
confidence: "observed",
},
);
const context = {
capturedOutput,
partialObservation,
diagnostics: { selected_url: "https://example.test/?keep=1" },
cleanup: { reason: "controlled close refusal", resources: ["one", "two"] },
};
it.each([
{
name: "cleanup",
create: () =>
new ProviderCleanupError("kuna", ["one", "two"], context.diagnostics, {
...context,
operation: "kuna:strings",
}),
},
{
name: "adapter",
create: () => new ProviderAdapterError("kuna", "kuna:strings", context),
},
{
name: "output",
create: () =>
new AnalysisOutputError("kuna:strings", "invalid scan", {
...context,
cleanupOnly: true,
}),
},
{
name: "timeout",
create: () => new AnalysisTimeoutError("kuna:strings", 17, context),
},
])(
"keeps $name caller projections bound to the exact producer cleanup state",
({ create }) => {
const source = create();
let resources = ["one", "two"];
source.bindCleanupResourceOwner({
get cleanupResources() {
return resources;
},
});
const callers = ["list_strings", "search_strings"].map((operation) =>
kunaQueryFailureForCaller(source, operation),
);
for (const caller of callers) {
expect(caller._tag).toBe(source._tag);
if (source instanceof ProviderCleanupError)
expect(caller).toBeInstanceOf(ProviderCleanupError);
if (source instanceof AnalysisTimeoutError)
expect(caller).toMatchObject({ timeoutMs: source.timeoutMs });
if (source instanceof AnalysisOutputError)
expect(caller).toMatchObject({
cleanupOnly: source.cleanupOnly,
reason: source.reason,
});
expect(caller.capturedOutput).toEqual(capturedOutput);
expect(caller.partialObservation).toEqual(partialObservation);
expect(caller.diagnostics).toMatchObject({
...context.diagnostics,
producer_operation: "kuna:strings",
});
expect(caller.cleanupIncomplete).toBe(true);
}
resources = ["two"];
for (const caller of callers)
expect(caller.cleanupResources).toEqual(["two"]);
source.resolveCleanup();
for (const caller of callers) expect(caller.cleanupIncomplete).toBe(false);
},
);
it("does not resurrect an already resolved cleanup failure", () => {
const source = new ProviderCleanupError(
"kuna",
["owner"],
{},
{ operation: "kuna:strings" },
);
source.resolveCleanup();
const caller = kunaQueryFailureForCaller(source, "search_strings");
expect(caller.cleanupIncomplete).toBe(false);
});
it.each([
null,
"caller stopped",
new ProviderCleanupError("foreign", ["foreign-owner"], {}),
])(
"preserves an opaque cancellation reason without adopting its cleanup authority: %j",
(reason) => {
const source = new AnalysisCancelledError("kuna:strings", {
cause: reason,
});
const caller = kunaQueryFailureForCaller(source, "search_strings");
expect(caller.cause).toBe(reason);
expect(caller.cleanupIncomplete).toBe(false);
},
);
it("restores a caller-attributed typed primary after its exact cleanup owner resolves", () => {
const primary = new AnalysisTimeoutError("list_procedures", 23, {
capturedOutput,
});
const cleanup = new ProviderCleanupError("kuna", ["owner"], {});
const source = analysisErrorWithCleanupFailure(
primary,
cleanup,
"list_procedures",
).bindCleanupDependencies([cleanup]);
const caller = kunaQueryFailureForCaller(source, "search_procedures");
expect(caller.cleanupPrimary).toMatchObject({
_tag: "AnalysisTimeoutError",
operation: "search_procedures",
timeoutMs: 23,
});
cleanup.resolveCleanup();
expect(projectAnalysisError(caller)).toMatchObject({
code: "provider_timeout",
details: {
operation: "search_procedures",
captured_output: capturedOutput,
},
});
});
it("keeps same-operation and unfamiliar error variants unchanged", () => {
const source = new AnalysisTimeoutError("search_strings", 19);
expect(kunaQueryFailureForCaller(source, "search_strings")).toBe(source);
const unfamiliar = new AnalysisInputError("producer");
expect(kunaQueryFailureForCaller(unfamiliar, "search_strings")).toBe(
unfamiliar,
);
});