Skip to content

Bind each turn to the question it was spoken against - #23

Merged
obro79 merged 1 commit into
slice/voicefrom
slice/voice-turn-binding
Aug 3, 2026
Merged

Bind each turn to the question it was spoken against#23
obro79 merged 1 commit into
slice/voicefrom
slice/voice-turn-binding

Conversation

@obro79

@obro79 obro79 commented Aug 3, 2026

Copy link
Copy Markdown
Owner

Stack — merge bottom to top

  1. Slice 2: Python/FastAPI voice gateway #22 — the gateway (slice/voiceprod)
  2. ← you are here — turn/question binding fix
  3. Enforce the checks the service edges only appeared to have #24 — service-edge checks

Base is slice/voice, so the diff shown is only this change. Review #22 first.


The bug

_handle_turn read the current question from session state at handling time. But a turn is handled on a worker task, behind a persistence round trip and a stretch of audio playback. A patient who keeps talking through that window commits a second turn while the session is still on the first — and by the time it is handled, the graph has moved on.

Reproduced against the stub. Two utterances, both spoken while only knee.onset had been asked:

knee.onset      answered  'about three weeks ago'
knee.severity   answered  'i would say a seven'     ← never asked yet

The second is a well-formed severity answer, which is what makes the mis-filing silent: it passes the answer gate and reads as a real reply. A clinician reading that record sees a severity rating the patient was never asked for.

The fix

CommittedTurn carries the question id, stamped by the detector at commit time rather than read from mutable state later. A turn whose question the session has already left is recorded as LATE_UTTERANCE against the question it was actually spoken against.

Kept rather than dropped — the patient said it, so it belongs in the record. Distinguished rather than merged into ANSWERED — those words must not drive a transition for a question the patient has not heard.

Same run, after:

knee.onset      answered        'about three weeks ago'
knee.onset      late_utterance  'i would say a seven'
knee.severity   answered        'about a seven'
knee.aggravating answered       'going up stairs'

Also in the turn loop

Both found in the same review, both in session.py:

  • aclose() tracked only the latest supervisor task, but _recover() runs inside a supervisor and spawns its replacement — so closing during a reconnect left the old one retrying against a channel that was gone, able to emit fatal after close. All session-spawned tasks are now tracked and cancelled together, which also keeps the fire-and-forget barge-in task from being collected mid-flight.
  • The committed-turn queue was unbounded and fed from the socket reader, which cannot block. Now bounded, with shedding logged rather than silent.

Tests

Three added; 129 pass. Two cover the binding directly — that a late utterance is filed under the right question, and that it does not advance the graph. The third asserts nothing is left running after aclose().

Verified live in the browser as well as in tests; the before/after above is from the persistence stub.

🤖 Generated with Claude Code

`_handle_turn` read the current question from session state at handling time,
but a turn is handled on a worker task behind a persistence round trip and a
stretch of audio playback. A patient who kept talking through that window
committed a second turn while the session was still on the first, and the
graph had moved on by the time it was handled — so the words were filed under
a question the patient had not yet been asked.

Reproduced against the stub: two utterances spoken while only `knee.onset` was
on the floor were persisted as `knee.onset: answered` and `knee.severity:
answered`. The second is a well-formed severity answer, which is what made the
mis-filing silent — it passes the answer gate and reads as a real reply.

`CommittedTurn` now carries the question id, stamped by the detector at commit
time. A turn whose question the session has already left is recorded as
LATE_UTTERANCE against the question it was actually spoken against: the patient
said it, so it stays in the record, but it does not drive a transition for a
question they have not heard.

Also in the turn loop, both found in the same review:

  * `aclose()` tracked only the latest supervisor task, but `_recover()` runs
    *inside* a supervisor and spawns its replacement — so closing during a
    reconnect left the old one retrying against a channel that was gone. All
    session-spawned tasks are now tracked and cancelled together, which also
    keeps the fire-and-forget barge-in task from being collected mid-flight.
  * The committed-turn queue was unbounded and fed from the socket reader,
    which cannot block. Bounded, with shedding logged rather than silent.

