fix(ida): preserve startup failure ownership

This commit is contained in:
morluto
2026-10-27 13:42:35 +08:00
parent 2733979b4d
commit d3af70fa27
2 changed files with 57 additions and 65 deletions
+43
View File
@@ -95,6 +95,49 @@ const fixture = async (
return { root, target, producer, client };
};
it.each(["initialize", "transport"])(
"preserves producer outcome facts when attributing a cached %s failure",
async (operation) => {
const { producer, client } = await fixture();
const primary = new AnalysisCancelledError("provider_startup");
const source = new ProviderAdapterError("ida", operation, {
cleanupPrimary: primary,
limit: {
setting: "regex.matching_deadline_ms",
effective_value: 100,
unit: "ms",
minimum: 1,
maximum: 1000,
applies_at: "next_operation",
agent_editable: true,
scope: "rea-runtime",
rea_restart_required: false,
},
diagnostics: { producer_fact: "retained" },
});
const connect = vi.spyOn(producer, "connect").mockRejectedValue(source);
for (const requested of ["list_procedures", "list_strings"] as const) {
const result = await client.execute(requested, {});
expect(result.ok).toBe(false);
if (result.ok) throw new Error("Expected startup failure");
expect(result.error).toBeInstanceOf(ProviderAdapterError);
expect(result.error).toMatchObject({
operation: requested,
limit: source.limit,
cleanupPrimary: { operation: requested },
diagnostics: { producer_fact: "retained" },
});
expect(result.error.cleanupPrimary).toBeInstanceOf(
AnalysisCancelledError,
);
}
expect(connect).toHaveBeenCalledTimes(1);
expect(source.operation).toBe(operation);
expect(source.cleanupPrimary).toBe(primary);
await client.close();
},
);
describe("headless IDA producer pagination", () => {
it("follows reported cursors for modern inventories, pseudocode, and bare-hex assembly without losing pages", async () => {
const { producer, client } = await fixture("headless");
+14 -65
View File
@@ -15,7 +15,6 @@ import {
AnalysisInputError,
AnalysisTimeoutError,
AnalysisResourceConstraintError,
resourceConstraintRecovery,
} from "../domain/analysisErrorCore.js";
import { AnalysisError } from "../domain/analysisErrorBase.js";
import { ProviderAdapterError } from "../domain/providerAdapterError.js";
@@ -61,6 +60,7 @@ import {
} from "../artifacts/ArtifactFileOperation.js";
import { ProviderCleanupError } from "../domain/providerCleanupError.js";
import { analysisErrorWithCleanupFailure } from "../domain/analysisErrorCleanup.js";
import { analysisClientFailureForOperation } from "../providers/AnalysisClientFailure.js";
const legacyTools = [
"get_metadata",
@@ -489,36 +489,19 @@ export class IdaSessionClient implements AnalysisClient {
cause instanceof AnalysisTimeoutError &&
cause.operation !== operation
) {
failure = new AnalysisTimeoutError(operation, cause.timeoutMs, {
failure = analysisClientFailureForOperation(
cause,
capturedOutput: cause.capturedOutput,
diagnostics: cause.diagnostics,
partialObservation: cause.partialObservation,
cleanup: cause.cleanup,
...(cause.userMessage === undefined
? {}
: { userMessage: cause.userMessage }),
...(cause.remediationAction === undefined
? {}
: { remediationAction: cause.remediationAction }),
})
.bindCleanupDependencies([cause])
.bindCleanupResourceOwner(cause);
"ida",
operation,
).bindCleanupResourceOwner(cause);
} else if (
cause instanceof ProviderAdapterError &&
cause.operation === "transport"
)
failure = new ProviderAdapterError("ida", operation, {
cause,
capturedOutput: cause.capturedOutput,
partialObservation: cause.partialObservation,
cleanup: cause.cleanup,
diagnostics: {
...cause.diagnostics,
recovery:
"Check the configured upstream MCP registration and selected target; see docs/ida-provider.md.",
},
});
failure = analysisClientFailureForOperation(cause, "ida", operation, {
recovery:
"Check the configured upstream MCP registration and selected target; see docs/ida-provider.md.",
}).bindCleanupResourceOwner(cause);
else if (cause instanceof AnalysisError) failure = cause;
else if (cause instanceof z.ZodError)
failure = new AnalysisOutputError(
@@ -633,46 +616,12 @@ export class IdaSessionClient implements AnalysisClient {
cause.operation === "transport"
)
throw cause;
const options = {
cause,
capturedOutput: cause.capturedOutput,
partialObservation: cause.partialObservation,
cleanup: cause.cleanup,
...(cause.userMessage === undefined
? {}
: { userMessage: cause.userMessage }),
diagnostics: {
...cause.diagnostics,
startup_operation: cause.operation,
},
};
const action =
cause.remediationAction === undefined
? {}
: { remediationAction: cause.remediationAction };
// Startup is shared, but each accepted operation owns its attribution.
// Keep the original cached failure unchanged for later callers.
const failure =
cause instanceof AnalysisTimeoutError
? new AnalysisTimeoutError(operation, cause.timeoutMs, {
...options,
...action,
})
: cause instanceof AnalysisResourceConstraintError
? new AnalysisResourceConstraintError(
operation,
cause.resource,
cause.reason,
cause.reportedLimits,
{ ...options, ...resourceConstraintRecovery(cause) },
)
: new ProviderAdapterError(cause.providerId, operation, {
...options,
...action,
});
throw failure
.bindCleanupDependencies([cause])
.bindCleanupResourceOwner(cause);
// The shared boundary preserves typed outcome and cleanup facts without
// mutating the cached startup failure.
throw analysisClientFailureForOperation(cause, "ida", operation, {
startup_operation: cause.operation,
}).bindCleanupResourceOwner(cause);
}
}