[GH-ISSUE #7427] [Bug] close_binary omits the saved snapshot receipt when provider cleanup fails #4433

Closed
opened 2026-10-31 00:49:45 +00:00 by bsakaguchi · 0 comments
Owner

Originally created by @coygeek on GitHub (Oct 27, 2026).
Original GitHub issue: https://github.com/morluto/rea/issues/7427

Before submitting

  • I searched the open and closed issues for a related report.
  • I removed credentials, tokens, private paths, and sensitive target data.

Area

MCP or agent integration

Observed behavior

A registered MCP close_binary request with snapshot_path publishes a valid snapshot, then returns only cleanup_incomplete when provider close fails. The response contains the retained provider resource but no published path, byte count or record counts. Reading the caller-selected file confirms that the snapshot exists and contains the observation. Repeating the snapshot-close request returns target_unavailable because the active target has already been cleared, while session status reports cleanup_pending: true.

Reproduction

The following production-server boundary probe ran once per case at the stated commit. Only the provider profile, analysis result and provider-close outcome are synthetic; the session, snapshot writer, MCP handler and SDK result projection run normally. It asserts the currently observed bug and a successful-close control. No external analysis engine starts. From a fresh checkout with Node 24.22.1, install/build and run:

export PATH="$(mise where node@24.22.1)/bin:$PATH"
WORK="$(mktemp -d)"
export WORK
mkdir -p "$WORK"/{home,config,data,cache,state,tmp}
export HOME="$WORK/home" XDG_CONFIG_HOME="$WORK/config" XDG_DATA_HOME="$WORK/data"
export XDG_CACHE_HOME="$WORK/cache" XDG_STATE_HOME="$WORK/state" TMPDIR="$WORK/tmp"
npm ci && npm run build:cached
cat > tests/boundary/mcp/snapshotCloseReceipt.test.ts <<'TEST'
import { readFile, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { Client, InMemoryTransport } from "@modelcontextprotocol/client";
import { expect, it } from "vitest";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
import { createTestBinarySession } from "../../fixtures/binarySession.js";
import { observed } from "../../fixtures/analysisExecution.js";
import { createServer } from "../../../src/server/createServer.js";
import { createAnalysisProfile } from "../../../src/domain/analysisProfile.js";
import { parseAnalysisSnapshot } from "../../../src/domain/analysisSnapshot.js";
import { ProviderCleanupError } from "../../../src/domain/providerCleanupError.js";
import { err, ok } from "../../../src/domain/result.js";

it.each([true, false])("snapshot receipt with closeFails=%s", async (closeFails) => {
  const directory = await createTestTempDirectory("rea-receipt-verification-");
  const target = join(directory, "fixture.hop");
  const snapshotPath = join(directory, "analysis.json");
  await writeFile(target, "synthetic provider input");
  const identity = { id: "fixture", name: "Fixture analysis provider", version: "1" } as const;
  const session = createTestBinarySession({
    identity: () => identity,
    resolveAnalysisProfile: async () => ok({ profile: createAnalysisProfile(identity, { fixture: true }) }),
    capabilities: () => [{
      provider: identity, operation: "address_name", available: true, reason: null,
      pagination: "none", exhaustive: true,
      effects: { mutatesArtifact: false, launchesProcess: false, mayShowUi: false,
        mayAccessNetwork: false, mayWriteFilesystem: false, changesPermissions: false, requiresRoot: false },
      limits: { maxResults: null, maxPayloadBytes: null, timeoutMs: null }, limitations: [],
    }],
    createClient: () => ({
      execute: async () => observed("entry"),
      close: async () => closeFails
        ? err(new ProviderCleanupError("fixture", ["fixture-document"], { reason: "synthetic close failure" }))
        : ok(null),
    }),
  });
  const server = createServer({ kind: "session", session });
  const client = new Client({ name: "receipt-verification", version: "1" });
  const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
  const payload = (response: any) => JSON.parse(response.content[0].text);
  try {
    await server.connect(serverTransport);
    await client.connect(clientTransport);
    expect((await client.callTool({ name: "open_binary", arguments: { path: target } })).isError).not.toBe(true);
    expect((await client.callTool({ name: "address_name", arguments: { address: "0x1000" } })).isError).not.toBe(true);
    const closed = await client.callTool({ name: "close_binary", arguments: { snapshot_path: snapshotPath } });
    const bytes = Buffer.byteLength(await readFile(snapshotPath));
    const snapshot = parseAnalysisSnapshot(JSON.parse(await readFile(snapshotPath, "utf8")));
    expect(snapshot.evidence_bundle.records).toHaveLength(1);
    if (closeFails) {
      expect(closed.isError).toBe(true);
      expect(payload(closed).error).toMatchObject({ code: "cleanup_incomplete", details: { operation: "close_binary", cleanup: "incomplete" } });
      expect(JSON.stringify(closed)).not.toContain(snapshotPath);
      const status = await session.status();
      expect(status).toMatchObject({ open: false, cleanup_pending: true });
      const retry = await client.callTool({ name: "close_binary", arguments: { snapshot_path: snapshotPath } });
      expect(payload(retry).error.code).toBe("target_unavailable");
      console.log(JSON.stringify({ closeFails, response: payload(closed), published: { bytes, evidence_records: 1 }, session: { open: status.open, cleanup_pending: status.cleanup_pending }, retry: payload(retry) }));
    } else {
      expect(closed.isError).not.toBe(true);
      expect(closed.structuredContent).toMatchObject({ result: { path: snapshotPath, bytes, evidence_records: 1 } });
      console.log(JSON.stringify({ closeFails, receipt: { ...((closed.structuredContent as any).result), path: "$WORK/analysis.json" } }));
    }
  } finally {
    await client.close();
    await server.close();
  }
});
TEST
npx vitest run tests/boundary/mcp/snapshotCloseReceipt.test.ts
rm tests/boundary/mcp/snapshotCloseReceipt.test.ts

The verifier checkout already had locked dependencies; npm ci above is fresh-checkout preparation. The runtime build and focused test were executed. Both cases pass by asserting the current behavior; the successful case verifies the receipt against the actual file length. The temporary test was deleted after execution.

Expected behavior

The failure must retain the completed snapshot publication receipt alongside the cleanup failure and unresolved resources. docs/tool-design.md:147-151 requires completed observations to survive later finalization failures through typed failure outcomes, and :195-196 requires preservation of a committed output's publication receipt. src/contracts/sessionToolContracts.ts:77-79 orders optional snapshot publication before provider close; docs/cli.md:534-539 documents the separate MCP receipt counts. A failed provider close must remain a failure and retain cleanup ownership.

Impact and scope

P2: callers lose the completed-save fact from the MCP result and cannot recover it by repeating the same snapshot-close request. The saved snapshot and its Evidence survive. Reading and parsing the supplied destination worked as an out-of-band workaround. This is one controlled provider-close failure plus a successful-close control at the stated commit; real-provider failure frequency, other operating systems and physical retained resources were not measured. No input corruption or security boundary crossing was observed.

REA version

Exact node scripts/rea.mjs --version output: 6.8.0. Source commit: c79bd41b5d6d65b7b2a9b6e0b58d376fc36910d6.

Runtime and platform

Node.js v24.22.1; Darwin arm64; macOS 27.0.1. Official MCP SDK client with linked in-memory transports; no Hopper, Ghidra or other external engine was used.

Target and artifact context

Synthetic .hop fixture accepted by the selected test provider, a concrete canonical provider profile, one address_name observation and a provider-neutral analysis snapshot. The target file is fixture data and is never executed.

Interface

MCP

Earliest failure stage

Cleanup, cancellation, or lifecycle

Relevant output or evidence

The focused run passed two assertions-based cases. Reduced observed output, with the caller-selected path normalized:

{
  "failed_close": {
    "isError": true,
    "error": {
      "code": "cleanup_incomplete",
      "details": {
        "provider_id": "fixture",
        "operation": "close_binary",
        "cleanup": "incomplete",
        "resources": ["fixture-document"],
        "diagnostics": {"reason": "synthetic close failure"},
        "execution_failure": "execution_failure"
      }
    },
    "saved_file": {"bytes": 1795, "evidence_records": 1},
    "session": {"open": false, "cleanup_pending": true},
    "snapshot_close_retry": "target_unavailable"
  },
  "successful_close_receipt": {
    "path": "$WORK/analysis.json",
    "bytes": 1795,
    "primitive_entries": 0,
    "workflow_entries": 0,
    "evidence_records": 1
  }
}

The failed response contained no occurrence of the snapshot destination anywhere, rather than merely omitting a proposed property name. The byte count is the measured fixture size; generated paths can change it. The snapshot parser accepted the actual file and its Evidence bundle.

  • Expected source: lifecycle preservation and snapshot contract passages cited above, at the stated commit.
  • Failure source: observed registered MCP replies; src/application/binary/BinarySession.ts:812-835 writes the snapshot and then returns the close error without the saved receipt; src/server/registerCloseLifecycleTool.ts:91-107 projects that failure unchanged.
  • Evidence provenance: observed SDK/MCP integration and real snapshot-file readback with a controlled provider; source-derived explanation of the failure branch.
  • Local verification: reproduced.
  • Reproduction completeness: complete for the provider-close fixture case.

Typed outcome or recovery expected

Keep cleanup_incomplete and the unresolved provider resource, while identifying the already-published snapshot and its actual byte and record counts in the typed failure. Preserve the successful-close receipt. Do not return success or clear pending cleanup to hide the failure. The contract does not prescribe a particular nesting for the retained receipt.

Analysis or suspected cause

Source-derived: BinarySession.closeWithSnapshot retains written.value only in the successful-close branch. The failure branch returns closed after publication, losing the saved output fact before MCP projection. The one-shot sibling in src/application/DirectAnalysis.ts:548-559 explicitly retains diagnostics.analysis_snapshot when finalization fails; tests/composition/directAnalysisCleanup.test.ts:344-413 covers that policy. Those CLI cases were inspected as source, not executed in this reproduction.

Additional context

Closed #1248 and merged #1265 concern separate counts on successful snapshot receipts. Closed #4277 concerns pending snapshot-staging cleanup visibility. Closed #5163 concerns combining two concurrent cleanup failures. These have different failure conditions and closure signals; here snapshot publication and process-resource cleanup succeed before the provider-close failure removes the receipt. No exact open duplicate was found. The first affected release is unknown.

Originally created by @coygeek on GitHub (Oct 27, 2026). Original GitHub issue: https://github.com/morluto/rea/issues/7427 ## Before submitting - [x] I searched the open and closed issues for a related report. - [x] I removed credentials, tokens, private paths, and sensitive target data. ## Area MCP or agent integration ## Observed behavior A registered MCP `close_binary` request with `snapshot_path` publishes a valid snapshot, then returns only `cleanup_incomplete` when provider close fails. The response contains the retained provider resource but no published path, byte count or record counts. Reading the caller-selected file confirms that the snapshot exists and contains the observation. Repeating the snapshot-close request returns `target_unavailable` because the active target has already been cleared, while session status reports `cleanup_pending: true`. ## Reproduction The following production-server boundary probe ran once per case at the stated commit. Only the provider profile, analysis result and provider-close outcome are synthetic; the session, snapshot writer, MCP handler and SDK result projection run normally. It asserts the currently observed bug and a successful-close control. No external analysis engine starts. From a fresh checkout with Node 24.22.1, install/build and run: ```sh export PATH="$(mise where node@24.22.1)/bin:$PATH" WORK="$(mktemp -d)" export WORK mkdir -p "$WORK"/{home,config,data,cache,state,tmp} export HOME="$WORK/home" XDG_CONFIG_HOME="$WORK/config" XDG_DATA_HOME="$WORK/data" export XDG_CACHE_HOME="$WORK/cache" XDG_STATE_HOME="$WORK/state" TMPDIR="$WORK/tmp" npm ci && npm run build:cached cat > tests/boundary/mcp/snapshotCloseReceipt.test.ts <<'TEST' import { readFile, writeFile } from "node:fs/promises"; import { join } from "node:path"; import { Client, InMemoryTransport } from "@modelcontextprotocol/client"; import { expect, it } from "vitest"; import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; import { createTestBinarySession } from "../../fixtures/binarySession.js"; import { observed } from "../../fixtures/analysisExecution.js"; import { createServer } from "../../../src/server/createServer.js"; import { createAnalysisProfile } from "../../../src/domain/analysisProfile.js"; import { parseAnalysisSnapshot } from "../../../src/domain/analysisSnapshot.js"; import { ProviderCleanupError } from "../../../src/domain/providerCleanupError.js"; import { err, ok } from "../../../src/domain/result.js"; it.each([true, false])("snapshot receipt with closeFails=%s", async (closeFails) => { const directory = await createTestTempDirectory("rea-receipt-verification-"); const target = join(directory, "fixture.hop"); const snapshotPath = join(directory, "analysis.json"); await writeFile(target, "synthetic provider input"); const identity = { id: "fixture", name: "Fixture analysis provider", version: "1" } as const; const session = createTestBinarySession({ identity: () => identity, resolveAnalysisProfile: async () => ok({ profile: createAnalysisProfile(identity, { fixture: true }) }), capabilities: () => [{ provider: identity, operation: "address_name", available: true, reason: null, pagination: "none", exhaustive: true, effects: { mutatesArtifact: false, launchesProcess: false, mayShowUi: false, mayAccessNetwork: false, mayWriteFilesystem: false, changesPermissions: false, requiresRoot: false }, limits: { maxResults: null, maxPayloadBytes: null, timeoutMs: null }, limitations: [], }], createClient: () => ({ execute: async () => observed("entry"), close: async () => closeFails ? err(new ProviderCleanupError("fixture", ["fixture-document"], { reason: "synthetic close failure" })) : ok(null), }), }); const server = createServer({ kind: "session", session }); const client = new Client({ name: "receipt-verification", version: "1" }); const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); const payload = (response: any) => JSON.parse(response.content[0].text); try { await server.connect(serverTransport); await client.connect(clientTransport); expect((await client.callTool({ name: "open_binary", arguments: { path: target } })).isError).not.toBe(true); expect((await client.callTool({ name: "address_name", arguments: { address: "0x1000" } })).isError).not.toBe(true); const closed = await client.callTool({ name: "close_binary", arguments: { snapshot_path: snapshotPath } }); const bytes = Buffer.byteLength(await readFile(snapshotPath)); const snapshot = parseAnalysisSnapshot(JSON.parse(await readFile(snapshotPath, "utf8"))); expect(snapshot.evidence_bundle.records).toHaveLength(1); if (closeFails) { expect(closed.isError).toBe(true); expect(payload(closed).error).toMatchObject({ code: "cleanup_incomplete", details: { operation: "close_binary", cleanup: "incomplete" } }); expect(JSON.stringify(closed)).not.toContain(snapshotPath); const status = await session.status(); expect(status).toMatchObject({ open: false, cleanup_pending: true }); const retry = await client.callTool({ name: "close_binary", arguments: { snapshot_path: snapshotPath } }); expect(payload(retry).error.code).toBe("target_unavailable"); console.log(JSON.stringify({ closeFails, response: payload(closed), published: { bytes, evidence_records: 1 }, session: { open: status.open, cleanup_pending: status.cleanup_pending }, retry: payload(retry) })); } else { expect(closed.isError).not.toBe(true); expect(closed.structuredContent).toMatchObject({ result: { path: snapshotPath, bytes, evidence_records: 1 } }); console.log(JSON.stringify({ closeFails, receipt: { ...((closed.structuredContent as any).result), path: "$WORK/analysis.json" } })); } } finally { await client.close(); await server.close(); } }); TEST npx vitest run tests/boundary/mcp/snapshotCloseReceipt.test.ts rm tests/boundary/mcp/snapshotCloseReceipt.test.ts ``` The verifier checkout already had locked dependencies; `npm ci` above is fresh-checkout preparation. The runtime build and focused test were executed. Both cases pass by asserting the current behavior; the successful case verifies the receipt against the actual file length. The temporary test was deleted after execution. ## Expected behavior The failure must retain the completed snapshot publication receipt alongside the cleanup failure and unresolved resources. `docs/tool-design.md:147-151` requires completed observations to survive later finalization failures through typed failure outcomes, and `:195-196` requires preservation of a committed output's publication receipt. `src/contracts/sessionToolContracts.ts:77-79` orders optional snapshot publication before provider close; `docs/cli.md:534-539` documents the separate MCP receipt counts. A failed provider close must remain a failure and retain cleanup ownership. ## Impact and scope P2: callers lose the completed-save fact from the MCP result and cannot recover it by repeating the same snapshot-close request. The saved snapshot and its Evidence survive. Reading and parsing the supplied destination worked as an out-of-band workaround. This is one controlled provider-close failure plus a successful-close control at the stated commit; real-provider failure frequency, other operating systems and physical retained resources were not measured. No input corruption or security boundary crossing was observed. ## REA version Exact `node scripts/rea.mjs --version` output: `6.8.0`. Source commit: `c79bd41b5d6d65b7b2a9b6e0b58d376fc36910d6`. ## Runtime and platform Node.js `v24.22.1`; Darwin arm64; macOS `27.0.1`. Official MCP SDK client with linked in-memory transports; no Hopper, Ghidra or other external engine was used. ## Target and artifact context Synthetic `.hop` fixture accepted by the selected test provider, a concrete canonical provider profile, one `address_name` observation and a provider-neutral analysis snapshot. The target file is fixture data and is never executed. ## Interface MCP ## Earliest failure stage Cleanup, cancellation, or lifecycle ## Relevant output or evidence The focused run passed two assertions-based cases. Reduced observed output, with the caller-selected path normalized: ```json { "failed_close": { "isError": true, "error": { "code": "cleanup_incomplete", "details": { "provider_id": "fixture", "operation": "close_binary", "cleanup": "incomplete", "resources": ["fixture-document"], "diagnostics": {"reason": "synthetic close failure"}, "execution_failure": "execution_failure" } }, "saved_file": {"bytes": 1795, "evidence_records": 1}, "session": {"open": false, "cleanup_pending": true}, "snapshot_close_retry": "target_unavailable" }, "successful_close_receipt": { "path": "$WORK/analysis.json", "bytes": 1795, "primitive_entries": 0, "workflow_entries": 0, "evidence_records": 1 } } ``` The failed response contained no occurrence of the snapshot destination anywhere, rather than merely omitting a proposed property name. The byte count is the measured fixture size; generated paths can change it. The snapshot parser accepted the actual file and its Evidence bundle. - Expected source: lifecycle preservation and snapshot contract passages cited above, at the stated commit. - Failure source: observed registered MCP replies; `src/application/binary/BinarySession.ts:812-835` writes the snapshot and then returns the close error without the saved receipt; `src/server/registerCloseLifecycleTool.ts:91-107` projects that failure unchanged. - Evidence provenance: observed SDK/MCP integration and real snapshot-file readback with a controlled provider; source-derived explanation of the failure branch. - Local verification: reproduced. - Reproduction completeness: complete for the provider-close fixture case. ## Typed outcome or recovery expected Keep `cleanup_incomplete` and the unresolved provider resource, while identifying the already-published snapshot and its actual byte and record counts in the typed failure. Preserve the successful-close receipt. Do not return success or clear pending cleanup to hide the failure. The contract does not prescribe a particular nesting for the retained receipt. ## Analysis or suspected cause Source-derived: `BinarySession.closeWithSnapshot` retains `written.value` only in the successful-close branch. The failure branch returns `closed` after publication, losing the saved output fact before MCP projection. The one-shot sibling in `src/application/DirectAnalysis.ts:548-559` explicitly retains `diagnostics.analysis_snapshot` when finalization fails; `tests/composition/directAnalysisCleanup.test.ts:344-413` covers that policy. Those CLI cases were inspected as source, not executed in this reproduction. ## Additional context [Closed #1248](https://github.com/morluto/rea/issues/1248) and [merged #1265](https://github.com/morluto/rea/pull/1265) concern separate counts on successful snapshot receipts. [Closed #4277](https://github.com/morluto/rea/issues/4277) concerns pending snapshot-staging cleanup visibility. [Closed #5163](https://github.com/morluto/rea/issues/5163) concerns combining two concurrent cleanup failures. These have different failure conditions and closure signals; here snapshot publication and process-resource cleanup succeed before the provider-close failure removes the receipt. No exact open duplicate was found. The first affected release is unknown.
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
morluto/rea#4433
No description provided.