---
phase: quick-260418-n7k
plan: 01
subsystem: messaging
tags: [bugfix, messaging, spool, poll, deferred]
requires:
  - quick-260414-8co (deferred delivery wiring — emission site)
provides:
  - deferred=1 spool column with idempotent migration
  - spool_message_deferred writer
  - drain_non_deferred / pending_count_non_deferred helpers
  - poll idle-drain respects deferred flag (real wake-path suppression)
affects:
  - src/common/spool.rs (schema + 3 new helpers + 7 unit tests)
  - src/owl/send.rs (deliver_body_deferred + run_deferred routed through new writer)
  - src/owl/poll.rs (idle-drain + D-09 timeout guard use filtered variants)
  - tests/native_owl.rs (regression guardrail integration test)
tech-stack:
  added: []
  patterns:
    - idempotent ALTER TABLE ADD COLUMN migration (mirrors existing delivered column)
    - filtered-variant mirror functions (drain_non_deferred ↔ drain_all)
requirements:
  - QUICK-260418-n7k
key-files:
  modified:
    - src/common/spool.rs
    - src/owl/send.rs
    - src/owl/poll.rs
    - tests/native_owl.rs
decisions:
  - Filter at consumer, not at writer — drain_all / peek_all / pending_count stay unfiltered so hook consumers (hook_check, hook_prompt) and failsafe drains (startup, TCP-wake idle, D-09 5-min final, TCP-respool) keep seeing deferred rows. Only the wake-signal check sites filter.
metrics:
  duration: ~6 min
  tasks: 3
  commits: [dbb973d, a0d6de4, 47fe073]
  completed: 2026-04-18
---

# Quick 260418-n7k: SubagentStart WORKING_PERCH_NOTICE Still Wakes Parent Poll — Summary

**One-liner:** Added `deferred=1` marker column to spool so the poll idle-drain skips SubagentStart notices while hook consumers still deliver them.

## Diagnosis

The prior quick task `260414-8co` wired `hook_subagent_start.rs:115` to call `send::deliver_body_deferred(...)` for the WORKING_PERCH_NOTICE — correctly tagging the emission site. But the regression persisted: the parent's poll listener still woke ~500 ms after each SubagentStart.

Tracing the wake path:

1. `hook_subagent_start.rs:115` → `send::deliver_body_deferred` (correct)
2. `send.rs:254-257` → `spool::spool_message(...)` — identical to normal offline-spool fallback. **No differentiation downstream.**
3. `spool.rs:49-57` → writes row, calls `inbox::set_has_messages()`
4. `poll.rs:270-284` — **the actual wake signal.** When `.idle-ready` is set, the poll loop unconditionally calls `spool::drain_all(...)` every ~500 ms. Any row returned causes `close_busy` + return (i.e., wake).

So the deferred *call site* was tagged, but the deferred *path* was a no-op: the spool row carried no marker, and the idle-drain returned it like any other message.

## Fix

Plumb a boolean through to the wake-signal suppression check sites:

| Layer | Change |
|-------|--------|
| **Schema** | Added `deferred INTEGER NOT NULL DEFAULT 0` column to `messages` table, with idempotent ALTER for existing DBs. |
| **Writer** | Added `spool_message_deferred` (`deferred=1`). `spool_message` now writes explicit `deferred=0`. |
| **Reader** | Added `drain_non_deferred` (mirror of `drain_all` + `AND deferred=0`) and `pending_count_non_deferred`. |
| **Emission** | `deliver_body_deferred` and `run_deferred` (the `owl send --deferred` CLI entry) now call `spool_message_deferred`. |
| **Wake-path suppression** | `poll.rs:272` idle-loop drain → `drain_non_deferred`. `poll.rs:197` D-09 timeout cancellation guard → `pending_count_non_deferred`. |

### Intentionally unchanged

These sites keep the unfiltered variants so deferred rows are never orphaned:

- `poll.rs:125` — startup flush (`drain_all_with_metadata`)
- `poll.rs:202` — D-09 5-minute failsafe final drain (`drain_all`)
- `poll.rs:248` — TCP-wake idle drain (`drain_all`) — wake already happened via an ordinary non-deferred TCP message, so ride-along delivery is correct
- `poll.rs:260` — TCP-respool (`spool_message`) — the inbound TCP message IS a normal (non-deferred) message
- `hook_check.rs` / `hook_prompt.rs` — `peek_all` + `mark_delivered` (unfiltered by design; hooks deliver on natural cadence)

Net result: WORKING_PERCH_NOTICE rows sit silently in the spool with `deferred=1` until the parent's PreToolUse or UserPromptSubmit hook fires, at which point they surface via the hook's peek_all/mark_delivered path. The poll listener is no longer woken.

## Files Modified

- `src/common/spool.rs` — 3 new pub fns, schema+migration, 7 unit tests (+1 helper)
- `src/owl/send.rs` — 2 line swaps (`spool_message` → `spool_message_deferred`)
- `src/owl/poll.rs` — 2 line swaps (idle-drain + D-09 guard)
- `tests/native_owl.rs` — 1 integration test (`deferred_send_does_not_wake_idle_poll`)

## Tests

**Unit (src/common/spool.rs)** — 7 new + 9 existing:
- `test_spool_message_deferred_sets_flag`
- `test_spool_message_default_not_deferred`
- `test_drain_non_deferred_skips_deferred_rows`
- `test_drain_non_deferred_empty_when_only_deferred`
- `test_pending_count_non_deferred_ignores_deferred`
- `test_drain_all_still_returns_deferred` (hook-path invariant)
- `test_schema_migration_adds_deferred_column_idempotent`

**Integration (tests/native_owl.rs)**:
- `deferred_send_does_not_wake_idle_poll` — spawns `owl poll parent --setup`, sends deferred message, asserts poll still running + no stdout after 1.5 s (>=3 idle iterations), then sends normal message, asserts poll wakes within 2 s and drains both (deferred rides out on wake via the TCP-wake drain_all path).

**Full suite:** `cargo test` → 51 lib + 22 native_owl + 43 cli_parse + 8 hook_chain + 9 golden_live + 7 golden_owl + 13 result_variants = 153 tests, 0 failures.

**Release build:** `cargo build --release` clean (4 pre-existing warnings unrelated to this task).

## Deviations from Plan

None — plan executed exactly as written.

The plan's self-check assertion #5 (`rg "spool::spool_message\b" src/owl/send.rs` returns NO hits) slightly over-reaches: line 96 inside `deliver_message` (the normal TCP-fallback spool path) correctly retains `spool_message` — this is the non-deferred path and MUST stay. The plan's surrounding prose acknowledges this ("Normal send paths still call via deliver_message which uses spool::spool_message internally"). The functional check that matters — both deferred call sites (`deliver_body_deferred`, `run_deferred`) now route through `spool_message_deferred` — holds.

## Commits

- `dbb973d` — feat(quick-260418-n7k): add deferred column + spool helpers
- `a0d6de4` — feat(quick-260418-n7k): wire deferred variants into send + poll idle-drain
- `47fe073` — test(quick-260418-n7k): integration test — deferred send does not wake idle poll

## Self-Check: PASSED

- `src/common/spool.rs` — FOUND (207 insertions)
- `src/owl/send.rs` — FOUND (2 edits)
- `src/owl/poll.rs` — FOUND (2 edits)
- `tests/native_owl.rs` — FOUND (+150 lines)
- Commit `dbb973d` — FOUND in git log
- Commit `a0d6de4` — FOUND in git log
- Commit `47fe073` — FOUND in git log
- `cargo test` full suite — GREEN (153 tests, 0 failures)
- `cargo build --release` — GREEN (no new warnings)
