---
phase: 260520-pra
plan: 01
type: execute
wave: 1
subsystem: live/wrapper + owl/poll (file_drop control path)
tags: [bugfix, observability, defense-in-depth, file_drop, spool]
requires: []
provides:
  - SURFACE-A-ENOENT-DROP
  - SURFACE-B-PRODUCER-DEDUPE
  - DEBUG-DOC-RESOLVE
affects:
  - src/live/wrapper/mod.rs (process_file_drop read-error arm)
  - src/owl/poll.rs (poll loop emit block + new helper)
tech_stack:
  added:
    - std::collections::HashSet (already in std, no new dep)
  patterns:
    - per-run-scope HashSet dedupe for stateless per-iteration scans
    - ErrorKind discrimination in fs::read_to_string error handling
key_files:
  created:
    - .planning/debug/resolved/spool-retain-replays-file-drop-after-delete.md
  modified:
    - src/live/wrapper/mod.rs
    - src/owl/poll.rs
  moved:
    - .planning/debug/spool-retain-replays-file-drop-after-delete.md -> .planning/debug/resolved/spool-retain-replays-file-drop-after-delete.md
decisions:
  - "ENOENT split from generic read-error: misleading `retaining` wording fixed without changing return value (still Continue)"
  - "Producer dedupe via in-run-scope HashSet rather than spool-level INSERT OR IGNORE: avoids schema migration risk and preserves legitimate duplicate user prose elsewhere in the spool"
  - "Helper `dedupe_drops` extracted to make the emit-once-per-lifecycle logic unit-testable without TCP/spool/filesystem fixtures"
metrics:
  duration: ~25min
  completed: 2026-05-20
  commits: 3
  new_tests: 4 (1 in file_drop_handler_tests, 3 in owl::poll::tests)
---

# Quick 260520-pra: Fix spool-retain race + producer-side dedupe for file_drop controls

## One-liner

Defense-in-depth fix for the diagnosed `spool-retain-replays-file-drop-after-delete` defect: consumer-side ENOENT discriminator in `process_file_drop` (Surface A) + producer-side `HashSet`-based emit dedupe in the Self listener poll loop (Surface B), wired with a small extracted `dedupe_drops` helper for unit-testability.

## Objective

Stop the misleading `[FILE-DROP] read failed ... retaining` log spam that fires hundreds of times after a wrapper consumes+deletes a file_drop control, and prevent Self's listener from queuing those duplicates into the wrapper's spool in the first place.

## What changed

### Surface A — Consumer-side: ENOENT discriminator (commit `c1adb43`)

`src/live/wrapper/mod.rs::process_file_drop` (around line 1209): the single catch-all read-error arm split into two arms.

- `Err(e) if e.kind() == std::io::ErrorKind::NotFound` → logs `[FILE-DROP] dropped kind={kind} path={path} (file gone)` and returns `Continue`.
- `Err(e)` (any other I/O failure: permission denied, disk error, etc.) → byte-identical to the previous wording (`[FILE-DROP] read failed kind={kind} path={path} err={e}; retaining`), still returns `Continue`. Operators reading the log see the correct semantic; legacy log-grep consumers do not drift.

### Surface B — Producer-side: per-lifecycle emit dedupe (commit `a9ba7d0`)

`src/owl/poll.rs`:

- New helper `dedupe_drops(emitted: &mut HashSet<String>, drops: &[(String, String)]) -> Vec<(String, String)>` placed alongside `compose_file_drop_event`. Mutates the set in place: `retain()` clears stale entries (files that disappeared since last scan), then iterates the scan and uses `HashSet::insert`'s `false` return to skip already-emitted paths.
- `poll::run`: `let mut emitted_drops: HashSet<String> = HashSet::new();` declared once outside the loop alongside `listener_cwd`. The per-iteration emit block routes `scan_drop_files` output through `dedupe_drops` before composing envelopes. Signoff teardown (`soft_stop_perch` + `exit(0)`) preserved exactly — it only fires when a signoff path is in the emit subset (i.e. first time it's seen).

### Surface C (docs) — Resolution Note (commit `d464b7d`)

`.planning/debug/spool-retain-replays-file-drop-after-delete.md` → `.planning/debug/resolved/spool-retain-replays-file-drop-after-delete.md`.

Frontmatter `status: diagnosed` → `resolved`; added `resolved_at`, `milestone`, `quick_id`. Appended `## Resolution Note` section with both surface commit hashes and a one-sentence summary.

The source file was untracked (never committed); `git mv` errored, so a plain `mv` was used. The doc-only commit captures the new path under `.planning/debug/resolved/`.

## Tasks completed

| Task | Name                                                                       | Commit    |
| ---- | -------------------------------------------------------------------------- | --------- |
| 1    | Surface A — ENOENT discriminator in `process_file_drop` + unit test         | `c1adb43` |
| 2    | Surface B — producer-side `dedupe_drops` helper + emit-loop wire + 3 tests  | `a9ba7d0` |
| 3    | Move diagnosed debug doc to `resolved/` with Resolution Note               | `d464b7d` |

## Tests added

1. **`file_drop_handler_tests::process_file_drop_enoent_logs_dropped_not_retaining`** (`src/live/wrapper/mod.rs`)
   - Builds a minimal `WrapperState` with a tempdir-backed `log_path`.
   - Calls real `process_file_drop("commune", <nonexistent>)`.
   - Asserts log contains `[FILE-DROP] dropped` + `(file gone)`, does NOT contain `retaining`, does NOT contain `[PSYCHE]` (no subprocess spawn), returns `Continue`.

2. **`owl::poll::tests::dedupe_drops_suppresses_repeat_emit_for_same_path`** (`src/owl/poll.rs`)
   - First scan with one drop: emit set is `[that drop]`, set carries the path.
   - Second scan with the same drop: emit set is empty (suppressed).

3. **`owl::poll::tests::dedupe_drops_reemits_after_path_disappears_and_returns`**
   - Present → emit. Empty scan → set cleared via `retain`. Reappears → re-emits exactly once. Same-path repeat → suppressed again.

4. **`owl::poll::tests::dedupe_drops_emits_both_kinds_on_first_scan`**
   - commune + signoff together both emit; ordering preserved (commune first, signoff second per existing `scan_drop_files` contract).

## Verification

- `cargo test --lib file_drop_handler_tests` — 18 passed, 0 failed (includes the new ENOENT test).
- `cargo test --lib owl::poll` — 26 passed, 0 failed (includes the 3 new dedupe tests).
- `cargo test` — 612 passed, 31 failed. The 31 failures match the pre-existing baseline (Phase 24 tracked-dir WIP infrastructure — `common::tracked::*`, `live::context::*`, `live::fork::*`, `live::signoff::*`, `live::wrapper::claude::*`, `live::wrapper::lifecycle::*`, `owl::doctor::*`, `owl::resume::*`, `owl::cleanup::*`, `owl::plugin_session_start::*`). None are in `owl::poll` or `live::wrapper::file_drop_handler_tests`.
- `cargo build --release` — clean. Only pre-existing warnings (`check_alive`, `source` field, `should_fire`); no new warnings introduced by this plan.
- `grep -n "ErrorKind::NotFound" src/live/wrapper/mod.rs` → hit at line 1221 inside `process_file_drop` (Surface A applied).
- `grep -n "emitted_drops" src/owl/poll.rs` → hits at line 252 (declaration), line 290 (call site) (Surface B applied).
- `.planning/debug/resolved/spool-retain-replays-file-drop-after-delete.md` exists; old path is gone; frontmatter `status: resolved`; `## Resolution Note` section present (Task 3 applied).

## Deviations from Plan

**None.** Plan executed exactly as written. Three atomic commits, in the order specified, with the exact commit messages from `<action>` blocks.

One operational note: the diagnosed debug doc was untracked at the start of Task 3, so `git mv` errored (`fatal: not under version control`). A plain `mv` produces the equivalent result for an untracked file — the doc enters git history as a `create mode` in the doc commit at the new path, identical to the post-`git mv` outcome for an untracked source. This is not a deviation from the plan's intent (which was "history preserved" — there was no history to preserve), only from the literal command. Documented in the doc commit message.

## Files modified

- `src/live/wrapper/mod.rs` — `process_file_drop` ENOENT branch + 1 new test
- `src/owl/poll.rs` — `dedupe_drops` helper + `emitted_drops` HashSet in run-scope + emit-loop rewrite + 3 new tests

## Files moved

- `.planning/debug/spool-retain-replays-file-drop-after-delete.md` → `.planning/debug/resolved/spool-retain-replays-file-drop-after-delete.md` (frontmatter updated, Resolution Note appended)

## Out-of-scope and untouched

Per plan constraints, the following pre-existing dirty/untracked items were NOT touched, NOT staged, NOT committed:

- `.gitignore` (pre-existing modification)
- `Cargo.lock` (pre-existing modification — restored after a stash-pop dance during baseline test verification)
- `.planning/ROADMAP.md` (pre-existing modification from a prior session's unintended action — explicitly out of scope per orchestrator constraint "Do NOT update ROADMAP.md")
- `src/common/tracked.rs` (Phase 24 WIP)
- `src/common/spool.rs` (explicitly excluded per plan `<interfaces>` OUT-OF-SCOPE)
- `docs/research/` (Phase 24 WIP)
- `.planning/phases/24-tracked-dir-...` (Phase 24 WIP)

## Success criteria

- [x] Surface A: ENOENT no longer logged as `retaining`; new `dropped ... (file gone)` wording confirmed by unit test.
- [x] Surface B: same path scanned twice emits once; disappear-then-reappear re-emits; signoff teardown preserved.
- [x] Three atomic commits on `main`:
  1. `c1adb43` — `fix(260520-pra): drop file_drop on ENOENT instead of retaining (Surface A)`
  2. `a9ba7d0` — `fix(260520-pra): dedupe file_drop emits per file lifecycle (Surface B)`
  3. `d464b7d` — `docs(260520-pra): resolve spool-retain-replays debug doc`
- [x] Pre-existing dirty files untouched.
- [x] `cargo test` matches baseline (no new failures); `cargo build --release` clean.

## Self-Check: PASSED

- `src/live/wrapper/mod.rs` — FOUND (modified, contains `ErrorKind::NotFound` at line 1221).
- `src/owl/poll.rs` — FOUND (modified, contains `emitted_drops` declaration at line 252 and use at line 290; `dedupe_drops` helper present).
- `.planning/debug/resolved/spool-retain-replays-file-drop-after-delete.md` — FOUND (frontmatter `status: resolved`, `## Resolution Note` section present).
- `.planning/debug/spool-retain-replays-file-drop-after-delete.md` — MISSING (correctly — moved to `resolved/`).
- Commit `c1adb43` — FOUND in `git log`.
- Commit `a9ba7d0` — FOUND in `git log`.
- Commit `d464b7d` — FOUND in `git log`.
