[GH-ISSUE #7281] [Bug] capture-process final snapshot fails when its scratch directory is removed #4322

Open
opened 2026-10-31 00:48:54 +00:00 by bsakaguchi · 0 comments
Owner

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

Before submitting

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

Area

Process capture or runtime observation

Observed behavior

One removal trial in each of three configurations rejected with an ordinary Error, code: ENOENT, syscall: realpath, no snapshot property, and no ProcessCaptureSnapshotFailure identity. The configurations put the observer directory inside the selected root, outside it, and outside an empty root selection. All three stable controls succeeded. With a two-entry budget, the nested control returned exactly root_0:. and root_0:kept.txt, truncated: false, and an observer_owned subtree; the excluded directory contained another file without exhausting that budget.

Source-derived command impact: the default snapshot is used for admission and finalization, with the same observer path. The final-snapshot catch records this ordinary error as an unavailable final checkpoint. When cleanup succeeds, final result resolution wraps the failure as capture_failed; earlier observations can remain in the partial failure result, but normal capture Evidence is not returned. A separate cleanup failure can take precedence. No CLI, MCP, or native PTY run was executed.

Reproduction

  1. From the source checkout with locked dependencies available, run the fixture under Relevant output or evidence using the repository's toolchain. It creates a private HOME and one temporary test without changing production source.
  2. Each of its three configurations parses and resolves a valid scenario, takes an admission snapshot and a stable final control, removes its own observer scratch directory, and repeats the final snapshot with the same admission and observer path.
  3. Inspect the output, then remove the temporary test and fixture HOME as shown. The nested, outside-selection, and empty-selection removal calls each failed once in one trial; the corresponding three stable controls succeeded. No CLI/MCP capture or native PTY was executed; their shared production path was traced in source.

Expected behavior

The capture should continue observing caller-selected roots when its excluded bookkeeping directory has already disappeared. If the directory remains present, its entries and descendants must remain excluded from both snapshots and file-entry capacity. Empty root selections must remain empty rather than fail because of an unrelated vanished scratch path. The applicable contract is docs/process-capture.md:264–279, which excludes the exact observer directory from snapshots and capacity and treats initial lookup absence as absence. docs/tool-design.md:147–151 preserves completed observations across later failures.

Impact and scope

P2: a bounded scratch-removal condition prevents a final checkpoint and, by source tracing, normal capture completion. It is independent of selected-root containment and also affects empty filesystem selections. Earlier admitted observations can survive in the partial failure result. Stable-directory controls establish a conditional workaround when callers control cleanup; no general workaround for required external removal was established. Relocating TMPDIR outside the selection does not avoid the reproduced failure. Production frequency is unknown. Target execution already has the same user's filesystem permissions; no security boundary is crossed.

REA version

615fbbbdd7 (package.json version 6.8.0)

Runtime and platform

Node.js v26.12.1; Darwin arm64; locked Vitest 5.0.3 and existing source dependencies. The focused source runner did not build artifacts. The assignment reports an unavailable native PTY (posix_spawnp failed); this verification did not probe it again.

Target and artifact context

Disposable directories containing a readable selected file and an observer directory containing an excluded file. Production filesystem snapshots only; no target process, provider, device, or external analysis API was invoked. The intended command artifact is process capture Evidence.

Interface

Other

Earliest failure stage

Capture or observation

Relevant output or evidence

  • Failure source: src/process/capture/FilesystemSnapshot.ts:377–400 unconditionally resolves observer paths before root iteration and typed snapshot failure handling. src/process/capture/ProcessCaptureLifecycle.ts:1683–1711 creates the admission with an observer path; src/process/capture/ProcessHarness.ts:573–610 passes the same path at final snapshot and marks the final observation unavailable on this error. src/process/capture/ProcessCaptureLifecycle.ts:1524–1647 wraps ordinary execution failures after cleanup while retaining valid earlier observations.
  • Reachability: src/cliCommandNames.ts:148, src/cli/processCommands.ts:17–27, and src/application/process/ProcessCli.ts:38–97 connect CLI capture-process to that default harness. src/server/registerSessionTools.ts:95–153 connects MCP capture_process_scenario to the same default. The supported platform, noncancelled valid-scenario, working-PTY and normally exiting-target conditions admit the final snapshot; no observation-root containment guard or scratch recreation prevents it. This adapter result was checked in source, not executed.

Executed fixture with only its temporary test filename changed for publication:

repro_home="$(mktemp -d)"
mkdir -p "$repro_home/home"
cat > src/process/capture/observerScratchRepro.test.ts <<'TEST'
import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises';
import { join } from 'node:path';
import { expect, it } from 'vitest';
import { processScenarioSchema } from '../../domain/process/processScenario.js';
import { snapshotRoots, ProcessCaptureSnapshotFailure } from './FilesystemSnapshot.js';
import { resolveProcessScenarioRuntimePaths } from './ProcessScenarioRuntimeValidation.js';

it.each(['nested', 'outside', 'no-roots'] as const)('removed observer root: %s', async (placement) => {
  const fixture = await mkdtemp(join(process.env.HOME!, 'fixture-'));
  try {
    const selected = join(fixture, 'selected');
    await mkdir(selected);
    await writeFile(join(selected, 'kept.txt'), 'retain me');
    const observer = await mkdtemp(join(placement === 'nested' ? selected : fixture, 'rea-process-'));
    await writeFile(join(observer, 'hidden.txt'), 'excluded content');
    const scenario = await resolveProcessScenarioRuntimePaths(processScenarioSchema.parse({
      executable: process.execPath,
      arguments: ['-e', 'console.log("done")'],
      working_directory: fixture,
      filesystem_observation_paths: placement === 'no-roots' ? [] : [selected],
      limits: { files: 2 },
    }));
    const resources = { observerPaths: [observer] };
    const before = await snapshotRoots(scenario, undefined, undefined, resources);
    const stable = await snapshotRoots(scenario, undefined, before, resources);
    expect(stable.truncated).toBe(false);
    expect(stable.files.map(f => f.path)).toEqual(placement === 'no-roots' ? [] : ['root_0:.', 'root_0:kept.txt']);
    expect(stable.coverage.unobserved_subtrees?.map(s => s.reason)).toEqual(placement === 'nested' ? ['observer_owned'] : []);
    await rm(observer, { recursive: true });
    let failure: unknown;
    try { await snapshotRoots(scenario, undefined, before, resources); }
    catch (cause) { failure = cause; }
    expect(failure).toBeInstanceOf(Error);
    expect(failure).not.toBeInstanceOf(ProcessCaptureSnapshotFailure);
    expect(failure).toMatchObject({ code: 'ENOENT', syscall: 'realpath' });
    expect(failure).not.toHaveProperty('snapshot');
    console.log(JSON.stringify({ placement, before: { files: before.files.map(f => f.path), completeRoots: before.completeRoots, truncated: before.truncated }, stable: { files: stable.files.map(f => f.path), truncated: stable.truncated, omittedReasons: stable.coverage.unobserved_subtrees?.map(s => s.reason) }, removed: { name: (failure as Error).name, code: (failure as NodeJS.ErrnoException).code, syscall: (failure as NodeJS.ErrnoException).syscall, typedSnapshotFailure: failure instanceof ProcessCaptureSnapshotFailure, snapshot: Object.hasOwn(failure as object, 'snapshot') } }));
  } finally { await rm(fixture, { recursive: true, force: true }); }
});
TEST
HOME="$repro_home/home" npm run test:local -- src/process/capture/observerScratchRepro.test.ts
rm src/process/capture/observerScratchRepro.test.ts
rm -r "$repro_home"

All three removal calls produced Error, ENOENT, realpath, typedSnapshotFailure: false, snapshot: false. The nested stable snapshot contained only the root and kept.txt, with truncated: false and an observer_owned subtree; the two-entry budget was sufficient despite the hidden observer file. One test file, three existing-defect assertions passed.

Typed outcome or recovery expected

The same final snapshot should resolve with selected data and honest complete coverage, or remain empty for an empty selection. Existing observer contents must remain excluded from observations and capacity. A supported-host command acceptance check should return normal capture Evidence after scratch removal and a successful target exit; this end-to-end check remains unexecuted here.

Analysis or suspected cause

Source-derived: observer-path canonicalization is unconditional and runs before the typed snapshot-failure boundary. A removed bookkeeping directory therefore aborts unrelated observation before any selected root is examined. This report does not cover the separate parent-disappearance race inside subtree exclusion.

Additional context

Related closed reports: #6536 concerns including the observer directory in effects and capacity; this report concerns failure after that directory is correctly excluded and then removed. #3238 concerns a selected path beneath a non-directory. #4575 concerns a symlink disappearing during a follow-up read. None describes this unconditional canonicalization of a disappeared observer path. Targeted open/closed issue searches found no matching open report.

Originally created by @coygeek on GitHub (Oct 26, 2026). Original GitHub issue: https://github.com/morluto/rea/issues/7281 ### 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 Process capture or runtime observation ### Observed behavior One removal trial in each of three configurations rejected with an ordinary `Error`, `code: ENOENT`, `syscall: realpath`, no `snapshot` property, and no `ProcessCaptureSnapshotFailure` identity. The configurations put the observer directory inside the selected root, outside it, and outside an empty root selection. All three stable controls succeeded. With a two-entry budget, the nested control returned exactly `root_0:.` and `root_0:kept.txt`, `truncated: false`, and an `observer_owned` subtree; the excluded directory contained another file without exhausting that budget. Source-derived command impact: the default snapshot is used for admission and finalization, with the same observer path. The final-snapshot catch records this ordinary error as an unavailable final checkpoint. When cleanup succeeds, final result resolution wraps the failure as `capture_failed`; earlier observations can remain in the partial failure result, but normal capture Evidence is not returned. A separate cleanup failure can take precedence. No CLI, MCP, or native PTY run was executed. ### Reproduction 1. From the source checkout with locked dependencies available, run the fixture under Relevant output or evidence using the repository's toolchain. It creates a private HOME and one temporary test without changing production source. 2. Each of its three configurations parses and resolves a valid scenario, takes an admission snapshot and a stable final control, removes its own observer scratch directory, and repeats the final snapshot with the same admission and observer path. 3. Inspect the output, then remove the temporary test and fixture HOME as shown. The nested, outside-selection, and empty-selection removal calls each failed once in one trial; the corresponding three stable controls succeeded. No CLI/MCP capture or native PTY was executed; their shared production path was traced in source. ### Expected behavior The capture should continue observing caller-selected roots when its excluded bookkeeping directory has already disappeared. If the directory remains present, its entries and descendants must remain excluded from both snapshots and file-entry capacity. Empty root selections must remain empty rather than fail because of an unrelated vanished scratch path. The applicable contract is `docs/process-capture.md:264–279`, which excludes the exact observer directory from snapshots and capacity and treats initial lookup absence as absence. `docs/tool-design.md:147–151` preserves completed observations across later failures. ### Impact and scope P2: a bounded scratch-removal condition prevents a final checkpoint and, by source tracing, normal capture completion. It is independent of selected-root containment and also affects empty filesystem selections. Earlier admitted observations can survive in the partial failure result. Stable-directory controls establish a conditional workaround when callers control cleanup; no general workaround for required external removal was established. Relocating TMPDIR outside the selection does not avoid the reproduced failure. Production frequency is unknown. Target execution already has the same user's filesystem permissions; no security boundary is crossed. ### REA version 615fbbbdd750de2df5d92b9827d0c7c4422c1d03 (package.json version 6.8.0) ### Runtime and platform Node.js v26.12.1; Darwin arm64; locked Vitest 5.0.3 and existing source dependencies. The focused source runner did not build artifacts. The assignment reports an unavailable native PTY (`posix_spawnp failed`); this verification did not probe it again. ### Target and artifact context Disposable directories containing a readable selected file and an observer directory containing an excluded file. Production filesystem snapshots only; no target process, provider, device, or external analysis API was invoked. The intended command artifact is process capture Evidence. ### Interface Other ### Earliest failure stage Capture or observation ### Relevant output or evidence - Failure source: `src/process/capture/FilesystemSnapshot.ts:377–400` unconditionally resolves observer paths before root iteration and typed snapshot failure handling. `src/process/capture/ProcessCaptureLifecycle.ts:1683–1711` creates the admission with an observer path; `src/process/capture/ProcessHarness.ts:573–610` passes the same path at final snapshot and marks the final observation unavailable on this error. `src/process/capture/ProcessCaptureLifecycle.ts:1524–1647` wraps ordinary execution failures after cleanup while retaining valid earlier observations. - Reachability: `src/cliCommandNames.ts:148`, `src/cli/processCommands.ts:17–27`, and `src/application/process/ProcessCli.ts:38–97` connect CLI `capture-process` to that default harness. `src/server/registerSessionTools.ts:95–153` connects MCP `capture_process_scenario` to the same default. The supported platform, noncancelled valid-scenario, working-PTY and normally exiting-target conditions admit the final snapshot; no observation-root containment guard or scratch recreation prevents it. This adapter result was checked in source, not executed. Executed fixture with only its temporary test filename changed for publication: ```sh repro_home="$(mktemp -d)" mkdir -p "$repro_home/home" cat > src/process/capture/observerScratchRepro.test.ts <<'TEST' import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises'; import { join } from 'node:path'; import { expect, it } from 'vitest'; import { processScenarioSchema } from '../../domain/process/processScenario.js'; import { snapshotRoots, ProcessCaptureSnapshotFailure } from './FilesystemSnapshot.js'; import { resolveProcessScenarioRuntimePaths } from './ProcessScenarioRuntimeValidation.js'; it.each(['nested', 'outside', 'no-roots'] as const)('removed observer root: %s', async (placement) => { const fixture = await mkdtemp(join(process.env.HOME!, 'fixture-')); try { const selected = join(fixture, 'selected'); await mkdir(selected); await writeFile(join(selected, 'kept.txt'), 'retain me'); const observer = await mkdtemp(join(placement === 'nested' ? selected : fixture, 'rea-process-')); await writeFile(join(observer, 'hidden.txt'), 'excluded content'); const scenario = await resolveProcessScenarioRuntimePaths(processScenarioSchema.parse({ executable: process.execPath, arguments: ['-e', 'console.log("done")'], working_directory: fixture, filesystem_observation_paths: placement === 'no-roots' ? [] : [selected], limits: { files: 2 }, })); const resources = { observerPaths: [observer] }; const before = await snapshotRoots(scenario, undefined, undefined, resources); const stable = await snapshotRoots(scenario, undefined, before, resources); expect(stable.truncated).toBe(false); expect(stable.files.map(f => f.path)).toEqual(placement === 'no-roots' ? [] : ['root_0:.', 'root_0:kept.txt']); expect(stable.coverage.unobserved_subtrees?.map(s => s.reason)).toEqual(placement === 'nested' ? ['observer_owned'] : []); await rm(observer, { recursive: true }); let failure: unknown; try { await snapshotRoots(scenario, undefined, before, resources); } catch (cause) { failure = cause; } expect(failure).toBeInstanceOf(Error); expect(failure).not.toBeInstanceOf(ProcessCaptureSnapshotFailure); expect(failure).toMatchObject({ code: 'ENOENT', syscall: 'realpath' }); expect(failure).not.toHaveProperty('snapshot'); console.log(JSON.stringify({ placement, before: { files: before.files.map(f => f.path), completeRoots: before.completeRoots, truncated: before.truncated }, stable: { files: stable.files.map(f => f.path), truncated: stable.truncated, omittedReasons: stable.coverage.unobserved_subtrees?.map(s => s.reason) }, removed: { name: (failure as Error).name, code: (failure as NodeJS.ErrnoException).code, syscall: (failure as NodeJS.ErrnoException).syscall, typedSnapshotFailure: failure instanceof ProcessCaptureSnapshotFailure, snapshot: Object.hasOwn(failure as object, 'snapshot') } })); } finally { await rm(fixture, { recursive: true, force: true }); } }); TEST HOME="$repro_home/home" npm run test:local -- src/process/capture/observerScratchRepro.test.ts rm src/process/capture/observerScratchRepro.test.ts rm -r "$repro_home" ``` All three removal calls produced `Error`, `ENOENT`, `realpath`, `typedSnapshotFailure: false`, `snapshot: false`. The nested stable snapshot contained only the root and kept.txt, with `truncated: false` and an `observer_owned` subtree; the two-entry budget was sufficient despite the hidden observer file. One test file, three existing-defect assertions passed. ### Typed outcome or recovery expected The same final snapshot should resolve with selected data and honest complete coverage, or remain empty for an empty selection. Existing observer contents must remain excluded from observations and capacity. A supported-host command acceptance check should return normal capture Evidence after scratch removal and a successful target exit; this end-to-end check remains unexecuted here. ### Analysis or suspected cause Source-derived: observer-path canonicalization is unconditional and runs before the typed snapshot-failure boundary. A removed bookkeeping directory therefore aborts unrelated observation before any selected root is examined. This report does not cover the separate parent-disappearance race inside subtree exclusion. ### Additional context Related closed reports: [#6536](https://github.com/morluto/rea/issues/6536) concerns including the observer directory in effects and capacity; this report concerns failure after that directory is correctly excluded and then removed. [#3238](https://github.com/morluto/rea/issues/3238) concerns a selected path beneath a non-directory. [#4575](https://github.com/morluto/rea/issues/4575) concerns a symlink disappearing during a follow-up read. None describes this unconditional canonicalization of a disappeared observer path. Targeted open/closed issue searches found no matching open report.
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#4322
No description provided.