mirror of
https://github.com/morluto/rea.git
synced 2026-11-07 05:56:19 -10:00
fix: preserve cleanup on partial recovery limits
This commit is contained in:
1 parent
011877468d
commit
e215b08906
7 files changed
+203
-62
No files matched your search
@@ -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) => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in new issue
Block a user