diff --git a/docs/native-investigation.md b/docs/native-investigation.md index 9eb73bcda1..2c785eedca 100644 --- a/docs/native-investigation.md +++ b/docs/native-investigation.md @@ -252,8 +252,18 @@ The manifest reports the procedure-inventory limitations of the current call in call's filters, selected and excluded counts, and inventory limitations; older runs remain represented only by their retained function records, so a later inventory warning is not presented as the history of every file. +Use `retry_failed: "independent_fresh"` (CLI: `--retry-failed independent_fresh`) +to request an independent attempt after a null pseudocode observation that the +provider explicitly reports as a function failure. The failed observation is +appended before the retry, so cancellation or a later failure keeps it. The +retry can use different analysis context. Other errors and entries merely +missing from an export are not retried. A null without a reported function +failure carries a limitation explaining why no independent retry was attempted. +A provider that does not report this distinction leaves the cause unknown. Each function's `decompile-index.json` record, whatever its status, keeps the -complete limitations of the observations it used. +complete limitations of the observations it used. The index keeps the latest +record for each function; `decompile-journal.jsonl` keeps earlier attempts and +their separate limitations. When available, `provider_source` records the source of that attempt's pseudocode observation: a file path, SHA-256 and byte range, or a single-function decompile. The latter describes the method, not a unique run. A file diff --git a/src/application/AnalysisProvider.ts b/src/application/AnalysisProvider.ts index 9928326533..0c63d62bcb 100644 --- a/src/application/AnalysisProvider.ts +++ b/src/application/AnalysisProvider.ts @@ -19,6 +19,10 @@ import type { EvidenceSubjectTarget, } from "../domain/evidence.js"; import { jsonValueSchema } from "../domain/jsonValue.js"; +import { + pseudocodeAbsenceSchema, + type PseudocodeAbsence, +} from "../domain/native/pseudocodeAbsence.js"; import { analysisSourceDescriptorSchema, type AnalysisSourceDescriptor, @@ -65,6 +69,8 @@ export type AnalysisOperation = /** Atomic successful provider observation with exact producing provenance. */ export interface AnalysisExecution extends AnalysisObservation { readonly provider: ProviderIdentity; + /** Provider-observed cause of a null pseudocode result, when known. */ + readonly pseudocodeAbsence?: PseudocodeAbsence; /** * Compact provider-owned facts for this execution. This is not an Evidence * record and is not restored by snapshot replay; consumers must treat its @@ -107,6 +113,7 @@ export interface AnalysisExecution extends AnalysisObservation { interface CreateAnalysisExecutionOptions { readonly rawResult?: unknown; + readonly pseudocodeAbsence?: PseudocodeAbsence; readonly sourceDescriptor?: AnalysisSourceDescriptor; readonly analysisProfile?: AnalysisProfileCommitment; readonly cachePolicy?: "live"; @@ -151,6 +158,13 @@ export function createAnalysisExecution( ? null : jsonValueSchema.parse(options.rawResult), provider, + ...(options.pseudocodeAbsence === undefined + ? {} + : { + pseudocodeAbsence: pseudocodeAbsenceSchema.parse( + options.pseudocodeAbsence, + ), + }), ...(options.sourceDescriptor === undefined ? {} : { diff --git a/src/application/WorkflowEvidence.ts b/src/application/WorkflowEvidence.ts index 0d30237cbd..fb0a22e425 100644 --- a/src/application/WorkflowEvidence.ts +++ b/src/application/WorkflowEvidence.ts @@ -63,7 +63,7 @@ const DECOMPILE_ALL_LIMITATIONS: ComposedLimitationBudget = { perItemOperations: new Set(["procedure_info", "procedure_pseudo_code"]), maxDistinct: 8, retainedIn: - "each function's record in decompile-index.json keeps the caveats of the observations it used", + "decompile-index.json keeps the latest record for each function; decompile-journal.jsonl keeps each attempt and its caveats", }; /** The caveat budget for a composed workflow, when it has one. */ diff --git a/src/application/decompileDirectory/decompileAll.test.ts b/src/application/decompileDirectory/decompileAll.test.ts index 3f9be556a2..f055ab427c 100644 --- a/src/application/decompileDirectory/decompileAll.test.ts +++ b/src/application/decompileDirectory/decompileAll.test.ts @@ -1416,9 +1416,9 @@ describe("decompile_all journal recovery", () => { expect(limitations).toContainEqual( expect.stringContaining("terminal call may cut the body"), ); - expect(limitations.at(-1)).toBe( - "33 per-item provider limitation observations were omitted from this answer; each function's record in decompile-index.json keeps the caveats of the observations it used.", - ); + expect(limitations.at(-1)).toContain("33 per-item provider limitation"); + expect(limitations.at(-1)).toContain("decompile-index.json"); + expect(limitations.at(-1)).toContain("decompile-journal.jsonl"); const last = await readFile( join(directory(), "functions/0x1130.c"), "utf8", diff --git a/src/application/decompileDirectory/decompileAll.ts b/src/application/decompileDirectory/decompileAll.ts index e7a937857e..4a8207aa03 100644 --- a/src/application/decompileDirectory/decompileAll.ts +++ b/src/application/decompileDirectory/decompileAll.ts @@ -153,6 +153,7 @@ export const decompileAll = async ( identity: loaded.value.identity, signal, limit: input.limit, + retryFailed: input.retry_failed, state, }); limitations = exportLimitations( @@ -361,6 +362,7 @@ interface PublishInput { readonly identity: ExportIdentity; readonly signal: AbortSignal | undefined; readonly limit: number | undefined; + readonly retryFailed: DecompileDirectoryInput["retry_failed"]; readonly state: PublishState; } @@ -485,6 +487,7 @@ const publishOne = async (input: { readonly signal: AbortSignal | undefined; readonly state: PublishState; readonly bodySupport: "unknown" | "unavailable"; + readonly retryFailed: DecompileDirectoryInput["retry_failed"]; }): Promise<{ readonly result: Result; readonly bodySupport: "unknown" | "unavailable"; @@ -515,7 +518,8 @@ const publishOne = async (input: { bodySupport: body.support, }; } - const decompiled = await input.analysis.execute( + let attemptedIndependent = false; + let decompiled = await input.analysis.execute( "procedure_pseudo_code", { procedure: input.procedure.address }, executionOptions(input.signal), @@ -525,6 +529,47 @@ const publishOne = async (input: { return { result: decompiled, bodySupport: body.support }; if (isAborted(input.signal)) return { result: err(cancelled(input.signal)), bodySupport: body.support }; + if ( + decompiled.ok && + input.retryFailed === "independent_fresh" && + decompiled.value.result === null && + decompiled.value.pseudocodeAbsence?.kind === "provider-failure" && + observationMismatch(input.identity, decompiled.value) === undefined + ) { + const original = decompiled.value; + const originalSource: NonNullable< + DecompileDirectoryFunctionRecord["provider_source"] + > = + original.sourceDescriptor === undefined + ? { status: "unknown", reason: "source_not_available" } + : { status: "reported", source: original.sourceDescriptor }; + const recorded = await input.session.append( + failureRecord(input.procedure, body.fact, pseudocodeFailure(null), { + limitations: [...new Set([...infoCaveats, ...original.limitations])], + providerSource: originalSource, + pseudocodeAbsence: original.pseudocodeAbsence, + }), + ); + if (!recorded.ok) return { result: recorded, bodySupport: body.support }; + if (isAborted(input.signal)) + return { + result: err(cancelled(input.signal)), + bodySupport: body.support, + }; + attemptedIndependent = true; + decompiled = await input.analysis.execute( + "procedure_pseudo_code", + { procedure: input.procedure.address, analysis: "independent_fresh" }, + executionOptions(input.signal), + ); + if (!decompiled.ok && decompiled.error.cleanupIncomplete) + return { result: decompiled, bodySupport: body.support }; + if (isAborted(input.signal)) + return { + result: err(cancelled(input.signal)), + bodySupport: body.support, + }; + } if (!decompiled.ok) { if (decompiled.error instanceof AnalysisCancelledError) return { result: decompiled, bodySupport: body.support }; @@ -553,7 +598,17 @@ const publishOne = async (input: { }; } const caveats = [ - ...new Set([...infoCaveats, ...decompiled.value.limitations]), + ...new Set([ + ...infoCaveats, + ...decompiled.value.limitations, + ...(input.retryFailed === "independent_fresh" && + !attemptedIndependent && + decompiled.value.result === null + ? [ + "Independent retry was not attempted because the provider did not report a function failure for this observation.", + ] + : []), + ]), ]; const observationSource: NonNullable< DecompileDirectoryFunctionRecord["provider_source"] @@ -563,13 +618,25 @@ const publishOne = async (input: { : { status: "reported", source: decompiled.value.sourceDescriptor }; const pseudocode = decompiled.value.result; if (typeof pseudocode !== "string" || pseudocode.length === 0) { - const reason = pseudocodeFailure(pseudocode); + const reason = + input.retryFailed === undefined && + pseudocode === null && + decompiled.value.pseudocodeAbsence?.kind === "provider-failure" + ? { + code: "no_pseudocode", + message: + "The provider reported a function decompilation failure. If it supports independent decompilation, resume this export with retry_failed: independent_fresh (CLI: --resume --retry-failed independent_fresh). The retry may use different analysis context.", + } + : pseudocodeFailure(pseudocode); recordFailure(input.state, input.procedure, reason); return { result: await input.session.append( failureRecord(input.procedure, body.fact, reason, { limitations: caveats, providerSource: observationSource, + ...(decompiled.value.pseudocodeAbsence === undefined + ? {} + : { pseudocodeAbsence: decompiled.value.pseudocodeAbsence }), }), ), bodySupport: body.support, @@ -923,6 +990,7 @@ const failureRecord = ( /** Caveats of the observations this attempt used. */ readonly limitations?: readonly string[]; readonly providerSource?: DecompileDirectoryFunctionRecord["provider_source"]; + readonly pseudocodeAbsence?: DecompileDirectoryFunctionRecord["pseudocode_absence"]; } = {}, ): DecompileDirectoryFunctionRecord => ({ address: procedure.address, @@ -933,6 +1001,9 @@ const failureRecord = ( ...(options.providerSource === undefined ? {} : { provider_source: options.providerSource }), + ...(options.pseudocodeAbsence === undefined + ? {} + : { pseudocode_absence: options.pseudocodeAbsence }), ...(options.relativePath === undefined ? {} : { relative_path: options.relativePath }), diff --git a/src/cli/decompileDirectoryCommand.ts b/src/cli/decompileDirectoryCommand.ts index c584cd1cd9..a80241eddc 100644 --- a/src/cli/decompileDirectoryCommand.ts +++ b/src/cli/decompileDirectoryCommand.ts @@ -44,6 +44,7 @@ export const registerDecompileDirectoryCommand = ( .describe( "Continue a matching export. Never overwrites a function file.", ), + retryFailed: decompileDirectoryInputSchema.shape.retry_failed, name: z .string() .min(1) @@ -96,6 +97,7 @@ const decompileAllArguments = (options: { readonly output: string; readonly layout: "address" | "dwarf"; readonly resume: boolean; + readonly retryFailed?: "independent_fresh" | undefined; readonly name?: string | undefined; readonly namespace?: string | undefined; readonly addressStart?: string | undefined; @@ -105,6 +107,9 @@ const decompileAllArguments = (options: { output_directory: absoluteCallerPath(options.output), layout: options.layout, resume: options.resume, + ...(options.retryFailed === undefined + ? {} + : { retry_failed: options.retryFailed }), ...(options.name === undefined ? {} : { name: options.name }), ...(options.namespace === undefined ? {} : { namespace: options.namespace }), ...(options.addressStart === undefined diff --git a/src/contracts/decompileDirectory.ts b/src/contracts/decompileDirectory.ts index f65d644ba1..0750a1be85 100644 --- a/src/contracts/decompileDirectory.ts +++ b/src/contracts/decompileDirectory.ts @@ -3,6 +3,7 @@ import { z } from "zod"; import { digestSchema } from "../domain/digests.js"; import { absoluteLocalPathStringSchema } from "../domain/localPath.js"; import { analysisSourceDescriptorSchema } from "../domain/analysisSource.js"; +import { pseudocodeAbsenceSchema } from "../domain/native/pseudocodeAbsence.js"; /** On-disk journal. Append-only so a crash keeps committed function files. */ export const DECOMPILE_DIRECTORY_JOURNAL_NAME = "decompile-journal.jsonl"; @@ -102,6 +103,12 @@ export const decompileDirectoryInputSchema = z.strictObject({ .describe( "Continue a journal for the same target SHA-256, provider, analysis profile, and layout. Both digests must be known. Verified files and functions recorded as external are skipped, conflicting files are left unchanged, and functions not yet tried run before retries of recorded failures.", ), + retry_failed: z + .enum(["independent_fresh"]) + .optional() + .describe( + "For a null result explicitly reported as a function failure, retain the failure and try an independent decompile when supported. It can use different analysis context; errors and omissions without that typed fact are not retried.", + ), name: z .string() .min(1) @@ -267,6 +274,7 @@ export const decompileDirectoryFunctionSchema = z }), ]) .optional(), + pseudocode_absence: pseudocodeAbsenceSchema.optional(), body: bodySchema, failure: z .strictObject({ diff --git a/src/contracts/enhancedToolContracts.ts b/src/contracts/enhancedToolContracts.ts index 306a9febc6..da8248aea8 100644 --- a/src/contracts/enhancedToolContracts.ts +++ b/src/contracts/enhancedToolContracts.ts @@ -54,7 +54,7 @@ export const ENHANCED_TOOL_CONTRACTS = [ ), enhanced( "decompile_all", - "Write provider pseudocode for every analyzed function, or a filtered subset, into a caller-selected directory and return only a manifest. Each file records the provider function name, canonical address, inclusive body ranges when procedure_info supplies them, and that function's procedure_info and pseudocode limitations. Each function's index record keeps its complete limitations. Evidence keeps at most eight first-observed distinct per-function limitations within an encoded-text budget; the omitted count measures observations, not distinct warnings. The manifest reports counts, the output directory, the index path, at most 32 failure details within 2,048 JSON-encoded UTF-8 bytes (names can be omitted; full names remain in the index), provider identity, and the analysis-profile digest. Pseudocode stays in the files. An existing directory is refused unless resume is set and the journal matches the target SHA-256, provider, and analysis profile; verified files are skipped and conflicting files are left unchanged. layout dwarf reports that compilation-unit paths are unavailable and names files by address. A failure of one function does not stop the others. Functions procedure_info classifies as external are counted as external, get no file, and are not decompiled; a resume skips them. Cancellation leaves files already written. Per-function limits do not bound the whole call; use limit and resume to select work that fits the client deadline, including import, listing and file work.", + "Write provider pseudocode for every analyzed function, or a filtered subset, into a caller-selected directory and return only a manifest. Each file records the provider function name, canonical address, inclusive body ranges when procedure_info supplies them, and that function's procedure_info and pseudocode limitations. Each function's index record keeps its latest attempt and its complete limitations; the journal keeps earlier attempts and their limitations. Evidence keeps at most eight first-observed distinct per-function limitations within an encoded-text budget; the omitted count measures observations, not distinct warnings. The manifest reports counts, the output directory, the index path, at most 32 failure details within 2,048 JSON-encoded UTF-8 bytes (names can be omitted; full names remain in the index), provider identity, and the analysis-profile digest. Pseudocode stays in the files. An existing directory is refused unless resume is set and the journal matches the target SHA-256, provider, and analysis profile; verified files are skipped and conflicting files are left unchanged. layout dwarf reports that compilation-unit paths are unavailable and names files by address. Set retry_failed: independent_fresh to retry a null result explicitly reported as a function failure when supported; the failed attempt stays in the journal and the new attempt may use different analysis context. Errors and omissions without that typed fact are not retried. A failure of one function does not stop the others. Functions procedure_info classifies as external are counted as external, get no file, and are not decompiled; a resume skips them. Cancellation leaves files already written. Per-function limits do not bound the whole call; use limit and resume to select work that fits the client deadline, including import, listing and file work.", enhancedInputSchemas.decompile_all, ), enhanced( diff --git a/src/contracts/officialToolContracts.ts b/src/contracts/officialToolContracts.ts index c2534a74fa..9b14990c7f 100644 --- a/src/contracts/officialToolContracts.ts +++ b/src/contracts/officialToolContracts.ts @@ -214,7 +214,7 @@ export const OFFICIAL_TOOL_CONTRACTS = [ ), official( "procedure_pseudo_code", - "Decompile one analyzed procedure by symbol name or provider-normalized address. To request a fresh independent attempt, use analysis: independent_fresh when binary_session reports independent_decompile for this provider operation; otherwise it is refused. The independent result may have different analysis context. Ghidra also accepts file-offset:0x... selectors and falls back to a unique source-file mapping when a 0x-prefixed number resolves to no procedure; existing analysis addresses take precedence and ambiguous mappings fail explicitly. Returns provider-specific pseudocode, never original source or cross-provider text equivalence, and may return null; request procedure_assembly when instruction precision matters. Ghidra no-return assumptions may cut the analyzed body; Evidence limitations report observed terminal calls and available callee identities. Select detail summary to retain complete Evidence and receive text availability and counts; inspect_analysis_view summary or native/pseudocode pages read the original observation without decompiling again. Complete remains the default.", + "Decompile one analyzed procedure by symbol name or provider-normalized address. To request a fresh independent attempt, use analysis: independent_fresh when binary_session reports independent_decompile for this provider operation; otherwise it is refused. The independent result may have different analysis context. Ghidra also accepts file-offset:0x... selectors and falls back to a unique source-file mapping when a 0x-prefixed number resolves to no procedure; existing analysis addresses take precedence and ambiguous mappings fail explicitly. Returns provider-specific pseudocode, never original source or cross-provider text equivalence, and may return null. When supplied, raw_result.pseudocode_absence distinguishes provider-failure from not-in-export; absence of that fact means unknown. Request procedure_assembly when instruction precision matters. Ghidra no-return assumptions may cut the analyzed body; Evidence limitations report observed terminal calls and available callee identities. Select detail summary to retain complete Evidence and receive text availability and counts; inspect_analysis_view summary or native/pseudocode pages read the original observation without decompiling again. Complete remains the default.", z.object({ procedure, document, diff --git a/src/domain/native/pseudocodeAbsence.ts b/src/domain/native/pseudocodeAbsence.ts new file mode 100644 index 0000000000..5ba98313c3 --- /dev/null +++ b/src/domain/native/pseudocodeAbsence.ts @@ -0,0 +1,15 @@ +import { z } from "zod"; + +/** Provider-observed reasons for a null pseudocode result. */ +export const pseudocodeAbsenceSchema = z.discriminatedUnion("kind", [ + z.strictObject({ + kind: z.literal("provider-failure"), + reason: z.string().min(1), + }), + z.strictObject({ + kind: z.literal("not-in-export"), + reason: z.string().min(1), + }), +]); + +export type PseudocodeAbsence = z.infer; diff --git a/src/kuna/KunaProjectReader.ts b/src/kuna/KunaProjectReader.ts index f8a4f9dc21..6198762376 100644 --- a/src/kuna/KunaProjectReader.ts +++ b/src/kuna/KunaProjectReader.ts @@ -35,6 +35,7 @@ import { type KunaProjectFunction, } from "./KunaProjectFiles.js"; import { KUNA_PROVIDER_ID } from "./KunaRelease.js"; +import type { PseudocodeAbsence } from "../domain/native/pseudocodeAbsence.js"; import { KUNA_FUNCTION_TEXT_BYTES } from "./KunaLimits.js"; import { kunaUnavailable } from "./KunaUnavailable.js"; @@ -49,6 +50,7 @@ export interface KunaProjectFileSpan { export interface KunaProjectText { readonly result: string | null; readonly unavailableReason?: string; + readonly pseudocodeAbsence?: PseudocodeAbsence; readonly files?: readonly KunaProjectFileSpan[]; readonly limitations: readonly string[]; } @@ -317,10 +319,11 @@ export class KunaProjectReader { const block = this.#blocks.get(address); if (block === undefined) { const reason = - "Kuna's export decompiles code entries only; this inventory entry, such as an import slot, has no body."; + "Kuna's project export contains no decompiled block for this inventory entry. Import slots may have no body; other omissions are not classified."; return ok({ result: null, unavailableReason: reason, + pseudocodeAbsence: { kind: "not-in-export", reason }, limitations: [reason], }); } @@ -337,6 +340,7 @@ export class KunaProjectReader { return ok({ result: null, unavailableReason: reason, + pseudocodeAbsence: { kind: "provider-failure", reason }, files, limitations: [ reason, diff --git a/src/kuna/KunaSessionClient.ts b/src/kuna/KunaSessionClient.ts index 4ad1b7f10e..111e156886 100644 --- a/src/kuna/KunaSessionClient.ts +++ b/src/kuna/KunaSessionClient.ts @@ -108,6 +108,7 @@ import { ArtifactResourceScope } from "../artifacts/ArtifactResourceScope.js"; import { ArtifactReaderFailure } from "../artifacts/ArtifactReader.js"; import type { OwnedFileHandle } from "../filesystem/OwnedFileHandle.js"; import { kunaQueryFailureForCaller } from "./KunaQueryErrors.js"; +import type { PseudocodeAbsence } from "../domain/native/pseudocodeAbsence.js"; /** How often a request waiting on the project export reports that it still waits. */ const WAIT_PROGRESS_INTERVAL_MS = 10_000; @@ -173,6 +174,7 @@ interface SingleFunctionText { interface Answer { readonly result: JsonValue; + readonly pseudocodeAbsence?: PseudocodeAbsence; readonly source: AnswerSource | null; readonly files?: readonly KunaProjectFileSpan[]; readonly locations: readonly EvidenceLocation[]; @@ -699,14 +701,21 @@ export class KunaSessionClient implements AnalysisClient { }, { ...(sourceDescriptor === undefined ? {} : { sourceDescriptor }), + ...(answer.pseudocodeAbsence === undefined + ? {} + : { pseudocodeAbsence: answer.pseudocodeAbsence }), // Kuna's identity is in the analysis profile; the raw result records // only where file-backed text came from. ...(source === null && + answer.pseudocodeAbsence === undefined && answer.referenceSource === undefined && answer.stringSource === undefined ? {} : { rawResult: { + ...(answer.pseudocodeAbsence === undefined + ? {} + : { pseudocode_absence: answer.pseudocodeAbsence }), ...(source === null ? {} : { @@ -1347,6 +1356,9 @@ export class KunaSessionClient implements AnalysisClient { return ok({ result: text.value.result, limitations: text.value.limitations, + ...(text.value.pseudocodeAbsence === undefined + ? {} + : { pseudocodeAbsence: text.value.pseudocodeAbsence }), ...(text.value.files === undefined ? {} : { files: text.value.files }), diff --git a/tests/boundary/providers/kuna/kunaBatchDecompileMcp.test.ts b/tests/boundary/providers/kuna/kunaBatchDecompileMcp.test.ts index eaa5971802..4dd57668c1 100644 --- a/tests/boundary/providers/kuna/kunaBatchDecompileMcp.test.ts +++ b/tests/boundary/providers/kuna/kunaBatchDecompileMcp.test.ts @@ -77,10 +77,16 @@ test("keeps each Kuna no-pseudocode observation's limitations on its batch item" const broken = await direct("broken"); const importSlot = await direct("puts"); expect(broken.normalized_result).toBeNull(); + expect(broken.raw_result).toMatchObject({ + pseudocode_absence: { kind: "provider-failure" }, + }); expect(broken.limitations).toContain( "Kuna could not decompile this function: Flow exceeded maximum instructions", ); expect(importSlot.normalized_result).toBeNull(); + expect(importSlot.raw_result).toMatchObject({ + pseudocode_absence: { kind: "not-in-export" }, + }); const batch = parseEvidence( ( diff --git a/tests/boundary/providers/kuna/kunaDirectoryIndependentRetryMcp.test.ts b/tests/boundary/providers/kuna/kunaDirectoryIndependentRetryMcp.test.ts new file mode 100644 index 0000000000..10f8b050c0 --- /dev/null +++ b/tests/boundary/providers/kuna/kunaDirectoryIndependentRetryMcp.test.ts @@ -0,0 +1,459 @@ +import { readFile, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { afterAll, expect, it as test, vi } from "vitest"; +import { AnalysisProviderRegistry } from "../../../../src/application/binary/AnalysisProviderRegistry.js"; +import { composeBinarySession } from "../../../../src/application/binary/BinarySessionComposition.js"; +import { parseEvidence } from "../../../../src/domain/evidence.js"; +import { KunaProvider } from "../../../../src/kuna/KunaProvider.js"; +import { elfNoteFixture } from "../../../../src/native/elf/ElfNotes.fixture.js"; +import { createApplicationMcpHarness } from "../../../fixtures/applicationMcpHarness.js"; +import { advertisedMcpToolSchemas } from "../../../fixtures/advertisedMcpTool.js"; +import { createMcpSchemaValidation } from "../../../fixtures/mcpSchemaValidation.js"; +import { awaitAcknowledgement } from "../../../fixtures/lifecycleAcknowledgement.js"; +import { createTestTempDirectory } from "../../../fixtures/temporaryDirectory.js"; +import { fakeKunaCalls, writeFakeKuna } from "./fakeKuna.js"; +import { FUNCTIONS, unprovenCleanupLauncher } from "./kunaSessionFixture.js"; + +const it = test.skipIf(process.platform === "win32"); +const schemas = createMcpSchemaValidation(); +afterAll(() => schemas.clear()); + +const expectPriorFailureInJournal = async (output: string) => { + type Attempt = { + address?: string; + pseudocode_absence?: { kind: string; reason: string }; + limitations?: string[]; + }; + const journal = ( + await readFile(join(output, "decompile-journal.jsonl"), "utf8") + ) + .trim() + .split("\n") + .map((line) => JSON.parse(line) as Attempt); + const index = JSON.parse( + await readFile(join(output, "decompile-index.json"), "utf8"), + ) as { functions: Attempt[] }; + const failed = journal.find( + (row) => + row.address === "0x1080" && + row.pseudocode_absence?.kind === "provider-failure", + ); + const latest = index.functions.find((row) => row.address === "0x1080"); + expect(failed?.pseudocode_absence?.reason).toEqual(expect.any(String)); + expect(failed?.limitations).toContain(failed?.pseudocode_absence?.reason); + expect(latest?.limitations).not.toContain(failed?.pseudocode_absence?.reason); +}; + +it.each(["compact", "full"] as const)( + "guides resume and retries only reported project failures (%s)", + async (profile) => { + const root = await createTestTempDirectory("rea-directory-independent-"); + const kuna = await writeFakeKuna(root, { functions: FUNCTIONS }); + const target = join(root, "program"); + const output = join(root, "output"); + await writeFile(target, elfNoteFixture({ bits: 64, type: 2 })); + const provider = new KunaProvider( + { REA_KUNA_COMMAND: kuna, REA_KUNA_CACHE_DIR: join(root, "cache") }, + { platform: process.platform }, + ); + const session = composeBinarySession( + new AnalysisProviderRegistry([provider], "kuna"), + ); + const harness = await createApplicationMcpHarness({ + session, + schemaValidator: schemas, + serverOptions: { + environment: { + ...process.env, + REA_MCP_INPUT_SCHEMA_PROFILE: profile, + REA_MCP_OUTPUT_SCHEMA_PROFILE: profile, + }, + }, + }); + try { + const call = async (name: string, args: Record) => { + const schema = await advertisedMcpToolSchemas( + harness.client, + name, + schemas.assertions, + ); + const result = await harness.client.callTool( + { name, arguments: args }, + { toolDefinition: schema.toolDefinition }, + ); + expect(result.isError, JSON.stringify(result)).not.toBe(true); + expect( + schema.ajv.validate(schema.outputSchema, result.structuredContent), + JSON.stringify(schema.ajv.errors), + ).toBe(true); + return result.structuredContent; + }; + await call("open_binary", { path: target, provider_id: "kuna" }); + await call("procedure_assembly", { procedure: "main" }); + const initial = parseEvidence( + await call("decompile_all", { output_directory: output }), + ); + expect(initial.normalized_result).toMatchObject({ + counts: { written: 2, failed: 2 }, + failures: expect.arrayContaining([ + expect.objectContaining({ + address: "0x1080", + message: expect.stringContaining("retry_failed"), + }), + expect.objectContaining({ + address: "0x2000", + code: "no_pseudocode", + }), + ]), + }); + const evidence = parseEvidence( + await call("decompile_all", { + output_directory: output, + resume: true, + retry_failed: "independent_fresh", + }), + ); + expect(evidence.normalized_result).toMatchObject({ + counts: { written: 1, failed: 1, skipped: 2 }, + }); + const index = JSON.parse( + await readFile(join(output, "decompile-index.json"), "utf8"), + ) as { + functions: Array<{ + address: string; + status: string; + provider_source?: unknown; + }>; + }; + expect( + index.functions.find((item) => item.address === "0x1080"), + ).toMatchObject({ + status: "written", + provider_source: { + status: "reported", + source: { kind: "single-function-decompile" }, + }, + }); + expect( + index.functions.find((item) => item.address === "0x2000"), + ).toMatchObject({ + status: "failed", + pseudocode_absence: { kind: "not-in-export" }, + }); + const journal = ( + await readFile(join(output, "decompile-journal.jsonl"), "utf8") + ) + .trim() + .split("\n") + .map( + (line) => + JSON.parse(line) as { + address?: string; + status?: string; + provider_source?: unknown; + }, + ); + expect(journal.filter((row) => row.address === "0x1080")).toEqual([ + expect.objectContaining({ + status: "failed", + pseudocode_absence: expect.objectContaining({ + kind: "provider-failure", + }), + failure: expect.objectContaining({ + message: expect.stringContaining("retry_failed"), + }), + }), + expect.objectContaining({ + status: "failed", + pseudocode_absence: expect.objectContaining({ + kind: "provider-failure", + }), + provider_source: { + status: "reported", + source: expect.objectContaining({ kind: "file-range" }), + }, + }), + expect.objectContaining({ + status: "written", + provider_source: { + status: "reported", + source: { kind: "single-function-decompile" }, + }, + }), + ]); + await expectPriorFailureInJournal(output); + expect( + (await fakeKunaCalls(kuna)) + .filter(({ argv }) => argv[0] === "decompile") + .map(({ argv }) => argv[2]), + ).toEqual(["0x1080"]); + } finally { + await harness.close(); + const closed = await provider.closeOwnedResources(); + if (!closed.ok) throw closed.error; + } + }, +); + +it("retains the project failure when the independent retry times out", async () => { + const root = await createTestTempDirectory( + "rea-directory-independent-timeout-", + ); + const kuna = await writeFakeKuna(root, { + functions: FUNCTIONS, + decompile: "hang", + }); + const target = join(root, "program"); + const output = join(root, "output"); + await writeFile(target, elfNoteFixture({ bits: 64, type: 2 })); + const provider = new KunaProvider( + { + REA_KUNA_COMMAND: kuna, + REA_KUNA_CACHE_DIR: join(root, "cache"), + REA_KUNA_TIMEOUT_MS: "300", + }, + { platform: process.platform }, + ); + const session = composeBinarySession( + new AnalysisProviderRegistry([provider], "kuna"), + ); + const harness = await createApplicationMcpHarness({ session }); + try { + const call = async (name: string, args: Record) => { + const result = await harness.client.callTool({ name, arguments: args }); + expect(result.isError, JSON.stringify(result)).not.toBe(true); + return result.structuredContent; + }; + await call("open_binary", { path: target, provider_id: "kuna" }); + await call("procedure_assembly", { procedure: "main" }); + const evidence = parseEvidence( + await call("decompile_all", { + output_directory: output, + address_start: "0x1080", + address_end: "0x1080", + retry_failed: "independent_fresh", + }), + ); + expect(evidence.normalized_result).toMatchObject({ + counts: { written: 0, failed: 1 }, + failures: [{ address: "0x1080", code: "deadline" }], + }); + const journal = ( + await readFile(join(output, "decompile-journal.jsonl"), "utf8") + ) + .trim() + .split("\n") + .map( + (line) => + JSON.parse(line) as { + address?: string; + status?: string; + failure?: { code: string }; + }, + ); + const attempts = journal.filter((row) => row.address === "0x1080"); + expect(attempts).toHaveLength(2); + expect(attempts[0]).toMatchObject({ + status: "failed", + pseudocode_absence: { kind: "provider-failure" }, + }); + expect(attempts[1]).toMatchObject({ + status: "failed", + failure: { code: "deadline" }, + }); + } finally { + await harness.close(); + const closed = await provider.closeOwnedResources(); + if (!closed.ok) throw closed.error; + } +}); + +it("retains the project failure when cancellation stops the independent retry", async () => { + const root = await createTestTempDirectory( + "rea-directory-independent-cancel-", + ); + const kuna = await writeFakeKuna(root, { + functions: FUNCTIONS, + decompile: "hang", + }); + const target = join(root, "program"); + const output = join(root, "output"); + await writeFile(target, elfNoteFixture({ bits: 64, type: 2 })); + const provider = new KunaProvider( + { + REA_KUNA_COMMAND: kuna, + REA_KUNA_CACHE_DIR: join(root, "cache"), + REA_KUNA_TIMEOUT_MS: "10000", + }, + { platform: process.platform }, + ); + const session = composeBinarySession( + new AnalysisProviderRegistry([provider], "kuna"), + ); + const harness = await createApplicationMcpHarness({ session }); + const controller = new AbortController(); + let pending: Promise | undefined; + try { + expect( + ( + await harness.client.callTool({ + name: "open_binary", + arguments: { path: target, provider_id: "kuna" }, + }) + ).isError, + ).not.toBe(true); + expect( + ( + await harness.client.callTool({ + name: "procedure_assembly", + arguments: { procedure: "main" }, + }) + ).isError, + ).not.toBe(true); + pending = harness.client + .callTool( + { + name: "decompile_all", + arguments: { + output_directory: output, + address_start: "0x1080", + address_end: "0x1080", + retry_failed: "independent_fresh", + }, + }, + { signal: controller.signal, timeout: 15000 }, + ) + .catch(() => undefined); + const launched = vi.waitFor( + async () => { + expect( + (await fakeKunaCalls(kuna)).some( + ({ argv }) => argv[0] === "decompile" && argv[2] === "0x1080", + ), + ).toBe(true); + }, + { timeout: 5000, interval: 20 }, + ); + await awaitAcknowledgement( + launched, + pending, + "independent retry launch", + 6000, + ); + controller.abort(); + await pending; + expect( + (await harness.client.callTool({ name: "close_binary", arguments: {} })) + .isError, + ).not.toBe(true); + const journal = ( + await readFile(join(output, "decompile-journal.jsonl"), "utf8") + ) + .trim() + .split("\n") + .map((line) => JSON.parse(line) as { address?: string; status?: string }); + expect(journal.filter((row) => row.address === "0x1080")).toEqual([ + expect.objectContaining({ + status: "failed", + pseudocode_absence: expect.objectContaining({ + kind: "provider-failure", + }), + }), + ]); + } finally { + controller.abort(); + await pending; + await harness.close(); + const closed = await provider.closeOwnedResources(); + if (!closed.ok) throw closed.error; + } +}); + +it("retains failed attempt and cleanup ownership when retry stop is unproven", async () => { + const root = await createTestTempDirectory( + "rea-directory-independent-cleanup-", + ); + const kuna = await writeFakeKuna(root, { + functions: FUNCTIONS, + decompile: "hang", + }); + const target = join(root, "program"); + const output = join(root, "output"); + await writeFile(target, elfNoteFixture({ bits: 64, type: 2 })); + const unproven = unprovenCleanupLauncher("decompile"); + const provider = new KunaProvider( + { + REA_KUNA_COMMAND: kuna, + REA_KUNA_CACHE_DIR: join(root, "cache"), + REA_KUNA_TIMEOUT_MS: "300", + }, + { + platform: process.platform, + launcher: unproven.launcher, + singleFunctionRuns: 1, + }, + ); + const session = composeBinarySession( + new AnalysisProviderRegistry([provider], "kuna"), + ); + const harness = await createApplicationMcpHarness({ session }); + try { + expect( + ( + await harness.client.callTool({ + name: "open_binary", + arguments: { path: target, provider_id: "kuna" }, + }) + ).isError, + ).not.toBe(true); + expect( + ( + await harness.client.callTool({ + name: "procedure_assembly", + arguments: { procedure: "main" }, + }) + ).isError, + ).not.toBe(true); + const failed = await harness.client.callTool({ + name: "decompile_all", + arguments: { + output_directory: output, + address_start: "0x1080", + address_end: "0x1080", + retry_failed: "independent_fresh", + }, + }); + expect(failed.isError).toBe(true); + expect( + JSON.parse((failed.content[0] as { text: string }).text), + ).toMatchObject({ error: { code: "cleanup_incomplete" } }); + const journal = ( + await readFile(join(output, "decompile-journal.jsonl"), "utf8") + ) + .trim() + .split("\n") + .map((line) => JSON.parse(line) as { address?: string; status?: string }); + expect(journal.filter((row) => row.address === "0x1080")).toEqual([ + expect.objectContaining({ + status: "failed", + pseudocode_absence: expect.objectContaining({ + kind: "provider-failure", + }), + }), + ]); + expect( + (await harness.client.callTool({ name: "close_binary", arguments: {} })) + .isError, + ).toBe(true); + unproven.release(); + expect( + (await harness.client.callTool({ name: "close_binary", arguments: {} })) + .isError, + ).not.toBe(true); + } finally { + unproven.release(); + await harness.close(); + const closed = await provider.closeOwnedResources(); + if (!closed.ok) throw closed.error; + } +});