# Quick 260513-63n: wire TCP wake into psyche-wrapper inner poll — Research

**Researched:** 2026-05-13
**Domain:** Live psyche-wrapper IPC, OS-level socket readiness, subprocess control flow
**Confidence:** HIGH

## Summary

The 20-min latency is NOT caused by a missing TCP-wake primitive in the inner poll — it is caused by the inner poll's **idle-ready stream behavior**: when a TCP message arrives at the `<psyche_id>` perch, the inner poll IS woken by `WSAPoll`/`libc::poll`, but it then takes the IDLE-MODE branch, **prints to stdout, and stays in the loop** (`once=false` at default). The wrapper consumes stdout only via `Command::output()`, which buffers stdout until child exit. The child only exits on `--pulse-interval` expiry. Result: stdout is buffered for up to 20 min.

**Primary recommendation:** Pass `--once` to the inner poll invocation in `src/live/wrapper/mod.rs::poll_psyche`. Adds two argv slots (`"--once"`) and zero new code paths. Inner poll already runs `check_message_blocking_until(pulse_deadline)` via `PollListener` and already supports `--once` via `if once { poll_listener.close_busy(); return; }` at three exit sites (post-spool-drain, post-IDLE-MODE TCP wake, post-spooltimeout-drain). This makes the wrapper exit-and-respawn per message — matching the existing PULSE_TRIGGER cadence semantics.

## Architectural Responsibility Map

| Capability | Primary Tier | Secondary Tier | Rationale |
|------------|-------------|----------------|-----------|
| OS-level socket-readiness wait | `common::listener::poll_ready` | — | Already cross-platform (libc::poll / WSAPoll) |
| Per-iteration deadline computation | `owl::poll::compute_loop_deadline` | — | Already extracted, unit-tested |
| Inbound message → wrapper bridge | `src/owl/poll.rs` (subprocess) | `src/live/wrapper/mod.rs` (parent) | Subprocess exit is the bridge mechanism |
| Pulse-cadence timer | `--pulse-interval` flag in `owl::poll::run` | — | Already wired |
| Subprocess wait + stdout capture | `wrapper::poll_psyche` via `Command::output` | `win_spawn::spawn_capture_no_inherit` | Buffers stdout until exit — locks the bug |

## User Constraints (project memory)

- Windows-native portability hard constraint — any new poll usage must already have WSAPoll equivalent. `common::listener::poll_ready` already does this; no new platform shim needed.
- Zero new runtime deps. The fix uses only argv string additions.
- Worktrees disabled — single-branch main work.
- Backward compat for skills — `--once` is an existing internal flag, no skill changes.

## Q1: Reference impl — how does the Self listener TCP-wake?

[VERIFIED: read `src/common/listener.rs:24-56`, `:142-175`]

- `poll_ready(listener, timeout_ms)` is the OS-level primitive:
  - **Unix** (`:24-43`): `libc::poll(&mut pollfd, 1, timeout_ms)` on `listener.as_raw_fd()` with `POLLIN`; retries on `EINTR`.
  - **Windows** (`:45-56`): `WSAPoll(&mut WSAPOLLFD, 1, timeout_ms)` on `listener.as_raw_socket()` with `POLLRDNORM`.
- `check_message_blocking_until(deadline)` (`:162-175`): caps wait to `min(timeout_ms, deadline - now)` clamped to `[0, i32::MAX]`, calls `poll_ready`, then `accept_one_message()` reads a framed message via `protocol::read_message`.
- In `src/owl/poll.rs:402` Self's loop iteration calls `poll_listener.check_message_blocking_until(loop_deadline)` where `loop_deadline` is `min(base_timeout, next_pulse_epoch)` (`:396-397`, `:626-641`).

So the OS-wake primitive is shared infrastructure — already lives in `common::listener`, already used by `src/owl/poll.rs` which IS the inner poll subprocess that the wrapper spawns. **The wake works; the exit is what's missing.**

## Q2: Current state — where does the wrapper poll block?

[VERIFIED: read `src/live/wrapper/mod.rs:343-425`]

