[GH-ISSUE #6042] [Bug] Web script export wraps the caller abort reason after successful rollback #3690

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/6042

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

Cancelling inside the first real output write returns AnalysisCancelledError whose cause is an internal ArtifactReaderFailure, rather than the exact object supplied to AbortController.abort. The original object remains at error.cause.cause. Output rollback succeeds and no owner remains pending. A control that aborts after the write returns, before the next script, preserves the exact direct cause. This was observed through the shared application workflow with actual filesystem I/O and controlled abort timing.

Reproduction

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

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

The complete focused run had six passing observation/control cases on each runtime; these two cases assert the current behavior, including the wrong cause identity. The spy schedules the abort and delegates to the original tree write; it does not synthesize the thrown cancellation or replace rollback.

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(['inside-write', 'between-writes'] as const)('publication cancellation: %s', async stage => {
  const { root, resources, input } = await setup();
  const controller = new AbortController();
  const reason = { request: 'stop export', stage };
  const create = SafeOutputTree.create.bind(SafeOutputTree);
  vi.spyOn(SafeOutputTree, 'create').mockImplementationOnce(async (...args) => {
    const tree = await create(...args);
    const write = tree.write.bind(tree);
    vi.spyOn(tree, 'write').mockImplementationOnce(async (...writeArgs) => {
      if (stage === 'inside-write') controller.abort(reason);
      const result = await write(...writeArgs);
      if (stage === 'between-writes') controller.abort(reason);
      return result;
    });
    return tree;
  });
  try {
    const result = await exportWebScripts(input, resources, {signal:controller.signal});
    if (result.ok) throw new Error('Expected cancellation');
    expect(result.error._tag).toBe('AnalysisCancelledError');
    expect(projectAnalysisError(result.error).code).toBe('cancelled');
    if (stage === 'inside-write') {
      expect(result.error.cause).toBeInstanceOf(ArtifactReaderFailure);
      expect((result.error.cause as ArtifactReaderFailure).cause).toBe(reason);
      expect(result.error.cause).not.toBe(reason);
    } else expect(result.error.cause).toBe(reason);
    expect(resources.hasPending).toBe(false);
    await expect(access(input.output_directory)).rejects.toMatchObject({code:'ENOENT'});
    console.log('PUBLICATION', JSON.stringify({stage, tag: result.error._tag, code: projectAnalysisError(result.error).code, causeRetained: result.error.cause === reason, wrapper: result.error.cause instanceof ArtifactReaderFailure, pending: resources.hasPending}));
  } finally {
    await resources.close();
    await rm(root,{recursive:true,force:true});
  }
});

Expected behavior

Keep AnalysisCancelledError with cause === signal.reason after successful rollback. The lifecycle contract requires exact caller-reason preservation through failure wrapping and awaited cleanup. The export guide requires rollback on cancellation. This does not require serializing an arbitrary cause into public JSON.

Impact and scope

P2: typed-result consumers lose the documented direct cancellation provenance at this checkpoint. The operation still cancels, public projection still says cancelled, and real rollback removes output. A caller retaining the signal can recover its reason; the observed nested cause also retains it. Each of the two controlled publication timings ran once on each runtime. Natural frequency, transport-delivered cancellation, other platforms and failed rollback were not tested in this report. No input corruption, leaked owner or successful-publication claim was observed.

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":"inside-write","tag":"AnalysisCancelledError","code":"cancelled","causeRetained":false,"wrapper":true,"pending":false}
{"stage":"between-writes","tag":"AnalysisCancelledError","code":"cancelled","causeRetained":true,"wrapper":false,"pending":false}

The inside-write case asserted error.cause instanceof ArtifactReaderFailure and error.cause.cause === reason. Both cases asserted that the output directory no longer existed. These are application-boundary observations, not CLI or MCP wire results.

Typed outcome or recovery expected

AnalysisCancelledError for export_web_scripts, exact original reason as its direct cause, complete cleanup and removed partial output. Preserve actual primary failures and incomplete-cleanup ownership independently.

Analysis or suspected cause

Source-derived explanation: the output checkpoint already wraps the exact signal reason. After successful output rollback, the wrapper reaches mapExportFailure, which constructs AnalysisCancelledError using the caught wrapper as its cause. The mapper should consume the established cancellation reason at this boundary.

Public reachability is source-derived: the MCP handler forwards the request signal to this service. The CLI handler calls the same workflow but currently supplies no cancellation signal. This report does not claim a CLI signal route was exercised.

Additional context

Related #6043 concerns cancellation discarded after a completed capture read and successful handle-close retry, before output creation. That case requires consuming the recorded file cancellation fact in readCapture/prepareCapture; it never reaches this publication failure mapper. The two reports and #5956 require separate boundary repairs.

#5956 concerns a different branch: failed rollback constructs a new WebScriptExportError in PublishWebScripts and loses typed cancellation there. This report requires successful rollback; changing only that failed-release wrapper leaves the demonstrated mapper defect. #6029 concerns existing-destination admission. #5332, #5935, #5944, #5987 and #5999 concern other cancellation producers or mappers. PR #5672 changes source-stream error ownership and leaves this service mapper unchanged; PR #2068 adds worker coverage to the export manifest; merged PR #1240 classifies capture selections.

Fetched origin/main at 6d356b84344659085f2b912857b5e27fe0160c9b. The publication mapper, publisher, file reader/owner and output cancellation checkpoint are unchanged. New close hooks and a captured-output projection refactor do not change this path; newer main was source-compared, not executed. No introducing revision or natural incident frequency was established.

Originally created by @coygeek on GitHub (Oct 21, 2026). Original GitHub issue: https://github.com/morluto/rea/issues/6042 ### 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 Cancelling inside the first real output write returns `AnalysisCancelledError` whose cause is an internal `ArtifactReaderFailure`, rather than the exact object supplied to `AbortController.abort`. The original object remains at `error.cause.cause`. Output rollback succeeds and no owner remains pending. A control that aborts after the write returns, before the next script, preserves the exact direct cause. This was observed through the shared application workflow with actual filesystem I/O and controlled abort timing. ### Reproduction In a source checkout with locked dependencies, save the following executed subset of the focused test as `tests/boundary/filesystem/exportPublicationCancellation.repro.test.ts` and run: ```sh node node_modules/vitest/vitest.mjs run tests/boundary/filesystem/exportPublicationCancellation.repro.test.ts --maxWorkers 1 ``` The complete focused run had six passing observation/control cases on each runtime; these two cases assert the current behavior, including the wrong cause identity. The spy schedules the abort and delegates to the original tree write; it does not synthesize the thrown cancellation or replace rollback. ```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(['inside-write', 'between-writes'] as const)('publication cancellation: %s', async stage => { const { root, resources, input } = await setup(); const controller = new AbortController(); const reason = { request: 'stop export', stage }; const create = SafeOutputTree.create.bind(SafeOutputTree); vi.spyOn(SafeOutputTree, 'create').mockImplementationOnce(async (...args) => { const tree = await create(...args); const write = tree.write.bind(tree); vi.spyOn(tree, 'write').mockImplementationOnce(async (...writeArgs) => { if (stage === 'inside-write') controller.abort(reason); const result = await write(...writeArgs); if (stage === 'between-writes') controller.abort(reason); return result; }); return tree; }); try { const result = await exportWebScripts(input, resources, {signal:controller.signal}); if (result.ok) throw new Error('Expected cancellation'); expect(result.error._tag).toBe('AnalysisCancelledError'); expect(projectAnalysisError(result.error).code).toBe('cancelled'); if (stage === 'inside-write') { expect(result.error.cause).toBeInstanceOf(ArtifactReaderFailure); expect((result.error.cause as ArtifactReaderFailure).cause).toBe(reason); expect(result.error.cause).not.toBe(reason); } else expect(result.error.cause).toBe(reason); expect(resources.hasPending).toBe(false); await expect(access(input.output_directory)).rejects.toMatchObject({code:'ENOENT'}); console.log('PUBLICATION', JSON.stringify({stage, tag: result.error._tag, code: projectAnalysisError(result.error).code, causeRetained: result.error.cause === reason, wrapper: result.error.cause instanceof ArtifactReaderFailure, pending: resources.hasPending})); } finally { await resources.close(); await rm(root,{recursive:true,force:true}); } }); ``` ### Expected behavior Keep `AnalysisCancelledError` with `cause === signal.reason` after successful rollback. The [lifecycle contract](https://github.com/morluto/rea/blob/52415975942a4b9c53a817915b313ccc404f280e/docs/tool-design.md#L101-L110) requires exact caller-reason preservation through failure wrapping and awaited cleanup. The [export guide](https://github.com/morluto/rea/blob/52415975942a4b9c53a817915b313ccc404f280e/docs/website-script-export.md#L30-L34) requires rollback on cancellation. This does not require serializing an arbitrary cause into public JSON. ### Impact and scope P2: typed-result consumers lose the documented direct cancellation provenance at this checkpoint. The operation still cancels, public projection still says cancelled, and real rollback removes output. A caller retaining the signal can recover its reason; the observed nested cause also retains it. Each of the two controlled publication timings ran once on each runtime. Natural frequency, transport-delivered cancellation, other platforms and failed rollback were not tested in this report. No input corruption, leaked owner or successful-publication claim was observed. ### 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":"inside-write","tag":"AnalysisCancelledError","code":"cancelled","causeRetained":false,"wrapper":true,"pending":false} {"stage":"between-writes","tag":"AnalysisCancelledError","code":"cancelled","causeRetained":true,"wrapper":false,"pending":false} ``` The inside-write case asserted `error.cause instanceof ArtifactReaderFailure` and `error.cause.cause === reason`. Both cases asserted that the output directory no longer existed. These are application-boundary observations, not CLI or MCP wire results. ### Typed outcome or recovery expected `AnalysisCancelledError` for `export_web_scripts`, exact original reason as its direct cause, complete cleanup and removed partial output. Preserve actual primary failures and incomplete-cleanup ownership independently. ### Analysis or suspected cause Source-derived explanation: the [output checkpoint](https://github.com/morluto/rea/blob/52415975942a4b9c53a817915b313ccc404f280e/src/artifacts/SafeOutputTree.ts#L662-L668) already wraps the exact signal reason. After successful [output rollback](https://github.com/morluto/rea/blob/52415975942a4b9c53a817915b313ccc404f280e/src/browser/assets/PublishWebScripts.ts#L118-L146), the wrapper reaches [mapExportFailure](https://github.com/morluto/rea/blob/52415975942a4b9c53a817915b313ccc404f280e/src/application/WebScriptExportService.ts#L167-L197), which constructs `AnalysisCancelledError` using the caught wrapper as its cause. The mapper should consume the established cancellation reason at this boundary. Public reachability is source-derived: the [MCP handler](https://github.com/morluto/rea/blob/52415975942a4b9c53a817915b313ccc404f280e/src/server/registerWebScriptTool.ts#L23-L48) forwards the request signal to this service. The [CLI handler](https://github.com/morluto/rea/blob/52415975942a4b9c53a817915b313ccc404f280e/src/cli/webScriptCommands.ts#L20-L48) calls the same workflow but currently supplies no cancellation signal. This report does not claim a CLI signal route was exercised. ### Additional context Related [#6043](https://github.com/morluto/rea/issues/6043) concerns cancellation discarded after a completed capture read and successful handle-close retry, before output creation. That case requires consuming the recorded file cancellation fact in readCapture/prepareCapture; it never reaches this publication failure mapper. The two reports and #5956 require separate boundary repairs. #5956 concerns a different branch: failed rollback constructs a new WebScriptExportError in PublishWebScripts and loses typed cancellation there. This report requires successful rollback; changing only that failed-release wrapper leaves the demonstrated mapper defect. #6029 concerns existing-destination admission. #5332, #5935, #5944, #5987 and #5999 concern other cancellation producers or mappers. PR #5672 changes source-stream error ownership and leaves this service mapper unchanged; PR #2068 adds worker coverage to the export manifest; merged PR #1240 classifies capture selections. Fetched origin/main at `6d356b84344659085f2b912857b5e27fe0160c9b`. The publication mapper, publisher, file reader/owner and output cancellation checkpoint are unchanged. New close hooks and a captured-output projection refactor do not change this path; newer main was source-compared, not executed. No introducing revision or natural incident frequency was established.
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#3690
No description provided.