---
phase: 03-retry-schedule-reshape
plan: 01
type: execute
wave: 1
depends_on: []
files_modified:
  - tests/test_network/test_connection_retry.py
  - tests/test_network/test_concurrent_requests.py
autonomous: true
requirements:
  - RETRY-01
  - RETRY-02
  - RETRY-03
  - RETRY-04
user_setup: []

must_haves:
  truths:
    - "The 16-row branch matrix from 03-RESEARCH.md exists as distinct, collectable tests in tests/test_network/test_connection_retry.py (RETRY-01, RETRY-02, RETRY-04)"
    - "TestRetryTimeoutBudget asserts the wall-time contract (elapsed <= timeout + tolerance) instead of the old sleep-exclusion contract it currently asserts the inverse of (RETRY-03, D3-03)"
    - "RED confirmed: the enumerated deterministic-RED tests fail against the current exponential-window/jitter implementation, and no assertion is weakened or xfail-marked to dodge that"
    - "No test file other than the two named in files_modified is created or modified"
  artifacts:
    - path: "tests/test_network/test_connection_retry.py"
      provides: "branch-matrix tests for the retransmit schedule, listen-during-backoff, and shared-queue correlation contract"
      min_lines: 250
    - path: "tests/test_network/test_concurrent_requests.py"
      provides: "rewritten TestRetryTimeoutBudget class asserting wall-time budget for GET and SET paths"
      contains: "TestRetryTimeoutBudget"
  key_links:
    - from: "tests/test_network/test_connection_retry.py"
      to: "src/lifx/network/connection.py"
      via: "patching the runtime-read module attributes lifx.network.connection.REQUEST_RETRANSMIT_GAPS and lifx.network.connection._STREAM_IDLE_TIMEOUT for fast deterministic schedule tests"
      pattern: "lifx\\.network\\.connection\\.(REQUEST_RETRANSMIT_GAPS|_STREAM_IDLE_TIMEOUT)"
    - from: "tests/test_network/test_connection_retry.py"
      to: "src/lifx/network/connection.py"
      via: "deterministic response injection straight into the shared queue grabbed from conn._pending_requests"
      pattern: "_pending_requests"
---

<objective>
Write the Wave-0 RED test suite for the Phase 3 retry reshape, before any implementation
lands: a new `tests/test_network/test_connection_retry.py` covering the full behavioural
branch matrix from 03-RESEARCH.md (RETRY-01 floored first window and escalating gaps,
RETRY-02 listen-during-backoff, RETRY-04 shared-queue correlation with late-reply
acceptance), plus the mandated rewrite of `TestRetryTimeoutBudget` in
`test_concurrent_requests.py` whose current assertions encode the exact wall-time defect
D3-03 removes (RETRY-03).

These tests define GREEN for plan 03-02. They are written against the FINAL contract:
gaps constant `REQUEST_RETRANSMIT_GAPS` read at runtime from `lifx.network.connection`
(D3-01, D3-05), retransmit-while-listening with no blind sleeps (D3-02), a single wall
deadline (D3-03), fresh sequence per retransmit against one shared queue with late replies
accepted on both GET and ACK paths (D3-04), and a timeout message reporting transmissions
actually sent ("after N attempts").

Purpose: Wave 0 of the phase validation contract (03-VALIDATION.md) — implementation may
not start until these exist and are confirmed RED.
Output: `tests/test_network/test_connection_retry.py` (new) and the rewritten
`TestRetryTimeoutBudget` class.
</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-VALIDATION.md
@.claude/skills/spike-findings-lifx-async/references/retry-schedule.md
@src/lifx/network/connection.py
@tests/test_network/test_concurrent_requests.py
@tests/test_network/test_discovery_rebroadcast.py
</context>

<tasks>

