---
status: resolved
trigger: "Phase 30 file_drop signoff path runs final_session and logs 'Final psyche invocation complete, wrapper exiting', deletes the signoff file, but does NOT actually break the wrapper's inner-poll loop. Loop continues polling and only exits ~14s later via the orphan detection path."
created: 2026-05-15T00:00:00Z
updated: 2026-05-15T00:00:00Z
---

## Current Focus

hypothesis: CONFIRMED. Three causally-linked defects between the file_drop signoff branch and the inner-poll loop:
  (1) `final_session` (src/live/wrapper/claude.rs:327) is documented as `Returns true to indicate the wrapper should break out of the loop` — the legacy init_signoff path at mod.rs:620-624 honors this by `if self.final_session(&msg) { break; }`.
  (2) `WrapperState::process_file_drop` (mod.rs:1127-1129) **discards** `final_session`'s return value: `self.final_session(&envelope); 0` — the break signal is dropped on the floor before it ever leaves the method.
  (3) `handle_file_drop_arm` (mod.rs:367-393) returns a single `bool` whose only meaning is "I claimed the message; caller should `continue` (skip the resume_session path)." The caller at mod.rs:666-668 unconditionally `continue`s on `true` — so even if process_file_drop *did* propagate a break signal, the wire-site has no slot to receive it.
  Net effect: the "Final psyche invocation complete, wrapper exiting" log fires inside `final_session` regardless of caller, then control returns through `process_file_drop` → `dispatch_drop` → `handle_file_drop_arm` → the loop, which `continue`s and runs another iteration. The wrapper only exits when the orphan-detection backstop at mod.rs:516-519 fires ~14s later.
test: verified by reading src/live/wrapper/mod.rs:508-668 (inner-poll loop + arms), mod.rs:1080-1157 (process_file_drop), mod.rs:367-393 (handle_file_drop_arm), mod.rs:332-345 (FileDropDispatcher trait + impl), src/live/wrapper/claude.rs:327-404 (final_session). Cross-checked git show 3b27264 (wiring commit) — confirms the `+if handle_file_drop_arm(self, &msg) { continue; }` diff has only one branch (continue), no break path. Confirmed `tests/file_drop_integration.rs` tests 2/3 covering wrapper-consume happy/retain are `#[ignore]`'d ("HARNESS GAP: requires WrapperState test-double + stub claude bin") so no regression coverage existed for the loop-break invariant.
expecting: three-line fix surface (see Proposed Fix below). DO NOT apply yet — user wants greenlight first.
next_action: present Root Cause Report; await greenlight to apply fix (continuation will write failing regression test against the dispatcher trait + wire-site, then apply the three propagation edits).

## Symptoms

expected: After file_drop signoff: `final_session` runs → "Final psyche invocation complete, wrapper exiting" logs → signoff file deleted → inner-poll loop breaks immediately → wrapper cleanup → process exits. Total wall time from signoff drop to process exit should be sub-second after final_session completes.
actual: `final_session` runs (logged at 04:48:35), "wrapper exiting" log fires (04:48:35), signoff file deleted (04:48:35), then "poll iteration 3 starting" (04:48:35) — loop continues. Orphan detection eventually fires "ECHO-FIRE orphan-path" then "Self parent session is gone (orphan detected via parent_pid), triggering INIT_SIGNOFF" at 04:48:49, "orphan confirmed, breaking inner-poll loop" at 04:48:54, cleanup at 04:48:54. Total dead-air: 14 seconds of unnecessary polling between intended exit point and actual exit.
errors: none — no panic, no exception, no log error. The bug is silent: wrong loop semantics, plus a misleading log claiming the wrapper is exiting when it is not. SC-5 in 30-VERIFICATION.md was reclassified to VERIFIED based on the LISTENER side (listener exits 0 cleanly via dropped signoff); WRAPPER side has this latent defect.
reproduction: drop a signoff file for an active psyche wrapper (e.g. `doyle-signoff.md`). Observe the wrapper log: `Final psyche invocation complete, wrapper exiting` appears, then `poll iteration N starting` appears AFTER it. Orphan detection eventually triggers the actual exit ~14s later.
started: Plan 30-02 (commits d6bd054 "wire file_drop arm", 25dd749, 3b27264 — file_drop signoff branch introduction). Pre-Phase-30 the init_signoff path was inbox-message-driven and used the mod.rs:620 predicate to break the loop after final_session.