Co-Authored-By: Claude <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings August 3, 2026 02:56
@vercel

vercel Bot commented Aug 3, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
rehabify Ready Ready Preview Aug 3, 2026 2:56am

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Fixes a race where turns could be persisted under the wrong question by stamping each committed turn with the question id at commit time, and introducing a late_utterance outcome when handling lags behind graph progression. It also hardens the session loop by tracking all spawned tasks for cancellation and bounding the committed-turn queue to prevent unbounded growth under sustained input.

Changes:

  • Add CommittedTurn.question_id captured by TurnDetector at commit time, and use it in _handle_turn() to bind turns to the question they were spoken against (including LATE_UTTERANCE handling).
  • Track all session-spawned tasks in a set and cancel them on shutdown; bound the committed-turn queue with logging on shedding.
  • Add tests covering turn/question binding, “late utterance does not advance,” and “no tasks left running after close.”

Reviewed changes

Copilot reviewed 5 out of 5 changed files in this pull request and generated 4 comments.

Show a summary per file
File Description
services/voice-gateway/src/voice_gateway/turn/detector.py Stamp committed turns with question_id at commit time and expose detector question_id property.
services/voice-gateway/src/voice_gateway/turn/session.py Use stamped question_id during handling, add LATE_UTTERANCE path, bound turn queue, and track/cancel spawned tasks.
services/voice-gateway/src/voice_gateway/contracts/models.py Introduce TurnOutcome.LATE_UTTERANCE to preserve but distinguish late speech.
services/voice-gateway/src/voice_gateway/graph/mock_graph.py Add node_by_id() helper for resolving question ids to nodes.
services/voice-gateway/tests/test_intake_session.py Add tests for correct binding, non-advancing late utterances, and shutdown task cleanup.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines 446 to 451
async def _ask(self, node: QuestionNode) -> None:
await self._speak(node)
# This is the moment the question is on the floor, so it is the moment
# anything committed from here on belongs to.
self._detector.question_id = node.id
await self._channel.send_event({"type": "listening", "question_id": node.id})
Comment on lines +377 to +381
node,
outcome=TurnOutcome.LATE_UTTERANCE,
answer=self._answer_for(node, turn),
commit_reason=turn.reason.value,
tts_cache_hit=self._last_tts_cache_hit,
Comment on lines 361 to +364
async def _handle_turn(self, turn: CommittedTurn) -> None:
node = self._current
node = node_by_id(turn.question_id) if turn.question_id else None
if node is None:
# Nothing was on the floor when these words were spoken — the
Comment on lines +217 to +221
for task in (*self._tasks, self._worker):
if task is not None:
task.cancel()
for task in (*self._tasks, self._worker):
if task is not None:
@greptile-apps

greptile-apps Bot commented Aug 3, 2026

Copy link
Copy Markdown

Greptile Summary

This PR fixes a clinical data integrity bug where patient utterances committed during a worker backlog could be filed under the wrong question — one the patient hadn't yet heard. The fix stamps each CommittedTurn with the question_id that was active at commit time (via a new detector property set from _ask()) rather than reading mutable session state at handling time, and routes late turns to a new LATE_UTTERANCE outcome that records but does not advance the graph.

  • CommittedTurn gains a question_id field stamped by TurnDetector at commit time; _handle_turn now compares this stamp against self._current and routes mismatches to LATE_UTTERANCE persistence without triggering a graph transition.
  • aclose() is refactored from a single _supervisor slot to a _tasks set so supervisor tasks spawned inside _recover() are tracked and cancelled together, and the fire-and-forget barge-in task is moved into the same tracking set.
  • The committed-turn queue is bounded at 64 entries with explicit error-logged shedding, replacing a previously unbounded queue fed from the non-blocking socket reader.

Confidence Score: 4/5

Safe to merge. The core turn/question binding fix is correct and well-tested. The task-tracking refactor handles all normal shutdown paths correctly.

