fix(kuna): report a failed export as not retryable in its session (#4070)

* fix(kuna): report a failed export as not retryable in its session

A session never rebuilds a failed project export, yet requests that need it
returned the export's raw failure. For a timeout that projected as
`retryable: true` with "Try a smaller request", and every retry returned the
same stored failure. Those requests now fail with a non-retryable
capability error that keeps the export failure as its cause and diagnostics
and names what to change (the deadline or jobs for a timeout) before
reopening the target. Cancellation and unproven cleanup keep their own
errors.

Refs #1957

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(kuna): keep a failed export's output and name the restart it needs

The capability error for a failed export dropped the export's captured Kuna
output, which the MCP projection only reads from the top-level error. It
also told callers to change a setting and reopen the target, but REA reads
Kuna settings once at startup. The error now carries the captured output
and partial observation, and its recovery text says to change the setting
where REA starts and restart REA.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(kuna): keep resource limits on a failed export's capability error

An export larger than REA_KUNA_CACHE_MAX_BYTES, or a Kuna command at its
output cap, fails with a resource constraint whose resource and reported
limits the projection exposes only on the top-level error. The wrapper now
copies them into its diagnostics under the same names.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* refactor(kuna): carry a failed export's whole projection on its wrapper

The capability error for a failed export copied the cause's facts field by
field (captured output, resource limits), and each missing field, such as an
artifact change's path and boundary, dropped evidence callers could not
reach through `cause`. The wrapper now carries the export failure's own
projected code and details in its diagnostics, so every typed field survives
in the shape its own projection gives it, and the duplicated top-level
copies are removed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Kaoru0822andClaude Opus 5.5 authored and GitHub committed 2026-10-15 05:20:08 -07:00
1 parent fc791bb606
commit a5024a9710
3 files changed
+132 -2

No files matched your search

+1 -1
View File
@@ -47,7 +47,7 @@ The first text or assembly request in a session looks for this target's export i
- REA starts `kuna decompile-project` in the background on a private copy of the target.
- Meanwhile, pseudocode comes from one `kuna decompile --addr` run. Name and address lookups, and listings, use the inventory the export takes with `kuna functions --json` before it decompiles. The session runs `kuna functions` itself, under `REA_KUNA_TIMEOUT_MS`, only when no export is building (an inventory-only request does not start one) or the export ends without an inventory.
- Requests for the same function share one run; it stops when its last waiting request is cancelled. Each run analyzes the whole image again in its own process, so one REA process runs at most as many at once as the host has cores beyond `REA_KUNA_JOBS` (at least one). Further requests wait in order, and a waiting request is answered from the export if it finishes first. A run whose stop REA cannot prove keeps its slot until `close_binary` proves it stopped.
- Assembly and data names wait for the export; progress notifications report the wait every ten seconds.
- Assembly and data names wait for the export; progress notifications report the wait every ten seconds. A session does not rebuild a failed export: requests that need it fail with `retryable: false`, the export's failure, its captured Kuna output and what to change before reopening the target. A setting such as `REA_KUNA_PROJECT_TIMEOUT_MS` or `REA_KUNA_JOBS` is read when REA starts, so changing it also needs a restart. Pseudocode keeps using single-function runs.
- A single-function answer says so in its limitations. It lacks the export's whole-program prototypes, so a later request can return different text for the same function.
Pseudocode, assembly, `analyze_function` and data-name answers record where their text came from in `raw_result.source`: `single-function`, or `project-export` with the cache directory. `procedure_pseudo_code` and `procedure_assembly` answers from the export also name the file, byte span and the file's SHA-256. Combined `analyze_function` answers retain both facet records in `raw_result.source.files`, each with its file, digest and byte span. Both texts come from one export; losing that project retries the complete report. Listings and procedure lookups carry no `raw_result`. Agents can open or search the export's `.c` and `.asm` there directly when that is cheaper than calling a tool per function. Text read that way is target-derived, like any decompiler output, but it bypasses the untrusted-content marker that REA's tool results carry.
+49 -1
View File
@@ -12,6 +12,8 @@ import {
AnalysisCancelledError,
AnalysisCapabilityUnavailableError,
AnalysisInputError,
AnalysisResourceConstraintError,
AnalysisTimeoutError,
} from "../domain/analysisErrorCore.js";
import type { AnalysisProfileCommitment } from "../domain/analysisProfile.js";
import {
@@ -23,6 +25,7 @@ import type { EvidenceLocation } from "../domain/evidence.js";
import { combineFunctionRelationshipCoverage } from "../domain/functionCallCoverage.js";
import { functionDossierSchema } from "../domain/hopperValues.js";
import type { JsonValue } from "../domain/jsonValue.js";
import { projectAnalysisError } from "../domain/analysisErrorProjection.js";
import type { RelationshipCoverage } from "../domain/relationshipCoverage.js";
import { ProviderAdapterError } from "../domain/providerAdapterError.js";
import { ProviderCleanupError } from "../domain/providerCleanupError.js";
@@ -140,6 +143,51 @@ const failure = (operation: string, cause: unknown): AnalysisError =>
userMessage: `Kuna ${operation} failed: ${message(cause)}`,
});
/**
* 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
* error says what to change before reopening the target. Kuna settings are read
* when REA starts, so changing one also needs a restart. Cancellation and
* unproven cleanup keep their own errors, which callers must still observe.
*/
const exportUnavailable = (
operation: string,
failure: AnalysisError,
): AnalysisError => {
if (failure instanceof AnalysisCancelledError || failure.cleanupIncomplete)
return failure;
const reason = failure.userMessage ?? failure.message;
const exported = projectAnalysisError(failure);
const restart =
"REA reads Kuna settings when it starts, so restart REA, then reopen the target.";
const next =
failure instanceof AnalysisTimeoutError
? `To retry, set REA_KUNA_PROJECT_TIMEOUT_MS above ${String(failure.timeoutMs)} ms or raise REA_KUNA_JOBS in the environment that starts REA. ${restart}`
: failure instanceof AnalysisResourceConstraintError &&
failure.remediationAction !== undefined
? `${failure.remediationAction.replace(/\.?$/u, ".")} ${restart}`
: "To retry, resolve the reported failure, then reopen the target.";
return new AnalysisCapabilityUnavailableError(
KUNA_PROVIDER_ID,
operation,
reason,
{
cause: failure,
// 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.
diagnostics: {
export_error: failure._tag,
export_code: exported.code,
...(exported.details === undefined
? {}
: { export_details: exported.details }),
},
userMessage: `Kuna's project export failed, so ${operation} is unavailable in this session: ${reason.replace(/\.?$/u, ".")} ${next}`,
},
);
};
/** Kuna names a procedure by its inventory entry or by an exact entry address. */
const procedureSelectorSchema = z.string().min(1);
@@ -1286,7 +1334,7 @@ export class KunaSessionClient implements AnalysisClient {
}, WAIT_PROGRESS_INTERVAL_MS);
try {
const built = await waitFor(project, options.signal, operation);
if (!built.ok) return built;
if (!built.ok) return err(exportUnavailable(operation, built.error));
return ok(this.#readerFor(built.value));
} finally {
clearInterval(timer);
@@ -15,6 +15,9 @@ import { expect, it as test, vi } from "vitest";
import type { BinaryTarget } from "../../../../src/domain/binaryTargetTypes.js";
import type { JsonValue } from "../../../../src/domain/jsonValue.js";
import type { KunaProcessLauncher } from "../../../../src/kuna/KunaCommand.js";
import type { AnalysisError } from "../../../../src/domain/analysisErrorBase.js";
import { AnalysisResourceConstraintError } from "../../../../src/domain/analysisErrorCore.js";
import { projectAnalysisError } from "../../../../src/domain/analysisErrorProjection.js";
import { KunaProvider } from "../../../../src/kuna/KunaProvider.js";
import { KUNA_FUNCTION_TEXT_BYTES } from "../../../../src/kuna/KunaLimits.js";
import { KunaProjectCache } from "../../../../src/kuna/KunaProjectCache.js";
@@ -754,6 +757,85 @@ it("reports a failed export as failed rather than building", async () => {
await client.close();
});
it.each([
{
behavior: { project: "hang" } as const,
environment: { REA_KUNA_PROJECT_TIMEOUT_MS: "300" },
cause: "AnalysisTimeoutError",
next: "To retry, set REA_KUNA_PROJECT_TIMEOUT_MS above 300 ms or raise REA_KUNA_JOBS in the environment that starts REA. REA reads Kuna settings when it starts, so restart REA, then reopen the target.",
},
{
behavior: { project: "exit-failure" } as const,
environment: {},
cause: "ProviderAdapterError",
next: "To retry, resolve the reported failure, then reopen the target.",
},
{
behavior: { asmPadding: 2 * 1024 ** 2 },
environment: { REA_KUNA_CACHE_MAX_BYTES: String(1024 ** 2) },
cause: "AnalysisResourceConstraintError",
next: "Raise REA_KUNA_CACHE_MAX_BYTES above the export size, or analyze this target with another provider. REA reads Kuna settings when it starts, so restart REA, then reopen the target.",
},
])(
"reports a failed export as not retryable in its session ($cause)",
async ({ behavior, environment, cause, next }) => {
const { open } = await session(
"rea-kuna-export-unavailable-",
behavior,
environment,
);
const client = open();
try {
for (const operation of [
"procedure_assembly",
"analyze_function",
] as const) {
const result = await client.execute(operation, { procedure: "main" });
if (result.ok) throw new Error(`${operation} unexpectedly succeeded`);
expect(result.error).toMatchObject({
_tag: "AnalysisCapabilityUnavailableError",
retryable: false,
});
expect(result.error.userMessage?.endsWith(next)).toBe(true);
const failure = result.error.cause as AnalysisError;
expect(failure._tag).toBe(cause);
// Callers see only the top-level projection; the export failure's own
// projected code and details travel whole inside it.
const exported = projectAnalysisError(failure);
const projected = projectAnalysisError(result.error);
expect(projected).toMatchObject({
code: "capability_unavailable",
retryable: false,
details: {
diagnostics: {
export_error: cause,
export_code: exported.code,
export_details: exported.details,
},
},
});
expect(
failure instanceof AnalysisResourceConstraintError
? exported.details
: exported.details?.["captured_output"],
).toMatchObject(
failure instanceof AnalysisResourceConstraintError
? {
resource: "capacity",
reported_limits: {
cache_max_bytes: 1024 ** 2,
export_bytes: expect.any(Number),
},
}
: { truncated: expect.any(Boolean) },
);
}
} finally {
expect(await client.close()).toEqual({ ok: true, value: null });
}
},
);
it("lets one caller stop waiting for the shared inventory without failing another", async () => {
const { open } = await session("rea-kuna-shared-inventory-", {
inventoryDelayMs: 700,