The wrapper does NOT block in-process. It **spawns a subprocess**:

```
src/live/wrapper/mod.rs:346-353
  args = ["poll", &self.psyche_id, "listen", "--psyche", "--pulse-interval", &period_str]
  // Unix: Command::output()                            (line 380)
  // Windows: win_spawn::spawn_capture_no_inherit(...)  (line 409)
```

Both call sites are **synchronous-collect-all-output** APIs:
- `Command::output()` on Unix returns `Output { stdout, stderr, status }` — populated by reading both pipes to EOF after the child exits.
- `spawn_capture_no_inherit` on Windows has the same contract (per the bug name and Phase 18.5 changelog).

The subprocess is `owl poll <psyche_id> listen --psyche --pulse-interval 1200`. Inside that subprocess, the same `compute_loop_deadline` + `check_message_blocking_until` pair runs the OS-level TCP wake. **So TCP-wake works at the subprocess level — but its `println!` outputs are buffered into the captured pipe and only flushed to the wrapper when the child exits.**

The child exits via three paths in `src/owl/poll.rs`:

| Exit Site | Trigger | Stdout | Stderr |
|-----------|---------|--------|--------|
| `:247-254` | `pulse_deadline` reached | `println!("PULSE_TRIGGER ({})", ts)` then `return` | empty |
| `:300-321` | stop_flag / ready-gone / poison | (none — just status to stderr) | `TERMINATED:{}` |
| `:419-422` | TCP message arrives AND `once==true` AND idle-ready | message printed | — |
| `:481-484` | spool drained AND `once==true` AND idle-ready | spool printed | — |
| `:277-280, :289-292` | spool timeout AND `once==true` | spool printed | `TIMEOUT:{}` |

The wrapper currently passes neither `--once` nor a `--setup` flag and (most importantly) **does not pass `--once`**. So none of the message-driven exit sites fire — the subprocess prints messages and stays alive (`continue` loop arms `inbox::set_idle_ready` at `:426, :487`). Stdout sits in OS pipe buffer until pulse expiry.

Symmetry observation:
- Self listener (foreground in `src/live/start.rs:278`): called with `once=false` because Self consumes its own stdout in-process as Claude Code's foreground Bash output — every `println!` is visible to the human immediately, no pipe-buffering issue.
- Psyche wrapper (subprocess): runs the SAME poll function but the parent uses `Command::output` to read stdout, which buffers. So the `once=false` stream model is wrong for this caller.

Important secondary asymmetry: **the inner poll's idle-mode branch (line 405) wraps in `if inbox::is_idle_ready(id)`** — and the inner poll itself calls `set_idle_ready(id)` at line 192 (post-bind) and re-arms it at lines 426 and 487. So the psyche perch is always idle-ready inside the subprocess. The respool-only branch (`:427-437`) is unreachable in this caller because nothing clears idle-ready externally.

## Q3: Minimal fix shape

[VERIFIED: cross-referenced `src/live/wrapper/mod.rs:343-425` with `src/owl/poll.rs:419-422` `once` arms]

**Two-character semantic, six-character argv addition.** In `src/live/wrapper/mod.rs`:

```rust
// Current (line 346-353):
let args: [&str; 6] = [
    "poll",
    &self.psyche_id,
    "listen",
    "--psyche",
    "--pulse-interval",
    &period_str,
];

// Proposed:
let args: [&str; 7] = [
    "poll",
    &self.psyche_id,
    "listen",
    "--psyche",
    "--pulse-interval",
    &period_str,
    "--once",
];
```

That's it. Both the Unix `Command::args(args)` (line 371) and the Windows `win_spawn::spawn_capture_no_inherit(&self.exe, &args, &envs)` (line 409) accept the same slice, so the array-length bump propagates cleanly. No `mod.rs` other than `wrapper/mod.rs` needs editing. No new functions, no new platform shims, no Cargo deps.