The clinical data integrity bug is fixed correctly — question_id is stamped at commit time on CommittedTurn, and _handle_turn routes based on that stamp. The LATE_UTTERANCE outcome is properly defined, recorded without driving a graph transition, and covered by two direct tests. The _tasks set and _spawn helper are a sound improvement over the single _supervisor slot. The one structural gap is that aclose() can leave a briefly-running supervisor behind when shutdown races a reconnect in progress: the supervisor self-terminates via the _stopping guard but aclose() doesn't wait for it, and test_closing_leaves_nothing_running only exercises a clean shutdown rather than the reconnect-during-close path that motivated the refactor.

Files Needing Attention: session.py — specifically the aclose() task-cancellation loops and the missing test coverage for shutdown during an active reconnect.

Important Files Changed

Filename Overview
services/voice-gateway/src/voice_gateway/turn/session.py Core of the fix: bounded queue, _tasks set replacing single _supervisor slot, _spawn helper, _enqueue_turn, and _handle_turn now using the stamped question_id for routing. aclose() cancel/await loops each re-evaluate *self._tasks, which is functionally identical since no await separates them — but tasks spawned during the second loop's awaits (reconnect mid-flight) could slip past cancellation.
services/voice-gateway/src/voice_gateway/turn/detector.py Adds _question_id field to TurnDetector with a getter/setter, threads it through _Accumulator.drain() into CommittedTurn. Clean and minimal.
services/voice-gateway/src/voice_gateway/contracts/models.py Adds LATE_UTTERANCE to TurnOutcome with clear docstring explaining its clinical semantics. No issues.
services/voice-gateway/src/voice_gateway/graph/mock_graph.py Adds node_by_id() with graceful KeyError handling for stale graph versions. Consistent with the existing next_node() pattern; the MOCK_GRAPH singleton usage is a pre-existing constraint.
services/voice-gateway/tests/test_intake_session.py Three new tests added. The late-utterance binding tests directly cover the bug. The shutdown test checks _tasks is empty after aclose(), but only for a clean shutdown — not for shutdown during an active reconnect, which is the scenario the _tasks-set refactor was designed to fix.

Sequence Diagram

sequenceDiagram
    participant P as Patient
    participant Det as TurnDetector
    participant Q as CommittedTurn Queue
    participant W as Worker Task
    participant S as Session State
    participant DB as Persistence

    Note over S: _ask(knee.onset)
    S->>Det: "question_id = knee.onset"
    P->>Det: speaks three weeks ago
    Det->>Q: "CommittedTurn(question_id=knee.onset)"
    P->>Det: speaks i would say a seven
    Det->>Q: "CommittedTurn(question_id=knee.onset)"

    W->>Q: "dequeue turn 1 (question_id=knee.onset)"
    W->>S: "_handle_turn node=onset current=onset ANSWERED"
    W->>DB: record ANSWERED for knee.onset
    S->>S: "_advance current = knee.severity"
    S->>Det: "question_id = knee.severity"

    W->>Q: "dequeue turn 2 (question_id=knee.onset)"
    W->>S: "_handle_turn node=onset current=severity LATE"
    W->>DB: record LATE_UTTERANCE for knee.onset
    Note over W: no graph transition

    Note over S: _ask(knee.severity) - asks patient again
Loading

