When a body is redeployed (or its Sunshine session drops for any reason), the iPad reconnects Moonlight but the AppState machine re-enters .selfService instead of .streaming. Because micRelay.start(bodyHost:) is only called on the .streaming transition in startStream(), the mic relay never starts for the new session. Voice is silently absent — no error, no log warning.
AppState.tick() only calls startStream() from .idle or .selfService when a server assignment is active. After a Sunshine session drop, the iPad may re-enter .selfService (e.g. if it was in a user-initiated self-service stream when the session dropped). The tick loop at line 165 of AppState.swift explicitly does NOT interfere with a self-service stream: "Tick: streaming (self-service) — not interfering". So if the app transitions from streaming → selfService (due to the disconnect) and the server assignment is no longer active (because the body cleared it), the mic relay is never restarted.
Confirmed on chunky-turnip-23 (node-b961f1c8) with ipad-head-3, 2026-05-24. Body was redeployed the day before; iPad showed the stream working but Mercator heard no voice.
Check head heartbeat: GET /api/v1/heads/{id} — status: self-service with the stream appearing connected is the tell.
Restart the stream on the iPad (exit experience + reconnect). When the app goes through startStream() again, micRelay.start() is called correctly. Runbook and testbook updated with this check as Step 2.
When the app re-enters .selfService after having been in .streaming with enableMicrophone: true, it should stop the mic relay (already done via stopStream() → micRelay.stop()) but the fix needs to ensure the next startStream() call always restarts the relay — OR the tick loop should detect a live Moonlight session with a body that has stream_status: streaming and call micRelay.start() if it is not already running.
Alternatively: add a micRelay.isRunning check to the tick loop so it self-heals without requiring user intervention.