Behavioral change:
- BEFORE: wrapper spawns subprocess; subprocess runs `check_message_blocking_until` in a loop, prints messages to stdout (buffered in pipe), stays alive until pulse-deadline. Wrapper gets bulk-of-messages on every 20-min boundary.
- AFTER: wrapper spawns subprocess; subprocess runs `check_message_blocking_until`; on first TCP wake AND idle-ready (always true for psyche perch by `:192`), it drains spool + prints message + `poll_listener.close_busy(); return;` (line 419-422). Wrapper's `Command::output()` returns immediately with the message in stdout. Wrapper loop iteration runs `resume_session_checked(&msg)`, drains markers, then loops back to spawn a fresh subprocess. PULSE_TRIGGER cadence preserved because the same subprocess exit also handles `pulse_deadline` expiry the same way as today.

Code paths confirmed reachable under `--once`:
- TCP-arrival path (`:402-422`): drains spool first via `spool::drain_all`, prints all spool entries + the TCP message, then exits. **This handles the "queued echo-commune lost" scenario from cycle 3 of the debug doc** — any backlog drains before the subprocess returns.
- Pulse-deadline path (`:247-254`): exits with `PULSE_TRIGGER` body. Existing wrapper PULSE_TRIGGER branch at `mod.rs:197` continues to work unchanged.
- Spool-only path (no TCP wake, but spool had stuff): handled at `:468-492` — same drain+exit shape under `once=true`.

## Q4: Pitfalls

### P1: Wrapper perch identity (different from Self perch — but same spool semantics)

[VERIFIED: `src/live/wrapper/mod.rs:347` (poll target = `&self.psyche_id`) vs `src/live/start.rs:278` (poll target = `id` i.e. self_id)]

- Self listener targets `<self_id>` perch (e.g. `doyle`).
- Wrapper inner poll targets `<self_id>-psyche` (e.g. `doyle-psyche`).
- Both use `common::spool::spool_message(target_id, …)` which is keyed by `target_id`, so the rows are correctly partitioned. No cross-talk risk.
- Both use `common::listener::PollListener::bind_blocking(id, …)` which registers an independent registry entry per id. No port collision.

The `__REPLY_TO__:<sender>\n<body>` wire format is preserved through spool (raw bytes) and the wrapper's downstream marker-parser at `mod.rs:215-217` already strips `__REPLY_TO__:` correctly. No format changes needed.

### P2: Double-drain / drop race when TCP-wake and pulse-deadline fire near-simultaneously

[VERIFIED: read `src/owl/poll.rs:402-422` vs `:247-254`]

Inside the inner poll loop, `pulse_deadline` is checked **before** `check_message_blocking_until` each iteration (`:247-254` runs at the top, well before `:402`). So:

- If pulse_deadline has already expired when the loop tops, it exits with `PULSE_TRIGGER` and the TCP message stays in the spool. The next subprocess spawn re-enters with empty pulse, calls `drain_all_with_metadata` at `:146` (the post-bind spool drain), pulls the queued message + emits `<EVENT>` lines + exits if `once=true`. **No drop.**
- If a TCP message arrives during `check_message_blocking_until` AND the pulse-deadline expires within the same poll wait window, the wake fires for whichever event the OS signals first. If TCP wins, message is delivered and subprocess exits via the once-arm. If pulse wins (unlikely since `compute_loop_deadline` already caps to the smaller of base-timeout and pulse-epoch), the loop tops, checks pulse_deadline, exits with PULSE_TRIGGER, and the TCP message goes back through TCP-respool on next bind via the standard `--respool on busy listener` path. **No drop.**

No double-drain risk because both arms exit via `return` after their respective `close_busy()`. The subprocess is single-threaded; there is no concurrent re-entrance.

### P3: Echo-commune subprocess vs TCP-wake

[VERIFIED: `src/live/wrapper/echo_fire.rs:176-208`]

`fire_echo_commune_if_due` spawns echo-commune **before** entering `poll_psyche` (line 98 in `wrapper/mod.rs`). It uses `setsid()` on Unix and `spawn_detached_no_inherit` on Windows — both DETACHED. The echo-commune child does not inherit the wrapper's stdio handles and does not interfere with the wrapper's subsequent `Command::output()` on a fresh poll subprocess.

