fix: preserve cleanup on partial recovery limits

This commit is contained in:
morluto committed 2026-11-04 09:35:42 +08:00
1 parent 011877468d
commit e215b08906
7 files changed
+203 -62

No files matched your search

+41
View File
@@ -6,6 +6,7 @@ import {
createWorkflowEvidence,
executeComposedWorkflow,
retainComposedWorkflowSources,
retainPartialWorkflowEvidence,
workflowSnapshotRecord,
} from "./WorkflowEvidence.js";
import { InvestigationRecords } from "./investigation/InvestigationRecords.js";
@@ -28,6 +29,46 @@ import { ghidraFunctionDossier } from "../domain/ghidraValues.fixture.js";
import { ANALYSIS_SNAPSHOT_PROFILE } from "../domain/analysisSnapshot.fixture.js";
import { createEvidence } from "../domain/evidence.js";
it("preserves cleanup and reports an omitted partial when authentication headroom is unavailable", () => {
const error = new AnalysisCancelledError("get_call_graph", {
cleanup: { reason: "close failed", resources: ["session:fixture"] },
});
const outcome = {
...err(error),
partial: { result: { nodes: ["observed"] }, limitations: [] },
sourceEvidence: [],
};
retainPartialWorkflowEvidence(
outcome,
{
target: undefined,
operation: "get_call_graph",
parameters: {},
upstreamProfile: undefined,
},
() =>
err(
new AnalysisResourceConstraintError(
"get_call_graph",
"capacity",
"no headroom",
null,
),
),
);
expect(projectAnalysisError(error)).toMatchObject({
code: "cleanup_incomplete",
details: {
cleanup: "incomplete",
resources: ["session:fixture"],
partial_observation: {
kind: "omitted-observation",
reason: "insufficient-authentication-headroom",
},
},
});
});
it.each([0, 1, 100])(
"admits enough recovery representation for %i completed batch items",
(count) => {
+88 -55
View File
@@ -233,6 +233,67 @@ export const createWorkflowEvidence = (
: {}),
});
const admitWorkflowRecovery = (
observation: ComposedWorkflowObservation,
evidenceInput: Pick<
WorkflowEvidenceInput,
"target" | "operation" | "parameters" | "upstreamProfile"
>,
admitRecovery: RecoveryAdmission,
) => {
const admissionInput = {
operation: evidenceInput.operation,
parameters: evidenceInput.parameters,
target:
evidenceInput.target === undefined
? null
: {
path: evidenceInput.target.path,
sha256: evidenceInput.target.sha256,
format: evidenceInput.target.format,
architecture: evidenceInput.target.architecture ?? null,
},
profile: evidenceInput.upstreamProfile ?? null,
result: observation.result,
limitations: observation.limitations,
links: observation.evidenceLinks ?? [],
};
// The completed result can also generate a location per batch item and
// the workflow profile embeds the upstream profile. Admit these copies
// before constructing or hashing the recovery Evidence.
const shell = evidenceEnvelopeForAdmission(
evidenceInput.target,
REA_WORKFLOW_PROVIDER,
{
operation: evidenceInput.operation,
parameters: {},
result: null,
...(evidenceInput.upstreamProfile === undefined
? {}
: {
analysisProfile: {
provider: REA_WORKFLOW_PROVIDER,
parameters: workflowAnalysisProfileParameters(
null,
evidenceInput.operation,
),
digest: "0".repeat(64),
},
}),
confidence: "derived",
limitations: [WORKFLOW_DERIVATION_LIMITATION],
locations: [],
evidenceLinks: [],
},
);
const additional = estimateJsonStorage([
observation.result,
evidenceInput.upstreamProfile ?? null,
shell,
]);
return admitRecovery(admissionInput, evidenceInput.operation, additional);
};
/** Experimental shared recovery of a workflow that completed before source admission. */
export const retainComposedWorkflowSources = (
recordEvidence: Parameters<typeof retainAnalysisResultSources>[1],
@@ -258,60 +319,10 @@ export const retainComposedWorkflowSources = (
result,
);
if (result.ok && !retained.ok) {
const admissionInput = {
operation: evidenceInput.operation,
parameters: evidenceInput.parameters,
target:
evidenceInput.target === undefined
? null
: {
path: evidenceInput.target.path,
sha256: evidenceInput.target.sha256,
format: evidenceInput.target.format,
architecture: evidenceInput.target.architecture ?? null,
},
profile: evidenceInput.upstreamProfile ?? null,
result: result.value.result,
limitations: result.value.limitations,
links: result.value.evidenceLinks ?? [],
};
// The completed result can also generate a location per batch item and
// the workflow profile embeds the upstream profile. Admit these copies
// before constructing or hashing the recovery Evidence.
const shell = evidenceEnvelopeForAdmission(
evidenceInput.target,
REA_WORKFLOW_PROVIDER,
{
operation: evidenceInput.operation,
parameters: {},
result: null,
...(evidenceInput.upstreamProfile === undefined
? {}
: {
analysisProfile: {
provider: REA_WORKFLOW_PROVIDER,
parameters: workflowAnalysisProfileParameters(
null,
evidenceInput.operation,
),
digest: "0".repeat(64),
},
}),
confidence: "derived",
limitations: [WORKFLOW_DERIVATION_LIMITATION],
locations: [],
evidenceLinks: [],
},
);
const additional = estimateJsonStorage([
result.value.result,
evidenceInput.upstreamProfile ?? null,
shell,
]);
const admitted = admitRecovery(
admissionInput,
evidenceInput.operation,
additional,
const admitted = admitWorkflowRecovery(
result.value,
evidenceInput,
admitRecovery,
);
if (!admitted.ok) {
retained.error.retainSourcePublication({
@@ -344,8 +355,30 @@ export const retainPartialWorkflowEvidence = (
Parameters<typeof createWorkflowEvidence>[0],
"target" | "operation" | "parameters" | "upstreamProfile"
>,
admitRecovery: RecoveryAdmission = (input, operation, additional) =>
admitInvestigationAuthentication(input, operation, {}, additional),
): void => {
if (outcome.ok || outcome.partial === undefined) return;
if (
outcome.ok ||
outcome.partial === undefined ||
outcome.error.partialObservation !== undefined
)
return;
const admitted = admitWorkflowRecovery(
outcome.partial,
context,
admitRecovery,
);
if (!admitted.ok) {
outcome.error.retainPartialObservation({
kind: "omitted-observation",
reason:
admitted.error._tag === "AnalysisResourceConstraintError"
? "insufficient-authentication-headroom"
: "recovery-input-invalid",
});
return;
}
const evidence = createWorkflowEvidence({
...context,
result: outcome.partial.result,
+6
View File
@@ -442,6 +442,12 @@ import type { WebArtifactAcquisition } from "./webArtifactAcquisition.js";
/** Provider-neutral evidence collected before a typed analysis failure. */
export type AnalysisPartialObservation =
| {
readonly kind: "omitted-observation";
readonly reason:
| "insufficient-authentication-headroom"
| "recovery-input-invalid";
}
| { readonly kind: "unretained-evidence"; readonly evidence: Evidence }
| { readonly kind: "retained-evidence"; readonly evidence_id: string }
| SavedAnalysisSnapshotPartialObservation
@@ -237,7 +237,9 @@ const summarizeBatch = (items: readonly BatchDecompileItem[]) => {
};
};
const batchUnknowns = (items: readonly BatchDecompileItem[]): string[] => {
export const batchUnknowns = (
items: readonly BatchDecompileItem[],
): string[] => {
const unknownIdentity = items.filter(
({ procedure }) => procedure.status === "unknown",
).length;
+15 -1
View File
@@ -231,8 +231,22 @@ export class ToolResultDelivery {
const partial = safeParseEvidence(error.partialObservation);
if (!partial.ok) return this.toErrorToolResult(error, targetContent);
const recorded = recordEvidence(partial.value);
if (!recorded.ok) return this.toErrorToolResult(error, targetContent);
const projected = projectAnalysisError(error);
if (!recorded.ok)
return this.projectedErrorResult(
{
...projected,
details: {
...projected.details,
partial_observation: {
kind: "inline-evidence",
retention: "unknown",
evidence: partial.value,
},
},
},
targetContent,
);
return this.projectedErrorResult(
{
...projected,
@@ -5,6 +5,7 @@ import type { CallToolResult } from "@modelcontextprotocol/server";
import { afterAll, expect, it, onTestFinished, vi } from "vitest";
import { z } from "zod";
import { ANALYSIS_VIEW_TOOL_CONTRACTS } from "../../../src/contracts/analysisViewToolContracts.js";
import { batchUnknowns } from "../../../src/domain/analysisView/batchDecompileView.js";
import { analysisViewResultSchema } from "../../../src/domain/analysisView/analysisView.js";
import { AnalysisOutputError } from "../../../src/domain/analysisErrorCore.js";
import {
@@ -264,10 +265,9 @@ it.each(["full", "compact"] as const)(
},
});
expect(summary.unknowns).toEqual(
expect.arrayContaining([
expect.stringContaining("1 items have unknown procedure identity"),
expect.stringContaining("not attributed to one successful item"),
]),
batchUnknowns(
batchDecompileResultSchema.parse(parent.normalized_result).items,
),
);
const callsAfterBatch = harness.calls.count;
+46 -1
View File
@@ -15,6 +15,7 @@ import { toolContract } from "../../../src/contracts/toolContracts.js";
import type { ToolContract } from "../../../src/contracts/toolContractTypes.js";
import { err } from "../../../src/domain/result.js";
import { EvidenceIntegrityError } from "../../../src/domain/evidenceErrors.js";
import { AnalysisCancelledError } from "../../../src/domain/analysisErrorCore.js";
import { HopperProcessError } from "../../../src/domain/hopperErrors.js";
import { processScenarioSchema } from "../../../src/domain/process/processScenario.js";
import { createToolResultDelivery } from "../../../src/server/toolResult.js";
@@ -526,12 +527,56 @@ describe("untrusted provenance on failed tool results", () => {
err(new EvidenceIntegrityError("fixture rejects partial")),
);
expect(unrecorded.structuredContent).toMatchObject({
error: { details: { partial_observation: evidence } },
error: {
details: {
partial_observation: {
kind: "inline-evidence",
retention: "unknown",
evidence,
},
},
},
});
expect(unrecorded._meta).toEqual(UNTRUSTED_TARGET_CONTENT);
expect(original.partialObservation).toBe(evidence);
});
it("preserves cleanup details when an unretained partial exceeds the response budget", () => {
const evidence = createEvidence(
undefined,
{ id: "fixture", name: "Fixture", version: "1" },
{
operation: "get_call_graph",
parameters: {},
result: { nodes: "x".repeat(8192) },
},
);
const error = new AnalysisCancelledError("get_call_graph", {
cleanup: { reason: "close failed", resources: ["session:fixture"] },
partialObservation: evidence,
});
const result = createToolResultDelivery(4096).toPartialErrorToolResult(
error,
true,
() => err(new EvidenceIntegrityError("fixture rejects partial")),
);
expect(result.structuredContent).toMatchObject({
error: {
code: "cleanup_incomplete",
details: {
cleanup: "incomplete",
resources: ["session:fixture"],
partial_observation_omitted: {
reason: "mcp-response-limit",
retention: "unknown",
evidence_id: evidence.evidence_id,
},
},
},
});
expect(result.content).toHaveLength(1);
});
it("marks the response-budget error that replaces an oversized target-content result", () => {
const small = createToolResultDelivery(4096);
const result = small.toCallToolResult(