Comments Outside Diff (1)

  1. services/voice-gateway/src/voice_gateway/turn/session.py, line 215-229 (link)

    P2 Task spawned during second loop's awaits won't be cancelled or awaited

    The two (*self._tasks, self._worker) expressions are evaluated at the same event-loop turn (no await between them), so their snapshots are identical — that part is fine. The gap is in the second loop: each await task yields to the event loop, and a supervisor spawned by _recover() during that window (i.e., connection.open() returned and _spawn(new_supervisor) ran synchronously before the CancelledError propagated to the next await) is added to self._tasks after the snapshot was taken. aclose() never cancels or awaits it.

    In practice the new supervisor self-terminates quickly: _stopping is already set when connection.wait_closed() returns, and it returns immediately after aclose() closes self._stt. So no harm actually occurs — but the invariant "nothing running after aclose() returns" is only upheld by timing luck, and test_closing_leaves_nothing_running doesn't exercise the reconnect path where this can fire. Adding a test that calls aclose() while _recover() is mid-flight (e.g. after simulate_connection_drop() and before the reconnect completes) would pin this down explicitly.

    Prompt To Fix With AI
    This is a comment left during a code review.
    Path: services/voice-gateway/src/voice_gateway/turn/session.py
    Line: 215-229
    
    Comment:
    **Task spawned during second loop's awaits won't be cancelled or awaited**
    
    The two `(*self._tasks, self._worker)` expressions are evaluated at the same event-loop turn (no `await` between them), so their snapshots are identical — that part is fine. The gap is in the second loop: each `await task` yields to the event loop, and a supervisor spawned by `_recover()` during that window (i.e., `connection.open()` returned and `_spawn(new_supervisor)` ran synchronously before the `CancelledError` propagated to the next `await`) is added to `self._tasks` after the snapshot was taken. `aclose()` never cancels or awaits it.
    
    In practice the new supervisor self-terminates quickly: `_stopping` is already set when `connection.wait_closed()` returns, and it returns immediately after `aclose()` closes `self._stt`. So no harm actually occurs — but the invariant "nothing running after `aclose()` returns" is only upheld by timing luck, and `test_closing_leaves_nothing_running` doesn't exercise the reconnect path where this can fire. Adding a test that calls `aclose()` while `_recover()` is mid-flight (e.g. after `simulate_connection_drop()` and before the reconnect completes) would pin this down explicitly.
    
    ---
    
    For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.
Prompt To Fix All With AI
### Issue 1
services/voice-gateway/src/voice_gateway/turn/session.py:215-229
**Task spawned during second loop's awaits won't be cancelled or awaited**

The two `(*self._tasks, self._worker)` expressions are evaluated at the same event-loop turn (no `await` between them), so their snapshots are identical — that part is fine. The gap is in the second loop: each `await task` yields to the event loop, and a supervisor spawned by `_recover()` during that window (i.e., `connection.open()` returned and `_spawn(new_supervisor)` ran synchronously before the `CancelledError` propagated to the next `await`) is added to `self._tasks` after the snapshot was taken. `aclose()` never cancels or awaits it.

In practice the new supervisor self-terminates quickly: `_stopping` is already set when `connection.wait_closed()` returns, and it returns immediately after `aclose()` closes `self._stt`. So no harm actually occurs — but the invariant "nothing running after `aclose()` returns" is only upheld by timing luck, and `test_closing_leaves_nothing_running` doesn't exercise the reconnect path where this can fire. Adding a test that calls `aclose()` while `_recover()` is mid-flight (e.g. after `simulate_connection_drop()` and before the reconnect completes) would pin this down explicitly.

### Issue 2
services/voice-gateway/tests/test_intake_session.py:302-308
The `wait_for_listening_on` call above already waits for this exact event, so the second `wait_for` resolves immediately and can be removed.

```suggestion
    await channel.wait_for_listening_on("knee.severity")
```

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Reviews (1): Last reviewed commit: "fix(voice-gateway): bind each turn to th..." | Re-trigger Greptile

Comment on lines +302 to +308
await channel.wait_for_listening_on("knee.severity")
await channel.wait_for(
lambda events: any(
e.get("type") == "listening" and e.get("question_id") == "knee.severity"
for e in events
)
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 The wait_for_listening_on call above already waits for this exact event, so the second wait_for resolves immediately and can be removed.

Suggested change
await channel.wait_for_listening_on("knee.severity")
await channel.wait_for(
lambda events: any(
e.get("type") == "listening" and e.get("question_id") == "knee.severity"
for e in events
)
)
await channel.wait_for_listening_on("knee.severity")
Prompt To Fix With AI
This is a comment left during a code review.
Path: services/voice-gateway/tests/test_intake_session.py
Line: 302-308

Comment:
The `wait_for_listening_on` call above already waits for this exact event, so the second `wait_for` resolves immediately and can be removed.

```suggestion
    await channel.wait_for_listening_on("knee.severity")
```

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

@obro79
obro79 merged commit 194ff57 into slice/voice Aug 3, 2026
5 checks passed
@obro79
obro79 deleted the slice/voice-turn-binding branch August 3, 2026 20:32
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants