---
status: resolved
trigger: "failed FILE-DROP kind=signoff at C:\\Users\\decid\\AppData\\Local\\spt\\logs_latest\\todlando.log:18 — wrapper drained spool envelope but signoff file already deleted, route_inbound_signoff never executed"
created: 2026-05-23T17:30:00-07:00
updated: 2026-05-23T17:55:00-07:00
fix_shape: receiver-side-kind-aware-diagnostic
fix_commit: pending
---

## Current Focus

hypothesis: CONFIRMED — signoff variant of the same spool-replay race shape diagnosed for commune at `.planning/debug/resolved/file-drop-file-gone-todlando.md`. Producer = Self listener's `scan_drop_files` emitted `<EVENT type="file_drop" kind="signoff" path=".claude/todlando-signoff.md">` to wrapper spool. Separate consumer = `drain_stale_signoff_file` empty-body branch at `src/live/start.rs:166` deleted the file at `$LIVE start` time, BEFORE the wrapper booted. Wrapper gen=31 then drained the now-stale envelope at 17:18:47, hit ENOENT, logged `(file gone)`, returned `FileDropOutcome::Continue` without calling `compose_init_signoff_payload` / `final_session` — graceful signoff signal lost.
test: code-trace + log timeline reconstruction
expecting: kind-aware ENOENT log clearly attributing the cause; targeted regression test
next_action: COMPLETE — receiver-side diagnostic landed; signal-loss remains as accepted-risk per Phase 30 cold-start defense (single-writer Option D fix is a larger refactor deferred).

## Symptoms

expected: `[FILE-DROP] route_inbound_signoff for todlando` line; signoff routed through `compose_init_signoff_payload` + `final_session`; INIT_SIGNOFF stamp materialized; wrapper teardown
actual: `[17:18:47] [FILE-DROP] dispatching: kind=signoff path=...` followed by `[17:18:47] [FILE-DROP] dropped kind=signoff path=... (file gone)` — wrapper saw envelope, file already deleted, no route, wrapper kept polling
errors: none (silent ENOENT branch, returns FileDropOutcome::Continue)
timeline: gen=31 wrapper started 17:18:21, init complete 17:18:47, poll iteration 1 drained spool with 2 queued messages from `todlando` (one being the stale signoff file_drop envelope), `(file gone)` logged 17:18:47
reproduction: operator drops `.claude/{id}-signoff.md` (empty body, graceful-stop convention), listener emits file_drop envelope to wrapper perch spool, then `$LIVE start <id>` runs `drain_stale_signoff_file` → empty-body branch `fs::remove_file` (src/live/start.rs:166), then wrapper boots and drains the now-stale envelope from spool
started: Phase 30 (stale-signoff cold-start defense introduction)
related: `.planning/debug/resolved/file-drop-file-gone-todlando.md` (commune variant — same shape; resolved standalone here)

## Evidence

- timestamp: 2026-05-23T17:30:00-07:00
  source: `C:\Users\decid\AppData\Local\spt\logs_latest\todlando.log:13-18`
  excerpt: |
    [17:18:47] poll exited code=0 stderr=✓ READY:todlando-psyche (spt v1.11.10)
    ✓ DRAIN:2 queued messages (senders: todlando)
    [17:18:47] poll returned 7501 bytes
    [17:18:47] [FILE-DROP] received file_drop control message
    [17:18:47] [FILE-DROP] dispatching: kind=signoff path=C:/Users/decid/Documents/projects/claude_skill_owl/.claude/todlando-signoff.md
    [17:18:47] [FILE-DROP] dropped kind=signoff path=C:/Users/decid/Documents/projects/claude_skill_owl/.claude/todlando-signoff.md (file gone)
  conclusion: spool envelope drained, file absent at read_to_string → ENOENT arm → no route

- timestamp: 2026-05-23T17:30:00-07:00
  source: grep `signoff.*remove_file|remove_file.*signoff` over src/
  excerpt: |
    src\live\start.rs:166:        let _ = fs::remove_file(&signoff_path);
    src\live\start.rs:232:        if let Err(e) = fs::remove_file(&signoff_path) {
  conclusion: two deletion sites in `drain_stale_signoff_file` — empty-body branch (line 166) and post-forward branch (line 232). Both run at `$LIVE start <id>` time, BEFORE wrapper spawn. Operator's `.claude/todlando-signoff.md` was empty (graceful-stop convention) so the empty-body branch at line 166 fired.

## Eliminated

- "Wrapper's own previous iteration deleted the file" — ruled out by log line 16 showing this is poll iteration 1 of gen=31. No prior iteration in this gen reached `process_file_drop`.
- "Producer-side dedupe should have suppressed re-emit" — `dedupe_drops` (`src/owl/poll.rs:1265`) operates on the LISTENER side per-file lifecycle, but the spool row was persisted BEFORE the listener was restarted by `$LIVE start`, so the wrapper drains it on first poll regardless of new listener state.
- "Phase 30 defense should have kept the file for route" — Phase 30 EXPLICITLY consumes (deletes) the file to PREVENT the listener from auto-firing a retained signoff; the design choice was "suppress the signoff on cold start" — which is the desired behavior when the file is from a prior gen but the wrong behavior when the file is fresh user-intent. The drain helper has no way to distinguish the two without a generation marker, so it deletes either way.

## Resolution

### Root Cause

Race shape: Producer (Self listener `scan_drop_files`) emits a `file_drop` envelope to the wrapper's spool while the signoff file is on disk. Consumer (`drain_stale_signoff_file` at `src/live/start.rs:143-247`) runs at `$LIVE start <id>` time and unconditionally deletes the file (empty-body branch at line 166; post-forward branch at line 232). The wrapper boots AFTER the delete and drains the (now-stale) spool envelope, hitting ENOENT in `process_file_drop` at `src/live/wrapper/mod.rs:1803-1808`.

Empty-body branch (line 166) is the variant that fired this session: operator's `.claude/todlando-signoff.md` was empty per the `/spt:signoff` graceful-stop convention. The empty-body branch has no `latent signoff` envelope to forward — it just deletes.

Result: the wrapper missed the signoff signal entirely. `compose_init_signoff_payload` + `final_session` were never invoked; the wrapper kept polling. The operator-visible effect was that the graceful signoff appeared to be ignored.

### Fix Applied

**Option C (receiver-side staleness diagnostic, kind-aware).** Smallest correct delta. Does NOT recover the lost signoff signal (Phase 30 cold-start defense remains the dominant design constraint), but kills the misleading `(file gone)` log noise and makes the cause grep-able for the next operator.

`src/live/wrapper/mod.rs::process_file_drop` ENOENT arm now branches on `kind`:
- `kind == "signoff"` → log appends ` (stale - likely consumed by drain_stale_signoff_file at $LIVE start; not re-firing per Phase 30 defense)`
- `kind == "commune"` → log unchanged (bare `(file gone)`, matches existing test contract)

Regression test added at `src/live/wrapper/mod.rs::file_drop_handler_tests::process_file_drop_enoent_signoff_logs_drain_stale_note` mirroring the existing `process_file_drop_enoent_logs_dropped_not_retaining` shape. Existing commune ENOENT test also extended with a kind-isolation assert (`!log_content.contains("drain_stale_signoff_file")`) so future regressions that leak the signoff-specific note into the commune path are caught.

`cargo build` clean. Targeted tests pass (2/2). Sibling `file_drop_handler_tests` still pass except for 5 pre-existing `git worktree add ... already exists` failures (parallel-test contention, unrelated to this change; identical failures on `main` baseline at HEAD `96faa63`).

### Alternative fixes considered (deferred)

- **Option A (make `drain_stale_signoff_file` non-destructive):** breaks Phase 30 cold-start defense — listener's first `scan_drop_files` would auto-fire the retained signoff and kill the fresh gen. Would require a generation-marker / mtime-epoch gate to distinguish stale prior-gen from fresh user-intent. Larger refactor.
- **Option B (notify wrapper of stale via control message):** requires new wire-form + handler. Heavier.
- **Option D (single-writer contract — wrapper as sole consumer/deleter):** orchestrator-preferred shape but conflicts with Phase 30's explicit need to suppress prior-gen retained signoffs at cold start. Would need the generation-marker plumbing from Option A. Deferred.

The applied Option C is a documented step on the path to Option D — the new comment in `process_file_drop` references this debug file for the future implementer.

### Files changed

- `src/live/wrapper/mod.rs` — ENOENT arm in `process_file_drop` (kind-aware log) + extended commune ENOENT test + new signoff ENOENT test
- `.planning/debug/resolved/signoff-file-gone-todlando.md` — this file

### Confidence: HIGH

- Single ENOENT arm, single log site, kind discrimination is a one-line conditional.
- Both deletion sites in `drain_stale_signoff_file` are verified by grep.
- Timeline reconstruction matches log exactly (DRAIN:2 from `todlando` → file_drop envelope dispatch → ENOENT).
- Empty-body convention for `/spt:signoff` operator-drop confirmed by `drain_stale_signoff_file` line-166 branch comment "empty file: nothing to forward; just delete."

## Specialist Hint

rust