The echo-commune itself (`src/owl/echo_commune.rs`) writes to the **psyche perch's** spool via the normal `$OWL deliver` codepath — this is one of the paths that gets stuck behind the 20-min wait in today's bug. With `--once` the echo-commune's spool write will be drained on the next iteration's `drain_all_with_metadata` at `poll.rs:146`. **The same fix that fixes user-typed `$LIVE commune` also fixes echo-commune delivery.**

### P4: Phase 18.4 HANDOFF rehydration

[VERIFIED: `src/live/wrapper/lifecycle.rs:17-55`, `src/common/wrapper_state.rs` via `wrapper_state::WrapperHandoffState` struct usage]

`wrapper-state.json` serializes:
- `session_uuid`
- `gen`
- `last_fresh_launch_epoch`

It does NOT serialize any poll-loop state, exit-code semantics, or subprocess argv. The HANDOFF child runs `WrapperState::new` → `run` and reaches the same `poll_psyche` callsite where the new argv is constructed fresh from `self.psyche_id` and `self.period`. **`--once` is purely a per-spawn argv addition — handoff rehydration carries nothing that needs migration.**

Also: the existing `OWL_UNDER_WRAPPER=1` env passing at `mod.rs:357-368` (Unix) and `:399-407` (Windows) is preserved unchanged — the new argv slot does not interact with env vars. The Bug #12 defer-on-handoff path (exit code 2 + `HANDOFF_DEFER:` stderr) at `poll.rs:329-338` runs strictly BEFORE `check_message_blocking_until` (per loop structure at `:328-391`), so `--once` does not change handoff semantics.

### P5: Phase 18.7 listener-owned firing (timed alarms)

[VERIFIED: `src/owl/poll.rs:206-224, :396-461`]

Phase 18.7 wired timed-pulse firing into the Self listener via `new_alarm::drain_due_into_cache` (after each TCP-wake) and `compute_loop_deadline` (cap wait by next pulse epoch). These are owned by the **Self** listener's poll loop (passes `id = self_id`), not the psyche wrapper's (passes `id = psyche_id`).

Cross-impact check: `new_alarm::next_pulse_epoch(&cached_entries)` reads `pulse_file = owlery::pulse_dir().join(format!("{}.json", id))` at `poll.rs:208`. For the inner-poll psyche invocation, `id = doyle-psyche`, so it reads `pulses/doyle-psyche.json` — which **never exists** because `$OWL new-alarm` only ever targets self_id, not psyche_id. The cached_entries vec is always empty inside the psyche subprocess, `next_pulse_epoch` returns `None`, `compute_loop_deadline` falls through to `base_timeout` (500ms) capped by `pulse_interval` deadline. **No interference with `--once` semantics; no interference with listener-owned firing.**

### P6: Spool-drain summary line under `--once`

[VERIFIED: `src/owl/poll.rs:152-164`]

When `--once` fires on initial spool-drain (`:146`), with `len > 1` messages, a `DRAIN:N queued messages (senders: …)` status goes to stderr via `output::owl_status`. The wrapper's `poll_psyche` already logs both `out.exit_code` and `stderr.trim()` at `:384-388` (Unix) and `:411-415` (Windows). No behavior change — the status line was already emitted (and ignored) when pulse expiry drained spool. No regression.

### P7: Empty-stdout safety backstop

[VERIFIED: `src/live/wrapper/mod.rs:78-181`]

The wrapper has a `MAX_CONSECUTIVE_EMPTY_EXITS = 3` backstop (`:87-88`) and `consecutive_empty` counter (`:164-181`) for the Windows TerminateProcess-by-job-object hazard. Under `--once`, the inner poll EXITS on every TCP wake — so each delivery iteration produces NON-empty stdout, resetting `consecutive_empty = 0` at `:182`. The PULSE_TRIGGER exit also produces non-empty stdout (`PULSE_TRIGGER (ts)`). **The backstop continues to function unchanged; in fact the only path that produces empty stdout — Windows job-object kill — is the same as today.**

## Q5: Test surface

[VERIFIED: searched `tests/` for `poll_psyche|select_pulse_period|psyche_wrapper` — no integration tests today]

