---
phase: 03-retry-schedule-reshape
plan: 02
type: execute
wave: 2
depends_on:
  - "03-01"
files_modified:
  - src/lifx/const.py
  - src/lifx/network/connection.py
  - tests/test_network/test_connection_retry.py
autonomous: true
requirements:
  - RETRY-01
  - RETRY-02
  - RETRY-03
  - RETRY-04
user_setup: []

must_haves:
  truths:
    - "On a healthy network a request transmits exactly one packet: the first-attempt window is floored at 0.2 s with escalating gaps thereafter (RETRY-01, D3-01)"
    - "A response arriving between retransmits is consumed the moment it arrives — there is no blind sleep anywhere in the request paths (RETRY-02, D3-02)"
    - "A failing request's elapsed wall time never exceeds its timeout plus scheduler tolerance, on both GET and ACK paths (RETRY-03, D3-03)"
    - "Late replies to any sequence issued for the logical request are accepted on both GET and ACK paths; duplicates are silently discarded, never protocol errors (RETRY-04, D3-04)"
    - "Existing callers work unmodified: request()/request_stream() signatures, timeout and max_retries parameters, _request_stream_impl/_request_ack_stream_impl names and signatures (the 12+ test mock seam), and StateUnhandled semantics are all preserved (D3-05, D3-06)"
    - "The full suite passes with ONLY the three enumerated TestRetryTimeoutBudget tests changed from the pre-phase baseline"
  artifacts:
    - path: "src/lifx/const.py"
      provides: "REQUEST_RETRANSMIT_GAPS Photons-shaped schedule constant"
      contains: "REQUEST_RETRANSMIT_GAPS"
    - path: "src/lifx/network/connection.py"
      provides: "_transmit_and_listen shared engine (wall deadline, retransmit-while-listening, shared-queue correlation) plus thin _request_stream_impl/_request_ack_stream_impl wrappers"
      contains: "_transmit_and_listen"
  key_links:
    - from: "src/lifx/network/connection.py"
      to: "src/lifx/const.py"
      via: "import plus RUNTIME read of the module attribute REQUEST_RETRANSMIT_GAPS inside the helper body (not a def-time default) so tests can patch lifx.network.connection.REQUEST_RETRANSMIT_GAPS"
      pattern: "REQUEST_RETRANSMIT_GAPS"
    - from: "src/lifx/network/connection.py::_request_stream_impl"
      to: "src/lifx/network/connection.py::_transmit_and_listen"
      via: "async-for delegation with ack_required=False, res_required=True, timeout_noun='response'"
      pattern: "_transmit_and_listen"
    - from: "src/lifx/network/connection.py::_request_ack_stream_impl"
      to: "src/lifx/network/connection.py::_transmit_and_listen"
      via: "async-for delegation with ack_required=True, res_required=False, timeout_noun='acknowledgement'; StateUnhandled check stays in the wrapper"
      pattern: "_transmit_and_listen"
    - from: "src/lifx/network/connection.py::_transmit_and_listen"
      to: "src/lifx/network/connection.py::_background_receiver"
      via: "the unchanged (source, sequence, serial) → shared queue registration in _pending_requests; one key per transmission, all popped in the helper's finally"
      pattern: "_pending_requests"
---

<objective>
Implement the Photons-shaped retry reshape in `src/lifx/network/connection.py` and make
every plan 03-01 RED test pass: add `REQUEST_RETRANSMIT_GAPS` to `const.py` (D3-01,
D3-05), extract one shared `_transmit_and_listen()` engine with a single monotonic wall
deadline and retransmit-while-listening (D3-02, D3-03), reduce both impls to thin
wrappers with unchanged names and signatures (the mock seam), move the ACK path onto the
shared-queue correlation contract so late ACKs are accepted (D3-04), and delete the
exponential-window/jitter machinery outright. Scope is the connection.py request paths
only (D3-06) — no discovery, animation, or ack-semantics changes.

Then prove the 100% branch patch coverage gate locally and run the full-suite regression.

Purpose: closes RETRY-01, RETRY-02, RETRY-03, and RETRY-04 — the whole Phase 3 goal.
Output: `REQUEST_RETRANSMIT_GAPS` in const.py; `_transmit_and_listen` plus thin wrappers
in connection.py; all plan 03-01 tests GREEN; full suite green with branch patch coverage
complete.
</objective>

<execution_context>
@$HOME/.claude/gsd-core/workflows/execute-plan.md
@$HOME/.claude/gsd-core/templates/summary.md
</execution_context>