<task type="auto" tdd="true">
  <name>Task 1: Write the RED retry branch-matrix tests (test_connection_retry.py)</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 B2-B16 these tests must cover), §Code Examples (send-spy pattern, queue-injection pattern, wall-time assertion, fast idle-exit pattern), §Common Pitfalls (Pitfall 5: inject via the real queue, never mock wait_for; keep timeouts tiny)
    - tests/test_network/test_connection.py — TestRequestStreamDebugLogging._header (the LifxHeader construction pattern for crafting valid injected responses with arbitrary source/sequence/target/pkt_type)
    - tests/test_network/test_concurrent_requests.py — TestConcurrentRequests.test_timeout_behavior (offline no-server connection convention, ip="192.168.1.100") and TestErrorHandling (emulator_server_with_scenarios fixture usage)
    - tests/test_network/test_discovery_rebroadcast.py — Phase 2's proven structure for schedule tests: runtime-constant patching idiom, CI-tolerant timing bounds, recording-send conventions
  </read_first>
  <behavior>
    Every test drives the real request paths of `DeviceConnection`. Offline tests use
    ip="192.168.1.100" (the existing no-server convention — sends vanish, no responses).
    Injection tests start the request as an asyncio task, wait (with a bounded ~2 s guard
    loop, never unbounded) for correlation keys to appear in `conn._pending_requests`,
    then `put_nowait` a crafted `(LifxHeader, payload)` into the registered shared queue.
    StatePower responses use pkt_type=22 with payload b"\x00\x00"; Acknowledgements use
    pkt_type=45 with payload b"". Targets are bytes.fromhex(serial) + b"\x00\x00".

    Class TestRetransmitSchedule (RETRY-01, D3-01):
    - Test 1 `test_healthy_network_single_transmission` (@pytest.mark.emulator): emulator_server_with_scenarios with scenarios={}, real (unpatched) gaps, send spy wrapping conn.send_packet; `await conn.request(Device.GetPower(), timeout=2.0)` succeeds; exactly 1 send. (RETRY-01 healthy path; B6b False)
    - Test 2 `test_no_retransmit_before_first_gap_floor` (offline): real gaps, max_retries=2, timeout=0.15 (below the 0.2 s first gap); LifxTimeoutError raised; exactly 1 send; elapsed in [0.15, 0.45). (RETRY-01 floor; B4 raise arm offline)
    - Test 3 `test_escalating_gaps_drive_retransmits` (offline): gaps patched to (0.05, 0.05), max_retries=2, timeout=0.5; exactly 3 sends (t≈0, 0.05, 0.10); LifxTimeoutError message contains "after 3 attempts"; elapsed in [0.5, 0.8). (B6/B6b True, B7 True→False, B10 raised arm)
    - Test 4 `test_retransmit_cap_then_keeps_listening` (offline): gaps patched to (0.05,), max_retries=1, timeout=0.4; exactly 2 sends, then the request keeps listening (does NOT fail early at the cap); elapsed in [0.4, 0.7). (B6 False post-cap, B7 False)
    - Test 5 `test_gap_exhaustion_repeats_final_gap` (offline): gaps patched to (0.05,), max_retries=3, timeout=0.5; exactly 4 sends at ~0.05 spacing — the single gap repeats after exhaustion. (B16)
    - Test 6 `test_direct_impl_call_explicit_max_retries_zero` (offline): `await conn.open()` then consume `conn._request_stream_impl(Device.GetPower(), timeout=0.2, max_retries=0)` with async for inside pytest.raises(LifxTimeoutError); exactly 1 send. (B2 False, B3 False)

    Class TestListenDuringBackoff (RETRY-02, D3-02):
    - Test 7 `test_response_between_retransmits_completes_immediately`: gaps patched to (0.5,), max_retries=8, timeout=5.0; start request task, inject a StatePower response as soon as the first key registers; task completes with total elapsed < 0.3 s and exactly 1 send — the arrived response is consumed immediately, not after a sleep or the next retransmit slot. (RETRY-02 core; B10 not-raised)
    - Test 8 `test_no_retransmit_after_first_response`: gaps patched to (0.1, 0.1), `lifx.network.connection._STREAM_IDLE_TIMEOUT` patched to 0.3, timeout=5.0; drive `_request_stream_impl` directly collecting yields; inject one response immediately; generator idle-exits ≈0.3 s later with exactly 1 yield and exactly 1 send — no retransmit fires once a response has been yielded. (B5 True, B6a False, B9 True)
    - Test 9 `test_second_response_before_idle_extends_stream`: real gaps, _STREAM_IDLE_TIMEOUT patched to 0.4, timeout=5.0; inject a response immediately and a second ~0.15 s later; exactly 2 yields (idle clock reset by the second response). (B5 False idle-not-elapsed, B9)
    - Test 10 `test_deadline_return_after_yield_no_raise`: _STREAM_IDLE_TIMEOUT patched to 10.0, timeout=0.3; inject one response immediately; generator completes WITHOUT raising at ≈0.3 s with exactly 1 yield — wall deadline with has_yielded returns quietly. (B4 True + yielded → return, B15 not-reached arm)

    Class TestCorrelationContract (RETRY-04, D3-04):
    - Test 11 `test_late_reply_to_earlier_sequence_accepted`: gaps patched to (0.05,), max_retries=3, timeout=2.0; wait until len(conn._pending_requests) >= 2 (two transmissions issued), inject a StatePower response with sequence=0; request completes normally — a late reply to the FIRST transmission satisfies the request. (B13 in-range; the RETRY-04 acceptance case)
    - Test 12 `test_late_ack_to_earlier_sequence_accepted`: same shape with Device.SetPower(level=65535) and an Acknowledgement (pkt_type=45) injected with sequence=0 after >= 2 keys exist; `conn.request(...)` returns True. This is the D3-04-mandated ACK-path behaviour change (today's per-attempt queue discards it).
    - Test 13 `test_wrong_source_raises_protocol_error`: inject a response whose header.source differs from the registered key's source; LifxProtocolError. (B12)
    - Test 14 `test_out_of_range_sequence_raises_protocol_error`: inject sequence=99; LifxProtocolError. (B13 out-of-range)
    - Test 15 `test_serial_mismatch_raises_protocol_error`: inject a response with a different target serial; LifxProtocolError. (B11 mismatch)
    - Test 16 `test_discovery_connection_accepts_any_target`: DeviceConnection(serial="000000000000", ...), `await conn.open()`, drive `_request_stream_impl` directly; inject a response with an arbitrary target; it is yielded (serial validation skipped for discovery connections). (B11 False)
    - Test 17 `test_duplicate_response_discarded_silently`: put TWO identical StatePower responses into the shared queue before the consumer runs; `conn.request()` returns the first; no exception; after completion conn._pending_requests is empty (all keys cleaned up).
  </behavior>
  <action>
    Create `tests/test_network/test_connection_retry.py` implementing the behaviour
    matrix above (RETRY-01/02/04; D3-01, D3-02, D3-04). Hard requirements:

    1. All imports at the top of the file (project rule): asyncio, time, typing as needed, unittest.mock patch, pytest, DeviceConnection, LifxProtocolError/LifxTimeoutError, LifxHeader, Device packets. Type annotations per existing test conventions — the repo runs pyright strict over tests.
    2. Build three module-level helpers, reused everywhere: (a) a send-spy factory that wraps the real bound `conn.send_packet` and appends `time.monotonic()` to a list before delegating (per 03-RESEARCH.md §Code Examples); (b) a header factory following the TestRequestStreamDebugLogging._header construction pattern, parameterised on source/sequence/target/pkt_type; (c) a bounded wait helper `await _wait_for_keys(conn, n, deadline=2.0)` that polls `conn._pending_requests` every 1 ms and fails the test if n keys never appear — never an unbounded spin (03-RESEARCH.md Pitfall 5).
    3. Patch the schedule via `patch("lifx.network.connection.REQUEST_RETRANSMIT_GAPS", <tuple>)` and the idle window via `patch("lifx.network.connection._STREAM_IDLE_TIMEOUT", <float>)` — module attributes of lifx.network.connection, exactly the Phase 2 idiom from test_discovery_rebroadcast.py. This only works because plan 03-02 reads both at runtime. Tests 1, 2, 9, 13-17 use the real (unpatched) gaps.
    4. Inject responses ONLY via `put_nowait` on the real queue obtained from `conn._pending_requests` — never mock `asyncio.wait_for` or the queue itself (Pitfall 5: an instantly-raising mocked wait spins the loop hot).
    5. Timing assertions use generous CI-tolerant bounds as given per test in the behaviour block; never assert a window narrower than 0.25 s or below 50 ms granularity (Phase 2 convention).
    6. Every request task created with asyncio.create_task is awaited or cancelled before the test ends, and every connection is closed in a finally block — no leaked tasks or sockets.
    7. This is the RED half of a tests-first sequence. At THIS commit: tests 3-5, 7-12 MUST fail (the patch targets REQUEST_RETRANSMIT_GAPS / _STREAM_IDLE_TIMEOUT do not exist yet, so patching raises AttributeError) and test 2 MUST fail on send count (the current 31 ms exponential windows fire ~3 sends inside 0.15 s). Tests 1, 6, 13-17 may pass coincidentally (the emulator answers inside the old first window; source/sequence/serial validation and first-wins consumption already exist on the GET path) — that is expected and must be recorded per-test in the summary, exactly as Phase 2's plan 02-01 did. Do not weaken any assertion and do not mark anything xfail. Commit message convention: test(03-01): add failing retry reshape branch-matrix tests.
  </action>
  <verify>
    <automated>uv run pytest tests/test_network/test_connection_retry.py --collect-only -q</automated>
  </verify>
  <acceptance_criteria>
    - `uv run pytest tests/test_network/test_connection_retry.py --collect-only -q` exits 0 and collects at least 17 tests across TestRetransmitSchedule, TestListenDuringBackoff, and TestCorrelationContract
    - `uv run pytest tests/test_network/test_connection_retry.py -q` exits NON-zero at this commit (RED: constant, module attribute, and reshaped loop absent) — record the per-test fail/pass breakdown in the task notes
    - `git status --porcelain tests/` shows only tests/test_network/test_connection_retry.py as new — no other test file touched by this task
    - `uv run ruff format . && uv run ruff check .` report no issues for the new file, and `uv run pyright` reports no new errors in it
  </acceptance_criteria>
  <done>The full RETRY-01/02/04 branch matrix from 03-RESEARCH.md exists as distinct, collectable tests that are RED against the current exponential/jitter implementation, with per-test RED/coincidental-pass status recorded.</done>
</task>

<task type="auto" tdd="true">
  <name>Task 2: Rewrite TestRetryTimeoutBudget to the wall-time contract (RED)</name>
  <files>tests/test_network/test_concurrent_requests.py</files>
  <read_first>
    - .planning/phases/03-retry-schedule-reshape/03-RESEARCH.md — §Existing Test Impact (the three-test enumeration this task implements), §Q4 (schedule maths: cumulative retransmit offsets 0.2, 0.5, 0.9, 1.4, 2.1 s and the max_retries/wall-budget "whichever binds first" rule), §Common Pitfalls (Pitfall 3)
    - tests/test_network/test_concurrent_requests.py — TestRetryTimeoutBudget (L219-434) in full: the class docstring and all three tests being rewritten; every other class in the file must remain byte-for-byte untouched
  </read_first>
  <behavior>
    - Replacement test `test_wall_time_budget_honoured_under_total_loss` (replaces test_retry_sleep_excluded_from_timeout_budget): emulator drop-all scenario ("20": 1.0), timeout=2.0, max_retries=5. Asserts: LifxTimeoutError raised; `2.0 <= elapsed < 2.3` (RETRY-03: wall budget honoured, generous CI tolerance); message contains "after 5 attempts" — transmissions at cumulative offsets 0, 0.2, 0.5, 0.9, 1.4 s fit the 2.0 s budget, the 6th (due 2.1 s) does not, and the error reports transmissions actually sent. max_retries=5 (not the research sketch's 3) is deliberate: the old code's message reads "after 6 attempts" (max_retries+1), so the message assertion is deterministic RED independent of jitter randomness.
    - `test_retry_timeout_calculation_consistency`: keep the scenario (timeout=1.5, max_retries=2, drop "20" and "21") and the existing GET/SET consistency assertion (diff < 0.5) and lower bounds; ADD upper bounds `elapsed_get < timeout + 0.3` and `elapsed_set < timeout + 0.3`; reword docstring — both paths honour the wall budget via the same engine.
    - `test_retry_all_attempts_get_fair_timeout`: assertions unchanged ("after 3 attempts" stays truthful: timeout=2.0, max_retries=2 → 3 transmissions at 0, 0.2, 0.5 s, then listen to 2.0 s); docstring reworded — it currently explains sleep-time starvation, which no longer exists.
  </behavior>
  <action>
    Rewrite ONLY the TestRetryTimeoutBudget class in
    tests/test_network/test_concurrent_requests.py (RETRY-03, D3-03; research §Existing
    Test Impact is the authoritative enumeration — exactly these three tests change,
    nothing else in the file):

    1. Rewrite the class docstring: it currently documents the sleep-exclusion behaviour as the fix; the new docstring states the wall-time budget contract — all waiting counts against the caller's timeout, a request can never take materially longer than its budget, and failing requests complete AT the budget (Australian English).
    2. Delete `test_retry_sleep_excluded_from_timeout_budget` entirely — it monkeypatches the jitter helper deleted in plan 03-02 and its core assertions (elapsed greater than timeout plus sleeps) assert the precise defect D3-03 fixes. Replace it with `test_wall_time_budget_honoured_under_total_loss` per the behaviour block. Do not monkeypatch anything: the schedule constants make timing deterministic.
    3. Update `test_retry_timeout_calculation_consistency` and `test_retry_all_attempts_get_fair_timeout` per the behaviour block — new upper bounds and docstrings; scenario setup, fixtures, and connection parameters stay as they are.
    4. Remove the exponential-backoff arithmetic comments inside the class (the total_weight/base_timeout walkthrough at ~L283-290) — replace with the new schedule arithmetic as comments where useful (cumulative offsets and the "cap vs budget, whichever binds first" rule).
    5. RED expectations at this commit: the new wall-time test fails deterministically (old message reads "after 6 attempts"; elapsed also overruns 2.3 s in ~98% of runs from jitter sleeps); the consistency upper bounds fail in most runs (probabilistic — jitter could sum under 0.3 s); test_retry_all_attempts_get_fair_timeout continues to pass (its message arithmetic is unchanged between old and new code). Record actual outcomes honestly in the task notes. Commit message: test(03-01): rewrite TestRetryTimeoutBudget to the wall-time budget contract.
  </action>
  <verify>
    <automated>uv run pytest tests/test_network/test_concurrent_requests.py --collect-only -q</automated>
  </verify>
  <acceptance_criteria>
    - `uv run pytest tests/test_network/test_concurrent_requests.py --collect-only -q` exits 0 and still collects TestConcurrentRequests, TestErrorHandling, TestAsyncGeneratorRequests (or the file's other existing classes) unchanged, plus the three TestRetryTimeoutBudget tests
    - `uv run pytest "tests/test_network/test_concurrent_requests.py::TestRetryTimeoutBudget" -q` exits NON-zero at this commit (RED), with the wall-time test among the failures — record the outcome breakdown
    - `git diff tests/test_network/test_concurrent_requests.py` touches only the TestRetryTimeoutBudget class body and docstring — no other class or module-level code modified
    - `uv run ruff format . && uv run ruff check .` and `uv run pyright` clean for the modified file
  </acceptance_criteria>
  <done>TestRetryTimeoutBudget encodes the RETRY-03 wall-time contract and is RED against the current implementation; every other test class in the file is untouched.</done>
</task>

</tasks>

## Artifacts this phase produces (plan 01)

| Artifact | Kind | Location |
|----------|------|----------|
| `TestRetransmitSchedule` (6 tests), `TestListenDuringBackoff` (4 tests), `TestCorrelationContract` (7 tests) — branch-matrix rows B2-B13, B15-B16 | New test file | `tests/test_network/test_connection_retry.py` |
| Rewritten `TestRetryTimeoutBudget` (3 tests, wall-time contract, RETRY-03) | Modified test class | `tests/test_network/test_concurrent_requests.py` |
| Send-spy, header-factory, and bounded key-wait helpers reused by plan 03-03's harness reporting | Test utilities (module-level) | `tests/test_network/test_connection_retry.py` |

<threat_model>
## Trust Boundaries

| Boundary | Description |
|----------|-------------|
| LAN → connection socket | Untrusted UDP datagrams cross into the response routing path; these tests encode the acceptance criteria for that boundary's validation |

## STRIDE Threat Register

| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|-----------|----------|-----------|----------|-------------|-----------------|
| T-03-01 | Spoofing | response validation in the reshaped request loop | medium | mitigate | Tests 13-15 pin the three-way (source, sequence-range, serial) validation for every issued sequence; test 16 pins the deliberate discovery-connection exception |
| T-03-02 | DoS (inbound flood) | shared response queue | low | mitigate | Test 17 pins first-wins consumption and full key cleanup; queue bound (maxsize=100) and QueueFull warning are unchanged existing code |
| T-03-03 | DoS (outbound, self-inflicted) | retransmit scheduler | low | mitigate | Tests 1-5 pin the packet budget: 1 send on healthy networks, escalating gaps, hard max_retries cap |

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 --collect-only -q` — ≥17 tests collected across the three classes
- `uv run pytest tests/test_network/test_connection_retry.py -q` — RED (non-zero exit) with the documented failure set
- `uv run pytest "tests/test_network/test_concurrent_requests.py::TestRetryTimeoutBudget" -q` — RED (non-zero exit)
- `git status --porcelain` — only the two files in files_modified changed
- `uv run ruff format . && uv run ruff check . && uv run pyright` — clean
</verification>

<success_criteria>
- Every row of the 03-RESEARCH.md branch matrix assigned to the new file (B2-B13, B15-B16) has a distinct test; B4's raise arm and RETRY-03 live in the rewritten TestRetryTimeoutBudget; B14 stays with the existing StateUnhandled emulator tests
- The wall-time contract test is deterministic RED (message arithmetic, not timing luck)
- Per-test RED/coincidental-pass status recorded for plan 03-02 to verify GREEN against
- No production code touched — this plan is Wave 0 only
</success_criteria>

<output>
Create `.planning/phases/03-retry-schedule-reshape/03-01-SUMMARY.md` when done, recording:
the per-test RED result table (fail reason: AttributeError vs behavioural vs coincidental
pass), the collected test count, and the commit hashes.
</output>
