[GH-ISSUE #6051] [Bug] Kuna staging-retirement failure hides an already published export #3698

Closed
opened 2026-10-31 00:43:10 +00:00 by bsakaguchi · 1 comment
Owner

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

Before submitting

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

Area

Native analysis or decompilation

Observed behavior

A controlled staging-retirement refusal after successful publication makes procedure_assembly return ProviderAdapterError with incomplete cleanup and no completed export receipt. The published project remains readable on disk. Removing the refusal does not restore assembly in the same client because it retains the failed build result; closing and reopening the client successfully reuses the published export.

Reproduction

Save this test as tests/boundary/providers/kuna/kunaExportRetirement.test.ts in the source checkout with locked dependencies installed, then run:

node node_modules/vitest/vitest.mjs run tests/boundary/providers/kuna/kunaExportRetirement.test.ts --maxWorkers=1 --reporter=verbose

The test executes the real provider session, cache and reader with the repository's simulated Kuna executable and controlled filesystem faults. It does not exercise a real Kuna installation or external CLI/MCP transport. Assertions describe the observed current behavior.

import { open, readdir } from 'node:fs/promises';
import { join } from 'node:path';
import { afterEach, expect, it, vi } from 'vitest';
import { run, session } from './kunaSessionFixture.js';

const fault = vi.hoisted(() => ({
  retire: false, span: '', short: false, reads: 0, closes: 0,
  handles: [] as import('node:fs/promises').FileHandle[],
  cause: Object.assign(new Error('controlled close refusal'), { code: 'EIO' }),
}));
vi.mock('node:fs/promises', async (importOriginal) => {
  const actual = await importOriginal<typeof import('node:fs/promises')>();
  return {
    ...actual,
    rename: async (...args: Parameters<typeof actual.rename>) => {
      if (fault.retire && String(args[0]).includes('/staging/build-') && String(args[1]).includes('/trash/build-'))
        throw Object.assign(new Error('controlled staging retirement refusal'), { code: 'EACCES' });
      return actual.rename(...args);
    },
    open: async (...args: Parameters<typeof actual.open>) => {
      const handle = await actual.open(...args);
      if (String(args[0]) !== fault.span) return handle;
      fault.handles.push(handle);
      return new Proxy(handle, {
        get(target, key) {
          if (key === 'read') return async (...readArgs: unknown[]) => {
            const read = await Reflect.apply(target.read, target, readArgs);
            fault.reads++;
            return fault.short ? { ...read, bytesRead: 0 } : read;
          };
          if (key === 'close') return async () => { fault.closes++; throw fault.cause; };
          const value = Reflect.get(target, key, target);
          return typeof value === 'function' ? value.bind(target) : value;
        },
      });
    },
  };
});
afterEach(async () => {
  fault.retire = false; fault.span = ''; fault.short = false;
  for (const handle of fault.handles) await handle.close();
  fault.handles = []; fault.reads = 0; fault.closes = 0;
});

it('loses a published export result when staging retirement fails', async () => {
  const { cacheRoot, open: createClient } = await session('rea-kuna-retire-result-');
  const client = createClient();
  try {
    fault.retire = true;
    const first = await client.execute('procedure_assembly', { procedure: 'main' }, {});
    expect(first.ok).toBe(false);
    if (first.ok) throw new Error('Expected failure');
    expect(first.error.cleanupIncomplete).toBe(true);
    expect(JSON.stringify(first.error)).not.toContain('project-export');
    const projects = await readdir(join(cacheRoot, 'projects'));
    expect(projects).toHaveLength(1);
    const asm = await open(join(cacheRoot, 'projects', projects[0]!, 'project', 'program.asm'), 'r');
    const text = await asm.readFile('utf8'); await asm.close();
    expect(text).toContain('RET');
    console.log(JSON.stringify({ case: 'retire', resultTag: first.error._tag, cleanupIncomplete: first.error.cleanupIncomplete, publishedProjects: projects.length, publishedAssemblyReadable: text.includes('RET') }));
    fault.retire = false;
    const repeated = await client.execute('procedure_assembly', { procedure: 'main' }, {});
    expect(repeated.ok).toBe(false);
    expect(await client.close()).toEqual({ ok: true, value: null });
    const next = createClient();
    try {
      const recovered = await run(next, 'procedure_assembly', { procedure: 'main' });
      expect(recovered.rawResult).toMatchObject({ source: { kind: 'project-export' } });
    } finally { await next.close(); }
  } finally { fault.retire = false; await client.close(); }
});

Expected behavior

Retain the completed project's publication receipt or partial observation alongside any incomplete-cleanup error. A cleanup failure may still fail the request, but it should preserve the completed export and a recovery action. This follows the atomic-publication and completed-observation contract.

Impact and scope

P2: a usable completed export becomes unavailable through the current analysis session after a bounded cleanup fault. Close/reopen successfully reused the published export in the control. Cleanup remained retained and was successfully recovered; no output deletion, actual repeated native decompilation, or natural failure frequency was measured.

REA version

Source revision 52415975942a4b9c53a817915b313ccc404f280e; package version 6.7.0.

Runtime and platform

Node v26.12.0; macOS 27.0.1; arm64. Simulated Kuna executable using the repository's existing fixture; no real Kuna analysis.

Target and artifact context

Synthetic target and generated C/assembly exports supplied by the existing Kuna session fixture. The injected EACCES affects only the staging-to-trash rename after successful publication; publication and reads use the real filesystem.

Interface

Other

Earliest failure stage

Cleanup, cancellation, or lifecycle

Relevant output or evidence

One focused control produced:

{"case":"retire","resultTag":"ProviderAdapterError","cleanupIncomplete":true,"publishedProjects":1,"publishedAssemblyReadable":true}

The repeated same-client assembly request still failed after the fault was disabled; after close/reopen, assembly returned source.kind: "project-export". Source: publication completes before return; retirement replaces that result.

Typed outcome or recovery expected

Keep the published project identity/location available in the typed cleanup outcome and preserve its successful build observation. Retry only the retained cleanup; do not require the caller to infer whether the export exists.

Analysis or suspected cause

Source-derived: #exportAndPublish returns the completed project, but #produce returns only err(retired) if staging retirement fails. Session adoption then stores an export failure instead of adopting that project.

Additional context

Upstream main was fetched and the relevant Kuna source files were unchanged. Related #4227 is a broad audit; #4704 concerns failed workspace rollback before analysis, #4702 concerns process/workspace error resolution, and #5105 concerns doctor/setup readiness. PR #4070 changes advice for genuinely failed exports; it does not preserve an already committed project on retirement failure. No known regression range or real-engine incidence was established.

Originally created by @coygeek on GitHub (Oct 21, 2026). Original GitHub issue: https://github.com/morluto/rea/issues/6051 ### 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 Native analysis or decompilation ### Observed behavior A controlled staging-retirement refusal after successful publication makes `procedure_assembly` return `ProviderAdapterError` with incomplete cleanup and no completed export receipt. The published project remains readable on disk. Removing the refusal does not restore assembly in the same client because it retains the failed build result; closing and reopening the client successfully reuses the published export. ### Reproduction Save this test as `tests/boundary/providers/kuna/kunaExportRetirement.test.ts` in the source checkout with locked dependencies installed, then run: ```sh node node_modules/vitest/vitest.mjs run tests/boundary/providers/kuna/kunaExportRetirement.test.ts --maxWorkers=1 --reporter=verbose ``` The test executes the real provider session, cache and reader with the repository's simulated Kuna executable and controlled filesystem faults. It does not exercise a real Kuna installation or external CLI/MCP transport. Assertions describe the observed current behavior. ```ts import { open, readdir } from 'node:fs/promises'; import { join } from 'node:path'; import { afterEach, expect, it, vi } from 'vitest'; import { run, session } from './kunaSessionFixture.js'; const fault = vi.hoisted(() => ({ retire: false, span: '', short: false, reads: 0, closes: 0, handles: [] as import('node:fs/promises').FileHandle[], cause: Object.assign(new Error('controlled close refusal'), { code: 'EIO' }), })); vi.mock('node:fs/promises', async (importOriginal) => { const actual = await importOriginal<typeof import('node:fs/promises')>(); return { ...actual, rename: async (...args: Parameters<typeof actual.rename>) => { if (fault.retire && String(args[0]).includes('/staging/build-') && String(args[1]).includes('/trash/build-')) throw Object.assign(new Error('controlled staging retirement refusal'), { code: 'EACCES' }); return actual.rename(...args); }, open: async (...args: Parameters<typeof actual.open>) => { const handle = await actual.open(...args); if (String(args[0]) !== fault.span) return handle; fault.handles.push(handle); return new Proxy(handle, { get(target, key) { if (key === 'read') return async (...readArgs: unknown[]) => { const read = await Reflect.apply(target.read, target, readArgs); fault.reads++; return fault.short ? { ...read, bytesRead: 0 } : read; }; if (key === 'close') return async () => { fault.closes++; throw fault.cause; }; const value = Reflect.get(target, key, target); return typeof value === 'function' ? value.bind(target) : value; }, }); }, }; }); afterEach(async () => { fault.retire = false; fault.span = ''; fault.short = false; for (const handle of fault.handles) await handle.close(); fault.handles = []; fault.reads = 0; fault.closes = 0; }); it('loses a published export result when staging retirement fails', async () => { const { cacheRoot, open: createClient } = await session('rea-kuna-retire-result-'); const client = createClient(); try { fault.retire = true; const first = await client.execute('procedure_assembly', { procedure: 'main' }, {}); expect(first.ok).toBe(false); if (first.ok) throw new Error('Expected failure'); expect(first.error.cleanupIncomplete).toBe(true); expect(JSON.stringify(first.error)).not.toContain('project-export'); const projects = await readdir(join(cacheRoot, 'projects')); expect(projects).toHaveLength(1); const asm = await open(join(cacheRoot, 'projects', projects[0]!, 'project', 'program.asm'), 'r'); const text = await asm.readFile('utf8'); await asm.close(); expect(text).toContain('RET'); console.log(JSON.stringify({ case: 'retire', resultTag: first.error._tag, cleanupIncomplete: first.error.cleanupIncomplete, publishedProjects: projects.length, publishedAssemblyReadable: text.includes('RET') })); fault.retire = false; const repeated = await client.execute('procedure_assembly', { procedure: 'main' }, {}); expect(repeated.ok).toBe(false); expect(await client.close()).toEqual({ ok: true, value: null }); const next = createClient(); try { const recovered = await run(next, 'procedure_assembly', { procedure: 'main' }); expect(recovered.rawResult).toMatchObject({ source: { kind: 'project-export' } }); } finally { await next.close(); } } finally { fault.retire = false; await client.close(); } }); ``` ### Expected behavior Retain the completed project's publication receipt or partial observation alongside any incomplete-cleanup error. A cleanup failure may still fail the request, but it should preserve the completed export and a recovery action. This follows the [atomic-publication and completed-observation contract](https://github.com/morluto/rea/blob/52415975942a4b9c53a817915b313ccc404f280e/docs/tool-design.md#L112-L139). ### Impact and scope P2: a usable completed export becomes unavailable through the current analysis session after a bounded cleanup fault. Close/reopen successfully reused the published export in the control. Cleanup remained retained and was successfully recovered; no output deletion, actual repeated native decompilation, or natural failure frequency was measured. ### REA version Source revision `52415975942a4b9c53a817915b313ccc404f280e`; package version 6.7.0. ### Runtime and platform Node v26.12.0; macOS 27.0.1; arm64. Simulated Kuna executable using the repository's existing fixture; no real Kuna analysis. ### Target and artifact context Synthetic target and generated C/assembly exports supplied by the existing Kuna session fixture. The injected EACCES affects only the staging-to-trash rename after successful publication; publication and reads use the real filesystem. ### Interface Other ### Earliest failure stage Cleanup, cancellation, or lifecycle ### Relevant output or evidence One focused control produced: ```json {"case":"retire","resultTag":"ProviderAdapterError","cleanupIncomplete":true,"publishedProjects":1,"publishedAssemblyReadable":true} ``` The repeated same-client assembly request still failed after the fault was disabled; after close/reopen, assembly returned `source.kind: "project-export"`. Source: [publication completes before return](https://github.com/morluto/rea/blob/52415975942a4b9c53a817915b313ccc404f280e/src/kuna/KunaProjectCache.ts#L876-L893); [retirement replaces that result](https://github.com/morluto/rea/blob/52415975942a4b9c53a817915b313ccc404f280e/src/kuna/KunaProjectCache.ts#L776-L783). ### Typed outcome or recovery expected Keep the published project identity/location available in the typed cleanup outcome and preserve its successful build observation. Retry only the retained cleanup; do not require the caller to infer whether the export exists. ### Analysis or suspected cause Source-derived: `#exportAndPublish` returns the completed project, but `#produce` returns only `err(retired)` if staging retirement fails. [Session adoption](https://github.com/morluto/rea/blob/52415975942a4b9c53a817915b313ccc404f280e/src/kuna/KunaSessionClient.ts#L1491-L1508) then stores an export failure instead of adopting that project. ### Additional context Upstream main was fetched and the relevant Kuna source files were unchanged. Related #4227 is a broad audit; #4704 concerns failed workspace rollback before analysis, #4702 concerns process/workspace error resolution, and #5105 concerns doctor/setup readiness. PR #4070 changes advice for genuinely failed exports; it does not preserve an already committed project on retirement failure. No known regression range or real-engine incidence was established.
Author
Owner

@morluto commented on GitHub (Oct 23, 2026):

Fixed in bbf7943ba. Published Kuna exports retain their completed receipt separately from unresolved staging retirement. The same session reuses the published export without another decompile while pending cleanup remains visible and retryable. Verified by tests/boundary/providers/kuna/kunaPublicationCleanup.test.ts. Verification used the real cache/session and a simulated Kuna executable with controlled filesystem refusal; no real Kuna run is claimed.

<!-- gh-comment-id:6376074093 --> @morluto commented on GitHub (Oct 23, 2026): Fixed in [bbf7943ba](https://github.com/morluto/rea/commit/bbf7943ba6536f22473b50297bb54355f1eca63b). Published Kuna exports retain their completed receipt separately from unresolved staging retirement. The same session reuses the published export without another decompile while pending cleanup remains visible and retryable. Verified by `tests/boundary/providers/kuna/kunaPublicationCleanup.test.ts`. Verification used the real cache/session and a simulated Kuna executable with controlled filesystem refusal; no real Kuna run is claimed.
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#3698
No description provided.