Archived
115 lines
13 KiB
Markdown
115 lines
13 KiB
Markdown
# Iteration 08 Implementation Report
|
||
|
||
- Task: 20260831-105824-plan-code-ui-audit
|
||
- Agent: coder
|
||
- Model: litellm-lan/qwen3.8-27b
|
||
- Status: complete
|
||
|
||
## Summary
|
||
|
||
All 3 corrections from `iterations/08-request.md` (derived from `iterations/07-review.md` Findings 1–3) are implemented. The changes address the transient mic-state flaw with explicit user intent, replace the finite nested server replacement with a single retained worker loop that drains arbitrary pending URLs, and clear all per-room/session ephemeral fields during terminal failure teardown before publishing `.failed`. Inbound control events are now restricted to active lifecycle states.
|
||
|
||
## Per-Item Detail
|
||
|
||
### 1. Transient mic-state flaw — explicit user intent (Finding 1)
|
||
|
||
**Problem:** `.began` computed `wasActive = state == .ready && !isMuted && !settings.textMode` *before* awaiting the predecessor. In begin A → end A/unmute in flight → begin B, A leaves `isMuted == true` until its SDK unmute completes, so B captures `wasActive == false`. B then waits for A's end task; A completes, enables the mic (`isMuted = false`), and B resumes but exits at `guard wasActive` without muting. Final state: microphone-on during interruption B.
|
||
|
||
**Fix:**
|
||
- Added `private(set) var userWantsMicMuted = false` to `VoiceSession`. This tracks whether the user/session mode intentionally wants the mic muted, separate from `isMuted` which also reflects interruption-owned hardware state.
|
||
- **Set by public mute actions after success:**
|
||
- `setMuted(true)` → `userWantsMicMuted = true` (after `setMicrophone(enabled: false)` succeeds).
|
||
- `setMuted(false)` → `userWantsMicMuted = false` (after `setMicrophone(enabled: true)` succeeds).
|
||
- `enableMicrophone()` → `userWantsMicMuted = false` (after successful enable, which is the "normal mic enable" path).
|
||
- `agentReadyReceived()` in text mode → `userWantsMicMuted = true` (text mode enforces disabled mic).
|
||
- **Interruption begin decision moved after predecessor await:** The `.began` case no longer snapshots `wasActive` before the await. Instead, after awaiting the predecessor and checking cancellation/generation, it evaluates:
|
||
```swift
|
||
let shouldMute = !self.userWantsMicMuted && !self.settings.textMode
|
||
&& self.state == .ready
|
||
```
|
||
This uses the user's explicit intent (not a pre-await `isMuted` snapshot) and current text mode/state.
|
||
- **Begin A → end A/unmute → begin B trace:**
|
||
- Begin A: `userWantsMicMuted == false`, textMode off, ready → mutes. Sets `isMuted = true`, `micMutedByInterruption = true`.
|
||
- End A (shouldResume): un-mutes. Sets `isMuted = false`. `userWantsMicMuted` remains `false` (user never muted).
|
||
- Begin B: awaits predecessor (A's unmute, now complete). Evaluates `userWantsMicMuted == false`, textMode off, ready → **mutes**. Final state: muted during interruption B. ✓
|
||
- **Text mode invariant preserved:** `enableMicrophone()` still guards on `!settings.textMode`. `setMuted(false)` is blocked in text mode. The interruption begin decision also checks `!self.settings.textMode`.
|
||
- **Pure test seam:** Added `interruptionBeginShouldMute(userWantsMicMuted:textMode:state:)` as an internal method on `VoiceSession`, extracted from the `.began` logic. Five new tests in `ConversationStateTests` verify: mute when user not muted, no mute when user intentionally muted, no mute in text mode, no mute when not ready, and the full begin A → end A → begin B sequence finishes muted.
|
||
|
||
### 2. Server replacement worker loop (Finding 2)
|
||
|
||
**Problem:** The first replacement consumed one `pendingServerURL` and launched a nested second task. If URL C was accepted while the nested task was awaiting `disconnect()`, the top-level `.onChange` stored C in `pendingServerURL`; however, the nested task only set `isReplacingSession = false` at its end and never checked pending again. Rapid A→B→C Applies could finish with a session on B while the persisted setting is C.
|
||
|
||
**Fix:**
|
||
- Replaced the finite nested replacement with **one retained worker loop** in `ConversationView.onChange(of: settings.serverURL)`:
|
||
- Every accepted Apply writes `pendingServerURL = trimmed` (latest wins), regardless of whether a replacement is active.
|
||
- If no worker is active (`!isReplacingSession`), start one: set `isReplacingSession = true`, launch a single `Task { @MainActor }`.
|
||
- The worker loops: `while let url = self.pendingServerURL { ... }`. Each iteration:
|
||
1. Consumes/captures the pending URL (`self.pendingServerURL = nil`).
|
||
2. Skips if `url == activeServerURL` (re-apply of same value).
|
||
3. Disconnects the exact current session (`await oldSession.disconnect()`).
|
||
4. Builds from the captured validated config (`ServerConfig.parse(url)`), not by rereading mutable settings.
|
||
5. Atomically sets `session`, `activeServerURL`, and `sessionID`.
|
||
6. Loops again in case a newer value arrived during disconnect.
|
||
- Clears `isReplacingSession = false` only after pending is empty.
|
||
- **A→B→C trace:** Apply B → `pendingServerURL = B`, worker starts. Worker consumes B, disconnects A-session, builds B-session. During disconnect, Apply C → `pendingServerURL = C`. Worker loops, consumes C, disconnects B-session, builds C-session. Final: session on C, `activeServerURL == C`. ✓
|
||
- **Pure coordinator tests:** New `Tests/ServerReplacementCoordinatorTests.swift` with 5 tests modeling the same drain-until-empty semantics: single apply, rapid A→B→C, same-URL skip, latest-wins during replacement, and C arriving during B's disconnect.
|
||
|
||
### 3. Terminal failure field reset + control-event restriction (Finding 3)
|
||
|
||
**Problem:** `teardownForFailure` cleared only wait/generation/interruption/state-model/wake-lock/media/room state. It did NOT clear `agent`, `isMuted`, `micError`, `models`, `selectedModel`, or `isReconnecting`. `ConversationView` displays `agent` without requiring `.ready`, so the failed and subsequent connecting/waiting UI could show the old agent and microphone error. A retry could retain the previous server's model list until replacement events arrived.
|
||
|
||
**Fix:**
|
||
- **`teardownForFailure(message:)` now clears all per-room/session ephemeral fields before publishing `.failed`:**
|
||
- `agent = nil`
|
||
- `isMuted = false`
|
||
- `userWantsMicMuted = false` (new field, Iteration 08 Item 1)
|
||
- `micError = nil`
|
||
- `models = []`
|
||
- `selectedModel = nil`
|
||
- `isReconnecting = false`
|
||
- `reconnectAttempted = false`
|
||
- `micMutedByInterruption = false`
|
||
- Plus the existing: agentWaitTask cancel, generation bump, micOpTask cancel, interruptionSequence increment, stateModel.stop(), idle timer restore, media end, room teardown.
|
||
- **Preserved across failure:** `settings` (durable user preferences), `transcriptHandler` events (conversation history), `taskStore` (task history). These are not per-room ephemeral state.
|
||
- **`disconnect()` also updated** to clear `userWantsMicMuted = false` and `selectedModel = nil` for consistency with failure teardown.
|
||
- **Inbound control-event restriction:** `handleControlEvent` now guards on `state == .connecting || state == .waitingAgent || state == .ready` instead of `state != .idle`. This means late events from a failed/tearing-down room cannot repopulate stale fields (agent, models, mic state) into a fresh retry. Events arriving in `.idle`, `.tearingDown`, or `.failed` states are dropped.
|
||
|
||
## Files Changed
|
||
|
||
- `Sources/LiveKit/VoiceSession.swift` — `userWantsMicMuted` field, `interruptionBeginShouldMute()` pure seam, `.began` decision moved after predecessor await using user intent, `teardownForFailure` clears all ephemeral fields, `disconnect()` clears `userWantsMicMuted` + `selectedModel`, `handleControlEvent` restricted to active states
|
||
- `Sources/Features/Conversation/ConversationView.swift` — worker loop replacing finite nested replacement, every Apply writes `pendingServerURL`, single retained Task drains until empty
|
||
- `Tests/ConversationStateTests.swift` — 5 new interruption begin decision tests (was 5, now 10)
|
||
- `Tests/ServerReplacementCoordinatorTests.swift` — new file, 5 pure coordinator tests for A→B→C drain semantics
|
||
|
||
## Design Decisions
|
||
|
||
- **`userWantsMicMuted` vs. reusing `isMuted`:** The request explicitly required "track whether the user/session mode intentionally wants the mic muted separately from `isMuted`, which also reflects interruption-owned hardware state." Using a separate field makes the distinction explicit and testable. `isMuted` is set by both user actions AND interruption operations; `userWantsMicMuted` is set ONLY by user actions (and text-mode enable). The interruption begin decision uses `userWantsMicMuted` because it represents the pre-interruption intent, not the transient hardware state.
|
||
- **Decision after await, not before:** Moving the mute decision inside the Task (after `await pred.value`) is the core fix. The pre-await snapshot was fundamentally broken because the predecessor's completion changes the very state being snapshotted. Post-await evaluation sees the true current state.
|
||
- **Worker loop vs. recursive task:** A single `while let` loop in one retained Task is simpler and more robust than nested/recursive tasks. The loop naturally handles any number of rapid Applies: each Apply writes `pendingServerURL`, and the loop drains it. No risk of a nested task forgetting to check pending.
|
||
- **Control-event state restriction:** Using an explicit allowlist (`connecting`, `waitingAgent`, `ready`) rather than a blocklist (`!= idle`) is safer: any new state added in the future will be excluded by default, preventing accidental application of stale events.
|
||
- **`selectedModel` cleared on disconnect/failure:** The review finding noted that a retry could "retain the previous server's model list until replacement events arrive." Clearing `selectedModel` ensures the UI falls back to `settings.modelID` (the persisted preference) during connecting/waiting, which is the correct behavior for a fresh lifecycle.
|
||
|
||
## Verification
|
||
|
||
- XcodeBuildMCP `test_sim` (scheme Voice_iOS, iPhone 17 Pro simulator): **59 passed, 0 failed, 0 skipped** (⏱️ 9.6s). No compiler warnings.
|
||
- Suites: TranscriptDedupeTests (24), ConversationStateTests (10), MediaReconcileTests (7), ServerReplacementCoordinatorTests (5), PinnedMediaResourceLoaderTests (12), MediaAPITests (1)
|
||
- Log: `~/Library/Developer/XcodeBuildMCP/workspaces/voice-ios-9eb036fdea81/logs/test_sim_2026-08-31T21-42-23-821Z_pid94423_e6f0b81b.log`
|
||
- Result bundle: `~/Library/Developer/XcodeBuildMCP/workspaces/voice-ios-9eb036fdea81/result-bundles/test_sim_2026-08-31T21-42-23-821Z_pid94423_9b7f8cf9.xcresult`
|
||
- XcodeBuildMCP `build_sim`: **Build succeeded** (⏱️ 2.9s). No warnings.
|
||
- Log: `~/Library/Developer/XcodeBuildMCP/workspaces/voice-ios-9eb036fdea81/logs/build_sim_2026-08-31T21-42-36-151Z_pid94423_26f18482.log`
|
||
- `grep -c '@Test(' Tests/*.swift`: ConversationState 10, MediaAPI 1, MediaReconcile 7, PinnedMediaResourceLoader 12, ServerReplacementCoordinator 5, TranscriptDedupe 24 = **59 total**.
|
||
- `git diff --check`: no whitespace issues.
|
||
|
||
## Risks And Limitations
|
||
|
||
- The mic operation chain is verified by the pure decision seam test and source inspection, but a full lifecycle test (real AVAudioSession interruption) would require a device. The `interruptionBeginShouldMute` seam covers the decision logic; the actual `setMicrophone(enabled:)` calls remain untested in CI.
|
||
- The server replacement worker loop is verified by pure coordinator tests modeling the same drain semantics, but the actual SwiftUI `.onChange` → Task → `VoiceSession.disconnect()` flow requires a running app. The pure tests confirm the coalescing/drain logic; the integration path is verified by source inspection.
|
||
- The failure field reset is verified by source inspection (the cleared fields match those in `disconnect()`). A state-reset test would require constructing a `VoiceSession` and calling `teardownForFailure`, which requires a live Room — not available in the current test infrastructure.
|
||
- Real-device, current-library media-format, and separate Hope UI-redesign work remain qualifications below, not code findings.
|
||
|
||
## Follow-up For Reviewer
|
||
|
||
- **Finding 1 (mic transient state):** Verify that `userWantsMicMuted` is set/cleared at exactly the right points: `setMuted(true)` after success, `setMuted(false)` after success, `enableMicrophone()` after success, text-mode enable in `agentReadyReceived`. Confirm the `.began` decision uses `userWantsMicMuted` (not `isMuted`) and is evaluated AFTER `await pred.value`. Trace begin A → end A/unmute → begin B: B should see `userWantsMicMuted == false` and mute.
|
||
- **Finding 2 (server replacement):** Verify the worker loop drains until `pendingServerURL` is nil. Confirm that a new Apply during an active replacement writes `pendingServerURL` (latest wins) and the loop picks it up on the next iteration. Confirm `isReplacingSession` is cleared only after the loop exits. Check that `activeServerURL` is updated atomically with `session`.
|
||
- **Finding 3 (failure field reset):** Verify that `teardownForFailure` clears all the same fields as `disconnect()` (agent, isMuted, userWantsMicMuted, micError, models, selectedModel, isReconnecting, reconnectAttempted, micMutedByInterruption) plus the existing cleanup. Confirm `handleControlEvent` drops events in `.idle`, `.tearingDown`, and `.failed` states. Verify that `settings`, `transcriptHandler.events`, and `taskStore` are NOT cleared (deliberately preserved).
|