## Eliminated

- hypothesis: "`final_session` doesn't actually signal teardown when called from process_file_drop" (perhaps it short-circuits internally based on call-site context)
  evidence: `final_session` (claude.rs:327-404) has no call-site-aware branching. It unconditionally executes the full `claude -p --resume` final invocation, the `auto-commit` git step, logs "Final psyche invocation complete, wrapper exiting" (line 402), then returns `true` (line 403). Doc comment at claude.rs:326 explicitly states the bool return contract: `Returns true to indicate the wrapper should break out of the loop`. The wrapper.log evidence confirms the body of final_session DID execute fully (the `[PSYCHE] final (exit=0)` block and the auto-commit + "wrapper exiting" lines are all present). The bool is set; the caller drops it.
  timestamp: 2026-05-15

- hypothesis: "The fix is to call the legacy `init_signoff` predicate from inside the file_drop signoff branch by re-routing the envelope"
  evidence: this would re-couple the two entry points the Plan 30-02 disjointness analysis (mod.rs:1058-1078) explicitly separated to prevent double-spawn of `final_session`. The cleaner fix lives entirely on the file_drop path: propagate the break signal that already exists at the bottom of the call stack (final_session's bool return) up through the layers that currently swallow it.
  timestamp: 2026-05-15

## Evidence

- timestamp: 2026-05-15
  checked: user-supplied wrapper log `C:\Users\decid\AppData\Local\spt\logs_latest\doyle.log` lines 374-391 (gen42 signoff)
  found: sequence shows `[PSYCHE] final (exit=0)` → auto-commit → "Final psyche invocation complete, wrapper exiting" (line 379) → "[FILE-DROP] consumed kind=signoff path=...doyle-signoff.md (deleted)" (line 380) → "poll iteration 3 starting" (line 381) — loop continues despite the "exiting" log. Then orphan detection sequence runs (lines 382-388) → "orphan confirmed, breaking inner-poll loop" (line 389) → "wrapper loop exited, cleaning up" (line 390) → "cleanup complete, wrapper process exiting" (line 391). Gap between intended exit (379) and actual exit (391) is ~19 seconds wall-clock.
  implication: the "wrapper exiting" log fires inside the file_drop signoff handler unconditionally — it is a stale/aspirational log message, not a true indicator of loop termination. The actual loop break is reached only via the orphan path. Confirms the file_drop signoff handler does not break the loop directly.

- timestamp: 2026-05-15
  checked: src/live/wrapper/claude.rs:325-404 — `final_session`
  found: signature `pub fn final_session(&self, msg: &str) -> bool`. Doc comment at line 325-326: `Fire one last Claude session invocation for INIT_SIGNOFF context save, then signal exit. Returns true to indicate the wrapper should break out of the loop.` Logs `"Final psyche invocation complete, wrapper exiting"` at line 402, then `return true` at line 403. The bool return value is the documented break-signal carrier.
  implication: the teardown signal exists and is well-typed. It is the propagation path that is broken, not the signal source. The misleading "wrapper exiting" log fires unconditionally inside final_session; it does not actually cause the wrapper to exit — that requires the caller to consume the `true` return.

- timestamp: 2026-05-15
  checked: src/live/wrapper/mod.rs:611-624 — legacy init_signoff predicate inside `run()`'s inner-poll loop
  found: legacy code reads
    ```
    if msg.to_ascii_lowercase().contains("init_signoff") {
        if self.final_session(&msg) {
            break;
        }
    }
    ```
  The `if self.final_session(&msg) { break; }` pattern consumes the bool return and breaks the loop. This is the correct contract honored at one of the two final_session entry points.
  implication: this is the contract template the file_drop signoff branch must mirror. Any equivalent fix must surface a bool (or similar) at the wire-site in run() so the loop can `break` after the signoff branch returns. Source-order also matters: the break must be reachable from inside the wire-site `if` arm without falling through into the rest of the iteration.

- timestamp: 2026-05-15
  checked: src/live/wrapper/mod.rs:644-668 — file_drop wire site inside `run()`
  found: code reads
    ```
    if handle_file_drop_arm(self, &msg) {
        continue;
    }
    ```
  Only one branch: `continue`. No path that translates "this was a signoff dispatch" into `break`. The wire-site is the surface where the loop's control flow is decided; today the wire-site has no slot to receive a break signal from the arm.
  implication: the wire-site must distinguish "handled, continue iterating" from "handled, break the loop." Either widen the arm's return type (e.g., to an enum or tuple-bool, or propagate the `final_session` bool through it) or split the arm to expose two signals.

- timestamp: 2026-05-15
  checked: src/live/wrapper/mod.rs:325-345 — `FileDropDispatcher` trait + WrapperState impl
  found: trait method `fn dispatch_drop(&self, kind: &str, path: &str);` returns unit (`()`). Production impl on WrapperState (line 337-341) calls `self.process_file_drop(kind, path)` which also returns unit. Mock impl in tests records calls but returns unit.
  implication: the trait surface itself drops any return value. Fixing this requires changing the trait method's return type (or adding a parallel method) to carry the break signal back to the caller. The MockFileDropDispatcher in `file_drop_handler_tests` must be updated accordingly. Three production callers exist: production impl (mod.rs:338) and the one test mock (mod.rs:2075-2084); both are in-repo.

- timestamp: 2026-05-15
  checked: src/live/wrapper/mod.rs:1080-1157 — `WrapperState::process_file_drop`
  found: function signature `pub(crate) fn process_file_drop(&self, kind: &str, path: &str)` returns unit. At line 1127-1129:
    ```
    let exit_code = if kind == "signoff" {
        self.final_session(&envelope);
        0
    } else { ... };
    ```
  The expression `self.final_session(&envelope)` is invoked in statement position — its `bool` return is dropped. The local `exit_code` is hard-coded to 0 (used for the D8 delete decision, which is correct: signoff *should* always delete the file). The break signal is never captured.
  implication: this is the principal break-signal-discard site. The cheapest fix here is `let should_terminate = if kind == "signoff" { let r = self.final_session(&envelope); /* signal break upward */ r } else { ... };` then propagate `should_terminate` up the return path. Note the D8 delete-on-exit-0 decision is preserved separately (signoff is always treated as success for delete; the new bool tracks teardown only).

- timestamp: 2026-05-15
  checked: src/live/wrapper/mod.rs:367-393 — `handle_file_drop_arm`
  found: signature `pub(crate) fn handle_file_drop_arm<D: FileDropDispatcher>(state: &D, msg: &str) -> bool`. Doc comment at line 359-365: "Returns `true` ONLY when: the classifier matches the full wire prefix, AND `parse_file_drop_event` returns Some(...). On parse failure with a matching prefix, the arm logs the skip line ... and returns `false` so the caller falls through to the normal MSG / resume_session_checked path." The bool return is overloaded with the single meaning "was this message a valid file_drop?" — it does NOT carry teardown semantics today.
  implication: the cleanest fix widens this return type. Two viable shapes:
    (a) **`Option<TeardownSignal>` or enum** — `enum FileDropOutcome { Miss, HandledContinue, HandledBreak }`. Most semantically clear; matches the wire-site's three-way control flow (fall through to resume / continue / break).
    (b) **`(bool, bool)` tuple** — `(handled, should_break)`. Cheaper diff but less self-documenting.
  Either way, `dispatch_drop`'s trait method must return the same teardown signal, and `process_file_drop` must compute it from `final_session`'s bool return.

- timestamp: 2026-05-15
  checked: tests/file_drop_integration.rs:189-216 — Tests 2 & 3 (wrapper_consume_happy_path, wrapper_retains_on_exit_nonzero)
  found: both `#[ignore]`'d with reason `"HARNESS GAP: requires WrapperState test-double + stub claude bin; tuple contract locked by Plan 02 file_drop_handler_tests (unit)"`. No active integration test exercises the wrapper-side loop-break invariant after a signoff file_drop. The Plan 02 `file_drop_handler_tests` MockFileDropDispatcher (mod.rs:2061-2084) records dispatch calls but does not assert anything about loop control flow because the trait method returns unit.
  implication: a regression test is achievable today at the **unit layer** without touching the ignored integration harness — extend MockFileDropDispatcher to return the new teardown signal and add a `file_drop_signoff_arm_signals_break` (or equivalent name) test that asserts `handle_file_drop_arm` returns the "break" outcome for a happy signoff envelope and the "continue" outcome for a happy commune envelope. This locks the wire-site contract without needing a stub claude binary. (Optionally, also un-ignore Test 2 in a follow-up; out of scope for the immediate fix.)

- timestamp: 2026-05-15
  checked: git show 3b27264 -- src/live/wrapper/mod.rs (Plan 30-02 wiring commit)
  found: diff adds the `+if handle_file_drop_arm(self, &msg) { continue; }` block with **only the `continue` branch**. No `break` path was ever wired. The commit body explains the disjointness with the legacy init_signoff predicate but does not address the wrapper-side teardown signal in the new path. This is consistent with the bug surfacing only at wrapper-runtime — no test failure forced the issue at commit time because the integration tests for this path are `#[ignore]`'d.
  timestamp confirmed by `git log --oneline` showing 3b27264 ("feat(30-02): wire file_drop arm + process_file_drop body in wrapper run()") as the introducing commit.
  implication: this is a Phase-30-introduced regression. Pre-Phase-30, all signoff went through the mod.rs:620 predicate and broke the loop correctly. Phase 30 added a parallel entry point and forgot to mirror the break wiring. Fix locality is contained to the file_drop call stack — no need to touch the legacy predicate or its tests.

- timestamp: 2026-05-15
  checked: src/live/signoff.rs:14-26 — `compose_init_signoff_payload`
  found: produces `<EVENT type="init_signoff" timestamp="...">...</EVENT>` — the **composed** envelope contains the substring "init_signoff". But this envelope is passed DIRECTLY into `final_session` (mod.rs:1128), never re-injected into the inner-poll loop's `msg` variable. So the legacy predicate at mod.rs:620 cannot see it; no double-fire risk from the proposed fix.
  implication: the disjointness analysis in process_file_drop's doc comment (mod.rs:1058-1078) remains intact under the proposed fix. The fix is additive — it propagates an already-emitted bool signal — and does not re-couple the two entry points.

## Proposed Fix (DO NOT APPLY YET — awaiting greenlight)

**Three propagation edits + a regression test, no logic changes to `final_session`, no changes to the legacy mod.rs:620 path:**

1. **Widen `FileDropDispatcher::dispatch_drop` return type** (mod.rs:332-345).
   Recommended shape: introduce `pub(crate) enum FileDropOutcome { Continue, BreakLoop }` and change the trait to `fn dispatch_drop(&self, kind: &str, path: &str) -> FileDropOutcome`. Production impl returns the outcome from `process_file_drop`; mock impl mirrors recorded outcomes.
   (Alternative: change the trait to `-> bool` where `true = break loop`; cheaper but less self-documenting. Either is acceptable.)

2. **Update `WrapperState::process_file_drop`** (mod.rs:1080-1157).
   Change signature to `-> FileDropOutcome`. In the signoff branch, capture `final_session`'s bool return:
   ```
   let exit_code = if kind == "signoff" {
       let _ = self.final_session(&envelope);  // bool=true == "break loop"; signaled via return below
       0
   } else { ... };
   ```
   At the bottom of the function, return `FileDropOutcome::BreakLoop` for `kind == "signoff"` (any successful final_session return) and `FileDropOutcome::Continue` otherwise. (For signoff, even if final_session's internal child spawn fails, we still want to break the loop — the wrapper is unambiguously in teardown state once we've reached this branch; orphan detection will catch any edge case.)

3. **Widen `handle_file_drop_arm` return type** (mod.rs:367-393).
   Change `-> bool` to `-> FileDropOutcome` (with an added `Miss` variant for the predicate-miss path that currently returns `false`). Caller at mod.rs:666-668 becomes:
   ```
   match handle_file_drop_arm(self, &msg) {
       FileDropOutcome::Miss => { /* fall through to normal MSG dispatch */ }
       FileDropOutcome::Continue => { continue; }
       FileDropOutcome::BreakLoop => {
           self.log("file_drop signoff teardown — breaking inner-poll loop");
           break;
       }
   }
   ```

4. **Regression test** in `file_drop_handler_tests` (mod.rs:2055+):
   ```
   #[test]
   fn signoff_dispatch_signals_break_loop() {
       let mock = MockFileDropDispatcherWithOutcome::new(FileDropOutcome::BreakLoop);
       let outcome = handle_file_drop_arm(&mock, &happy_signoff_envelope());
       assert!(matches!(outcome, FileDropOutcome::BreakLoop));
   }
   #[test]
   fn commune_dispatch_signals_continue() {
       let mock = MockFileDropDispatcherWithOutcome::new(FileDropOutcome::Continue);
       let outcome = handle_file_drop_arm(&mock, &happy_commune_envelope());
       assert!(matches!(outcome, FileDropOutcome::Continue));
   }
   ```
   The mock dispatcher records the desired outcome at construction time and returns it from `dispatch_drop`. This locks the wire-site contract at the unit layer without needing the `#[ignore]`'d integration harness.

5. **Addendum to `.planning/phases/30-VERIFICATION.md`** (one short paragraph): note that SC-5 listener-side passed at commit 98642e1 but a wrapper-side teardown defect (this bug) was discovered post-passed and fixed in a follow-up commit; reference this debug file's slug.

**Surface area:** ~30 lines changed across 1 source file (mod.rs) + 1 test addition in the same file. Zero changes to claude.rs, signoff.rs, the legacy mod.rs:620 path, or any integration test.

**Out of scope for the immediate fix:**
- Un-ignoring `tests/file_drop_integration.rs` Test 2/Test 3 (harness gap is real and orthogonal).
- Removing or rewording the "Final psyche invocation complete, wrapper exiting" log inside final_session itself. Once the propagation fix lands the log is no longer misleading because the wrapper *will* be exiting immediately after; leaving the log unchanged also preserves the legacy init_signoff path's logging shape.

## Resolution

**Status:** RESOLVED 2026-05-15.
**Goal achieved:** find_and_fix (greenlit by user after find_root_cause_only phase).

### Commits

- `57f4ef6` — `test(30): RED — assert file_drop signoff path signals BreakLoop`
  Adds two failing regression tests (`signoff_dispatch_signals_break_loop`,
  `commune_dispatch_signals_continue`) plus an outcome-aware mock
  dispatcher (`MockFileDropDispatcherWithOutcome`) referencing a
  `FileDropOutcome` enum that does not yet exist — RED via compile failure.
- `5c1749e` — `fix(30): GREEN — propagate FileDropOutcome::BreakLoop through file-drop signoff path`
  Introduces the enum, widens `dispatch_drop` / `process_file_drop` /
  `handle_file_drop_arm` return types, rewires the run() inner-poll
  loop wire site to an exhaustive `match` with a real `break` arm.
  Tests B/C/E migrated from `bool` assertions to enum matches. Legacy
  `MockFileDropDispatcher` defaults to `Continue`.
- `8ded19d` — `docs(30): record post-passed wrapper-side signoff loop-break fix`
  Adds a `## Post-Passed Wrapper-Side Fix` section to
  `.planning/phases/30-commune-signoff-file-drop-flow-change/30-VERIFICATION.md`.
  Phase 30 status remains `passed` (6/6 SC) — listener-side SC-5 was
  already verified at the original pass mark; this is documented
  post-closure correctness work, not a phase reopening.

### Surface area

- 1 source file edited: `src/live/wrapper/mod.rs` (+118 / −30 net).
- 1 verification doc appended: `30-VERIFICATION.md` (+79 lines, addendum).
- 0 changes to: `claude.rs`, `signoff.rs`, the legacy `init_signoff`
  predicate at mod.rs:620, any integration test, ROADMAP.md.

### Test verification

- Pre-fix (after RED commit): build fails with
  `error[E0425]: cannot find type 'FileDropOutcome' in module 'super'` —
  the two new tests don't compile, which is the RED gate.
- Post-fix (after GREEN commit): `cargo test --lib --test-threads=1`
  reports 356 passed / 0 failed / 1 ignored. The two new tests pass:
    - `live::wrapper::file_drop_handler_tests::signoff_dispatch_signals_break_loop ... ok`
    - `live::wrapper::file_drop_handler_tests::commune_dispatch_signals_continue ... ok`
- Integration coverage unchanged: `signoff_listener_exits_zero` and
  `signoff_file_drop_teardown_returns_zero` both PASS. Listener side
  was never touched.
- Full workspace `cargo test --test-threads=1`: every test binary
  reports `ok` with zero failures.

### Why this didn't break SC-5

SC-5's contract is listener-side (D12 visual goal: Claude Code does
not show "failed" after signoff). That contract held — the listener
exited 0 cleanly. The wrapper-side defect was purely a latency issue:
teardown happened via the orphan-detection backstop ~14s after the
intended exit point. No user-visible regression. The fix lowers that
teardown latency to sub-second by honouring `final_session`'s already-
emitted break signal at the trait/arm/wire-site layers that were
discarding it.