<context>
@.planning/STATE.md
@.planning/ROADMAP.md
@.planning/phases/03-retry-schedule-reshape/03-CONTEXT.md
@.planning/phases/03-retry-schedule-reshape/03-RESEARCH.md
@.planning/phases/03-retry-schedule-reshape/03-PATTERNS.md
@.planning/phases/03-retry-schedule-reshape/03-01-SUMMARY.md
@.claude/skills/spike-findings-lifx-async/references/retry-schedule.md
@src/lifx/network/connection.py
@src/lifx/const.py
@src/lifx/network/discovery.py
</context>

<tasks>

<task type="auto" tdd="true">
  <name>Task 1: Implement the shared transmit-and-listen engine (GREEN)</name>
  <files>src/lifx/const.py, src/lifx/network/connection.py</files>
  <read_first>
    - .planning/phases/03-retry-schedule-reshape/03-RESEARCH.md — §Q1 (the authoritative per-line edit tables for module level, _request_stream_impl, and _request_ack_stream_impl — verify line numbers against the current file before editing), §Q2 (the reshaped loop contract and its five key properties), §Q3 (unification shape and the preserved-differences table), §Q4 (constant text, max_retries interaction rule, sequence numbering), §Anti-Patterns, §Common Pitfalls 1-4 and 7
    - tests/test_network/test_connection_retry.py — the RED tests from plan 03-01 that define GREEN
    - tests/test_network/test_concurrent_requests.py — the rewritten TestRetryTimeoutBudget (the RETRY-03 contract)
    - src/lifx/network/discovery.py — the shipped Phase 2 interleave (~L249-304): the proven due-send/slice-cap/continue shape this loop mirrors
    - .claude/skills/spike-findings-lifx-async/sources/002-retry-storm-vs-fresh-deadline/race.py — regime_photons() (~L248-283), the raced reference for the send-while-listening shape
  </read_first>
  <behavior>
    GREEN is defined entirely by the Wave-0 tests: all 17 tests in
    tests/test_network/test_connection_retry.py pass, all 3 rewritten
    TestRetryTimeoutBudget tests pass, and every other existing test passes unmodified —
    in particular the 12+ tests in test_connection.py that patch
    _request_stream_impl/_request_ack_stream_impl as their mock seam, and the
    StateUnhandled emulator tests (GET yields the packet, SET raises
    LifxUnsupportedCommandError).
  </behavior>
  <action>
    Implement per the 03-RESEARCH.md edit tables (§Q1), loop contract (§Q2), and
    unification shape (§Q3). The research file is authoritative for line-level detail;
    the binding decisions are D3-01 through D3-06. Summary of the edits:

    1. `src/lifx/const.py`: after the DEFAULT_MAX_RETRIES block, add `REQUEST_RETRANSMIT_GAPS: Final[tuple[float, ...]]` with the value (0.2, 0.3, 0.4, 0.5, 0.7, 0.9, 1.0, 2.0, 3.0, 4.0, 5.0) — the exact expansion raced in spike 002 (1/180 failures across all loss rates). Leading comment (Australian English, mirroring the DISCOVERY_REBROADCAST_GAPS comment convention): Photons-shaped gaps in seconds between successive request transmissions; the first gap floors the first-attempt window (an acked bulb answers within 200 ms); after exhaustion the final gap repeats; retransmits are capped by max_retries and by the caller's wall-time budget, whichever binds first (D3-01).
    2. `src/lifx/network/connection.py` module level: remove the random import (its sole user is deleted below); add REQUEST_RETRANSMIT_GAPS to the existing lifx.const import block; delete the retry-sleep base constant and the dead idle-timeout constant flagged in research §Q1; add module attribute `_STREAM_IDLE_TIMEOUT: float = 2.0` (hoists the magic number from the stream impl; must be read at runtime inside the helper so tests can patch it).
    3. Class docstring: replace the exponential-backoff-and-jitter feature line with the new behaviour — wall-time timeout budget with escalating Photons-shaped retransmits that listen continuously between sends. `__init__` docstring: fix the timeout default to 16.0 (currently wrongly says 8.0 — research Pitfall 6) and document max_retries as the maximum retransmits within the wall-time budget: total transmissions at most max_retries + 1; after the cap the request keeps listening until the deadline; whichever of cap and budget binds first wins (D3-05).
    4. Delete the static jitter-sleep calculation method entirely (D3-02 — its one remaining test reference was removed by plan 03-01).
    5. Add the shared engine `_transmit_and_listen(self, request, timeout, max_retries, *, ack_required, res_required, timeout_noun)` as a private async generator yielding raw (LifxHeader, bytes) tuples, implementing research §Q2 exactly:
       - Owns the open guard and the timeout/max_retries None-default resolution (moved from the impls, kept ONLY here — preserve the existing pragma no cover markers on the guard and timeout-default lines; the max_retries None arm stays coverable per Pitfall 7 and matrix row B2).
       - One `allocate_source()` per logical request; ONE shared asyncio.Queue maxsize=100; a correlation_keys list; `deadline = time.monotonic() + timeout` computed once (D3-03).
       - Gaps iterator built by reading the MODULE attribute REQUEST_RETRANSMIT_GAPS at runtime (not a def-time default, not the imported-name-frozen-at-def pattern) with the repeat-final-gap default via next(gaps, last_gap); transmission 0 registers its key BEFORE sending (existing pattern), sends with the parameterised ack_required/res_required flags, then arms next_tx_at — None when max_retries is 0 (matrix row B3).
       - Loop, in order: (1) wall-deadline check — on expiry return if has_yielded else break to the final raise (RETRY-03); (2) idle check — only after has_yielded, against _STREAM_IDLE_TIMEOUT read at runtime, return on expiry (preserves stream semantics unchanged); (3) retransmit-due check gated on next_tx_at is not None AND not has_yielded AND now >= next_tx_at — register fresh key (sequence = transmission count, same numbering as today) with the SAME queue, send, advance the cap (next_tx_at becomes None once tx_count exceeds max_retries), then `continue` so the loop re-reads monotonic time (Pitfall 2); (4) compute the wait as deadline remainder, capped by next_tx_at when armed and un-yielded, and by the idle remainder when yielded; (5) `asyncio.wait_for(queue.get(), timeout=wait)` with `except TIMEOUT_ERRORS: continue` — the slice ended, the loop top decides why (D3-02: this replaces the jitter sleep where arrived responses sat unread); (6) the three validations moved VERBATIM from the current GET path — discovery-aware serial check, source match, sequence-in-issued-range via len(correlation_keys); (7) set has_yielded, refresh the idle clock, yield.
       - `finally`: pop ALL correlation keys (unchanged cleanup contract — late replies then hit _background_receiver's unmatched path, DEBUG-logged and discarded, which is D3-04's silent-discard requirement with zero new code).
       - After the loop breaks un-yielded, raise LifxTimeoutError with the message pattern "No {timeout_noun} from {ip} after {tx_count} attempts" — reporting transmissions actually sent keeps the surviving message assertions truthful (research §Q4; open question 1 resolved per research recommendation: the noun parameter gives one raise site, no wrapper re-raising).
       - PROHIBITED inside the helper (research §Anti-Patterns): any asyncio.sleep; any budget-exclusion bookkeeping; any defensive max()/floor clamp on the wait value — every operand is provably positive at its point of use and a clamp is an uncoverable branch that fails the 100% branch patch gate (proven in Phase 2, discovery.py); retransmitting after the first yielded response (would duplicate whole multi-response sets — Pitfall 1).
       - Full docstring on the helper documenting the schedule, the wall budget, the max_retries interaction rule, and the correlation contract (D3-04, D3-05).
    6. Reduce `_request_stream_impl` to a thin wrapper: UNCHANGED name and signature (mock seam for 12+ tests — locked by research §Q3); async-for over the helper with ack_required=False, res_required=True, timeout_noun="response", re-yielding (header, payload). Update its docstring to describe the delegation and the preserved multi-response idle-streaming semantics.
    7. Reduce `_request_ack_stream_impl` to a thin wrapper: UNCHANGED name and signature; async-for over the helper with ack_required=True, res_required=False, timeout_noun="acknowledgement"; on the first yielded header, raise LifxUnsupportedCommandError("Device does not support this command") when pkt_type equals the StateUnhandled constant, else yield True and return (D3-06: ack semantics unchanged). The per-attempt queue and strict same-sequence check are gone — that is the D3-04-mandated behaviour change (late ACKs now satisfy the request); the wrapper keeps ONLY the ack-specific semantics.
    8. Logging: keep the idle_timeout debug dict log (updating its method key to the helper's name); add a retransmit_sent debug dict log at each retransmit with class/method/action/sequence keys — the structured dict-log convention, no f-string logs (03-PATTERNS.md).
    9. Run the Wave-0 suites plus the seam file and iterate until GREEN. Do NOT modify any test to get there — if a test looks wrong, stop and re-read research §Existing Test Impact before touching anything. Commit message: feat(03-02): reshape request retries onto the Photons wall-time schedule.
  </action>
  <verify>
    <automated>uv run pytest tests/test_network/test_connection_retry.py tests/test_network/test_concurrent_requests.py tests/test_network/test_connection.py -x</automated>
  </verify>
  <acceptance_criteria>
    - `uv run pytest tests/test_network/test_connection_retry.py tests/test_network/test_concurrent_requests.py tests/test_network/test_connection.py -x` exits 0 (all Wave-0 tests GREEN; the entire mock-seam file passes unmodified)
    - `uv run python -c "import lifx.network.connection as c; assert hasattr(c, 'REQUEST_RETRANSMIT_GAPS'); assert hasattr(c, '_STREAM_IDLE_TIMEOUT'); assert not hasattr(c, '_RETRY_SLEEP_BASE'); assert not hasattr(c, '_DEFAULT_IDLE_TIMEOUT'); assert not hasattr(c.DeviceConnection, '_calculate_retry_sleep_with_jitter'); assert not hasattr(c, 'random')"` exits 0 (additions present, deletions complete, random import gone)
    - `git status --porcelain tests/` prints nothing for this task (GREEN reached without touching any test)
    - `uv run ruff format .`, `uv run ruff check . --fix`, and `uv run pyright` all pass clean
  </acceptance_criteria>
  <done>Both request paths run on the shared wall-deadline retransmit-while-listening engine; all Wave-0 tests and the entire mock-seam test file pass with no test modifications.</done>
</task>

<task type="auto">
  <name>Task 2: Branch-coverage audit and full-suite regression</name>
  <files>tests/test_network/test_connection_retry.py</files>
  <read_first>
    - .planning/phases/03-retry-schedule-reshape/03-RESEARCH.md — §Branch Coverage Matrix (rows B1-B16 with the arm-to-test mapping to audit against) and §Project Constraints (100% BRANCH patch coverage; check branch partials, not just lines)
    - .planning/phases/03-retry-schedule-reshape/03-VALIDATION.md — sampling-rate table (this task is the wave-merge and phase-gate check)
    - src/lifx/network/connection.py — the helper and wrappers as landed in Task 1
  </read_first>
  <action>
    Prove the CI coverage gate locally and close out the regression contract:

    1. Run `uv run pytest tests/test_network/ --cov-report=term-missing` (branch coverage is on via addopts). Inspect the src/lifx/network/connection.py row: every line AND every branch partial (NNN->MMM entries) within the Task 1 edit ranges must be absent from the Missing column. Audit against matrix rows B1-B16 — pay particular attention to the compound retransmit-due condition arms (B6/B6a/B6b), the wait-cap conditions (B8/B9), and both arms of the max_retries None default (B2, Pitfall 7).
    2. If any partial remains, add a narrow purpose-built test to tests/test_network/test_connection_retry.py using the established injection/spy helpers — do not restructure existing tests, and never add clamps or guards to production code to dodge a branch (that trades a covered branch for an uncoverable one).
    3. Run the full suite: `uv run --frozen pytest` — green, including emulator-marked tests. Compare against the pre-phase baseline: the ONLY behavioural test changes in this phase are the three TestRetryTimeoutBudget tests enumerated in research §Existing Test Impact; every other test passes unmodified. Confirm with `git status --porcelain` that nothing outside this plan's files_modified has changed.
    4. Suite timing sanity: failing-path tests now complete AT their timeout instead of overshooting (research §Existing Test Impact: the suite gets faster, nothing approaches pytest's 30 s cap). Note total runtime vs the ~109 s Phase 2 baseline in the summary.
    5. Record the Phase 5 handoff notes for the summary (D3-06 keeps these out of this phase): docs/api/network.md and docs/architecture/overview.md still describe exponential backoff and must be updated by the documentation phase; project CLAUDE.md still mentions a _request_lock that no longer exists (housekeeping note).
  </action>
  <verify>
    <automated>uv run --frozen pytest</automated>
  </verify>
  <acceptance_criteria>
    - `uv run pytest tests/test_network/ --cov-report=term-missing` shows no missing lines and no branch partials for src/lifx/network/connection.py within the Task 1 edit ranges
    - `uv run --frozen pytest` exits 0 (full suite green)
    - `uv run ruff check .` and `uv run pyright` remain clean after any coverage-driven test additions
    - The summary records the B1-B16 audit outcome and the Phase 5 handoff notes
  </acceptance_criteria>
  <done>Branch patch coverage for the reshaped request paths is complete locally, the entire suite passes, and the docs-rot handoff for Phase 5 is recorded.</done>
</task>

</tasks>

## Artifacts this phase produces (plan 02)

| Artifact | Kind | Location |
|----------|------|----------|
| `REQUEST_RETRANSMIT_GAPS: Final[tuple[float, ...]] = (0.2, 0.3, 0.4, 0.5, 0.7, 0.9, 1.0, 2.0, 3.0, 4.0, 5.0)` | New constant | `src/lifx/const.py` |
| `_transmit_and_listen()` — shared wall-deadline retransmit-while-listening engine (one source, shared queue, fresh sequence per retransmit, all-keys cleanup) | New private async generator | `src/lifx/network/connection.py` |
| `_STREAM_IDLE_TIMEOUT: float = 2.0` module attribute (patchable, replaces the inline magic number and the dead idle constant) | New module constant | `src/lifx/network/connection.py` |
| Thin `_request_stream_impl` / `_request_ack_stream_impl` wrappers — unchanged names/signatures (mock seam), GET/ACK semantics preserved | Modified generators | `src/lifx/network/connection.py` |
| Deletions: jitter-sleep helper, retry-sleep base constant, dead idle constant, budget-exclusion bookkeeping, random import | Removed code | `src/lifx/network/connection.py` |
| Corrected class/__init__ docstrings (wall-time budget description; timeout default 16.0; max_retries interaction rule) | Docs-in-code | `src/lifx/network/connection.py` |

<threat_model>
## Trust Boundaries

| Boundary | Description |
|----------|-------------|
| LAN → connection socket | Untrusted UDP datagrams cross into `_background_receiver` routing and the helper's validation |
| Library → LAN | Outbound retransmit load is now schedule-driven (at most max_retries + 1 transmissions per request) |

## STRIDE Threat Register

| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|-----------|----------|-----------|----------|-------------|-----------------|
| T-03-01 | Spoofing | `_transmit_and_listen` response acceptance | medium | mitigate | Three-way validation (serial/discovery-aware, source match, sequence-in-issued-range) moved verbatim and applied to EVERY issued sequence; source remains per-request random via allocate_source(); routing key (source, sequence, serial) unchanged |
| T-03-02 | DoS (inbound flood) | shared response queue | low | mitigate | Bounded Queue(maxsize=100) with the unchanged QueueFull warning in `_background_receiver`; the wall deadline hard-caps request lifetime regardless of response volume |
| T-03-03 | DoS (outbound, self-inflicted) | retransmit scheduler | low | mitigate | Escalating gaps + max_retries cap + retransmits stop on first response: worst case 9 packets/request at defaults (same as today), 1 vs 1.37 on healthy networks (spike 002) |

No packages are installed by this plan (zero-dependency project) — no supply-chain gate applies.
</threat_model>

<verification>
- `uv run pytest tests/test_network/test_connection_retry.py -q` — all branch-matrix tests green
- `uv run pytest "tests/test_network/test_concurrent_requests.py::TestRetryTimeoutBudget" -q` — wall-time contract green
- `uv run pytest tests/test_network/test_connection.py -q` — mock-seam file green, unmodified
- `uv run pytest tests/test_network/ --cov-report=term-missing` — no missing lines or branch partials in src/lifx/network/connection.py for the edited ranges
- `uv run --frozen pytest` — full suite green
- `uv run ruff format . && uv run ruff check . && uv run pyright` — clean
</verification>

<success_criteria>
- Healthy network sends exactly 1 packet per request (RETRY-01; baseline 1.37 packets/trial)
- A response arriving between retransmits completes the request immediately (RETRY-02; no blind sleeps survive anywhere in the request paths)
- Elapsed wall time ≤ timeout + 0.3 s on both GET and SET failure paths (RETRY-03; a 16 s budget can never take 29 s)
- Late replies from earlier retransmits accepted on both paths; duplicates and post-cleanup stragglers silently discarded at DEBUG (RETRY-04)
- Public API and mock-seam signatures unchanged; StateUnhandled semantics unchanged (D3-05, D3-06)
- 100% branch patch coverage on the new/changed lines, locally verified before the phase gate
</success_criteria>

<output>
Create `.planning/phases/03-retry-schedule-reshape/03-02-SUMMARY.md` when done, recording:
the final edit shape, the coverage audit result against matrix rows B1-B16, full-suite
count and runtime vs baseline, the Phase 5 docs handoff notes, and the commit hashes.
</output>