### Existing coverage
- `src/common/listener.rs:213-281`: three unit tests for `check_message_blocking_until` deadline semantics. Direct OS-wake correctness covered.
- `src/owl/poll.rs:643-753`: unit tests for `compute_loop_deadline`, `build_handoff_child_argv`, `is_handoff_child`. Argv-build paths covered.
- `src/live/wrapper/mod.rs:542-743`: unit tests for `select_pulse_period` and `compose_passive_context`. Pulse-period override math covered.
- `tests/golden_owl.rs`, `tests/golden_live.rs`: stdout fixture diffs. **Will need a new psyche-wrapper fixture only if we change the wire format — we don't.**

### New coverage proposed

Two tests, both unit-level (no integration spawn needed):

1. **`poll_psyche_passes_once_flag`** — verify the argv built in `poll_psyche` contains `"--once"`. Mirror Phase 18.5's `under_wrapper_env_name_matches_poll_contract` and `poll_exit_code_contract_is_2_for_defer` contract tests: extract the argv construction into a `pub(crate) fn build_psyche_poll_argv(period: u64) -> [&'static str; 7]` or similar, unit-test the slice contents. Lives in `src/live/wrapper/mod.rs` test module.

2. **`once_exits_after_idle_mode_tcp_delivery`** — already covered by `src/owl/poll.rs` line 419-422 source — but no test asserts it today. Either add a `--once` arm to an existing poll_listener test (in `tests/native_owl.rs` if one exists for poll behavior, otherwise write a small bin-style integration test that:
   - sets up a perch with idle-ready
   - spawns `owl poll <id> listen --once --pulse-interval 5`
   - delivers a TCP message
   - asserts child exits in < 1s with message body on stdout

   Optional — the existing `--once` path is already used by the legacy Bash-fallback callers, so it has live coverage in production.

### Integration tests to add (recommended but not strictly required)

A wrapper-level integration test in `tests/` that:
- spawns `_psyche-wrapper` via `Command`
- sends a TCP message to its psyche perch
- waits with a < 30s timeout for the wrapper log to show "poll returned N bytes" within seconds (not 20 min)

This is the **direct regression guard** for the cycle 4 trace. Without it, the bug class can silently regress.

## Open Questions

1. **Q-1 (planner decision):** Pass `--once` literally in argv, OR add a `Cli` flag (e.g. `--exit-on-message`) that's semantically equivalent but distinct from the legacy `--once`?
   - Recommendation: use `--once`. It already has the exact semantics needed (close_busy + return on first IDLE delivery). Two-char argv addition. Avoids new flag surface.
   - Risk: `--once` also returns on the initial spool drain at `:146` — but the wrapper's existing loop is designed to handle "poll returns, has body, resume claude, loop back to poll" so this is desirable, not problematic.

2. **Q-2 (planner decision):** Should the `--once` exit on `PULSE_TRIGGER` continue to skip the `set_idle_ready` re-arm? Currently at `:419-422` and `:481-484` the once-branch returns BEFORE the post-exit `inbox::set_idle_ready` line — leaving idle-ready cleared. Next spawn re-arms it at `:192`. This means a TCP race between this child's close and next spawn's bind could land a message in the registry-stale entry. Mitigation: same as today's pulse-exit behavior — `bind_blocking` rebinds and `drain_all_with_metadata` at `:146` catches anything that landed between spawns. No new risk.

3. **Q-3 (operational):** Pulse-interval default in `src/live/start.rs:59` is 1200 (20 min). With `--once`, the *effective* pulse period becomes "max gap with no inbound traffic" rather than "actual scheduled interval", which slightly changes wrapper behavior. PULSE_TRIGGER still fires every 1200s of no-message. Echo-commune gate is still time-windowed. Resume sessions may run more frequently (per-message rather than per-pulse). **Cost analysis: each resume costs ~30-60s of claude tokens.** If user-typed `$LIVE commune` arrives every 5 min during heavy work, that's 12 resume sessions/hour vs 3 today. Acceptable for correctness — these are the messages we WANT to flush — but worth flagging for user awareness.

## Environment Availability

| Dependency | Required By | Available | Version | Fallback |
|------------|------------|-----------|---------|----------|
| Rust toolchain | build | ✓ | per Cargo.toml | — |
| `cargo test` | unit tests | ✓ (project) | — | — |
| `cargo build --release` | deploy | ✓ (project) | — | — |

No new dependencies. No new platform tooling.

## Validation Architecture

### Test Framework
| Property | Value |
|----------|-------|
| Framework | `cargo test` (Rust 2021 edition) |
| Config file | `Cargo.toml` |
| Quick run command | `cargo test --lib live::wrapper` (wrapper unit tests only) |
| Full suite command | `cargo test` |

### Phase Requirements → Test Map
| Req ID | Behavior | Test Type | Automated Command | File Exists? |
|--------|----------|-----------|-------------------|-------------|
| REQ-WAKE-01 | poll_psyche argv contains `--once` | unit | `cargo test --lib live::wrapper -- once_in_argv` | ❌ Wave 0 |
| REQ-WAKE-02 | inner poll exits on TCP delivery under `--once` | already covered by source semantics; optional integration | (manual or new integ test) | optional |
| REQ-NO-REG-01 | PULSE_TRIGGER cadence still fires after no-message interval | manual UAT | — | manual-only |
| REQ-NO-REG-02 | Echo-commune content reaches psyche.md within seconds of fire | manual UAT (Cycle 4 trace repro) | — | manual-only |

### Sampling Rate
- Per task commit: `cargo test --lib live::wrapper`
- Per wave merge: `cargo test`
- Phase gate: full `cargo test` green + manual repro of Cycle 4 trace (commune within 30s of send)

### Wave 0 Gaps
- New unit test in `src/live/wrapper/mod.rs#tests` for argv shape — single test, no infra needed
- Optional: new integration test `tests/psyche_wrapper_tcp_wake.rs` — spawn `_psyche-wrapper`, deliver TCP, assert < 30s flush. Defer to follow-up if time-constrained.

