[GH-ISSUE #6043] [Bug] Web script export reports I/O failure after cancelled capture close recovers #3688

Closed
opened 2026-10-31 00:43:05 +00:00 by bsakaguchi · 0 comments
Owner

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

Before submitting

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

Area

Evidence, artifacts, or reconstruction

Observed behavior

After a valid capture is fully read, cancellation during a failed first handle close is lost when the real close succeeds on retry. The export returns ArtifactOperationError / artifact_operation_failed, with the close rejection as its cause, rather than cancellation with the caller reason. The handle closes after two attempts, no owner remains pending, and no output directory is created. Cancellation during an immediately successful close and ordinary read cancellation both return the correct AnalysisCancelledError with exact cause.

Reproduction

In a source checkout with locked dependencies, save the following executed subset of the focused test as tests/boundary/filesystem/exportCaptureCancellation.repro.test.ts and run:

node node_modules/vitest/vitest.mjs run tests/boundary/filesystem/exportCaptureCancellation.repro.test.ts --maxWorkers 1

The complete focused test passed six observation/control cases on each runtime. These four cases assert the current behavior. Only the first capture-handle close rejection and abort timing are injected; reading, owner retention, retry and the successful real close use production code. No external provider is involved.

import { access, mkdtemp, open as fsOpen, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { afterEach, expect, it, vi } from 'vitest';
import { exportWebScripts } from '../../../src/application/WebScriptExportService.js';
import { ArtifactResourceScope } from '../../../src/artifacts/ArtifactResourceScope.js';
import { SafeOutputTree } from '../../../src/artifacts/SafeOutputTree.js';
import { ArtifactReaderFailure } from '../../../src/artifacts/ArtifactReader.js';
import { projectAnalysisError } from '../../../src/domain/analysisErrorProjection.js';
import { scriptScenarioFixture } from '../../fixtures/webScriptCapture.js';

vi.mock('node:fs/promises', async importOriginal => {
  const actual = await importOriginal<typeof import('node:fs/promises')>();
  return { ...actual, open: vi.fn(actual.open) };
});
afterEach(() => vi.restoreAllMocks());

async function setup() {
  const root = await mkdtemp(join(tmpdir(), 'rea-cancel-'));
  const resources = new ArtifactResourceScope();
  const input = { capture_path: join(root, 'capture.json'), output_directory: join(root, 'out') };
  await writeFile(input.capture_path, JSON.stringify(scriptScenarioFixture([
    { url: 'https://fixture.test/first.js', bytes: Buffer.from('export const a = 1;') },
    { url: 'https://fixture.test/second.js', bytes: Buffer.from('export const b = 2;') },
  ])));
  return {root, resources, input};
}

it.each(['abort-close-retry', 'abort-close-success', 'close-retry-no-abort', 'abort-read'] as const)('capture cancellation: %s', async stage => {
  const { root, resources, input } = await setup();
  const actual = await vi.importActual<typeof import('node:fs/promises')>('node:fs/promises');
  const controller = new AbortController();
  const reason = { request:'stop export', stage };
  let closeCalls = 0;
  vi.mocked(fsOpen).mockImplementation(async (...args) => {
    const handle = await actual.open(...args);
    if (String(args[0]) !== input.capture_path) return handle;
    if (stage === 'abort-read') {
      const read = handle.read.bind(handle);
      vi.spyOn(handle,'read').mockImplementationOnce(async (...readArgs: any[]) => {
        const value = await (read as any)(...readArgs);
        controller.abort(reason);
        return value;
      });
    }
    const close = handle.close.bind(handle);
    vi.spyOn(handle, 'close').mockImplementation(async () => {
      closeCalls++;
      if (closeCalls === 1) {
        if (stage === 'abort-close-retry' || stage === 'abort-close-success') controller.abort(reason);
        if (stage === 'abort-close-retry' || stage === 'close-retry-no-abort') throw new Error('controlled first close rejection');
      }
      await close();
    });
    return handle;
  });
  try {
    const result = await exportWebScripts(input, resources, {signal:controller.signal});
    if (result.ok) throw new Error('Expected failure');
    const p = projectAnalysisError(result.error);
    if (stage === 'abort-close-retry' || stage === 'close-retry-no-abort') {
      expect(result.error._tag).toBe('ArtifactOperationError');
      expect(p.code).toBe('artifact_operation_failed');
      expect(result.error.cause).not.toBe(reason);
      expect(closeCalls).toBe(2);
    } else {
      expect(result.error._tag).toBe('AnalysisCancelledError');
      expect(result.error.cause).toBe(reason);
      expect(p.code).toBe('cancelled');
      expect(closeCalls).toBe(1);
    }
    expect(resources.hasPending).toBe(false);
    await expect(access(input.output_directory)).rejects.toMatchObject({code:'ENOENT'});
    console.log('CAPTURE', JSON.stringify({stage,tag:result.error._tag,code:p.code,causeRetained:result.error.cause === reason,closeCalls,pending:resources.hasPending,partial:result.error.partialObservation !== undefined}));
  } finally {
    vi.mocked(fsOpen).mockImplementation((...args) => actual.open(...args));
    await resources.close();
    await rm(root,{recursive:true,force:true});
  }
});

Expected behavior

For a valid completed capture read whose close recovers, return typed cancellation and preserve signal.reason by identity. The lifecycle contract carries cancellation through awaited finalization, separately from cleanup ownership. The file owner explicitly records completionCancellation for this case, and the existing hash regression requires that fact to survive successful close retry. Preserve any independently established primary input or physical failure; this reproduction uses valid data and no read failure.

Impact and scope

P2: a caller that branches on typed results treats an intentional cancellation as an artifact execution failure despite completed cleanup. Exact cancellation provenance is also lost. The trigger requires cancellation coinciding with a transient capture-close rejection. The abort-with-retry case reproduced once on each runtime; successful-close and read-abort controls retained the reason. No output corruption or persistent descriptor leak was observed. Natural filesystem incidence, other platforms and public transport delivery were not measured.

REA version

rea-agents 6.7.0; source revision 52415975942a4b9c53a817915b313ccc404f280e.

Runtime and platform

macOS 27.0.1 arm64; Node.js v24.18.0 (repository pin) and supported v26.12.0; locked Vitest 5.0.3. Both runtime runs produced identical observations.

Target and artifact context

Synthetic retained browser-scenario capture with two JavaScript responses, read from a real local regular file. No captured code was fetched or executed.

Interface

Other

Earliest failure stage

Cleanup, cancellation, or lifecycle

Relevant output or evidence

Observed on both runtimes:

{"stage":"abort-close-retry","tag":"ArtifactOperationError","code":"artifact_operation_failed","causeRetained":false,"closeCalls":2,"pending":false,"partial":false}
{"stage":"abort-close-success","tag":"AnalysisCancelledError","code":"cancelled","causeRetained":true,"closeCalls":1,"pending":false,"partial":false}
{"stage":"close-retry-no-abort","tag":"ArtifactOperationError","code":"artifact_operation_failed","causeRetained":false,"closeCalls":2,"pending":false,"partial":false}
{"stage":"abort-read","tag":"AnalysisCancelledError","code":"cancelled","causeRetained":true,"closeCalls":1,"pending":false,"partial":false}

All cases asserted no output directory existed and no retained owner remained. The failed case reported the same artifact classification as the non-aborted close-retry control, despite the caller signal being aborted during the first close.

Typed outcome or recovery expected

AnalysisCancelledError for export_web_scripts, with the exact recorded completion-cancellation reason as its cause and cleanup complete after successful descriptor retry. Any unresolved cleanup must remain separately owned and visible.

Analysis or suspected cause

Source-derived: readCapture retries the retained handle, then reduces its completed outcome to bytes plus a close diagnostic without consulting completionCancellation or the regularFileCancellation accessor. prepareCapture parses the valid bytes and turns any such diagnostic into WebScriptExportError(io). The service early return bypasses the publication failure mapper, so fixing that mapper alone cannot restore this recorded fact.

Public reachability is source-derived from the MCP handler, which forwards its request signal to the shared service tested here. No CLI or MCP transport cancellation was executed.

Additional context

Related #6042 concerns the direct caller-reason identity after successful output rollback. This report instead concerns the recorded completion-cancellation fact discarded by readCapture/prepareCapture before output creation; it never reaches the publication failure mapper. The two reports and #5956 require separate boundary repairs.

#5956 concerns failed rollback after output acquisition; this case occurs before output creation and never enters PublishWebScripts. #6029 concerns a pre-existing output destination. Closed #4598 introduced/used producer facts to preserve physical digest failures separately from close cancellation; the file owner already has the fact needed here, but this export consumer drops it. #5332, #5935, #5944, #5987 and #5999 concern separate consumers or producers. Merged PR #1240 classifies capture selections, PR #5672 changes output-source ownership and PR #2068 adds worker capture metadata; none repairs this completed capture-close consumer.

Fetched origin/main at 6d356b84344659085f2b912857b5e27fe0160c9b and compared the relevant source: this reader, owner and service are unchanged. New resource close hooks and a projection refactor do not change this path; newer main was not executed. Invalid capture data plus late cancellation and a new parsed-script partial-result schema are outside the demonstrated claim.

Originally created by @coygeek on GitHub (Oct 21, 2026). Original GitHub issue: https://github.com/morluto/rea/issues/6043 ### 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 Evidence, artifacts, or reconstruction ### Observed behavior After a valid capture is fully read, cancellation during a failed first handle close is lost when the real close succeeds on retry. The export returns `ArtifactOperationError` / `artifact_operation_failed`, with the close rejection as its cause, rather than cancellation with the caller reason. The handle closes after two attempts, no owner remains pending, and no output directory is created. Cancellation during an immediately successful close and ordinary read cancellation both return the correct `AnalysisCancelledError` with exact cause. ### Reproduction In a source checkout with locked dependencies, save the following executed subset of the focused test as `tests/boundary/filesystem/exportCaptureCancellation.repro.test.ts` and run: ```sh node node_modules/vitest/vitest.mjs run tests/boundary/filesystem/exportCaptureCancellation.repro.test.ts --maxWorkers 1 ``` The complete focused test passed six observation/control cases on each runtime. These four cases assert the current behavior. Only the first capture-handle close rejection and abort timing are injected; reading, owner retention, retry and the successful real close use production code. No external provider is involved. ```ts import { access, mkdtemp, open as fsOpen, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { afterEach, expect, it, vi } from 'vitest'; import { exportWebScripts } from '../../../src/application/WebScriptExportService.js'; import { ArtifactResourceScope } from '../../../src/artifacts/ArtifactResourceScope.js'; import { SafeOutputTree } from '../../../src/artifacts/SafeOutputTree.js'; import { ArtifactReaderFailure } from '../../../src/artifacts/ArtifactReader.js'; import { projectAnalysisError } from '../../../src/domain/analysisErrorProjection.js'; import { scriptScenarioFixture } from '../../fixtures/webScriptCapture.js'; vi.mock('node:fs/promises', async importOriginal => { const actual = await importOriginal<typeof import('node:fs/promises')>(); return { ...actual, open: vi.fn(actual.open) }; }); afterEach(() => vi.restoreAllMocks()); async function setup() { const root = await mkdtemp(join(tmpdir(), 'rea-cancel-')); const resources = new ArtifactResourceScope(); const input = { capture_path: join(root, 'capture.json'), output_directory: join(root, 'out') }; await writeFile(input.capture_path, JSON.stringify(scriptScenarioFixture([ { url: 'https://fixture.test/first.js', bytes: Buffer.from('export const a = 1;') }, { url: 'https://fixture.test/second.js', bytes: Buffer.from('export const b = 2;') }, ]))); return {root, resources, input}; } it.each(['abort-close-retry', 'abort-close-success', 'close-retry-no-abort', 'abort-read'] as const)('capture cancellation: %s', async stage => { const { root, resources, input } = await setup(); const actual = await vi.importActual<typeof import('node:fs/promises')>('node:fs/promises'); const controller = new AbortController(); const reason = { request:'stop export', stage }; let closeCalls = 0; vi.mocked(fsOpen).mockImplementation(async (...args) => { const handle = await actual.open(...args); if (String(args[0]) !== input.capture_path) return handle; if (stage === 'abort-read') { const read = handle.read.bind(handle); vi.spyOn(handle,'read').mockImplementationOnce(async (...readArgs: any[]) => { const value = await (read as any)(...readArgs); controller.abort(reason); return value; }); } const close = handle.close.bind(handle); vi.spyOn(handle, 'close').mockImplementation(async () => { closeCalls++; if (closeCalls === 1) { if (stage === 'abort-close-retry' || stage === 'abort-close-success') controller.abort(reason); if (stage === 'abort-close-retry' || stage === 'close-retry-no-abort') throw new Error('controlled first close rejection'); } await close(); }); return handle; }); try { const result = await exportWebScripts(input, resources, {signal:controller.signal}); if (result.ok) throw new Error('Expected failure'); const p = projectAnalysisError(result.error); if (stage === 'abort-close-retry' || stage === 'close-retry-no-abort') { expect(result.error._tag).toBe('ArtifactOperationError'); expect(p.code).toBe('artifact_operation_failed'); expect(result.error.cause).not.toBe(reason); expect(closeCalls).toBe(2); } else { expect(result.error._tag).toBe('AnalysisCancelledError'); expect(result.error.cause).toBe(reason); expect(p.code).toBe('cancelled'); expect(closeCalls).toBe(1); } expect(resources.hasPending).toBe(false); await expect(access(input.output_directory)).rejects.toMatchObject({code:'ENOENT'}); console.log('CAPTURE', JSON.stringify({stage,tag:result.error._tag,code:p.code,causeRetained:result.error.cause === reason,closeCalls,pending:resources.hasPending,partial:result.error.partialObservation !== undefined})); } finally { vi.mocked(fsOpen).mockImplementation((...args) => actual.open(...args)); await resources.close(); await rm(root,{recursive:true,force:true}); } }); ``` ### Expected behavior For a valid completed capture read whose close recovers, return typed cancellation and preserve `signal.reason` by identity. The [lifecycle contract](https://github.com/morluto/rea/blob/52415975942a4b9c53a817915b313ccc404f280e/docs/tool-design.md#L101-L110) carries cancellation through awaited finalization, separately from cleanup ownership. The [file owner](https://github.com/morluto/rea/blob/52415975942a4b9c53a817915b313ccc404f280e/src/filesystem/OwnedRegularFile.ts#L177-L198) explicitly records completionCancellation for this case, and the [existing hash regression](https://github.com/morluto/rea/blob/52415975942a4b9c53a817915b313ccc404f280e/tests/boundary/filesystem/stableFileHashOwnership.test.ts#L593-L645) requires that fact to survive successful close retry. Preserve any independently established primary input or physical failure; this reproduction uses valid data and no read failure. ### Impact and scope P2: a caller that branches on typed results treats an intentional cancellation as an artifact execution failure despite completed cleanup. Exact cancellation provenance is also lost. The trigger requires cancellation coinciding with a transient capture-close rejection. The abort-with-retry case reproduced once on each runtime; successful-close and read-abort controls retained the reason. No output corruption or persistent descriptor leak was observed. Natural filesystem incidence, other platforms and public transport delivery were not measured. ### REA version rea-agents 6.7.0; source revision `52415975942a4b9c53a817915b313ccc404f280e`. ### Runtime and platform macOS 27.0.1 arm64; Node.js v24.18.0 (repository pin) and supported v26.12.0; locked Vitest 5.0.3. Both runtime runs produced identical observations. ### Target and artifact context Synthetic retained browser-scenario capture with two JavaScript responses, read from a real local regular file. No captured code was fetched or executed. ### Interface Other ### Earliest failure stage Cleanup, cancellation, or lifecycle ### Relevant output or evidence Observed on both runtimes: ```json {"stage":"abort-close-retry","tag":"ArtifactOperationError","code":"artifact_operation_failed","causeRetained":false,"closeCalls":2,"pending":false,"partial":false} {"stage":"abort-close-success","tag":"AnalysisCancelledError","code":"cancelled","causeRetained":true,"closeCalls":1,"pending":false,"partial":false} {"stage":"close-retry-no-abort","tag":"ArtifactOperationError","code":"artifact_operation_failed","causeRetained":false,"closeCalls":2,"pending":false,"partial":false} {"stage":"abort-read","tag":"AnalysisCancelledError","code":"cancelled","causeRetained":true,"closeCalls":1,"pending":false,"partial":false} ``` All cases asserted no output directory existed and no retained owner remained. The failed case reported the same artifact classification as the non-aborted close-retry control, despite the caller signal being aborted during the first close. ### Typed outcome or recovery expected `AnalysisCancelledError` for `export_web_scripts`, with the exact recorded completion-cancellation reason as its cause and cleanup complete after successful descriptor retry. Any unresolved cleanup must remain separately owned and visible. ### Analysis or suspected cause Source-derived: [readCapture](https://github.com/morluto/rea/blob/52415975942a4b9c53a817915b313ccc404f280e/src/application/WebScriptExportService.ts#L231-L263) retries the retained handle, then reduces its completed outcome to bytes plus a close diagnostic without consulting `completionCancellation` or the [regularFileCancellation accessor](https://github.com/morluto/rea/blob/52415975942a4b9c53a817915b313ccc404f280e/src/filesystem/OwnedRegularFile.ts#L88-L112). [prepareCapture](https://github.com/morluto/rea/blob/52415975942a4b9c53a817915b313ccc404f280e/src/application/WebScriptExportService.ts#L131-L165) parses the valid bytes and turns any such diagnostic into WebScriptExportError(io). The [service early return](https://github.com/morluto/rea/blob/52415975942a4b9c53a817915b313ccc404f280e/src/application/WebScriptExportService.ts#L89-L101) bypasses the publication failure mapper, so fixing that mapper alone cannot restore this recorded fact. Public reachability is source-derived from the [MCP handler](https://github.com/morluto/rea/blob/52415975942a4b9c53a817915b313ccc404f280e/src/server/registerWebScriptTool.ts#L23-L48), which forwards its request signal to the shared service tested here. No CLI or MCP transport cancellation was executed. ### Additional context Related [#6042](https://github.com/morluto/rea/issues/6042) concerns the direct caller-reason identity after successful output rollback. This report instead concerns the recorded completion-cancellation fact discarded by readCapture/prepareCapture before output creation; it never reaches the publication failure mapper. The two reports and #5956 require separate boundary repairs. #5956 concerns failed rollback after output acquisition; this case occurs before output creation and never enters PublishWebScripts. #6029 concerns a pre-existing output destination. Closed #4598 introduced/used producer facts to preserve physical digest failures separately from close cancellation; the file owner already has the fact needed here, but this export consumer drops it. #5332, #5935, #5944, #5987 and #5999 concern separate consumers or producers. Merged PR #1240 classifies capture selections, PR #5672 changes output-source ownership and PR #2068 adds worker capture metadata; none repairs this completed capture-close consumer. Fetched origin/main at `6d356b84344659085f2b912857b5e27fe0160c9b` and compared the relevant source: this reader, owner and service are unchanged. New resource close hooks and a projection refactor do not change this path; newer main was not executed. Invalid capture data plus late cancellation and a new parsed-script partial-result schema are outside the demonstrated claim.
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#3688
No description provided.