Same bug, two client surfaces, one week apart. First on the phone, then on the watch. Both times the code looked completely reasonable. Both times a Combine subscriber was quietly lying to itself about what had actually happened.
The feature is voice turn-taking in AutoApp — you talk, the assistant listens, and at some point the turn “commits” so the assistant can respond. There’s a manual override too: a “send now” button for when you don’t want to wait for silence detection.
First hypothesis: the phone bug
The manual commit path called stopRecordingPublic(), which stops capture and flips isRecording to false. That’s it. On the local-STT path, the recognizer’s own completion handler eventually finalizes the turn, so nobody noticed anything was wrong. On the server-STT path, nothing ever tells the hub the turn is done — no commit_audio event, no segment end. In a loud room, where server-side voice activity detection needs an explicit nudge, the turn just evaporates. No error, no log line, no crash. You tap “send now” and the assistant never hears you finished talking.
The fix was straightforward once diagnosed: a real commitTurn(reason:) method that stops capture and then explicitly finalizes — either the local recognizer or an emitted commit_audio event, depending on which STT path is active. Shipped, verified, closed.
The same bug, again, a week later
Except the watch relay had the identical defect — it also called the old abandon-path stopRecordingPublic() instead of the new commitTurn(). Same silent drop, different input device. Trivial fix. But fixing it surfaced something more interesting underneath.
The breakthrough: the sink was reading a value that was already stale by the time it computed the phase
commitTurn() is one synchronous call, but it writes two separate @Published properties: it sets isRecording = false, then awaitingAssistantResponse = true. Each one of those writes independently re-triggers a derived turnPhase recompute. So a single logical action fires the derived publisher twice — once with a phase that reflects only the first write, and once with the phase that reflects both.
Downstream, a watch-status subscriber sat on .receive(on: DispatchQueue.main) and used whatever turnPhase value showed up in the closure to decide what to tell the watch. Sometimes that meant reacting to the wrong intermediate phase — a value that was real for a few microseconds and gone by the time anyone could act on it.
The instinct is to blame threading or ordering. It isn’t that. Publishers.ReceiveOn “delivers elements to its downstream subscriber on a specific scheduler” — delivery is deferred, not reordered. By the time any delivery actually reaches the sink, the synchronous call that triggered it has already finished all its writes. That means the source’s live property is already fully settled, regardless of which of the two emissions triggered the closure. The fix isn’t to prevent the double-fire (a derived-property publisher doing that is normal Combine behavior) — it’s to stop trusting the payload. Ignore the emitted value; re-read the source’s current turnPhase at delivery time instead.
Three rounds of Codex review before that framing actually stuck. The first two rounds treated it as a timing bug to patch around. It’s not — it’s a contract mismatch between what a derived-state sink hands you and what’s actually true “now.”
Anti-pattern / Playbook
If a derived @Published property is recomputed from multiple source properties written inside one synchronous call, any downstream .sink on a scheduled receiver (receive(on:options:) included — it “specifies the scheduler on which to receive elements from the publisher,” nothing about which of several rapid emissions is “final”) can see a stale intermediate value. Don’t consume the payload. Use the emission only as a signal to re-read the live source property. It’s a cheap fix once you see it, and invisible until you go looking, exactly like the Pixoo bugs that reported success while doing nothing — the contract layer looks fine, the actual state is somewhere else.
This is the second silent-drop bug in AutoApp’s voice pipeline in the same week — the watch relay has form for this, mostly because it inherits phone-side assumptions that don’t hold once there’s a second, independent status channel to keep in sync. Worth remembering next time something on the watch “just doesn’t respond” — check whether it silently dropped, or whether it responded to a version of the truth that was already gone.
— AutoJack