## Sources

### Primary (HIGH confidence)
- `src/owl/poll.rs` (read in full) — inner poll structure, `--once` exit sites at lines 247, 419, 481, 277; idle-ready set at line 192
- `src/live/wrapper/mod.rs` (read in full) — `poll_psyche` subprocess spawn at lines 343-425, argv slot at lines 346-353, `Command::output()` buffer-until-exit at lines 380, 409
- `src/common/listener.rs` (read in full) — `poll_ready` Unix/Windows at lines 24-56, `check_message_blocking_until` at lines 162-175
- `src/live/wrapper/echo_fire.rs` (read in full) — confirms detached spawn, no stdio inherit
- `src/live/wrapper/lifecycle.rs` (read in full) — handoff state has zero poll-state fields
- `src/live/start.rs` (read in full) — confirms Self listener calls `poll::run(…, once=false)` because it consumes own stdout in-process
- `.planning/debug/psyche-stale-after-clear.md` Cycle 4 trace — empirical confirmation of 20-min latency
- `src/common/spool.rs:1-80` — confirms per-perch SQLite spool, target_id partitioning

### Secondary (MEDIUM confidence)
- Phase 18.5 SUMMARY notes (CLAUDE.md / STATE.md decisions log) — inner-poll handoff exit-code 2 contract preserved alongside new `--once` argv slot

## Assumptions Log

| # | Claim | Section | Risk if Wrong |
|---|-------|---------|---------------|
| — | — | — | — |

(No assumed claims — every behavioral claim cites a file:line.)

## Metadata

- **Standard stack confidence:** HIGH — all infra exists in `common::listener` and `owl::poll`
- **Architecture confidence:** HIGH — `--once` semantics already in production via legacy Bash-fallback callers (see `src/owl/poll.rs:172-175, :419-422, :481-484, :277-280, :289-292`)
- **Pitfalls confidence:** HIGH — each pitfall directly traced to file:line
- **Research date:** 2026-05-13
- **Valid until:** 30 days (stable infrastructure)
