From 9044b6fb36b8f8c839d95e191ece5bf28ac84c08 Mon Sep 17 00:00:00 2001 From: Rishikesh Gowda K K Date: Fri, 6 Nov 2026 14:52:02 +0530 Subject: [PATCH] fix(kuna): mark project-dependent operations unavailable on export failure (#8748) * fix(kuna): mark project-dependent operations unavailable on export failure * fix(providers): preserve sibling operations in capability error copies * fix(kuna): propagate sibling unavailability on export fallback * fix(binary): preserve observed sibling recovery instructions across fallback observations * fix(binary): build sibling unavailability from structured reasons without rewriting display text * test(kuna): synchronize fallback test on settled export workspace retirement * test(kuna): release and join export synchronization barrier across exit paths * test(kuna): synchronize fallback test on observed export process exit * test(kuna): gate fallback completion on settled export cache build * test(kuna): preserve build promise chain and restore spy after teardown --- src/application/AnalysisProvider.ts | 10 + .../binary/SessionRuntimeAvailability.ts | 58 ++++- src/domain/analysisErrorCore.ts | 6 + src/domain/explicitSensitiveFailure.ts | 13 + .../javascript/javascriptSemanticAnalysis.ts | 12 +- src/kuna/KunaSessionClient.ts | 59 ++++- src/providers/AnalysisClientFailure.test.ts | 38 +++ src/providers/AnalysisClientFailure.ts | 9 + .../mcp/sessionAvailabilityRecovery.test.ts | 240 ++++++++++++++++++ .../kuna/kunaSingleFunctionRuns.test.ts | 71 +++++- 10 files changed, 498 insertions(+), 18 deletions(-) diff --git a/src/application/AnalysisProvider.ts b/src/application/AnalysisProvider.ts index 4277072e16..b43002b7a1 100644 --- a/src/application/AnalysisProvider.ts +++ b/src/application/AnalysisProvider.ts @@ -9,6 +9,7 @@ import type { NativeToolName } from "../contracts/native/nativeToolContracts.js" import type { ArtifactAnalysisOperation } from "../contracts/artifactToolContracts.js"; import type { ManagedToolName } from "../contracts/managed/managedToolContracts.js"; import type { AnalysisError } from "../domain/analysisErrorBase.js"; +import type { AnalysisCapabilityUnavailableError } from "../domain/analysisErrorCore.js"; import type { AnalysisProfileCommitment } from "../domain/analysisProfile.js"; import type { JsonValue } from "../domain/jsonValue.js"; import type { Result } from "../domain/result.js"; @@ -81,6 +82,11 @@ export interface AnalysisExecution extends AnalysisObservation { * output. */ readonly referencesTruncated?: boolean; + /** + * Sibling operations known to be unavailable at the provider level when this + * operation answered from a standalone or fallback path. + */ + readonly siblingUnavailability?: AnalysisCapabilityUnavailableError; } interface CreateAnalysisExecutionOptions { @@ -91,6 +97,7 @@ interface CreateAnalysisExecutionOptions { readonly limitations?: readonly string[]; readonly relationshipCoverage?: RelationshipCoverage; readonly referencesTruncated?: boolean; + readonly siblingUnavailability?: AnalysisCapabilityUnavailableError; readonly locations?: readonly EvidenceLocation[]; readonly sourceEvidence?: readonly Evidence[]; readonly subject?: EvidenceSubjectTarget | null; @@ -149,6 +156,9 @@ export function createAnalysisExecution( ...(options.referencesTruncated === undefined ? {} : { referencesTruncated: options.referencesTruncated }), + ...(options.siblingUnavailability === undefined + ? {} + : { siblingUnavailability: options.siblingUnavailability }), locations: [...(options.locations ?? [])], ...(options.sourceEvidence === undefined ? {} diff --git a/src/application/binary/SessionRuntimeAvailability.ts b/src/application/binary/SessionRuntimeAvailability.ts index 748c65a5e9..4b6089a454 100644 --- a/src/application/binary/SessionRuntimeAvailability.ts +++ b/src/application/binary/SessionRuntimeAvailability.ts @@ -1,4 +1,5 @@ import type { AnalysisError } from "../../domain/analysisErrorBase.js"; +import { AnalysisCapabilityUnavailableError } from "../../domain/analysisErrorCore.js"; import type { Result } from "../../domain/result.js"; import type { CapabilityDescriptor } from "../AnalysisProvider.js"; @@ -96,12 +97,37 @@ export class SessionRuntimeAvailability { if (result.ok) { operation.succeeded = Math.max(operation.succeeded, dispatch); provider.succeeded = Math.max(provider.succeeded, dispatch); - // A success can only remove unavailability. - if (this.#projected.size === 0) return false; + const siblingUnavailability = + typeof result.value === "object" && + result.value !== null && + "siblingUnavailability" in result.value && + result.value.siblingUnavailability instanceof + AnalysisCapabilityUnavailableError + ? result.value.siblingUnavailability + : undefined; + if (siblingUnavailability !== undefined) { + this.#observeSiblingUnavailability( + dispatch, + capability.operation, + siblingUnavailability, + ); + } else if (this.#projected.size === 0) { + return false; + } } else { const reason = runtimeFailureReason(result.error); if (reason === undefined) return false; operation.failure = newer(operation.failure, { dispatch, reason }); + if ( + result.error instanceof AnalysisCapabilityUnavailableError && + result.error.siblingOperations !== undefined + ) { + this.#observeSiblingUnavailability( + dispatch, + capability.operation, + result.error, + ); + } if (PROVIDER_WIDE_FAILURES.has(result.error._tag)) provider.failure = newer(provider.failure, { dispatch, @@ -112,6 +138,34 @@ export class SessionRuntimeAvailability { return this.#project(route); } + #observeSiblingUnavailability( + dispatch: number, + currentOperation: string, + error: AnalysisCapabilityUnavailableError, + ): void { + if (error.siblingOperations === undefined) return; + for (const sibling of error.siblingOperations) { + if (sibling !== currentOperation) { + const siblingOutcomes = outcomes(this.#operations, sibling); + const activeFailure = + siblingOutcomes.failure !== undefined && + siblingOutcomes.failure.dispatch > siblingOutcomes.succeeded + ? siblingOutcomes.failure + : undefined; + const reason = + activeFailure?.reason ?? + error.siblingReasons?.[sibling] ?? + (error.remediationAction !== undefined + ? `Provider ${error.providerId} cannot execute ${sibling}: ${error.reason}. ${error.remediationAction}` + : `Provider ${error.providerId} cannot execute ${sibling}: ${error.reason}`); + siblingOutcomes.failure = newer(siblingOutcomes.failure, { + dispatch, + reason, + }); + } + } + } + /** Forget runtime outcomes when the active provider client is replaced. */ clear(): boolean { this.#operations.clear(); diff --git a/src/domain/analysisErrorCore.ts b/src/domain/analysisErrorCore.ts index cb689c448e..4246904ee8 100644 --- a/src/domain/analysisErrorCore.ts +++ b/src/domain/analysisErrorCore.ts @@ -220,6 +220,8 @@ export class AnalysisCapabilityUnavailableError extends AnalysisError { readonly _tag = "AnalysisCapabilityUnavailableError"; override readonly userMessage: string | undefined; readonly remediationAction: string | undefined; + readonly siblingOperations: readonly string[] | undefined; + readonly siblingReasons: Readonly> | undefined; /** Incomplete cleanup must settle before the operation can be retried. */ readonly retryable: boolean; @@ -230,6 +232,8 @@ export class AnalysisCapabilityUnavailableError extends AnalysisError { options?: AnalysisErrorOptions & { readonly userMessage?: string; readonly remediationAction?: string; + readonly siblingOperations?: readonly string[]; + readonly siblingReasons?: Readonly>; readonly retryable?: boolean; }, ) { @@ -239,6 +243,8 @@ export class AnalysisCapabilityUnavailableError extends AnalysisError { ); this.userMessage = options?.userMessage; this.remediationAction = options?.remediationAction; + this.siblingOperations = options?.siblingOperations; + this.siblingReasons = options?.siblingReasons; this.retryable = options?.retryable === true && !this.cleanupIncomplete; } } diff --git a/src/domain/explicitSensitiveFailure.ts b/src/domain/explicitSensitiveFailure.ts index a7e4e5f226..bbd0e43140 100644 --- a/src/domain/explicitSensitiveFailure.ts +++ b/src/domain/explicitSensitiveFailure.ts @@ -286,6 +286,19 @@ const redactFailure = ( ...(error.userMessage === undefined ? {} : { userMessage: text(error.userMessage) }), + ...(error.siblingOperations === undefined + ? {} + : { siblingOperations: error.siblingOperations }), + ...(error.siblingReasons === undefined + ? {} + : { + siblingReasons: Object.fromEntries( + Object.entries(error.siblingReasons).map(([op, msg]) => [ + op, + text(msg), + ]), + ), + }), ...(error.retryable ? { retryable: true } : {}), }, ); diff --git a/src/domain/javascript/javascriptSemanticAnalysis.ts b/src/domain/javascript/javascriptSemanticAnalysis.ts index 1e98d51d1d..fd5e60af1f 100644 --- a/src/domain/javascript/javascriptSemanticAnalysis.ts +++ b/src/domain/javascript/javascriptSemanticAnalysis.ts @@ -40,14 +40,12 @@ import { semanticWriteSiteTarget, unwrapJavaScriptExpression, } from "./javascriptAstValues.js"; -import type { - JavaScriptSemanticAnalysisState, - JavaScriptSemanticBindingState, - JavaScriptSemanticScopeState, - JavaScriptBindingIndex, - JavaScriptSemanticReferenceEffectsAtSite, -} from "./javascriptSemanticState.js"; import { + type JavaScriptSemanticAnalysisState, + type JavaScriptSemanticBindingState, + type JavaScriptSemanticScopeState, + type JavaScriptBindingIndex, + type JavaScriptSemanticReferenceEffectsAtSite, currentSemanticScope, semanticPrototypeOwners, type SemanticPrototypeOwner, diff --git a/src/kuna/KunaSessionClient.ts b/src/kuna/KunaSessionClient.ts index df5b0fe4d4..cf50340eb8 100644 --- a/src/kuna/KunaSessionClient.ts +++ b/src/kuna/KunaSessionClient.ts @@ -180,6 +180,7 @@ interface Answer { readonly referenceSource?: JsonValue; readonly stringSource?: JsonValue; readonly relationshipCoverage?: RelationshipCoverage; + readonly siblingUnavailability?: AnalysisCapabilityUnavailableError; } /** @@ -242,6 +243,23 @@ const descriptorCleanupError = ( const FAILED_PROJECT_RECORDS_GUIDANCE = "REA does not expose per-function records from a failed project export."; +/** + * Operations that unconditionally require Kuna's shared project export and + * cannot answer from the quick function inventory, reference walk, or a + * standalone single-function decompile run. + */ +const KUNA_PROJECT_DEPENDENT_OPERATIONS = Object.freeze([ + "procedure_assembly", + "analyze_function", +] as const satisfies readonly string[]); + +const exportUnavailableMessage = ( + operation: string, + reason: string, + next: string, +): string => + `Kuna's project export failed, so ${operation} is unavailable in this session: ${reason.replace(/\.?$/u, ".")} ${FAILED_PROJECT_RECORDS_GUIDANCE} Inspect any captured diagnostics. ${next}`; + /** * How a request that needs the export reports the export's failure. A session * never rebuilds a failed export, so repeating the request cannot succeed; the @@ -267,12 +285,24 @@ const exportUnavailable = ( ? // A fixed buffer (`memory`) is no setting, so a restart cannot help. `${failure.remediationAction.replace(/\.?$/u, ".")}${failure.resource === "memory" ? "" : ` ${restart}`}` : "To retry, resolve the reported failure, then reopen the target."; + const userMessage = exportUnavailableMessage(operation, reason, next); + const siblingReasons = Object.freeze( + Object.fromEntries( + KUNA_PROJECT_DEPENDENT_OPERATIONS.map((sibling) => [ + sibling, + exportUnavailableMessage(sibling, reason, next), + ]), + ), + ); return new AnalysisCapabilityUnavailableError( KUNA_PROVIDER_ID, operation, reason, { cause: failure, + siblingOperations: KUNA_PROJECT_DEPENDENT_OPERATIONS, + siblingReasons, + remediationAction: next, // Callers see only the top-level projection, so the export failure's // own projected code and details (its limits, target path, captured // output and diagnostics) travel whole rather than field by field. @@ -284,7 +314,7 @@ const exportUnavailable = ( ? {} : { export_details: exported.details }), }, - userMessage: `Kuna's project export failed, so ${operation} is unavailable in this session: ${reason.replace(/\.?$/u, ".")} ${FAILED_PROJECT_RECORDS_GUIDANCE} Inspect any captured diagnostics. ${next}`, + userMessage, }, ); }; @@ -695,6 +725,9 @@ export class KunaSessionClient implements AnalysisClient { ...(answer.relationshipCoverage === undefined ? {} : { relationshipCoverage: answer.relationshipCoverage }), + ...(answer.siblingUnavailability === undefined + ? {} + : { siblingUnavailability: answer.siblingUnavailability }), locations: [ { kind: "artifact-path", path: this.#target.path }, ...answer.locations, @@ -1270,19 +1303,22 @@ export class KunaSessionClient implements AnalysisClient { ); return outcome; } - const exportFailure = await this.#settledExportFailure(); + const exportUnavailable = await this.#settledExportUnavailable(operation); return ok({ result: outcome.value, source: { kind: "single-function" }, limitations: [ KUNA_SINGLE_FUNCTION_LIMITATION, - ...(exportFailure === undefined + ...(exportUnavailable === undefined ? [] : [ - `The project export failed, so later requests also use single-function runs: ${exportFailure}`, + `The project export failed, so later requests also use single-function runs: ${exportUnavailable.reason}`, FAILED_PROJECT_RECORDS_GUIDANCE, ]), ], + ...(exportUnavailable === undefined + ? {} + : { siblingUnavailability: exportUnavailable }), }); } } @@ -2058,8 +2094,14 @@ export class KunaSessionClient implements AnalysisClient { return error; } - /** The export's failure once it has settled; undefined while it builds or after success. */ - async #settledExportFailure(): Promise { + /** + * The export's structured unavailability once it has settled as failed; + * undefined while it builds, after success, or when the failure was + * cancellation or incomplete cleanup. + */ + async #settledExportUnavailable( + operation: string, + ): Promise { const settled = await Promise.race([ this.#project, Promise.resolve(undefined), @@ -2070,7 +2112,10 @@ export class KunaSessionClient implements AnalysisClient { settled.error instanceof KunaPublishedProjectCleanupError ) return undefined; - return settled.error.userMessage ?? settled.error.message; + const unavailable = exportUnavailable(operation, settled.error); + return unavailable instanceof AnalysisCapabilityUnavailableError + ? unavailable + : undefined; } /** Wait for the export, reporting progress, without cancelling it for other callers. */ diff --git a/src/providers/AnalysisClientFailure.test.ts b/src/providers/AnalysisClientFailure.test.ts index b03c27a764..07d546b31c 100644 --- a/src/providers/AnalysisClientFailure.test.ts +++ b/src/providers/AnalysisClientFailure.test.ts @@ -1,4 +1,5 @@ import { expect, it } from "vitest"; +import { AnalysisCapabilityUnavailableError } from "../domain/analysisErrorCore.js"; import { ProviderCleanupError } from "../domain/providerCleanupError.js"; import type { FileOffsetPartialObservation } from "../domain/native/fileOffsetPartialObservation.js"; import { analysisClientFailureForOperation } from "./AnalysisClientFailure.js"; @@ -91,3 +92,40 @@ it("keeps a regex deadline's continuing-session guidance when attributing it to environment: "REA_REGEX_MATCHING_DEADLINE_MS", }); }); + +it("preserves sibling operations, sibling reasons, and remediation action when rebuilding capability unavailable errors", () => { + const source = new AnalysisCapabilityUnavailableError( + "kuna", + "procedure_assembly", + "Kuna project export failed", + { + userMessage: "Export failed", + remediationAction: "Retry without project requirement", + siblingOperations: ["procedure_assembly", "analyze_function"], + siblingReasons: { + analyze_function: "analyze_function export failed", + }, + retryable: true, + }, + ); + const attributed = analysisClientFailureForOperation( + source, + "kuna", + "procedure_assembly", + ); + expect(attributed).toBeInstanceOf(AnalysisCapabilityUnavailableError); + if (!(attributed instanceof AnalysisCapabilityUnavailableError)) + throw new Error("Expected AnalysisCapabilityUnavailableError"); + expect(attributed.siblingOperations).toEqual([ + "procedure_assembly", + "analyze_function", + ]); + expect(attributed.siblingReasons).toEqual({ + analyze_function: "analyze_function export failed", + }); + expect(attributed.remediationAction).toBe( + "Retry without project requirement", + ); + expect(attributed.userMessage).toBe("Export failed"); + expect(attributed.retryable).toBe(true); +}); diff --git a/src/providers/AnalysisClientFailure.ts b/src/providers/AnalysisClientFailure.ts index ef985844c2..4110017c6e 100644 --- a/src/providers/AnalysisClientFailure.ts +++ b/src/providers/AnalysisClientFailure.ts @@ -228,6 +228,15 @@ export const analysisClientFailureForOperation = ( ...(cause.userMessage === undefined ? {} : { userMessage: cause.userMessage }), + ...(cause.remediationAction === undefined + ? {} + : { remediationAction: cause.remediationAction }), + ...(cause.siblingOperations === undefined + ? {} + : { siblingOperations: cause.siblingOperations }), + ...(cause.siblingReasons === undefined + ? {} + : { siblingReasons: cause.siblingReasons }), retryable: cause.retryable, }, ); diff --git a/tests/boundary/mcp/sessionAvailabilityRecovery.test.ts b/tests/boundary/mcp/sessionAvailabilityRecovery.test.ts index d0343707a0..6e639680b1 100644 --- a/tests/boundary/mcp/sessionAvailabilityRecovery.test.ts +++ b/tests/boundary/mcp/sessionAvailabilityRecovery.test.ts @@ -630,6 +630,246 @@ it("returns provider-inferred siblings to declared availability after one succes }); }); +it("marks declared sibling operations unavailable when a shared prerequisite fails", async () => { + const respond = { + current: (operation: string): Promise => { + if (operation === "procedure_assembly") + return Promise.resolve( + err( + new AnalysisCapabilityUnavailableError( + FIXTURE.id, + operation, + "project export failed", + { siblingOperations: ["procedure_assembly", "procedure_info"] }, + ), + ), + ); + return Promise.resolve(observed("fixture-text")); + }, + }; + const { call, availability } = await connectScripted({ + operations: DIRECT_OPERATIONS, + respond, + }); + expect(await call("procedure_assembly", PROCEDURE)).toBe("error"); + // Independent operations remain available. + expect(await call("address_name", ADDRESS)).toBe("ok"); + expect( + await availability([ + "procedure_assembly", + "procedure_info", + "address_name", + "list_procedures", + ]), + ).toEqual({ + procedure_assembly: refusal("procedure_assembly", "project export failed"), + procedure_info: refusal("procedure_info", "project export failed"), + address_name: "available", + list_procedures: "available", + }); +}); + +it("marks declared sibling operations unavailable when fallback reports export failure", async () => { + const respond = { + current: (operation: string): Promise => { + if (operation === "procedure_pseudo_code") + return Promise.resolve( + ok( + createAnalysisExecution("int main() { return 0; }", FIXTURE, { + siblingUnavailability: new AnalysisCapabilityUnavailableError( + FIXTURE.id, + operation, + "project export failed", + { + siblingOperations: ["procedure_assembly", "procedure_info"], + }, + ), + }), + ), + ); + return Promise.resolve(observed("fixture-text")); + }, + }; + const { call, availability } = await connectScripted({ + operations: [...DIRECT_OPERATIONS, "procedure_pseudo_code"], + respond, + }); + expect(await call("procedure_pseudo_code", PROCEDURE)).toBe("ok"); + // Independent operations remain available. + expect(await call("address_name", ADDRESS)).toBe("ok"); + expect( + await availability([ + "procedure_pseudo_code", + "procedure_assembly", + "procedure_info", + "address_name", + "list_procedures", + ]), + ).toEqual({ + procedure_pseudo_code: "available", + procedure_assembly: refusal("procedure_assembly", "project export failed"), + procedure_info: refusal("procedure_info", "project export failed"), + address_name: "available", + list_procedures: "available", + }); +}); + +it("preserves observed sibling recovery instructions when a later fallback reports sibling unavailability", async () => { + const recoveryMessage = + "Project export failed. Set REA_FIXTURE_TIMEOUT_MS and restart REA."; + const respond = { + current: (operation: string): Promise => { + if (operation === "procedure_assembly") + return Promise.resolve( + err( + new AnalysisCapabilityUnavailableError( + FIXTURE.id, + operation, + "project export failed", + { + userMessage: recoveryMessage, + siblingOperations: ["procedure_assembly", "procedure_info"], + }, + ), + ), + ); + if (operation === "procedure_pseudo_code") + return Promise.resolve( + ok( + createAnalysisExecution("int main() { return 0; }", FIXTURE, { + siblingUnavailability: new AnalysisCapabilityUnavailableError( + FIXTURE.id, + operation, + "project export failed", + { + siblingOperations: ["procedure_assembly", "procedure_info"], + }, + ), + }), + ), + ); + return Promise.resolve(observed("fixture-text")); + }, + }; + const { call, availability } = await connectScripted({ + operations: [...DIRECT_OPERATIONS, "procedure_pseudo_code"], + respond, + }); + expect(await call("procedure_assembly", PROCEDURE)).toBe("error"); + expect(await availability(["procedure_assembly"])).toEqual({ + procedure_assembly: recoveryMessage, + }); + + // A later successful fallback must not overwrite the full recovery explanation. + expect(await call("procedure_pseudo_code", PROCEDURE)).toBe("ok"); + expect(await availability(["procedure_assembly"])).toEqual({ + procedure_assembly: recoveryMessage, + }); +}); + +it("replaces inactive past sibling failures with the current export failure when fallback reports sibling unavailability", async () => { + let assemblyFails = true; + const oldRefusal = "Import slot 0x4000 cannot be disassembled."; + const exportFailureMessage = + "Kuna's project export failed, so procedure_assembly is unavailable in this session."; + const respond = { + current: (operation: string): Promise => { + if (operation === "procedure_assembly") { + if (assemblyFails) + return Promise.resolve( + err( + new AnalysisCapabilityUnavailableError( + FIXTURE.id, + operation, + oldRefusal, + { userMessage: oldRefusal }, + ), + ), + ); + return Promise.resolve(observed("mov eax, 1")); + } + if (operation === "procedure_pseudo_code") + return Promise.resolve( + ok( + createAnalysisExecution("int main() { return 0; }", FIXTURE, { + siblingUnavailability: new AnalysisCapabilityUnavailableError( + FIXTURE.id, + operation, + "project export failed", + { + siblingOperations: ["procedure_assembly"], + siblingReasons: { + procedure_assembly: exportFailureMessage, + }, + }, + ), + }), + ), + ); + return Promise.resolve(observed("fixture-text")); + }, + }; + const { call, availability } = await connectScripted({ + operations: [...DIRECT_OPERATIONS, "procedure_pseudo_code"], + respond, + }); + // 1. Initial refusal on procedure_assembly + expect(await call("procedure_assembly", PROCEDURE)).toBe("error"); + expect(await availability(["procedure_assembly"])).toEqual({ + procedure_assembly: oldRefusal, + }); + + // 2. procedure_assembly succeeds later, resolving the active failure + assemblyFails = false; + expect(await call("procedure_assembly", PROCEDURE)).toBe("ok"); + expect(await availability(["procedure_assembly"])).toEqual({ + procedure_assembly: "available", + }); + + // 3. Fallback observes export failure: must show the new export failure, not resurrect oldRefusal + expect(await call("procedure_pseudo_code", PROCEDURE)).toBe("ok"); + expect(await availability(["procedure_assembly"])).toEqual({ + procedure_assembly: exportFailureMessage, + }); +}); + +it("preserves source paths and diagnostics unchanged in structured sibling unavailability", async () => { + const pathWithOpName = "/var/log/procedure_pseudo_code/target.c"; + const siblingMessage = `Kuna's project export failed, so procedure_assembly is unavailable in this session: Error compiling ${pathWithOpName}.`; + const respond = { + current: (operation: string): Promise => { + if (operation === "procedure_pseudo_code") + return Promise.resolve( + ok( + createAnalysisExecution("int main() { return 0; }", FIXTURE, { + siblingUnavailability: new AnalysisCapabilityUnavailableError( + FIXTURE.id, + operation, + `export failed for ${pathWithOpName}`, + { + siblingOperations: ["procedure_assembly"], + siblingReasons: { + procedure_assembly: siblingMessage, + }, + }, + ), + }), + ), + ); + return Promise.resolve(observed("fixture-text")); + }, + }; + const { call, availability } = await connectScripted({ + operations: [...DIRECT_OPERATIONS, "procedure_pseudo_code"], + respond, + }); + expect(await call("procedure_pseudo_code", PROCEDURE)).toBe("ok"); + // The path containing "procedure_pseudo_code" must remain completely intact + expect(await availability(["procedure_assembly"])).toEqual({ + procedure_assembly: siblingMessage, + }); +}); + it("keeps each provider's failures and ignores cancelled requests", async () => { let fixtureBroken = false; let auxiliaryBroken = true; diff --git a/tests/boundary/providers/kuna/kunaSingleFunctionRuns.test.ts b/tests/boundary/providers/kuna/kunaSingleFunctionRuns.test.ts index 468dd9329f..d82b3a0702 100644 --- a/tests/boundary/providers/kuna/kunaSingleFunctionRuns.test.ts +++ b/tests/boundary/providers/kuna/kunaSingleFunctionRuns.test.ts @@ -1,7 +1,12 @@ -import { expect, it as test } from "vitest"; +import { expect, it as test, vi } from "vitest"; import { projectAnalysisError } from "../../../../src/domain/analysisErrorProjection.js"; -import { kunaTimeoutRecovery } from "../../../../src/kuna/KunaCommand.js"; +import { + kunaTimeoutRecovery, + type KunaProcessLauncher, +} from "../../../../src/kuna/KunaCommand.js"; +import { KunaProjectCache } from "../../../../src/kuna/KunaProjectCache.js"; +import { spawnOwnedProviderProcess } from "../../../../src/process/ProviderProcess.js"; import { fakeKunaCalls, @@ -235,6 +240,10 @@ it("keeps answering repeats from the completed run after the export fails", asyn expect(answer.limitations).toContainEqual( expect.stringContaining("The project export failed"), ); + expect(answer.siblingUnavailability?.siblingOperations).toEqual([ + "procedure_assembly", + "analyze_function", + ]); } expect(await decompilesOf(kuna)).toHaveLength(1); } finally { @@ -242,6 +251,64 @@ it("keeps answering repeats from the completed run after the export fails", asyn } }); +const deferred = () => { + let resolve!: (value: T) => void; + let reject!: (reason?: unknown) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, resolve, reject }; +}; + +it("reports sibling unavailability when fallback observes settled export failure without prior assembly call", async () => { + const exportSettled = deferred(); + exportSettled.promise.catch(() => undefined); + const originalBuild = KunaProjectCache.prototype.build; + const exportBuild = vi + .spyOn(KunaProjectCache.prototype, "build") + .mockImplementation(function (this: KunaProjectCache, ...args) { + const promise = originalBuild.apply(this, args); + return promise.finally(() => exportSettled.resolve()); + }); + const launcher: KunaProcessLauncher = async (options) => { + if (options.arguments[0] === "decompile") { + await exportSettled.promise; + } + return spawnOwnedProviderProcess(options); + }; + const { kuna, open } = await session( + "rea-kuna-single-fallback-unavail-", + { project: "exit-failure" }, + {}, + launcher, + ); + const client = open(); + try { + const answer = await run(client, "procedure_pseudo_code", { + procedure: "main", + }); + expect(answer.rawResult).toMatchObject({ + source: { kind: "single-function" }, + }); + expect(answer.siblingUnavailability).toBeDefined(); + expect(answer.siblingUnavailability?.siblingOperations).toEqual([ + "procedure_assembly", + "analyze_function", + ]); + expect(answer.siblingUnavailability?.providerId).toBe("kuna"); + expect(await decompilesOf(kuna)).toHaveLength(1); + await exportSettled.promise; + } finally { + exportSettled.resolve(); + try { + expect(await client.close()).toEqual({ ok: true, value: null }); + } finally { + exportBuild.mockRestore(); + } + } +}); + it("does not keep a failed run, so a repeat runs again", async () => { const { kuna, open } = await session( "rea-kuna-single-failed-not-kept-",