Skip to content

feat(voice): migrate to bodhi-realtime-agent 0.4.0 (npm) with the public recovery contract - #4972

Merged
liususan091219 merged 6 commits into
mainfrom
feat/bodhi-0.4.0-migration
Oct 3, 2026
Merged

liususan091219 merged 6 commits into
mainfrom
feat/bodhi-0.4.0-migration

Conversation

@liususan091219

@liususan091219 liususan091219 commented Oct 1, 2026 •

Copy link
Copy Markdown
Collaborator

What changed, and why

Moves bodhi-realtime-agent from the sonichi fork pin (0.1.5, github:sonichi/bodhi_realtime_agent#c726e1d) to the npm release ^0.4.0 (published 2026-09-30, gitHead dec34e0 = release/0.4.0), and adapts the voice agent to 0.4.0's public recovery contract instead of the internals it reached into before.

Two things broke on a plain dependency bump:

  1. A failed first Gemini dial closed the whole session, listener included. 0.1.5 left the session at CONNECTING and we forced it to CLOSED so the health tick could reconnect; 0.4.0 finalizes the session on connect_failed, so the "Staying alive — WS server on :PORT" line was no longer true and the web/phone clients could not connect at all.
  2. The private handleUserSpeechEvidence hook is gone. The task bridge wrapped it for its speech-recency stamp (_speechMayBeLanding); without the stamp every voice task waits the full cap.

The transcript canary (tests/task-bridge-spoken-block.test.ts) also matched the old dist source by regex and failed on 0.4.0's reshaped handleInput.

Changes

  • src/voice-agent.ts
    • VoiceSession runs with upstreamLossPolicy: 'hold': a lost upstream parks the session in UPSTREAM_LOST with the WS listener up. The forced CONNECTING→CLOSED transition after a failed start() is removed.
    • One host dial owner, triggerUpstreamRedial(): from UPSTREAM_LOST it calls recoverUpstream() (gated on getRecoveryCapabilities().recoverUpstream), from CLOSED it keeps the legacy cast-call reconnect. The F5 redial timer and the 30s health tick both use it (they duplicated the same try/finally before).
    • The stuck-CONNECTING watchdog replaces the hung dial with recoverUpstream() (allowed from CONNECTING per the 0.4.0 contract) and falls back to forcing CLOSED only when the capability is absent.
    • The speech-recency stamp subscribes to the public speech.user_started / speech.user_ended events.
  • src/voice-redial-scheduler.ts — isUpstreamDown(); shouldEventDial accepts UPSTREAM_LOST as well as CLOSED.
  • src/voice-agent-state.ts — comment only: UPSTREAM_LOST falls into the existing down branch (backoff with a client attached, else idle), now stated and pinned by a test vector.
  • tests/task-bridge-spoken-block.test.ts — the canary asserts the exported TranscriptManager's behaviour (inputBuffer accumulates on handleInput, drains on flushInput) and the new agent wiring, instead of matching the old dist source text.
  • tests/voice-redial-scheduler.test.ts, tests/agent-state-protocol.test.ts — vectors for the parked state.
  • package.json, package-lock.json — the dependency.

Review focus

src/voice-agent.ts: the startup-failure block, triggerUpstreamRedial, the stuck-CONNECTING branch of the health tick, and the speech-event subscriptions.

Evidence

Startup failure keeps the listener (before/after, same build of bodhi 0.4.0, same rejected model, isolated workspaces, ports 19910/19911)

Both instances were started with a model the Live API rejects at setup (gemini-3.8-live-extended-thinking without a thinking level → close 1007), so the first dial fails deterministically.

Before — main @ 0c03031b0 + bodhi 0.4.0:

[VoiceSession] Transport closed (state=CONNECTING code=1007 reason="Thinking level must be specified for this model.")
[VoiceSession] LLM transport connect failed: Gemini socket closed before setupComplete (code=1007) — closing session
[Startup] session.start() failed: Gemini socket closed before setupComplete (code=1007)
[Startup] Staying alive — WS server on :19910, will retry LLM transport on next client connect
[Startup] Could not transition to CLOSED (state=CLOSED): Invalid transition: CLOSED → CLOSED
--- port 19910 after failure: NOT LISTENING   (process alive: yes)

After — this branch @ eb4f335c2:

[VoiceSession] Transport closed (state=CONNECTING code=1007 reason="Thinking level must be specified for this model.")
[VoiceSession] LLM transport connect failed: Gemini socket closed before setupComplete (code=1007) — session parked in UPSTREAM_LOST
[VoiceSession] Upstream lost (reason=connect-failed) — session parked in UPSTREAM_LOST
[Startup] session.start() failed: Gemini socket closed before setupComplete (code=1007)
[Startup] Staying alive — WS server on :19911, will retry LLM transport on next client connect
[Startup] session parked (state=UPSTREAM_LOST) — a client attach or the redial scheduler redials it
--- port 19911 after failure: LISTEN pid 62471   (process alive: yes)

A WebSocket client then attached to the parked instance; the attach and the F5 scheduler both redial through recoverUpstream(), the dial keeps failing (same rejected model), the session stays parked and the listener stays up:

[VoiceSession] client attach: redialing UPSTREAM_LOST session, attempt 4
[VoiceSession] Transport closed (state=RECONNECTING code=1007 ...)
[Redial] setup-failed — dial in 2323ms (failures=2)
[VoiceSession] Upstream lost (reason=recover-upstream-failed) — session parked in UPSTREAM_LOST
[Redial] event-driven reconnect (failures=2)
[Redial] recoverUpstream(human-retry) from UPSTREAM_LOST
[VoiceSession] recoverUpstream(human-retry): dialing attempt 7
...
--- port 19911 still listening: 1 ; process alive: yes

The client saw agent.state frames alternating connecting / backoff with clientAttached: true.

Tests

main + bodhi 0.4.0 (dependency bump only), tsx --test 'tests/**/*.test.ts':

not ok 26 - agent.state emission (integration, spawned agent)
not ok 325 - setup() hooks: skill identity is the manifest name, not the directory name
not ok 348 - the spoken block

(26 and 348 are the two breaks above; 325 is a host-layout issue of the machine the run was made on, also seen on the old pin. That run did not reach a final count — it hung in a later test file, as the earlier gap audit also reported for voice-agent-crash-only.)

This branch:

# tests 1571
# pass 1568
# fail 0
# skipped 3

tsc --noEmit clean; eslint src/voice-agent.ts 17 warnings (main: 18, all pre-existing no-explicit-any); bash scripts/review-checks.sh --diff <(git diff origin/main...HEAD) → PARTIAL (no Python in the diff; hardcoded-paths and root-artifacts clean).

Live round trip (owner host, 2026-10-01, this branch running as the live voice-agent)

The branch head eb4f335c2 was started as the owner's live voice-agent from a worktree, with the workspace and memory pinned to the real ones, model gemini-3.8-live, Search grounding on. Startup:

00:10:49.466 [VoiceSession] LLM transport setup complete (clientConnected=false)
00:10:49.467 [VoiceSession] LLM transport connected and setup complete
00:10:49.467 [Startup] session.start() succeeded
  Workspace:     <live workspace>
    Native audio:    gemini-3.8-live (googleSearch=true)

Owner spoke through the web client; rows from data/conversation.sqlite (voice table), 9 user turns and 6 tool calls in the session:

ts                   kind       text
2026-10-01 00:13:00  user       Hi, can you hear me?
2026-10-01 00:13:00  agent      Yes, I can hear you clearly. How can I help you today?
2026-10-01 00:13:05  tool_call  get_current_time
2026-10-01 00:13:05  user       What time is it now?
2026-10-01 00:13:09  agent      It's 5:13 PM Pacific Daylight Time.
2026-10-01 00:14:39  user       Hello, what is on my screen?
2026-10-01 00:14:43  tool_call  describe_screen
2026-10-01 00:14:46  agent      I can see your screen; it shows an inactivity logout message...
2026-10-01 00:16:18  user       Can you tell me where it how to get the OAuth token of Discord?
2026-10-01 00:16:21  tool_call  point_at
2026-10-01 00:16:25  agent      I see you are looking at the Discord Developer Portal. The Token section is at the bottom...

Automatic reconnection still works under 'hold' — it is not the parking path. The provider closed the connection twice mid-session (code 1000) and bodhi's own reconnect recovered both times in ~1.5s without parking; the conversation above continued across both:

00:11:49.213 [VoiceSession] Transport closed (state=ACTIVE code=1000)
00:11:49.213 [VoiceSession] Reconnect attempt 1/3 in 1000ms (reason=transport-close)
00:11:50.980 [VoiceSession] Reconnect complete; session ACTIVE
...
00:14:15.771 [VoiceSession] Transport closed (state=ACTIVE code=1000)
00:14:17.371 [VoiceSession] Reconnect complete; session ACTIVE

No Upstream lost, no UPSTREAM_LOST, no recoverUpstream call in the healthy session — the parked path is reached only when automatic recovery is exhausted, which the startup-failure evidence above exercises.

Documentation

N/A — no user-visible feature changes; the recovery behaviour is internal to the voice host. docs/src-map.md entries for the touched modules remain accurate.

Live phone round trip (Chi's blocking item 1)

Inbound call on this branch head, with the phone server restarted onto it (bodhi 0.4.0,
feedAudioFromClient + notifyClientConnected, as any removed). Durable record in
workspace/data/conversation.sqlite, session #603, call CAfe36061a0cc1b60e0aa3b463dffe1e63:

time (PDT) kind text
10:35:32 agent Hi, this is Sutando calling.
10:35:46 user What is 16 uh plus 7?
10:35:46 agent 16 plus 7 is 23.
10:35:55 tool_call get_current_time
10:36:00 user And what time is it now?
10:36:00 agent The current time is 10 35 55 AM PDT.

The caller's speech reached the model and was answered correctly, including a tool call, so the
renamed audio entry point does carry audio. The rename is now covered statically as well: with the
as any gone, reintroducing the old method name fails typecheck:skills, which CI runs — a unit
test could not have caught the original break, and this does.

Two secondary observations from the same call, neither load-bearing:

  • The greeting was sent once (the single agent greeting row above). A mid-call upstream reconnect
    occurred and did not re-greet, but the server log for that call is not retained (/tmp, truncated
    by the next restart), so the greeting-once claim rests on the sqlite row and the no-re-greet note
    is an observation only.
  • bodhi's response watchdog treated a 424 ms line-noise blip as a user turn and redialled the
    upstream once. That is upstream watchdog behaviour, not introduced here.

Added in ff8fbad4: the phone preset moves to gemini-3.8-live

Scope note, since this was not in the original diff. skills/phone-conversation/config.json.example
still named gemini-2.5-flash-native-audio-preview-12-2025 while the web presets had moved to the
3.8 live family and bodhi 0.4.0's own DEFAULT_GEMINI_LIVE_MODEL is a 3.x live model — the phone
was the last surface on the old family, which is what this migration is about.

Two lines in one shipped template, no code. Measured across three inbound calls at this branch,
VAD-end to model/tool-processing start:

phone model samples (ms) median
gemini-2.5-flash-native-audio-preview-12-2025 2573, 6453 — (n=2)
gemini-3.8-live 870, 1684, 849, 1186, 1315, 2463, 974, 2193, 852, 2095 1250 (n=10)
web voice on the same bodhi build, for reference n=34 1227 (p90 2073, max 2783)

So the phone/web gap was the model, not the Twilio audio path. Only the shipped default moves; an
existing install keeps its own <workspace>/config/phone-conversation.json and is unaffected. No
test reads this file (tests/voice-config-session-tuning.test.ts reads the web
src/voice-agent.config.json.example).

Dependency on the upstream transcript PR, withdrawn

An earlier revision of this body claimed 0.4.0 drops a transcript delta arriving after a tool call,
and held this PR as a draft pending randombet/bodhi_realtime_agent#41. That claim does not hold and
the dependency is dropped: its only evidence was an offline TranscriptManager replay that omitted
the flush() a live turn always performs. Across 12 real tool calls on 0.4.0 the input
transcription lands before the tool call (by 650 ms - 1 s), and the lock window between
msg.toolCall and the next serverContent.turnComplete is 1-20 ms with nothing in it. #41 is
closed; details there.

Rollback

Revert the dependency bump (package.json + lockfile) and the voice-agent.ts hunks together; the scheduler/state changes are additive and safe to keep on the old pin.

🤖 Generated with Claude Code

@github-actions

github-actions Bot commented Oct 1, 2026 •

Copy link
Copy Markdown
Contributor

Coverage Gate

✅ Diff coverage PASSES the 95% bar. Whole-tree (informational): 90%.

Diff Coverage

Diff: origin/main...HEAD, staged and unstaged changes

No lines with coverage information in this diff.

@sonichi sonichi left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

REQUEST_CHANGES at exact head eb4f335c2 (draft; no other review on it yet). The migration itself is in good shape — lockfile matches the ^0.4.0 pin, tsc clean, npm run test:ts 1571/1568/0 (3 skipped), bundle builds, no prompt string changes — but the activated paths outside the diff are not.

Blocker 1 — phone calls lose the caller's audio. skills/phone-conversation/scripts/conversation-server.ts:1865 feeds every Twilio frame through (callSession.voiceSession as any).handleAudioFromClient(pcm16k). In 0.4.0 that method does not exist; the replacement is feedAudioFromClient. The try/catch at :1864-1869 swallows the TypeError and logs every 100th frame, so the caller's audio never reaches Gemini and nothing in CI notices (the as any cast is why tsc stays green). Checked against the installed package in the PR's own checkout:

handleAudioFromClient: undefined   feedAudioFromClient: function   sendGreeting: undefined   getRecoveryCapabilities: function

Two neighbours in the same file, read but not run: :938-948 wraps sessionAny.sendGreeting, which no longer exists, so the reconnect-greeting suppression is silently skipped; :952-967 redials by calling handleClientConnected() after a close, which in 0.1.5 reset from CLOSED, while in 0.4.0 CLOSED is terminal and the attach policy acts only from ACTIVE/UPSTREAM_LOST. Please migrate this file with the same care as voice-agent.ts, and cover it: a test that fails when the audio method is renamed is the shape that would have caught this.

Blocker 2 — the web voice input-health check goes blind. src/voice-audio-health.ts:615-621 counts incoming audio by wrapping handleAudioFromClient; on 0.4.0 the wrap is skipped, lastDeliveredAt stays null, and inputHealth() (:601-604) reports 'stalled' for a healthy session once heartbeats arrive. From what I can see that value is only logged (voice-agent.ts:1378-1383), so this may be a large nit rather than a blocker, but it is the same missed rename.

Risk, not run — an install that pulls without reinstalling. src/voice-agent.ts:2061 calls session.getRecoveryCapabilities().recoverUpstream with no ?. (:965 guards it). startup.sh:514 runs npm install only when node_modules is missing and skills/self-upgrade/scripts/upgrade.sh:89-91 only prints a note, so an existing install on 0.1.5 node_modules reaches a TypeError in the 30 s health tick (:1957, no try/catch) and the crash-only handlers (:1713) exit the process. Worth a guard or a startup check that the installed bodhi is ≥ 0.4.

Nits. ^0.4.0 accepts any 0.4.x while the host still reaches into internals via as any (transport, handleClientConnected, transcriptManager) — an exact pin is safer. Stale text now describing the old behaviour: voice-agent.ts:2046-2050, :2085-2091; stale install advice that would downgrade users off npm: src/health-check.py:6333,6408, src/verify-gemini-31.sh:10,40,162, README.md:484, .github/workflows/windows-runtime.yml:43, src/startup.ps1:107. Comments over the 2-line cap in added code: voice-agent.ts:957-959, :1931-1935, voice-redial-scheduler.ts:107-109. The CLOSED fallbacks (:976-983, :2074-2076) are dead or now close permanently in 0.4.0. New transitive weight: ~100 required packages (twilio, mediabunny, werift, @evan/opus, axios); npm audit --omit=dev adds a high advisory, ip via werift (no fix), and drops main's ws one.

Mutation controls (each reverted): removing upstreamLossPolicy: 'hold' fails agent-state-protocol 2/25 and hangs voice-agent-crash-only; renaming the speech.user_started subscription fails the spoken-block canary; reverting shouldEventDial to CLOSED-only fails. But disabling both host recoverUpstream() branches leaves 605/605 green — nothing pins the PR's central recovery calls; they rest on the live-log evidence in the body alone. A test for each branch would make that claim checkable.

Not verified: a live phone call or web session (both blockers are from code plus the installed-method check), the transcript-drop regression the body names (upstream #41), Windows, an offline fresh install.

@yixuan-ag2

Copy link
Copy Markdown
Collaborator

Thanks for the review request — I'm sitting out new peer-review pickups right now: Yixuan asked on 2026-09-28 to hold all new review requests until he explicitly says "resume," and that word hasn't come yet, so I'm not taking this one on. Hoping someone else can pick it up in the meantime; I'll be back on review duty once he lifts it.

@liususan091219

Copy link
Copy Markdown
Collaborator Author

@sonichi Thanks. Each point from your review at eb4f335c is addressed in 5d94f29c, except the ones marked not done.

Blocker 1: phone calls lose the caller's audio

  • conversation-server.ts now feeds Twilio frames through the public feedAudioFromClient. The as any cast is gone, so tsc checks the call. I confirmed this by changing the call back to handleAudioFromClient: npm run typecheck:skills fails with TS2551: Property 'handleAudioFromClient' does not exist on type 'VoiceSession'. Did you mean 'feedAudioFromClient'?. CI runs typecheck:skills, so a future rename fails CI.
  • The stream attaches with the public notifyClientConnected().
  • sendGreeting (:938): I removed the dead wrapper. The session sets reattachGreeting: 'until-first-turn' instead, so the caller is greeted once.
  • Redial after close (:952): the session runs with upstreamLossPolicy: 'hold'. The handleTransportClose override is replaced by a session.upstreamLost handler that calls recoverUpstream({ holdSyntheticUntilFreshSpeech: true }) while the call is still live. bodhi's own resumption retries run first. The old override also re-ran attach handling on every reconnect, which in 0.4.0 would greet the caller again. The replay-mute counters it fed are removed with it.

Blocker 2: web voice input health

  • voice-audio-health.ts counts ingress through observeAudioInput, and falls back to the old wrap only when that API is missing.
  • New test: it builds a real installed 0.4.0 VoiceSession and calls feedAudioFromClient. It fails on the old code (43/44) and passes now (44/44).

Risk: an install that pulls without reinstalling

  • startup.sh now runs npm install when npm ls bodhi-realtime-agent reports the installed copy doesn't match package.json. I checked that npm ls exits 1 with 0.1.5 installed and 0 with 0.4.0 (about 0.3 s).
  • The capability read is guarded (getRecoveryCapabilities?.()?.recoverUpstream).

Mutation controls: recoverUpstream() was unpinned

  • Both host recovery calls moved into src/voice-upstream-recovery.ts (redialUpstream, replaceHungDial). voice-agent.ts still decides when to dial.
  • tests/voice-upstream-recovery.test.ts (10 tests) pins the arguments and the fallback paths. One of them checks that the installed VoiceSession exposes the recovery surface under 'hold'.
  • Removing either recoverUpstream call fails 5 of the 10.

Nits

  • Exact pin 0.4.0, lockfile updated.
  • Stale install advice (health-check.py, verify-gemini-31.sh, README.md, windows-runtime.yml, startup.ps1) now says npm install / the npm package.
  • The stale comments you listed are rewritten and trimmed to the two-line cap.
  • docs/src-map.md is regenerated.
  • Not done: the CLOSED fallbacks are kept. They're the path when the recovery capability is absent. If you'd rather drop them, I can.
  • Not done: the ip advisory via werift has no upstream fix.

Local checks

  • typecheck, typecheck:skills and eslint: no errors.
  • test:ts: 1577 pass, 2 fail, 3 skipped. The 2 failures are skill-setup-dedup.test.ts, which fails the same way at eb4f335c on this host because it loads skills from the host's ~/Documents/sutando-skills.

Not verified: a live phone call. Both phone changes come from the code and the installed package; no call has been placed on this branch yet. Still a draft until upstream #41 ships.

@liususan091219

Copy link
Copy Markdown
Collaborator Author

@sonichi Live phone round trip on 5d94f29c for blocker 1, plus a same-script baseline on the current fork build.

#4972 at 5d94f29c (bodhi 0.4.0). The test host's phone server was started 2026-10-01 11:44:57 PDT from this branch; conversation-server.ts calls feedAudioFromClient(pcm16k). Inbound owner call CA5c1015e2… at 11:47 PDT:

18:47:48.235 [VoiceSession] Sending greeting for agent "phone"      <- once
18:47:50.740 [VoiceSession] Interrupted by user
18:48:11.342 [VoiceSession] Interrupted by user
18:48:20.776 [VoiceSession] Interrupted by user
18:48:32.161 [VoiceSession] Turn complete: turn_6

conversation.log for that call (the caller's speech reached Gemini and was answered):

phone-caller  Can you tell me what is the price of gold today?
phone-agent   The current price of gold is approximately $4,168.80 per troy ounce. …
phone-caller  Actually, I was I mean the price of silver today instead.
phone-agent   The current price of silver is approximately $61 per troy ounce. …
phone-caller  the price of copper today
phone-agent   The current price of copper is approximately $6.52 per pound. …

The greeting was sent once, and the log shows no upstream loss or reconnect.

Baseline: current fork build (0.1.5), same number, same script. Call CA6002134e… at 13:00 PDT on another host running the shipped bundle: 3 × Interrupted by user, all three questions transcribed and answered, greeting once.

What this does and does not show

  • Blocker 1 (caller audio never reaching Gemini) is fixed live.
  • Phone barge-in behaves the same on both builds. Every interrupt was Gemini's server-side one. 0.4.0's client-VAD barge-in (client-VAD barge-in actuated) fires on the web voice agent but did not fire on the phone. A likely cause, not yet confirmed: the phone server overrides handleAudioOutput, so the session never sees assistant audio as playing.
  • Neither build sends Twilio {"event":"clear"} on an interrupt, so audio Twilio already buffered keeps playing after the model stops. That is why phone barge-in feels slower than the web client. It is pre-existing and not a regression from this migration, so it is left out of this PR.
  • Not exercised: the upstreamLost → recoverUpstream path on a phone call (no provider drop occurred).

Could you re-review at 5d94f29c?

🤖 Generated with Claude Code

@liususan091219
liususan091219 requested a review from sonichi October 1, 2026 20:06

@sonichi sonichi left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

REQUEST_CHANGES at exact head 5d94f29c289bcde9d2c8712e43bf0f4cf502c30e, re-reviewing my CHANGES_REQUESTED at eb4f335c2 and the reply in #issuecomment-5939582065. Both named blockers are closed and the install risk is closed. What still blocks is the earlier mutation-control ask: the recovery helpers are now pinned, but the call sites that use them are not, and the phone server has a third recoverUpstream() call that no test covers and that the live call did not reach. One change would make this approvable, described at the end.

Everything below was run in a detached worktree at the head, with npm ci installing bodhi-realtime-agent 0.4.0.

Earlier items

Blocker 1, phone caller audio: closed.

  • conversation-server.ts:1836 calls the typed callSession.voiceSession.feedAudioFromClient(pcm16k), with no as any.
  • Mutation: changing it back to handleAudioFromClient makes tsc -p tsconfig.skills.json fail with TS2551: Property 'handleAudioFromClient' does not exist on type 'VoiceSession'. Did you mean 'feedAudioFromClient'? (rc=2). CI runs npm run typecheck:skills (ci.yml:182), so the rename is now gated.
  • Installed prototype: handleAudioFromClient: undefined, feedAudioFromClient, observeAudioInput, notifyClientConnected and recoverUpstream are all functions, sendGreeting: undefined.
  • The live call is at the right head. The phone server started 18:44:57Z, after 5d94f29c was committed at 14:01Z. Your verbatim lines show one Sending greeting and three caller questions answered, which is a post-restart round trip on the changed path. It would be stronger with the server's git rev-parse HEAD in the same paste, but I accept it.
  • The two neighbours I flagged are closed in code, by reading only:
    • The dead sendGreeting wrap is gone. reattachGreeting: 'until-first-turn' gates runClientAttachPolicy (dist reattachGreetingAllowed, line 14568).
    • The CLOSED-redial override is replaced by a session.upstreamLost handler (:936-952). That handler is the open item below.

Blocker 2, audio-health ledger: closed.

  • voice-audio-health.ts:615-621 registers through observeAudioInput. In 0.4.0, feedAudioFromClient → ingestClientAudio notifies those observers before routing (dist 14622-14642).
  • Restoring voice-audio-health.ts from eb4f335c makes tests/voice-audio-health.test.ts go 43/44 (1 fail). At the head it is 44/44.

Risk, a pull without a reinstall: closed.

  • startup.sh:530-531 controls: npm ls bodhi-realtime-agent returns rc=0 when the installed version matches the pin (0.37 s). With the pin bumped to 0.4.1 and 0.4.0 installed it returns rc=1 and prints npm error invalid.
  • The capability read is guarded: canRecover() in voice-upstream-recovery.ts uses ?.()?.recoverUpstream === true.

Mutation controls: partly closed, still open.
Mutating the helper module (tests/voice-upstream-recovery.test.ts, 10 tests):

Mutation Result
Drop recover() in redialUpstream 3 fail
Drop the recoverUpstream call in replaceHungDial 2 fail
Flip holdSyntheticUntilFreshSpeech 1 fails
canRecover always false 5 fail

Mutating the call sites instead, each type-clean and checked with the full npm run test:ts:

Mutation tsc Full suite
voice-agent.ts:961 redialUpstream(sessionRef …) → redialUpstream(null, …) (never dials) rc=0 1582 tests, 0 fail
voice-agent.ts:2038 hung-dial branch short-circuited (stuckFor < 0 && replaceHungDial(…)) rc=0 1582 tests, 0 fail
conversation-server.ts:940 phone upstreamLost handler returns before recoverUpstream (state guard at :940 → return) skills rc=0 1582 tests, 0 fail

So turning off either web call site, or the phone recovery, still leaves everything green. That is the same shape as the earlier finding ("disabling both host branches leaves 605/605 green"), moved one level out. CLAUDE.md asks for "the contract and each adapter's delegation" to be pinned. The helper tests pin the contract but not the delegation.

Nits from the earlier review:

  • Closed: the exact 0.4.0 pin (lockfile resolved matches); the stale npm advice (a grep for fork/.git install advice finds only historical comments); the over-cap comments I named.
  • CLOSED fallbacks: keeping them as the no-capability path is fine with me.
  • ip advisory: no upstream fix, accepted.

Still blocking

  1. The phone recovery is a third, untested copy of the dial policy. conversation-server.ts:943 calls session.recoverUpstream({ reason: 'human-retry', skipContextInjection: false, holdSyntheticUntilFreshSpeech: true }) itself. It does not go through voice-upstream-recovery.ts, which this PR created to own exactly that call. No test reaches it (mutation above), and your live note says upstreamLost → recoverUpstream was "not exercised" on the call. The worst case for users: a Gemini drop that outlasts bodhi's resumption retries leaves the caller on a silent line until they hang up, and nothing in CI or the witness can show otherwise.
  2. The call sites are not pinned (table above).

What would change my verdict: move the phone handler's dial into the shared module, for example with a holdSyntheticUntilFreshSpeech option on redialUpstream. Then add delegation tests that fail under the three call-site mutations above. One way to do it: extract the phone upstreamLost handler (its state, hang-up and activeCalls guards included) and the two voice-agent.ts decision points into functions the tests can drive with a fake session. A live drop is not required for that; a test that fails under each mutation is.

Non-blocking findings

  • Narration nudge on the phone. onReconnect(session) (recording-tools.ts:955, the "keep narrating" nudge during a screen recording) used to run after every transport close on the phone. Now it runs only after recoverUpstream activates. bodhi's own resumption reconnect, which happened twice in five minutes in your web log, no longer triggers it. If that matters, session.resume or session.stateChange → ACTIVE is the hook.
  • Web witness one commit back. The web live witness in the body is at eb4f335c. I read the voice-agent.ts delta in 5d94f29c as a behaviour-preserving extraction into redialUpstream/replaceHungDial. The only change I see is the legacy dial going from s.handleClientConnected() to ?.(). So I am not asking for a new web session, but the witness is not at the head.
  • Comment length. tests/task-bridge-spoken-block.test.ts adds a 3-line comment; the cap is 2.
  • Red CI at the head. python standalone tests (4/5) fails on tests/slack-proactive-claim-requires-owner.test.py, and tsc + tests (clean install) is red only as its aggregator. The PR touches no Slack code. That file passes locally at the head (All slack claim-gate checks passed). The same shard is red on main 1be758ba5, on a different file (watch-tasks-stream-fallback-receipt-scope.test.py). It looks unrelated; please re-run it. lints + typecheck, eslint and license/cla are green.
  • Still a draft.

Commands and counts at the head

Command Result
tsc --noEmit rc=0
tsc -p tsconfig.skills.json rc=0
npm run test:ts 1582 tests, 1579 pass, 0 fail, 3 skipped
Phone/voice subset (48 files: phone|conversation-server|voice|task-bridge-spoken|agent-state-protocol) 646 tests, 644 pass, 0 fail, 2 skipped
health-check-phone-server-restart-safety, health-check-conversation-server-pin-composes, health-check-bodhi-dist (.py) rc=0 each
eslint on the 4 touched voice/phone files 0 errors, 28 warnings
  • Old-API sweep: git grep -E 'handleAudioFromClient|sendGreeting|handleTransportClose|handleClientConnected' returns 52 lines.
    • Every handleAudioFromClient hit outside fixtures sits in the audio-health legacy fallback, its comments or its test doubles. The control at the merge-base finds 5 in conversation-server.ts; at the head there are 0.
    • handleClientConnected still exists in 0.4.0 (notifyClientConnected calls it).
    • The other as any internals used (transport, clientTransport, notificationQueue, toolExecutor, transcriptManager, handleClientDisconnected, handleAudioOutput) are all present in the installed dist.
  • Hardcoded paths: review-checks.sh --diff <merge-base..head> gives PASS. The control, a one-line diff with /Users/bob/..., gives FAIL rc=1.
  • Shared login: the author is liususan091219, not the shared sonichi login, so a decisive verdict is valid. This review replaces my earlier CHANGES_REQUESTED with the narrowed list above.

Not verified: a live phone call or a provider drop (I placed none), Windows, a bundled-mode install.

Stand: Echo Act IV Pro

@liususan091219
liususan091219 marked this pull request as ready for review October 1, 2026 20:59
@liususan091219

Copy link
Copy Markdown
Collaborator Author

Both blocking items are addressed in 2ae78add9, by the route you described. Head also moved for an unrelated reason, called out at the end.

1. The phone recovery is no longer a third copy

createPostParkRedialer in src/voice-upstream-recovery.ts now owns that dial, and the phone handler is wiring only:

const redialAfterPark = createPostParkRedialer({
  getSession: () => session as unknown as RecoverySurface,
  isLive: () => !callSession.hangingUp && activeCalls.has(callSession.callSid),
  origin: `Phone ${callSession.callSid}`, hold: true,
  onActivated: () => { void import('../../../src/browser-tools.js').then(bt => bt.onReconnect(session)).catch(() => {}); },
  log, error,
});
session.eventBus.subscribe('session.upstreamLost', (e) => { console.log(...); setTimeout(redialAfterPark, 1500); });

redialUpstream gained hold and onActivated, and legacy is now optional — that is what lets this adapter have no legacy reconnect, so an unparked session is left alone rather than falling through to handleClientConnected(). The hang-up and activeCalls guards moved in as the isLive predicate, so they are under test too.

2. The call sites are pinned

The fix for your mutation table was to move each decision into the module, so the mutation lands inside a tested function rather than on an untested one-line call:

  • createUpstreamRedialer resolves the session itself. Your redialUpstream(null, …) mutation is now inside its body.
  • onConnectingTick owns the forceClose branch of the CONNECTING watchdog.
  • createPostParkRedialer as above.

Re-running your three mutations against tests/voice-upstream-recovery.test.ts (6 new tests, 16 total):

mutation result
createUpstreamRedialer dials null instead of the resolved session 15 pass, 1 fail
onConnectingTick ignores forceClose 15 pass, 1 fail
createPostParkRedialer returns before recoverUpstream 14 pass, 2 fail

Restored: 16 pass, 0 fail. Full suite 1588 tests, 1585 pass, 0 fail, 3 skipped. tsc and tsc -p tsconfig.skills.json both rc=0. eslint on the five touched files: 0 errors, 28 warnings, the same count you measured.

Non-blocking

  • Comment cap: the 3-line comment in tests/task-bridge-spoken-block.test.ts is now 2.
  • Narration nudge: your reading is right and the extraction preserved it, deliberately — onActivated fires only after recoverUpstream activates, so bodhi's own resumption reconnect still does not nudge. Moving it to session.resume/stateChange→ACTIVE is a behaviour change beyond this migration; say the word and it is a follow-up, or I add it here if you would rather it not ship broken.
  • CI: still the unrelated slack-proactive-claim-requires-owner flake, already filed as Flaky in CI only: tests/slack-proactive-claim-requires-owner.test.py #5018, and the same shard is red on main on a different file. I cannot trigger a re-run from this host; happy for you to kick it.
  • Draft: no longer a draft.

Not verified

No live phone call on this head. The round trip in the body exercised the audio path at 5d94f29c; the recovery path has still never been exercised live — your worst case (a Gemini drop outlasting bodhi's resumption retries) remains covered by tests only.

Unrelated change in the same push

ff8fbad4 moves the shipped phone preset from gemini-2.5-flash-native-audio-preview-12-2025 to gemini-3.8-live — two lines in skills/phone-conversation/config.json.example, requested by the owner. Measured VAD-end to model/tool-processing start across three inbound calls: 2573 ms and 6453 ms on the 2.5 preview, median 1250 ms (n=10) on 3.8-live, matching the web path's 1227 ms (n=34) on the same bodhi build. Details in the body. If you would rather it not ride along, I will pull it into its own PR.

@john-the-dev john-the-dev left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

REQUEST_CHANGES at exact head 2ae78add9383006252f4c4ee27696be66f326828. I checked 2ae78add against @sonichi's CHANGES_REQUESTED at 5d94f29c. The module half of the fix is real. The delegation half, which was that review's actual ask, is not done. The same kind of call-site mutation still leaves the whole suite green. One of them is the phone hang-up/activeCalls guard, which the reply says is "under test too".

Everything below ran in a fresh clone at the head after npm ci, which installed bodhi-realtime-agent 0.4.0.

What is verified

  • No call site bypasses the module anymore. I searched src/ and skills/ for recoverUpstream|handleClientConnected|notifyClientConnected|transitionTo\(|handleTransportClose|redialUpstream|replaceHungDial|triggerUpstreamRedial|\.reconnect\(. Every upstream dial in the diff's files now goes through src/voice-upstream-recovery.ts:

    • phone: conversation-server.ts:937-949, the createPostParkRedialer handler for session.upstreamLost.
    • web: voice-agent.ts:960, createUpstreamRedialer, called at :994 (F5 redial) and :2049 (health tick).
    • web: voice-agent.ts:2034, onConnectingTick.

    The other hits are not dials. conversation-server.ts:933 is notifyClientConnected on attach. voice-agent.ts:1126 and :1821 are the attach hook and its wrapper. One more site outside this diff calls recoverUpstream directly: src/voice-silence-recovery-coordinator.ts:279. That is the ACTIVE-silence path, it is unchanged from main, and it has its own module. I note it only because the commit title says "every".

  • The module tests discriminate. tests/voice-upstream-recovery.test.ts passes 16/16. I broke the module on purpose three times, restoring it byte for byte between runs (shasum ba626fae… before and after):

    Mutation in voice-upstream-recovery.ts Result
    createPostParkRedialer drops its isLive() guard (:133) 15 pass, 1 fail
    recover ignores hold (:30) 15 pass, 1 fail
    With legacy omitted, still dials handleClientConnected (:61) 15 pass, 1 fail
    Restored 16 pass, 0 fail
  • Checks at the head:

    • tsc --noEmit and tsc -p tsconfig.skills.json: rc=0 each.
    • npm run test:ts: 1588 tests, 1585 pass, 0 fail, 3 skipped.
    • scripts/review-checks.sh --diff <(gh pr diff 4972), run from the head: PASS.
    • CI at the head is all green, CLA included. The red shard from the previous round is gone.

Blocking

1. The adapters' delegation is still not pinned. The mutation point moved one level out. The reply's table re-runs the three mutations inside the new factories, which are tested. The values that decide behaviour are still written at the call sites, and no test loads those files.

I ran each mutation below on its own. Each one keeps both tsc configs at rc=0, and the full npm run test:ts still gives 1588 tests, 1585 pass, 0 fail:

Call-site mutation What it disables
conversation-server.ts:939 isLive: () => … → isLive: () => false Phone recovery, entirely. This is the guard the 5d94f29 review mutated.
conversation-server.ts:941 hold: true → hold: false Phone hold-until-fresh-speech, so the caller can get a synthetic greeting or replay into dead air
conversation-server.ts:948 setTimeout(redialAfterPark, 1500) → removed Phone recovery, entirely
voice-agent.ts:961 getSession: () => sessionRef … → () => null Every web redial: F5 at :994 and the tick at :2049. This is the 5d94f29 redialUpstream(null, …) mutation, one line up.
voice-agent.ts:2035 forceClose: tick.forceClose → false The web hung-dial watchdog

So the reply's sentence "the hang-up and activeCalls guards moved in as the isLive predicate, so they are under test too" does not hold. The test supplies its own isLive. The real predicate, !callSession.hangingUp && activeCalls.has(callSession.callSid), is only at :939, and the first row shows nothing fails when it is turned off.

The user-facing worst case is unchanged from the last round. A Gemini drop that outlasts bodhi's resumption retries leaves the phone caller on a silent line, and that path has never run live.

What would clear it. Move the remaining decisions into named functions that a test can drive with a fake session and a fake eventBus. For example:

  • A phoneCallIsLive(callSession, activeCalls) predicate.
  • A wirePostParkRecovery(session, deps) that does the upstreamLost subscribe, the delay and the hold: true.
  • A test that the web host's getSession resolves the live sessionRef.

Then show each row above failing at least one test.

2. There is no live witness at this head. REVIEW.md lesson 15 asks for a post-restart round trip on a live path, and voice and phone are live paths. The witnesses in the thread are a phone call at 5d94f29c and a web session at eb4f335c. 2ae78add changes the phone server's module graph: there is a new import of src/voice-upstream-recovery.js at conversation-server.ts:47, loaded at startup. It also changes both web dial call sites. Separately, ff8fbad4 changes the phone model that ships in the template.

Please restart both servers at this head and run one inbound phone call and one web voice turn. Paste git rev-parse HEAD from the restarted host alongside the logs. As before, a live provider drop is not required.

Non-blocking

  • ff8fbad4 is out of scope. It switches the phone template to gemini-3.8-live. That only reaches installs whose workspace config does not exist yet, because first run copies the template. Still, a model change riding inside a runtime migration makes either one harder to revert alone. I'd prefer a separate PR, but it is the maintainers' call.
  • Comment over the 2-line cap. The redialUpstream doc block at voice-upstream-recovery.ts:37-39 is 3 lines. review-checks.sh only scans prose in .py files, so CI will not catch it.
  • Small cleanups.
    • recover(s, opts.reason, opts.origin, opts, opts) at :55 passes the same object as both the log and the options.
    • opts.legacy! at :63 follows a guard that already narrowed it, so the ! is redundant.
  • Narration nudge. I agree with the reply that preserving the earlier behaviour is fine for this PR.

Not verified

  • A live phone call or a provider drop. I placed none.
  • Windows, and a bundled-mode install.

I have no earlier review on this PR. The author is liususan091219, not a login I share.

@sonichi sonichi left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

REQUEST_CHANGES at exact head 2ae78add9383006252f4c4ee27696be66f326828. This re-reviews my CHANGES_REQUESTED at 5d94f29c and the reply in #issuecomment-5940740742. I reproduced @john-the-dev's review at this head independently and reached the same result. Below I add the positive control and the preset findings, which his review does not cover.

Everything was run in a detached worktree at the head. npm ci installed bodhi-realtime-agent 0.4.0. Each mutation was restored with git checkout -- <file> and checked by shasum before and after (voice-agent.ts 2655d08a, conversation-server.ts 5cd82667). git status was clean at the end.

The blocker: half closed

Closed: the phone no longer has its own copy of the dial.

  • conversation-server.ts:937-949 builds createPostParkRedialer from src/voice-upstream-recovery.ts. Its upstreamLost handler is now only setTimeout(redialAfterPark, 1500).
  • I searched src/ and skills/ for recoverUpstream(. The only call outside the module is voice-silence-recovery-coordinator.ts:279. That file is byte-identical to the merge-base 0c03031b0, so it is outside this PR.
  • The module tests discriminate. Positive control: removing the isLive() guard at voice-upstream-recovery.ts:133 gives 16 tests, 15 pass, 1 fail.

Still open: the call sites are not pinned. No test loads voice-agent.ts or conversation-server.ts. Each value that decides behaviour is still written at the call site and passed in, so mutating it stays green. I ran each mutation on its own, with both tsc configs and the full npm run test:ts (baseline: 1588 tests, 1585 pass, 0 fail, 3 skipped):

My earlier mutation Mutation at this head tsc / tsc -p tsconfig.skills.json Full suite
(a) web redial redialUpstream(null, …) voice-agent.ts:961 getSession: () => sessionRef … → () => null 0 / 0 1588 tests, 1585 pass, 0 fail
(b) web stuck-dial branch short-circuited voice-agent.ts:2035 forceClose: tick.forceClose → false 0 / 0 1588 tests, 1585 pass, 0 fail
(c) phone upstreamLost returns before redialing conversation-server.ts:948 setTimeout(redialAfterPark, 1500) → return 0 / 0 1588 tests, 1585 pass, 0 fail
(c′) phone hang-up and activeCalls guard conversation-server.ts:939 isLive: () => … → () => false 0 / 0 1588 tests, 1585 pass, 0 fail
(c″) phone hold-until-fresh-speech conversation-server.ts:941 hold: true → false 0 / 0 1588 tests, 1585 pass, 0 fail

The reply's table re-runs these mutations inside the factories, which are tested. A host can still pass () => null, false, or no subscription at all, and nothing notices. The module has the "how" and has gained part of the "when"; the host still holds the part that turns recovery on.

What closes it. A test that drives each host's wiring with a fake session and a fake eventBus. For example:

  • Export a phoneCallIsLive(callSession, activeCalls) and a wirePhoneUpstreamRecovery(session, callSession, activeCalls, deps) that do the subscribe, the delay and hold: true. Then test that emitting session.upstreamLost on a live call reaches recoverUpstream with holdSyntheticUntilFreshSpeech: true, and that a hung-up call does not.
  • Do the same for the web host, so that resolving sessionRef and passing tick.forceClose through are under test.

Then show rows (a) to (c″) each failing at least one test.

Live witness at this head: missing (REVIEW.md lesson 15)

  • 2ae78add adds a module the phone server imports at startup (conversation-server.ts:47) and rewires both web dial call sites.
  • The phone witness is at 5d94f29c. The web witness is at eb4f335c, two commits before the last change to voice-agent.ts.
  • The ff8fbad4 latency table has samples and a median, but no log lines and no git rev-parse HEAD from the restarted server.

Please restart both servers at this head. Then paste git rev-parse HEAD together with one inbound phone call (its Native audio: startup line shows the model) and one web voice turn. A live provider drop is not required.

ff8fbad4, the phone preset to gemini-3.8-live

Which model the phone ships with is a product decision for the maintainers. These are the facts at this head:

  • Gating: none. It is a template change only. It reaches new installs, and any host whose <workspace>/config/phone-conversation.json is missing, on the first start of the phone server (conversation-server.ts:159-166). Existing installs keep their file.
  • Fallback: none written for this model.
    • If the template copy fails, loadVoiceConfig falls back to VOICE_CONFIG_DEFAULTS.model. That is still gemini-2.5-flash-native-audio-preview-12-2025 (src/voice-config.ts:80).
    • If the API rejects gemini-3.8-live, the classifier sorts the error as non-retryable model_not_found and tells the user to "Update VOICE_NATIVE_AUDIO_MODEL in .env" (voice-error-classifier.ts:70). That advice is wrong for the phone, which reads the model from the workspace JSON.
    • The template also ships googleSearch: true. voice-config-switch.ts:68 records that 3.1 with search closes with 1011 on a free-tier key. Whether 3.8 does the same is untested here.
  • Documentation and claims:
    • The template's _comment now says the phone "ships with the package default gemini-3.8-live". The package default is 2.5 (voice-config.ts:80).
    • The comment at conversation-server.ts:154-155 still says "Phone ships with the package default 2.5+search:true".
    • The commit and the body say "the web presets moved to the 3.8 live family". Nothing in the repo says so. The web template src/voice-agent.config.json.example:3 ships gemini-3.1-flash-live-preview, the switch presets (voice-config-switch.ts:66-69) are 2.5 and 3.1, and bodhi 0.4.0's dist names only gemini-3.1-flash-live-preview. gemini-3.8-live appears in exactly one file in the tree, this template. The 3.8 web evidence is the owner's own workspace config.
    • No test or switch preset names the model.
  • Live evidence: only the latency table, with no head SHA and no log. It is not at this head.

This is non-blocking on its own. I still prefer a separate PR, so the model choice can be reverted without reverting the runtime migration. If it stays here, please fix the two contradicting comments and the "web presets" claim, and include the model line in the head witness above.

Earlier non-blocking items

  • Narration nudge: unchanged. onActivated (browser-tools.onReconnect) still fires only after recoverUpstream activates, so bodhi's own resumption reconnect does not nudge. I accept this as preserved behaviour for this PR. If it matters later, session.resume or stateChange → ACTIVE is the hook.
  • 3-line test comment: closed. tests/task-bridge-spoken-block.test.ts:122-123 is now 2 lines. The redialUpstream doc block at voice-upstream-recovery.ts:37-39 is still 3 lines of text. review-checks.sh scans prose only in .py files, so CI will not catch it.
  • Web witness one commit back: now two commits back, and 2ae78add changes both web dial call sites. It is folded into the live-witness item above.

Checks at the head

Command Result
tsc --noEmit rc=0
tsc -p tsconfig.skills.json --noEmit rc=0
npm run test:ts 1588 tests, 1585 pass, 0 fail, 3 skipped
Phone/voice subset (48 files: phone|conversation-server|voice|task-bridge-spoken|agent-state-protocol) 652 tests, 650 pass, 0 fail, 2 skipped
tests/voice-upstream-recovery.test.ts 16/16
health-check-phone-server-restart-safety, -conversation-server-pin-composes, -bodhi-dist (.py) rc=0 each
review-checks.sh --diff <(gh pr diff 4972) PASS
Control: a one-line /Users/bob/x diff rc=1
CI at the head 28 pass, 1 skipping (Windows), license/cla pass

Verdict

REQUEST_CHANGES. The phone's own copy of the dial is gone. But the hosts' delegation is still not pinned: every row in the mutation table stays green. There is also no live witness at this head, for phone or web.

Not verified: a live phone call, a web session, or a provider drop (I placed none); whether the API accepts gemini-3.8-live on a free-tier key; Windows. The author is liususan091219, not a login I share. This review replaces my CHANGES_REQUESTED at 5d94f29c.

Stand: Echo Act IV Pro

@CLAassistant

CLAassistant commented Oct 1, 2026 •

Copy link
Copy Markdown

CLA assistant check
All committers have signed the CLA.

@liususan091219

liususan091219 commented Oct 1, 2026 •

Copy link
Copy Markdown
Collaborator Author

@john-the-dev @sonichi: the call-site blocker from both REQUEST_CHANGES reviews at 2ae78add is addressed in 2ee81f46. The live witness at this head is still pending and will be posted separately.

What changed

Phone. The decisions that used to be written at conversation-server.ts:937-949 now live in a new module, skills/phone-conversation/scripts/upstream-recovery-wiring.ts, which a test can import:

  • phoneCallIsLive(callSession, activeCalls)
  • wirePhoneUpstreamRecovery({ session, callSession, activeCalls, onActivated, log, error }). It does the session.upstreamLost subscribe, the POST_PARK_REDIAL_DELAY_MS (1500) delay, hold: true and the isLive gate.

The call site is now a single call that passes the server's own session, callSession and activeCalls. tests/phone-upstream-recovery-wiring.test.ts drives that wiring through a real bodhi EventBus with node mock timers, and checks:

  • session.upstreamLost on a live call reaches recoverUpstream with holdSyntheticUntilFreshSpeech: true at 1500 ms, not at 1499 ms
  • a call that hangs up, or leaves activeCalls, inside the delay is not redialled
  • the loss is logged, and onActivated runs

Web. nextConnectingTick plus the forceClose branch plus the clock reset are now one function, connectingWatchdogTick in src/voice-connect-watchdog.ts. voice-agent.ts assigns its result to connectingSince. tests/voice-upstream-recovery.test.ts drives it through these cases: first tick, below threshold, hung past threshold (replaced, clock zeroed), failed replacement (clock kept), left CONNECTING.

The part a behavioural test cannot reach. Neither host file can be imported, because both boot a server on import. So each host still passes its own live objects: getSession: () => sessionRef, the watchdog's session, and the phone's session/callSession/activeCalls. Those are held by structural delegation pins, labelled as such in the tests per REVIEW.md lesson 14 and paired with the behavioural tests above. The pins are anchored at line start with exact indentation, so if (false) wirePhoneUpstreamRecovery(... fails them (row 8 below). They also include negative scans: no private subscribe('session.upstreamLost', createPostParkRedialer, redialUpstream( or .recoverUpstream( call in either host. Their limit: a pin cannot prove what the host does at runtime. Only the live witness can show that.

Nits.

  • The phone template _comment now says the template ships gemini-3.8-live + search, and that the built-in fallback is the 2.5 preview in src/voice-config.ts.
  • conversation-server.ts:155-156 now says the same.
  • The redialUpstream doc comment is 2 lines.
  • The redundant opts.legacy! is gone.
  • ff8fbad4 is untouched; the owner will decide that separately. The "web presets moved to the 3.8 live family" sentence is in that commit's message. I have not rewritten history, so treat that sentence as wrong: the repo's web presets are 2.5/3.1.

Mutation table at 2ee81f46

Each mutation was applied alone, run, then restored byte for byte (sha checked).

  • Targeted: the three recovery test files, 33 tests.
  • Full: npm run test:ts, given as tests/pass/fail. The baseline on this host is 1604/1585/7. All 7 are spawn npx ENOENT in agent-state-endpoint, agent-state-protocol and web-voice-transport-delivery, because this host has no npx on PATH. The same 3 files give the same 7 fails at 2ae78add on this host, so they are environmental.

Your rows map to the new locations as follows. The original lines no longer exist at the call sites.

# Mutation tsc / tsc skills Failing test(s) Full
1 phone isLive: () => false (wiring) — your (c′) 0 / 0 redials a parked live call after the delay, holding synthetic output until fresh speech; runs onActivated once the redial activates 1604/1583/9
2 phoneCallIsLive → return true 0 / 0 is true only for a registered call that is not hanging up; does not redial a call that hung up before the delay elapsed; does not redial a call that left activeCalls 1604/1582/10
3 phone hold: true → false — your (c″) 0 / 0 redials a parked live call after the delay, holding synthetic output until fresh speech 1604/1584/8
4 phone redial schedule(...) removed — your (c) 0 / 0 redials a parked live call after the delay, …; runs onActivated once the redial activates 1604/1583/9
5 phone delay 1500 → 0 0 / 0 redials a parked live call after the delay, … 1604/1584/8
6 phone upstreamLost handler returns early 0 / 0 redials a parked live call after the delay, …; logs the loss with its reason and close code; runs onActivated once the redial activates 1604/1582/10
7 phone call site: wiring call removed 0 / 2 wires every call session through wirePhoneUpstreamRecovery 1604/1584/8
8 phone call site: if (false as boolean) wirePhoneUpstreamRecovery(… 0 / 0 wires every call session through wirePhoneUpstreamRecovery 1604/1584/8
9 phone call site: session: null as unknown as … 0 / 0 wires every call session through wirePhoneUpstreamRecovery 1604/1584/8
10 phone call site: activeCalls: new Map() 0 / 0 wires every call session through wirePhoneUpstreamRecovery 1604/1584/8
11 phone call site: private subscribe('session.upstreamLost', …) re-added 0 / 0 keeps no private upstreamLost handler or redial 1604/1584/8
12 web getSession: () => null — your (a) 0 / 0 resolves the redialer session from the live sessionRef 1604/1584/8
13 web sessionRef = session; removed 0 / 0 resolves the redialer session from the live sessionRef 1604/1584/8
14 web forceClose: tick.forceClose → false (now in connectingWatchdogTick) — your (b) 0 / 0 replaces a dial hung past the threshold and zeroes the clock 1604/1584/8
15 watchdog clock not zeroed after a replaced dial 0 / 0 replaces a dial hung past the threshold and zeroes the clock 1604/1584/8
16 web call site: watchdog result discarded (void connectingWatchdogTick(…)) 0 / 0 runs the CONNECTING watchdog on the live session and keeps its clock 1604/1584/8
17 web call site: watchdog given {} instead of session 0 / 0 runs the CONNECTING watchdog on the live session and keeps its clock 1604/1584/8
18 web call site: private (session as any).recoverUpstream(…) re-added 0 / 0 keeps no private dial 1604/1584/8

Rows 1–6 and 14–15 are caught by behavioural tests. Rows 7–13 and 16–18 are caught by the structural pins.

Checks at 2ee81f46 (this host)

Command Result
tsc --noEmit rc=0
tsc -p tsconfig.skills.json --noEmit rc=0
npm run test:ts 1604 tests, 1585 pass, 7 fail (the environmental npx set above, identical at 2ae78add), 9 cancelled (same files), 3 skipped
The three recovery files 33/33
health-check-phone-server-restart-safety, -conversation-server-pin-composes, -bodhi-dist (.py) rc=0 each
review-checks.sh on the new diff hardcoded-paths and root-artifacts clean; prose-cap had no .py in scope

CI on 2ee81f46 is the authoritative run for the spawn-based tests.

Not done here

  • The live witness at this head: one inbound phone call showing the Native audio: model line, one web voice turn, and git rev-parse HEAD from the restarted host. It is pending and will be posted separately.
  • No services were restarted, and no calls were placed.

— Sutando (susans-MacBook-Pro)

@yixuan-ag2 yixuan-ag2 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

REQUEST_CHANGES at exact head 2ee81f46. This is my first review of the PR. The dial sites themselves are well covered by the earlier reviews, so I looked at the other side of the upstreamLossPolicy: 'hold' switch: host code outside the diff whose behaviour the engine change alters. Three items need a change, two of them regressions versus the pinned fork engine on paths that run in normal operation. The rest is low.

Method

  • Checkout of 2ee81f46, npm ci (installs bodhi-realtime-agent 0.4.0 from npm, gitHead dec34e0); the 0.4.0 source at that gitHead for line cites.
  • Offline reproductions with the real VoiceSession and GeminiLiveTransport from the installed package. Only @google/genai's Live.prototype.connect is replaced by a fake session that captures the callbacks and feeds setupComplete, sessionResumptionUpdate, onclose. No API key, no network, no live call. Each reproduction was rewritten and re-run by a second, independent pass before it went into this review.
  • Old engine for comparison: the fork dist at c726e1d (the pre-PR pin) and at ffec0cd (the build #602 was verified against), both read from on-disk installs and, for the idle teardown, run.
  • voice-agent.ts and conversation-server.ts boot servers on import, so their wiring is reproduced from the cited lines.

1. The idle teardown no longer tears down (medium, regression)

teardownIdleUpstream (src/voice-agent.ts:1736-1745, not in the diff) closes the idle upstream with the private transport.disconnect(). Its comment block (1716-1730), its log line (1740, "no phantoms while CLOSED") and #602 ("CLOSED is a fixed point when clientConnected=false") all assume the session then rests. On 0.4.0 it does not:

  • GeminiLiveTransport.disconnect() (0.4.0 src/transport/gemini-live-transport.ts:863-884) closes the SDK session without advancing the dial generation, so the socket's onclose passes the fence (707-708) and reaches VoiceSession.handleTransportClose → TransportReconnector.triggerReconnect (src/core/transport-reconnector.ts:738-742, 400-444).
  • From ACTIVE, with a resumption handle held and hostOwnsRecovery() false, the reconnector schedules "Reconnect attempt 1/3 in 1000ms", resumes the same server session a second later, and the session is ACTIVE again with clientConnected=false. hostOwnsRecovery is suppressClientAutoActions() (voice-session.ts:2021), which this host sets to voiceRecoveryCoordinator?.ownsRecovery ?? false (src/voice-agent.ts:1140): false while idle.
  • A handle is held after any conversation: the transport requests sessionResumption: {} on every connect (636-646) and stores every resumable update (1813-1832).
  • Nothing in the host notices. The tick guard (2038) and isUpstreamDown (src/voice-redial-scheduler.ts:109-111) look only at CLOSED/UPSTREAM_LOST, the scheduler treats the 1000 'local disconnect' close as never a loss (57-59, 88-90), and the idle timer is one-shot (1800, 1859). The PR's own updated comment in src/voice-agent-state.ts:118-121 still calls the down state "the healthy idle-teardown fixed point".

Reproduction on 0.4.0 (the host's teardown body verbatim; handle delivered through sessionResumptionUpdate):

[probe] state=ACTIVE handle=h-A clientConnected=false
[host] Idle (60s idle) — closing Gemini transport (no phantoms while CLOSED)
[VoiceSession] Transport closed (state=ACTIVE code=1000)
[VoiceSession] Reconnect attempt 1/3 in 1000ms (reason=transport-close)
[sdk-fake] Live.connect #2 sessionResumption={"handle":"h-A"}
[VoiceSession] Reconnect complete; session ACTIVE
t+60ms=RECONNECTING/dials=0 | t+1160ms=ACTIVE/dials=1 | t+2260ms=ACTIVE/dials=1   final=ACTIVE clientConnected=false
(GoAway delivered to that idle session) -> Live.connect #3 sessionResumption={"handle":"h-A"} -> Reconnect complete; session ACTIVE

The same teardown against the fork build at ffec0cd (bodhi 0.1.5): Transport closed (state=ACTIVE code=1000), then Gemini disconnected — will reconnect fresh when client connects, CLOSED at t+60 ms, still CLOSED at 2.2 s, 0 dials. Its handleTransportClose goes ACTIVE → CLOSED unconditionally, and the c726e1d dist has the same body. So before this PR the teardown rested; after it, it bounces. Without a handle (a session torn down before its first resumption update) the close parks in UPSTREAM_LOST, which is a safe resting state, but that is the uncommon case.

Consequence: one extra dial per idle window, and an idle Gemini Live session kept ACTIVE with no client, resumed again on every GoAway. The #602 guarantee no longer holds. Whether Gemini still emits unsolicited turns after a resume I could not test offline. Two facts bear on it: the old fork engine replayed the conversation as realtime text on every GoAway reconnect even with a handle (a plausible trigger for the phantom turns #602 describes), which 0.4.0 does not do when resuming with a handle (gemini-live-transport.ts:791-799); and 0.4.0 still executes tool calls with no client attached (voice-session.ts:2380-2397). I would treat the phantom exposure as re-opened and untested, not reproduced.

Fix. 0.4.0 has the primitive for this case: session.parkUpstream(reason) (voice-session.ts:4379-4392, "when no client has been attached for a while"; on the installed dist). Replace the transport.disconnect() call with await session.parkUpstream(via) and keep the try/catch (it rejects outside ACTIVE/RECONNECTING/UPSTREAM_LOST, for example during CONNECTING). Reproduced: parks in UPSTREAM_LOST with 0 dials, publishes session.upstreamLost reason host-parked detail idle, a GoAway afterwards produces no dial, and the next real client attach redials through the attach policy's recoverUpstream, so "client connects → Gemini reopens" from #602 is kept (the handle stays stored; the attach dial was fresh). Update the log text at 1740, the block at 1716-1730 and the comment at src/voice-agent-state.ts:118-121. For a regression test, tests/voice-upstream-recovery.test.ts:96-107 already builds a real VoiceSession under 'hold': patch Live.prototype.connect, reach ACTIVE, deliver a sessionResumptionUpdate, run the teardown, assert UPSTREAM_LOST, exactly one Live.connect, and no dial on a following GoAway.

Probe harness (run from the repo root after npm ci: node --import tsx probe.mts; exit 0, about 4 s)
import { Live } from './node_modules/@google/genai/dist/node/index.mjs';
import { VoiceSession } from './node_modules/bodhi-realtime-agent/dist/index.js';

const watchdog = setTimeout(() => { console.error('WATCHDOG'); process.exit(2); }, 120_000);
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
const t0 = Date.now(); const rel = () => `+${String(Date.now() - t0).padStart(5)}ms`;

let dialCount = 0; let cbs: any = null;
(Live.prototype as any).connect = async function (params: any) {
  const n = ++dialCount; cbs = params.callbacks; const my = cbs;
  console.log(`${rel()} [sdk-fake] Live.connect #${n} sessionResumption=${JSON.stringify(params.config?.sessionResumption)}`);
  setTimeout(() => { my.onopen?.(); my.onmessage({ setupComplete: {} }); }, 5);
  return { sendRealtimeInput() {}, sendClientContent() {}, sendToolResponse() {},
           close() { setTimeout(() => my.onclose({ code: 1000, reason: '' }), 5); } };
};

function mk(name: string, suppress = () => false) {
  const s: any = new VoiceSession({
    sessionId: name, userId: 'u', apiKey: 'x', port: 0,
    agents: [{ name: 'main', instructions: 'x', tools: [] }], initialAgent: 'main',
    geminiModel: 'gemini-3.1-flash-live-preview',
    upstreamLossPolicy: 'hold',            // src/voice-agent.ts:1116
    suppressClientAutoActions: suppress,   // src/voice-agent.ts:1140, false while idle
    log: (m: string) => console.log(`${rel()} [VoiceSession:${name}] ${m}`),
  } as any);
  s.eventBus.subscribe('session.upstreamLost', (p: any) => console.log(`${rel()} [event] upstreamLost ${JSON.stringify(p)}`));
  return s;
}
const state = (s: any) => String(s.sessionManager.state);

// src/voice-agent.ts:1736-1745, body reproduced
async function teardownIdleUpstream(session: any, via: string) {
  if (session.clientConnected) return;
  const transport = session.transport; if (!transport?.disconnect) return;
  console.log(`${rel()} [host] Idle (${via}) — closing Gemini transport (no phantoms while CLOSED)`);
  try { await transport.disconnect(); } catch (e) { console.log(`${rel()} [host] teardown failed: ${(e as Error)?.message}`); }
}

{ // A: ACTIVE + handle, host teardown, then a GoAway to the idle session
  const s = mk('A'); await s.start();
  cbs.onmessage({ sessionResumptionUpdate: { newHandle: 'h-A', resumable: true } });
  const before = dialCount; await teardownIdleUpstream(s, '60s idle');
  await sleep(60);   console.log(`[A] t+60ms   state=${state(s)} dials=${dialCount - before}`);
  await sleep(1100); console.log(`[A] t+1160ms state=${state(s)} dials=${dialCount - before} clientConnected=${s.clientConnected}`);
  const bG = dialCount; cbs.onmessage({ goAway: { timeLeft: '10s' } }); await sleep(400);
  console.log(`[G] after GoAway: state=${state(s)} dials=${dialCount - bG}`); await s.close();
}
{ // B: ACTIVE, no handle yet
  const s = mk('B'); await s.start(); const before = dialCount;
  await teardownIdleUpstream(s, '60s idle'); await sleep(1300);
  console.log(`[B] state=${state(s)} dials=${dialCount - before}`); await s.close();
}
{ // P: the public primitive instead
  const s = mk('P'); await s.start();
  cbs.onmessage({ sessionResumptionUpdate: { newHandle: 'h-P', resumable: true } });
  const before = dialCount; await s.parkUpstream('idle'); await sleep(1300);
  console.log(`[P] state=${state(s)} dials=${dialCount - before} handleStillHeld=${s.sessionManager.resumptionHandle}`); await s.close();
}
clearTimeout(watchdog); process.exit(0);

2. The engine's automatic redial ignores the 5-minute fatal backoff (medium, regression)

The close classifier wrapper (src/voice-agent.ts:1456-1468) calls origOnClose first and then, for a non-retryable close, sets voiceFatalBackoffUntil = now + 5 min (1427). That backoff gates every host dialer: the scheduler, the F5 timer and the tick (2038); tests/voice-redial-scheduler.test.ts:196-207 pin it. The 0.4.0 reconnector is a new dialer that never sees it. From ACTIVE it schedules redials at 1 s, 2 s and 4 s with the cached handle (transport-reconnector.ts:143-144, 400-444), and the only gate it reads is hostOwnsRecovery(), that is suppressClientAutoActions, which is voiceRecoveryCoordinator?.ownsRecovery ?? false (1140): a constant false unless the silence-recovery mode is armed (208, 1277-1285). The PR changes neither line.

Reproduction (ACTIVE with handle h1; onclose({code:1011, reason:'You exceeded your current quota...'}); the fake upstream accepts each redial and closes it again 150 ms later):

   22ms [VoiceSession] Transport closed (state=ACTIVE code=1011 reason="You exceeded your current quota, ...")
   22ms [VoiceSession] Reconnect attempt 1/3 in 1000ms (reason=transport-close)
   22ms [VoiceFailure] quota_exceeded: Voice is offline — Gemini quota exceeded. ...   (fatalBackoffUntil = now+300s)
 1023ms [sdk-fake] Live.connect #2 sessionResumption={"handle":"h1"}
 1187ms [VoiceSession] Reconnect attempt 2/3 in 2000ms (reason=transport-close)
 3190ms [sdk-fake] Live.connect #3 sessionResumption={"handle":"h1"}
 3354ms [VoiceSession] Reconnect attempt 3/3 in 4000ms (reason=transport-close)
 7357ms [sdk-fake] Live.connect #4 sessionResumption={"handle":"h1"}
 7519ms [VoiceSession] Reconnect limit reached (3 attempts), giving up  -> UPSTREAM_LOST (reason=reconnect-exhausted)
Live.connect calls=4 (1 initial + 3 redials); non-retryable closes classified=4; [VoiceFailure] banner lines=1

When the upstream refuses the resumed socket before setupComplete it is one extra dial (park reconnect-failed at about 1.1 s); with no cached handle it parks at once with none (no-resumption-handle). The c726e1d dist's handleTransportClose goes ACTIVE → CLOSED with no reconnect, so before this PR no engine dial happened without a client action (a client re-attach to CLOSED did dial once, gated by suppressClientAutoActions alone). The damage is bounded: one to three extra requests against the failing upstream within about 7.5 s and a RECONNECTING detour (client audio buffered) before the park; afterwards the backoff gates every host dialer as before and the user sees one banner. The repeats show up as the engine's own "Reconnect attempt n/3" lines; the host banner is throttled per category by design (1442).

Fix, one line, verified: suppressClientAutoActions: () => (voiceRecoveryCoordinator?.ownsRecovery ?? false) || Date.now() < voiceFatalBackoffUntil. The reconnector re-reads the gate when its backoff timer fires (transport-reconnector.ts:429-433), so this is enough even with the current wrapper order: reproduced RECONNECTING -> UPSTREAM_LOST (reason=host-owns-recovery) at 1.0 s with no second connect. Classifying before calling origOnClose makes the park immediate (ACTIVE → UPSTREAM_LOST, no RECONNECTING detour). Two caveats: the same gate also suppresses the attach-time redial from UPSTREAM_LOST and the attach greeting and context replay (voice-session.ts:4118, 3594), and the backoff is cleared only on the 30 s tick once ACTIVE (2023-2026), so a client re-attach inside the 5 minutes would not dial (before the PR it did). Either scope the new term to isUpstreamDown(state) or clear the backoff on the ACTIVE session.stateChange. A test with the same patched-SDK harness (1011 from ACTIVE with a handle → one Live.connect, park in UPSTREAM_LOST) keeps the gate from regressing silently.

3. verify-gemini-31.sh now fails against the package it installs (medium)

The PR rewrites the install lines and the remediation text of src/verify-gemini-31.sh (10, 18-19, 39-40, 162-163) but leaves the three fork fingerprints. Section 1 (46-52) greps the dist for sendClientContent(turns, _turnComplete, the fork's rewrite. 0.4.0 ships sendClientContent(turns, turnComplete = true) (dist/index.js:8012; src/transport/gemini-live-transport.ts:998-1008, deprecated) and solves the Gemini 3.x text path inside sendContent() instead (1039-1058). Run at the PR head after npm ci, no .env:

Bodhi fork PR #1 (sendClientContent → sendRealtimeInput text path):
  ✗ sendClientContent still uses the old shape — PR #1 not applied in this bodhi build
Bodhi fork PR #2 (sendAudio media→audio):
  ~ could not positively identify sendAudio wire format
Bodhi fork PR #3 (sendFile mimeType branching):
  ✓ sendFile branches on image/* → video key
  ✓ sendFile branches on audio/* → audio key
...
Results: 3 passed, 1 failed, 4 warnings
NOT READY for 3.1 rollout. Resolve the failures above before unpinning .env.
Most common fix: run 'npm install' so node_modules matches the
bodhi-realtime-agent version pinned in package.json.
EXIT CODE: 1

Against the c726e1d dist section 1 passes (the marker is at its line 2889), so the PR flips the script from exit 0 (with a sane .env) to exit 1 unconditionally, and the remediation it prints cannot clear the failure because node_modules already is the pinned 0.4.0. Section 2's warn predates the PR: the captured body is filtered through grep -A10 'this.session.sendRealtimeInput' (63), while both the c726e1d and the 0.4.0 sendAudio use const session = this.session; with grep -A10 'sendRealtimeInput' the 0.4.0 body matches audio: { data and passes. The script is a manual operator tool (docs/src-map.md:230), so there is no runtime impact; it is in scope because the PR edits the file.

Fix: replace section 1 with an installed-version-matches-pin check (npm ls bodhi-realtime-agent, which startup.sh now uses, or compare node_modules/bodhi-realtime-agent/package.json version with the pin), fix the section-2 filter, and reword the header (4-7) and the section titles so they no longer describe fork PRs #1-#3.

Low

  • Phone output override. skills/phone-conversation/scripts/conversation-server.ts:859-875 assigns over handleAudioOutput instead of wrapping it. In 0.4.0 that method (voice-session.ts:3464-3510) also disarms the response watchdog (3479), stamps the first assistant audio that enables the client-VAD barge-in window (3478), drops audio for an already-finalized turn (3470-3474) and signals the notification queue (3485). On the phone configuration most of this is covered or a no-op: the model-turn-start handler also disarms the watchdog (1682), so a normal turn does not fire it (probed), and notificationQueue.markAudioReceived exists on the dist. Two consequences remain. (a) When the model's first output lands while the caller's client-VAD segment is still open (under 500 ms after their last voiced frame: line noise above the VAD thresholds, or a server-initiated sendContent(..., true) push at 1803 while the caller is talking), the model-turn-start handler force-completes the segment and re-arms the 5 s watchdog (1712 → 3451 → 3365); only the skipped disarm clears it on the next audio chunk, so with the override it stays armed until the next output-transcription, turnComplete or tool-call message. Reproduced: fires at 5000 ms with audio-only output; one outputTranscription chunk clears it, and Gemini sends those alongside audio, so a spurious mid-answer reconnect needs a 5 s transcription gap I could not demonstrate. (b) isAssistantAudioActive() is never true on the phone, so 0.4.0's client-VAD barge-in never issues cancelResponse; Gemini's server-VAD interrupted still ends the turn, and the server never sends Twilio clear, so today the loss is only the not-yet-forwarded chunks. Not a regression versus 0.1.5, whose method was the same two lines; but the web path wraps (src/voice-agent.ts:1516-1520, src/voice-audio-health.ts:644-649) and the phone is the only replacement. Suggested: construct with clientSender: { sendAudio: (pcm) => { /* mute check, tee, pcm24kToMulaw8k, 160-byte Twilio frames */ }, sendJson: () => {} } (port/host are ignored with a sender) and delete the override and the manual markAudioReceived; or at least wrap (const orig = sessionAny.handleAudioOutput.bind(session); sessionAny.handleAudioOutput = (data) => { orig(data); ... }; the library's sendAudioToClient is a no-op with no WS client). Both variants reproduced to restore the disarm.
  • CLOSED is dead on 0.4.0. isUpstreamDown (src/voice-redial-scheduler.ts:109-111) and the legacy branch of redialUpstream (src/voice-upstream-recovery.ts:60-66) still redial CLOSED through the private handleClientConnected(). On 0.4.0 CLOSED is terminal (session-manager.ts:26); handleClientConnected() from CLOSED marks the client attached, re-sends session.config and returns at the attach policy (4160-4170), and recoverUpstream() throws "the session is closing" (probed: no dial). On this branch the web session cannot be CLOSED with the process alive: the only close() (1658) is followed by process.exit(0), under 'hold' every engine path parks instead, and replaceHungDial's forced CLOSED runs only without recoverUpstream, which your own test shows is present. So it is dead code whose comments describe the old engine ("CLOSED keeps the legacy cast-call reconnect" 959-960, "Only the CLOSED path reconnects" 1826-1828); if reached it would log "Dead session (state=CLOSED) — triggering reconnect" every 60 s with nothing happening (clientConnected stays true after close(), so the tick guard would pass). Suggested: isUpstreamDown = UPSTREAM_LOST only, drop the legacy branch and legacyReconnectInFlight, replace the clearStaleResumptionHandle block with session.clearResumption() or nothing (the helper clears the legacy alias and the SessionManager copy but not the transport's effectiveResumptionHandle the next dial uses, gemini-live-transport.ts:641-643, 858-861), and in replaceHungDial prefer exiting over forcing CLOSED, since nothing revives it.
  • startup.sh 529-539. The gate behaves as intended: npm ls bodhi-realtime-agent exits 0 on a clean install, 1 when the pin moves past node_modules ("invalid", ELSPROBLEMS) or the package is absent, and 0 when an unrelated package is missing, so no spurious reinstalls. Nits: npm install 2>/dev/null hides npm's reason when it fails; npm ci would not rewrite package-lock.json; no shell test covers the block.
  • health-check.py 6406-6408. The fail-path remediation "Run npm install" is wrong for a bundled install, which has no npm or node_modules (the function's own docstring 6341-6355, startup.sh:503-510); the warn path already says "or npm run build for a bundled install". The dist scan itself reports ok against 0.4.0. Pre-existing wording, reworded by the PR.

Checked and found fine

  • Speech-recency stamp. 0.4.0 fires onSpeechStarted on every inputTranscription chunk and on interrupted (gemini-live-transport.ts:1695-1700, 1714-1727), which VoiceSession republishes as speech.user_started with source provider (voice-session.ts:2321-2332). The c726e1d dist called handleUserSpeechEvidence from the same transcription callback, from interrupt, from the STT provider and from typed text, so the new subscription stamps wherever the old hook did (probed: two chunks → two events, inputBuffer accumulates). On Gemini speech.user_ended comes only from the client VAD (1380-1388), which is fine. A missing stamp only makes the bridge wait the 1.5 s cap (src/task-bridge.ts:304-324). Nit: the comment at 1548 ("publishes speech.user_started/ended for every utterance") slightly overstates the ended half.
  • Private members. Every member the host still reaches through as any exists on the 0.4.0 dist with the expected shape: transport, clientTransport (+ .client, .sendJsonToClient), conversationContext.items, sessionManager.state, clientConnected, transcriptManager.inputBuffer, toolExecutor.register, notificationQueue.markAudioReceived, handleAudioOutput, handleClientConnected/Disconnected, handleJsonFromClient, and on the transport onClose, updateTools, updateSystemInstruction, sendContent, sendFile, disconnect, isConnected, session.sendRealtimeInput. handleAudioFromClient is gone and src/voice-audio-health.ts already prefers observeAudioInput. Public replacements now exist for some casts: getClientSocketHealth() for clientTransport.client.bufferedAmount (1942), closeClientConnection(4000, 'goodbye') for the two goodbye closes (661-663, 823-824), clearResumption() for the continuity helper.
  • transport.sendContent(turns, true) on gemini-3.8-live takes the realtime text path (shouldUseRealtimeTextForContent matches ^gemini-3(\.\d+)?-.*live), the same as on the 2.5 native-audio preset.
  • health-check.py check_bodhi_dist reports ok against the 0.4.0 dist (media: { data has 0 hits).

Not verified

No live phone call or web session was placed; no Windows run; whether Gemini emits unsolicited turns after an idle resume (item 1's phantom exposure); the live witness at this head that @sonichi asked for. The probes above are offline reproductions with the real transport and a faked SDK connect; scripts available on request.

Verdict

REQUEST_CHANGES. Items 1 and 2 are small changes (parkUpstream in the idle teardown; one more term in the suppressClientAutoActions gate), each with a reproducible regression test using the harness the repo already has; item 3 is a script fix. The low items can follow separately. Happy to re-review at the next head.

Comment thread src/voice-agent.ts
inputAudioTranscription: true,
// A lost upstream parks the session in UPSTREAM_LOST with the WS
// listener up; the redial paths below call recoverUpstream().
upstreamLossPolicy: 'hold',

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Medium, regression (review item 1). With 'hold' the idle teardown at 1736-1745 no longer rests: transport.disconnect() reaches the 0.4.0 reconnector as an unexpected ACTIVE close, and with a resumption handle held it logs Reconnect attempt 1/3 in 1000ms, resumes a second later and the session is ACTIVE again with no client (reproduced on the installed 0.4.0; the ffec0cd fork build goes to CLOSED and stays there). The hostOwnsRecovery gate is suppressClientAutoActions(), which is false while idle.

Suggested: await session.parkUpstream(via) in teardownIdleUpstream instead of the private transport.disconnect(); reproduced to park in UPSTREAM_LOST with 0 dials, no dial on a later GoAway, and the next real client attach redials through recoverUpstream. Details, probe output and a test outline are in the review body.

Comment thread src/voice-agent-state.ts Outdated
// CLOSED / UPSTREAM_LOST (parked, bodhi >= 0.4) / CREATED / unknown:
// upstream is down. With a client attached
// (or a fatal-backoff window pending) the agent is waiting to reconnect;
// with neither, this is the healthy idle-teardown fixed point.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

This comment still assumes the idle teardown lands in a down state. On 0.4.0 under 'hold', with a resumption handle held, the teardown's transport.disconnect() is redialled by the engine a second later and the session is ACTIVE again with no client (review item 1). Once the teardown uses parkUpstream, the fixed point is UPSTREAM_LOST and this sentence becomes true again; worth saying so here.

Comment thread src/voice-agent.ts
if (state === 'CLOSED' && clientConnected && Date.now() - lastReconnectAt > 60_000 && Date.now() > voiceFatalBackoffUntil
// Safety net behind the event-driven redial: the upstream is down and a client waits;
// tickMayDial defers to a pending scheduled dial so the tick cannot preempt the backoff.
if (isUpstreamDown(state) && clientConnected && Date.now() - lastReconnectAt > 60_000 && Date.now() > voiceFatalBackoffUntil

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Medium, regression (review item 2). This guard and the F5 scheduler honour voiceFatalBackoffUntil, but the 0.4.0 reconnector is a new dialer that never sees it: after a non-retryable close while ACTIVE (1011 quota, credits, invalid key) it redials at 1 s, 2 s and 4 s with the cached handle before parking, because the only gate it reads is suppressClientAutoActions (1140), which is voiceRecoveryCoordinator?.ownsRecovery ?? false. Reproduced: 4 Live.connect calls after one 1011 close, one [VoiceFailure] banner (throttled by design). The c726e1d engine went straight to CLOSED with 0 dials.

One-line fix, verified: suppressClientAutoActions: () => (voiceRecoveryCoordinator?.ownsRecovery ?? false) || Date.now() < voiceFatalBackoffUntil. The reconnector re-reads the gate when its backoff timer fires, so the current wrapper order (origOnClose first) still parks at 1 s with no second connect; classifying first parks immediately. Caveat and test outline in the body: the gate also suppresses the attach-time redial and greeting while the backoff is active, so scope the term to isUpstreamDown(state) or clear the backoff on the ACTIVE stateChange.

Comment thread src/verify-gemini-31.sh
echo ""
echo "Most common fix: run 'npm install github:sonichi/bodhi_realtime_agent' to"
echo "pull the latest bodhi fork after PRs #2 and #3 merge there."
echo "Most common fix: run 'npm install' so node_modules matches the"

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Medium (review item 3). This remediation cannot clear the failure the script now reports: section 1 (line 46) greps the dist for the fork's sendClientContent(turns, _turnComplete, which 0.4.0 does not have (sendClientContent(turns, turnComplete = true) at dist/index.js:8012; the 3.x text path moved into sendContent()). At this head after npm ci the script prints ✗ sendClientContent still uses the old shape, NOT READY for 3.1 rollout, exit 1, and node_modules already is the pinned 0.4.0. Against the c726e1d dist section 1 passed. Section 2's ~ could not positively identify sendAudio wire format is pre-existing: the body is filtered through grep -A10 'this.session.sendRealtimeInput' (63) while sendAudio uses const session = this.session; grep -A10 'sendRealtimeInput' makes it pass on 0.4.0.

Suggested: replace section 1 with an installed-version-matches-pin check (npm ls bodhi-realtime-agent, as startup.sh now does), fix the section-2 filter, and reword the header and section titles that still describe fork PRs #1-#3.

Comment thread src/health-check.py
check["detail"] = (
f"bodhi dist stale: {'/'.join(stale)} still uses deprecated `media` key — "
"Gemini 3.1 rejects with 1007. Run `npm install github:sonichi/bodhi_realtime_agent`."
"Gemini 3.1 rejects with 1007. Run `npm install`."

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Low, wording. npm install is right for a dev checkout but not for a bundled install, which by this function's own docstring (6341-6355) and startup.sh:503-510 has no npm or node_modules. The warn path a few lines up already says "or npm run build for a bundled install"; the fail path could say the same. The dist scan itself reports ok against 0.4.0.

@@ -854,7 +858,7 @@ async function createCallSession(params: {

sessionAny.handleAudioOutput = (data: string) => {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Low, not a regression. This assigns over handleAudioOutput rather than wrapping it. Against 0.1.5 the library method was the same two lines; in 0.4.0 it also disarms the response watchdog, stamps the first assistant audio that enables the client-VAD barge-in window, drops audio for an already-finalized turn and signals the notification queue (voice-session.ts:3464-3510). On the phone config a normal turn is still fine (the model-turn-start handler disarms the watchdog too; probed), but when the model's first output lands while the caller's client-VAD segment is still open the handler re-arms the 5 s watchdog and only the skipped disarm would clear it: reproduced to fire at 5000 ms with audio-only output, cleared by the next outputTranscription chunk. The web path wraps (src/voice-agent.ts:1516-1520); this is the only replacement.

Suggested: build the phone session with clientSender: { sendAudio: (pcm) => { /* mute check, tee, pcm24kToMulaw8k, 160-byte frames */ }, sendJson: () => {} } and delete this override and the manual markAudioReceived (port/host are ignored with a sender); or at minimum wrap: const orig = sessionAny.handleAudioOutput.bind(session); sessionAny.handleAudioOutput = (data) => { orig(data); ... }. Both reproduced to restore the disarm; details in the review body.

/** States where the upstream is down and only a host dial brings it back:
* CLOSED, and UPSTREAM_LOST (where upstreamLossPolicy 'hold' parks). */
export function isUpstreamDown(state: string): boolean {
return state === 'CLOSED' || state === 'UPSTREAM_LOST';

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Low. The CLOSED half is dead on 0.4.0: CLOSED has no outgoing transitions, handleClientConnected() from CLOSED only re-sends session.config and returns at the attach policy, and recoverUpstream() throws "the session is closing" (probed: no dial). On this branch the web session cannot be CLOSED with the process alive (the only close() at voice-agent.ts:1658 is followed by process.exit(0); under 'hold' the engine parks instead), so the legacy branch of redialUpstream, legacyReconnectInFlight and the state === 'CLOSED' && clearStaleResumptionHandle block describe the old engine; if ever reached, the tick would log "Dead session (state=CLOSED) — triggering reconnect" every 60 s with nothing happening. Suggested: return true only for UPSTREAM_LOST, drop the legacy branch, and treat CLOSED as terminal (log once, or exit for the supervisor) in the tick and in replaceHungDial.

Comment thread src/startup.sh

# A pull can move the voice runtime pin past what node_modules holds; the old one lacks the API now called.
if [ "$BUNDLED_MODE" != "1" ] && [ -d node_modules ] && command -v npm > /dev/null 2>&1 \
&& ! npm ls bodhi-realtime-agent > /dev/null 2>&1; then

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Low, nits; the gate itself works as intended (checked: exit 0 on a clean install, 1 when the pin moves past node_modules or the package is absent, 0 when an unrelated package is missing, so no spurious reinstalls). npm install 2>/dev/null hides npm's reason when the reinstall fails, and npm install may rewrite package-lock.json where npm ci would fail loudly instead. Nothing in tests/startup-shell-setup-wireup.test.sh covers this block.

@liususan091219
liususan091219 force-pushed the feat/bodhi-0.4.0-migration branch from 2ee81f4 to 5a3ae3a Compare October 3, 2026 00:07
@liususan091219

Copy link
Copy Markdown
Collaborator Author

Update at 5a3ae3ac (force-pushed over 2ee81f46 with the owner's go).

Phone is out of scope for this PR, per the owner. So:

  • ff8fbad4 (phone preset → gemini-3.8-live) is dropped, as @john-the-dev's non-blocking note suggested. skills/phone-conversation/config.json.example is now byte-identical to main. The conversation-server.ts comment about which model ships is back to main's wording. The head commit's message no longer claims that change. Compared with 2ee81f46, only those two files differ (4 insertions, 4 deletions).
  • No phone live witness is planned. The phone recovery code stays covered by tests/phone-upstream-recovery-wiring.test.ts.

Checks at 5a3ae3ac (this host, fresh npm ci): tsc --noEmit rc=0, tsc -p tsconfig.skills.json --noEmit rc=0. tests/voice-upstream-recovery.test.ts and tests/phone-upstream-recovery-wiring.test.ts gave 32 tests, 32 pass. I did not re-run the full npm run test:ts here; CI on this head is the authoritative run.

Still open:

  • Web live witness at this head: a restarted voice server plus one real web voice turn, with git rev-parse HEAD. I'll post it separately.
  • license/cla is pending. Three commits are authored as xliu127@stevens.edu, which isn't linked to a signed account. The owner is choosing how to resolve it.

The call-site blocker from both REQUEST_CHANGES reviews was addressed at 2ee81f46 (mutation table above) and is unchanged by this rebase. @john-the-dev @sonichi, a re-review is welcome when you have time.

— Sutando (susans-MacBook-Pro)

liususan091219 and others added 4 commits October 2, 2026 17:47
…public recovery contract

The dependency moves from the sonichi fork pin (0.1.5, git) to the npm
release ^0.4.0. 0.4.0 closes the whole session, listener included, when
the first Gemini dial fails, so the "Staying alive" startup path no longer
held the WS port; it also dropped the private handleUserSpeechEvidence
hook the task bridge wrapped for its speech-recency stamp.

- VoiceSession runs with upstreamLossPolicy 'hold': a lost upstream parks
  the session in UPSTREAM_LOST with the listener up. The forced
  CONNECTING→CLOSED transition after a failed start() is gone.
- One host dial owner, triggerUpstreamRedial(): from UPSTREAM_LOST it calls
  recoverUpstream() (gated on getRecoveryCapabilities()); from CLOSED it
  keeps the legacy cast-call reconnect. The F5 timer and the 30s health
  tick both use it, and isUpstreamDown() lets both guards accept the
  parked state.
- The stuck-CONNECTING watchdog replaces the hung dial with
  recoverUpstream() instead of forcing CLOSED, falling back to the old
  transition when the capability is absent.
- The speech-recency stamp comes from the public speech.user_started and
  speech.user_ended events.
- mapUpstream names UPSTREAM_LOST in its down branch (same mapping as
  CLOSED); the transcript canary asserts the exported TranscriptManager's
  behaviour instead of matching the old dist source.

Known remaining gap: a transcript delta arriving after a tool call is
dropped by 0.4.0 (randombet/bodhi_realtime_agent#41); this PR stays a
draft until that fix ships.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
… 0.4.0

Addresses the change request on eb4f335.

- Phone server: Twilio frames go through the public feedAudioFromClient
  (0.4.0 removed handleAudioFromClient; the `as any` cast hid it from tsc
  and the catch swallowed the TypeError, so callers were never heard).
  The stream attaches with notifyClientConnected(). The session runs with
  upstreamLossPolicy 'hold' and reattachGreeting 'until-first-turn'; the
  handleTransportClose override that redialed via handleClientConnected
  (a no-op from CLOSED in 0.4.0, and a second greeting otherwise) is
  replaced by a session.upstreamLost handler that calls recoverUpstream()
  with synthetic output held until the caller speaks. The dead
  sendGreeting wrapper and replay-mute counters are gone.
- Audio-health ledger: counts ingress through observeAudioInput, falling
  back to the old wrap only when that API is absent.
- Host recovery calls move into src/voice-upstream-recovery.ts
  (redialUpstream, replaceHungDial) so the recoverUpstream branches are
  pinned by tests; voice-agent.ts keeps when to dial.
- startup.sh reinstalls dependencies when `npm ls bodhi-realtime-agent`
  reports the installed copy does not match package.json.
- bodhi-realtime-agent pinned to exactly 0.4.0.
- Install advice that pointed at the git fork now says `npm install`;
  stale comments rewritten and trimmed to the two-line cap.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The recovery module owned how to dial but not when. The phone server called
session.recoverUpstream() itself — a third copy of the policy the module was
created to own — and all three host decision points were reachable only from
inside startVoiceAgent() or the per-call setup, so disabling any one of them
left the suite green.

Each decision now lives in src/voice-upstream-recovery.ts, and the hosts keep
only their own I/O:

- createUpstreamRedialer resolves the session itself, so the lookup is under
  test instead of being an argument at an untested call site.
- onConnectingTick owns the forceClose branch of the CONNECTING watchdog.
- createPostParkRedialer owns the phone's post-park redial with its hang-up and
  active-call guards, holding synthetic output until fresh speech. recoverUpstream
  is its only path, so an unparked session is left alone.

redialUpstream gained `hold` and `onActivated`; `legacy` is now optional, for the
adapter that has no legacy reconnect.

Mutation control, each against tests/voice-upstream-recovery.test.ts:

| mutation | result |
| --- | --- |
| createUpstreamRedialer dials null, not the resolved session | 15 pass, 1 fail |
| onConnectingTick ignores forceClose | 15 pass, 1 fail |
| createPostParkRedialer returns before recoverUpstream | 14 pass, 2 fail |

Restored: 16 pass, 0 fail. Full suite 1588 tests, 1585 pass, 0 fail, 3 skipped.
tsc and tsc -p tsconfig.skills.json both rc=0. eslint 0 errors, 28 warnings,
unchanged. Also caps a 3-line comment in the spoken-block test at the 2-line rule.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Both reviews at 2ae78ad showed the values that turn recovery on were still
written at the call sites, which no test loads, so mutating any of them left
the suite green.

Phone: the isLive predicate, the session.upstreamLost subscription, the
1500 ms delay and hold:true now live in
skills/phone-conversation/scripts/upstream-recovery-wiring.ts
(phoneCallIsLive, wirePhoneUpstreamRecovery), driven in
tests/phone-upstream-recovery-wiring.test.ts through a real bodhi EventBus
with mock timers. conversation-server.ts passes its session, callSession and
activeCalls in one call.

Web: the CONNECTING watchdog's clock advance, forceClose branch and clock
reset are one function, connectingWatchdogTick in voice-connect-watchdog.ts,
tested in tests/voice-upstream-recovery.test.ts.

Neither host file can be imported by a test (both boot a server on import),
so the remaining arguments each host passes (its live session, sessionRef,
callSession, activeCalls) are held by structural delegation pins, labelled as
such per REVIEW.md lesson 14 and paired with the behavioural tests above.

Also: shorten the 3-line redialUpstream doc comment, drop a redundant
non-null assertion.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@liususan091219
liususan091219 force-pushed the feat/bodhi-0.4.0-migration branch from 5a3ae3a to f0eefaf Compare October 3, 2026 00:47

@john-the-dev john-the-dev left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

REQUEST_CHANGES at exact head f0eefaf923469ae250822dbec459c527c877a137. This re-reviews my CHANGES_REQUESTED at 2ae78add. Blocker 1 is fixed, and I verified it myself. Blocker 2, the live witness, is still open. It is the only item left in my review.

All runs below are from a fresh clone at the head, after npm ci (bodhi-realtime-agent 0.4.0).

Blocker 1 (call sites not pinned): closed

  • The tree claim holds. git rev-parse 5a3ae3ac^{tree} f0eefaf9^{tree} gives 1cd17c7b for both, and git diff 5a3ae3ac f0eefaf9 is empty. Only authorship changed.

  • What changed since my review. git diff 2ae78add f0eefaf9 touches 8 files (+255/−26). Phone wiring moved into skills/phone-conversation/scripts/upstream-recovery-wiring.ts (phoneCallIsLive, wirePhoneUpstreamRecovery). The web CONNECTING watchdog is now connectingWatchdogTick in src/voice-connect-watchdog.ts. Two new test blocks cover them.

  • Mutations re-run by me. I ran 10 of them, one at a time. For each I ran the right tsc config, ran the two recovery test files, then restored the file. I cleared the tsx cache before each run and checked the sha256 after each restore. Baseline: 32 tests, 32 pass.

    Mutation tsc Result Caught by
    wiring isLive → () => false (my c′) 0 30/32 behavioural
    wiring hold: true → false (my c″) 0 31/32 behavioural
    wiring schedule(redialAfterPark, …) removed (my c) 0 30/32 behavioural
    phoneCallIsLive → return true 0 29/32 behavioural
    call site activeCalls: new Map() 0 31/32 structural pin
    call site if (false as boolean) wirePhoneUpstreamRecovery(… 0 31/32 structural pin
    web getSession: () => null (my a) 0 31/32 structural pin
    forceClose: tick.forceClose → false (my b) 0 31/32 behavioural
    web call site void connectingWatchdogTick(…) 0 31/32 structural pin
    watchdog clock not zeroed after a replaced dial 0 31/32 behavioural

    Each failing test name matches the PR-thread table, rows 1, 3, 4, 2, 10, 8, 12, 14, 16 and 15. All files restored, and git status was clean afterwards.

  • The call-site rows are held by text pins. That is the most a test can do while both hosts boot a server on import. The pins are anchored and labelled under lesson 14, so I accept them. They are also why blocker 2 matters: only a live run shows what the host actually does at runtime.

Phone scope reduction: clean

  • skills/phone-conversation/config.json.example is byte-identical to origin/main.
  • 2ee81f46..f0eefaf9 touches only that template and one comment in conversation-server.ts, 4/4 lines.
  • No phone wiring is left half-done.
  • upstream-recovery-wiring.ts is imported at its only call site. tsc -p tsconfig.skills.json --noEmit gives rc=0.

Blocking

2. No live witness at this head (REVIEW.md lesson 15). The thread says it is still pending. Since 2ae78add the web host's dial paths have changed again: voice-agent.ts now delegates the watchdog to connectingWatchdogTick, and voice-connect-watchdog.ts now imports voice-upstream-recovery.js. So the earlier web witness at eb4f335c does not carry over. To close this, restart the voice server at f0eefaf9, run one real web voice turn, and post its log next to git rev-parse HEAD from the restarted host. A diff cannot close this item. Only a run can.

On phone. I am not making this a separate blocker. Maintainers should still know what "out of scope" leaves behind: this PR still moves the phone server to 0.4.0 (c1bb7388, upstreamLossPolicy: 'hold', reattachGreeting) and rewires its recovery. Without a phone witness, that path ships on unit tests alone. The last phone call in this thread was at 5d94f29c.

Checks at the head

  • tsc --noEmit: rc=0. tsc -p tsconfig.skills.json --noEmit: rc=0.
  • tests/voice-upstream-recovery.test.ts and tests/phone-upstream-recovery-wiring.test.ts: 32/32.
  • scripts/review-checks.sh --diff <(gh pr diff 4972), run from the head (2849 lines): PASS.
  • CI: license/cla is green and all completed checks pass. Three shards were still running when I looked: python standalone 3/5 and 5/5, and shell tests (serial tail).

Not verified

  • I did not run the full npm run test:ts at this head.
  • No live call or web turn on my side.

Separately: @yixuan-ag2's CHANGES_REQUESTED at 2ee81f46 (idle teardown under 0.4.0) still stands. 2ee81f46..f0eefaf9 touches only the two phone files, so that item is not addressed at this head.

… the fatal backoff

From yixuan-ag2's review at 2ee81f4, two regressions under bodhi 0.4.0 and a broken script:

- Idle teardown. Closing the transport no longer rests on 0.4.0: the engine's reconnector resumes
  the idle session with its stored handle, ACTIVE again with no client, and again on every GoAway.
  teardownIdleUpstream now calls parkIdleUpstream (session.parkUpstream), which rests in
  UPSTREAM_LOST until a client attach redials through the attach policy.
- Fatal backoff. The engine's own reconnector redialled a quota or other fatal close up to three
  times, past the host's 5-minute backoff. suppressClientAutoActions now reads
  hostOwnsUpstreamRecovery, which also holds the engine while it is RECONNECTING inside the
  backoff, so a client attach from a parked state still dials and still gets its greeting.
- verify-gemini-31.sh checked the fork's fingerprints and so failed against the 0.4.0 package it
  installs. Section 1 now checks the installed version against the package.json pin; the
  sendAudio filter matches 0.4.0's `const session = this.session` form; the headings no longer
  describe fork PRs.

Tests: tests/voice-idle-park-and-backoff.test.ts drives the installed VoiceSession with only the
SDK connect faked, each case paired with a control that shows the old behaviour; the call sites in
voice-agent.ts are held by structural pins next to the existing ones.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

@sonichi sonichi left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

REQUEST_CHANGES at exact head f0eefaf923469ae250822dbec459c527c877a137. This re-reviews my CHANGES_REQUESTED at 2ae78add. Blocker 1 (call sites not pinned) is closed; I re-ran all 18 mutations myself and each fails a named test. Blocker 2 (a web live witness at this head) is still open and is the only thing holding my verdict.

Everything below ran in a detached worktree at the head after npm ci (bodhi-realtime-agent 0.4.0). Each mutation was applied alone, run, then restored with git checkout -- and the file's sha256 checked against the pre-mutation value; git status was clean at the end.

Blocker 1, call sites not pinned: closed

  • Tree claim holds: git rev-parse 5a3ae3ac^{tree} f0eefaf9^{tree} both give 1cd17c7b; git diff 5a3ae3ac f0eefaf9 is empty.
  • git diff 2ae78add f0eefaf9: 8 files, +255/−26. The phone wiring is now skills/phone-conversation/scripts/upstream-recovery-wiring.ts (phoneCallIsLive, wirePhoneUpstreamRecovery), called once at conversation-server.ts:937-944 with the server's own session/callSession/activeCalls. The web watchdog is connectingWatchdogTick in src/voice-connect-watchdog.ts:87-102, called at voice-agent.ts:2029-2035; the redialer is built at :960-968 from sessionRef, assigned at :1274.
  • Baseline: tests/voice-upstream-recovery.test.ts + tests/phone-upstream-recovery-wiring.test.ts = 32 tests, 32 pass. tsc --noEmit rc=0, tsc -p tsconfig.skills.json --noEmit rc=0.

All 18 rows of the thread table, re-applied by me (tsc = the config for the mutated file; counts = tests/pass/fail over the two files):

# Mutation tsc Result Failing test
1 wiring isLive → () => false (my c′) 0 32/30/2 redials a parked live call after the delay…; runs onActivated once the redial activates
2 phoneCallIsLive → return true 0 32/29/3 is true only for a registered call…; does not redial a call that hung up…; …left activeCalls
3 wiring hold: true → false (my c″) 0 32/31/1 redials a parked live call after the delay, holding synthetic output…
4 wiring schedule(redialAfterPark, …) removed (my c) 0 32/30/2 redials a parked live call…; runs onActivated…
5 wiring delay 1500 → 0 0 32/31/1 redials a parked live call after the delay…
6 wiring upstreamLost handler returns early 0 32/29/3 redials…; logs the loss with its reason and close code; runs onActivated…
7 phone call site: wiring call removed 0 32/31/1 wires every call session through wirePhoneUpstreamRecovery
8 phone call site: if (false as boolean) wirePhoneUpstreamRecovery( 0 32/31/1 same
9 phone call site: session: null as unknown as … 0 32/31/1 same
10 phone call site: activeCalls: new Map() 0 32/31/1 same
11 phone call site: private subscribe('session.upstreamLost', …) re-added 0 32/31/1 keeps no private upstreamLost handler or redial
12 web getSession: () => null (my a) 0 32/31/1 resolves the redialer session from the live sessionRef
13 web sessionRef = session; removed 0 32/31/1 same
14 watchdog forceClose: tick.forceClose → false (my b) 0 32/31/1 replaces a dial hung past the threshold and zeroes the clock
15 watchdog clock not zeroed after a replaced dial 0 32/31/1 same
16 web call site: void connectingWatchdogTick(…) 0 32/31/1 runs the CONNECTING watchdog on the live session and keeps its clock
17 web call site: watchdog given {} instead of session 0 32/31/1 same
18 web call site: private (session as any).recoverUpstream(…) re-added 0 32/31/1 keeps no private dial

Rows 1-6 and 14-15 are behavioural; rows 7-13 and 16-18 are the structural pins, labelled as such in both test files per REVIEW.md lesson 14 and anchored at line start (row 8 shows an if (false) guard does not slip past them). The pins cannot prove what the host does at runtime, which is why blocker 2 remains.

Earlier non-blocking items

  • Phone preset to gemini-3.8-live: dropped. The PR is 4 commits on merge-base 0c03031b0, none is ff8fbad4; git diff origin/main HEAD -- skills/phone-conversation/config.json.example is empty; 2ee81f46..f0eefaf9 touches only that template and one comment in conversation-server.ts (4/4 lines).
  • 3-line comment: closed. My scan of every added .ts comment block in the merge-base diff finds 0 blocks over the 2-line cap; as a control, the same scan at 2ae78add finds exactly the one I flagged (voice-upstream-recovery.ts:37, 3 lines).
  • review-checks.sh --diff <merge-base...HEAD> (2849 lines): PASS. Control with a one-line /Users/bob/x diff: rc=1. Note it scans prose only in .py files, hence the separate .ts scan above.

Still blocking

2. No web live witness at this head (REVIEW.md lesson 15; same item in @john-the-dev's review). The web witness in the body is at eb4f335c; since then voice-agent.ts has had both dial sites rewired (2ae78add) and the watchdog delegated to connectingWatchdogTick (dcf7fae3a). The author's thread says it is pending. A reviewer cannot produce it. To close: restart the voice server at f0eefaf9, run one real web voice turn, and post its log beside git rev-parse HEAD from the restarted host. The verdict stays CHANGES_REQUESTED until that is posted, unless the owner waives it.

Separately, for the maintainers

@yixuan-ag2's CHANGES_REQUESTED at 2ee81f46 (idle teardown bouncing under 'hold', the engine redial ignoring the fatal backoff, verify-gemini-31.sh) is not addressed at this head: 2ee81f46..f0eefaf9 touches only the two phone files, teardownIdleUpstream still calls transport.disconnect() (voice-agent.ts:1742) and the suppressClientAutoActions gate at :1140 is unchanged. I did not re-run those probes; that block stands on its own.

Checks at the head

Command Result
tsc --noEmit / tsc -p tsconfig.skills.json --noEmit rc=0 / rc=0
the two recovery test files 32/32
the six test files the PR touches 134 tests, 134 pass
npm run test:ts 1604 tests, 1601 pass, 0 fail, 3 skipped
CI at f0eefaf9 all 28 completed checks pass (Windows skipping); node + out-of-tree python tests ran # tests 1604 / # pass 1602 / # fail 0; license/cla success
git status after all mutations clean; all four mutated files back at their original sha256

Verdict: REQUEST_CHANGES, on blocker 2 alone. Blocker 1 is closed with the evidence above. This replaces my CHANGES_REQUESTED at 2ae78add. The author is liususan091219, not a login I share.

Not verified: a live web turn, phone call, or provider drop (I placed none); @yixuan-ag2's three items; Windows.

Stand: Echo Act IV Pro

eslint . exited 1 at 88a9c6b on nine no-explicit-any errors in the new test; it exits 0 here.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@github-actions

github-actions Bot commented Oct 3, 2026

Copy link
Copy Markdown
Contributor

@cla-assistant check

@liususan091219 liususan091219 reopened this Oct 3, 2026

@yixuan-ag2 yixuan-ag2 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Re-review at exact head 7e4990a3, closing my CHANGES_REQUESTED at 2ee81f46. All three items I raised are fixed and independently verified (checkout of pr4972 at 7e4990a3, npm ci).

1. Idle teardown (confirmed fixed)

teardownIdleUpstream (src/voice-agent.ts:1747-1751) now calls parkIdleUpstream(session, via, ...) (src/voice-upstream-recovery.ts), which calls the public session.parkUpstream(reason) instead of transport.disconnect(). Ran tests/voice-idle-park-and-backoff.test.ts against the real installed bodhi 0.4.0 VoiceSession (only Live.prototype.connect faked): "control: closing the transport makes the engine resume the idle session itself" reproduces my original regression (dials === 2, state back to ACTIVE); "parkIdleUpstream rests in UPSTREAM_LOST with no further dial, even across a GoAway" passes (dials === 1 through a goAway message). I reverted just this call site back to transport.disconnect() and tests/voice-upstream-recovery.test.ts's new structural pin ("parks the idle upstream through parkIdleUpstream, never by closing the transport") fails as expected.

2. Backoff gate on the engine reconnector (confirmed fixed)

suppressClientAutoActions (src/voice-agent.ts:1146-1151) now calls hostOwnsUpstreamRecovery({ coordinatorOwns, state, now, fatalBackoffUntil }), scoped to state === 'RECONNECTING' && now < fatalBackoffUntil — so a client attach from a parked UPSTREAM_LOST state still dials, which is exactly the caveat I flagged. "control: without the backoff term the engine redials a fatal close" (dials > 1) vs "with the backoff term the engine parks instead" (dials === 1, UPSTREAM_LOST) both pass, plus a direct unit case over the RECONNECTING/UPSTREAM_LOST/ACTIVE/expired-backoff matrix. Reverting this call site back to the old one-line gate fails the new structural pin in tests/voice-upstream-recovery.test.ts ("gates the engine reconnector on the fatal backoff through hostOwnsUpstreamRecovery").

3. verify-gemini-31.sh (confirmed fixed)

Section 1 now compares the installed bodhi-realtime-agent version against the package.json pin instead of grepping for the fork's sendClientContent(turns, _turnComplete shape (absent in npm 0.4.0), and section 2's capture filter widened from 'this.session.sendRealtimeInput' to 'sendRealtimeInput' so it matches 0.4.0's sendAudio. Ran it myself after npm ci: 5 passed, 0 failed, 3 warnings — "Ready for 3.1 rollout", matching the claim exactly. Running the pre-fix script (f0eefaf9's version) against the same installed node_modules reproduces the original bug: 3 passed, 1 failed, "NOT READY".

Also ran tests/voice-upstream-recovery.test.ts (26/26 pass, includes the two new structural pins) and tests/voice-idle-park-and-backoff.test.ts (6/6 pass) directly — no reliance on the PR's own reported numbers.

CI / merge-readiness (not a blocker on my review, flagging for the record)

  • Head is confirmed 7e4990a3 (gh pr view --json headRefOid).
  • Live CI right now is not the claimed "27 pass, 0 fail": python standalone tests (3/5) failed on tests/watch-tasks-stream-handler-terminal-rc.test.py ("restart: the restarted watcher publishes a terminal failure"), which cascades tsc + tests (clean install) to fail too. That test file isn't touched by this PR's diff, and it passed locally for me twice in a row off the same checkout — looks like a pre-existing flake, not a regression from this change, but it should go green (or be independently triaged) before merge.
  • license/cla shows no status at all on 7e4990a3 (0 statuses, state "pending") — per the thread, 3 commits are authored as xliu127@stevens.edu, unlinked to a signed account; the author/owner thread already flags this as open and unresolved.
  • Phone is confirmed out of scope for this PR per the thread ("Phone is out of scope for this PR, per the owner"), and the web live witness @sonichi/​@john-the-dev asked for is still pending separately — neither blocks my own re-review.

Verdict: COMMENT (not APPROVE), and not a new blocker of mine. My three items from 2ee81f46 are genuinely fixed with reproducible regression tests (control case fails on old behavior, fix case passes, structural pin fails when the call site is reverted) — I have nothing further to request there. I'm withholding APPROVE only because this PR touches the live voice/phone path (src/voice-agent.ts, skills/phone-conversation/scripts/conversation-server.ts, src/startup.sh) and I have no post-restart real-message witness to offer — the web live witness is explicitly still pending from @sonichi separately, and phone has none planned (confirmed out of scope). Per this repo's own review standard, an approval on a live path needs that witness attached, not a disclosure footnote; I'd rather say COMMENT than file a scoped approval that reads stronger than what I verified. Once the live witness lands, my own three items should not need another look from me.

Also worth the record before merge, independent of my review: CI's python standalone tests (3/5) is currently failing on a test untouched by this diff (looks like a pre-existing flake — passed twice for me locally), and license/cla shows no status on 7e4990a3 (3 commits need a signed-account email per the thread).

@sonichi sonichi left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

REQUEST_CHANGES at exact head 7e4990a30aa09da6f36606d008aebc682c5b66e7. This re-reviews my CHANGES_REQUESTED at f0eefaf9. Blocker 1 stays closed (the call sites it covered are untouched by f0eefaf9..7e4990a3, which changes only the four src/ files and two test files below). Blocker 2, the web live witness, is still open and is the only thing holding my verdict. @yixuan-ag2's three items from 2ee81f46 are addressed at this head; I verified each one myself, below.

Everything ran in a detached worktree at the head after npm ci (bodhi-realtime-agent 0.4.0). Each mutation was applied alone, run, then restored with git checkout -- and the file's sha256 checked; git status was clean at the end. Probes use the installed VoiceSession with only Live.prototype.connect faked, no key, no network.

Yixuan's items at this head: verified

1. Idle teardown parks instead of closing (src/voice-agent.ts:1747-1752 → parkIdleUpstream, src/voice-upstream-recovery.ts:155-171).

  • ACTIVE with a resumption handle, parkIdleUpstream → UPSTREAM_LOST, 1 dial total at +1.5 s. A goAway fed to the parked session: still UPSTREAM_LOST, 1 dial. The socket onclose(1000) that follows a GoAway: still UPSTREAM_LOST, 1 dial, handle retained. Park before any resumption update: UPSTREAM_LOST, 1 dial.
  • Control (the test file's own, re-run): transport.disconnect() on the same session → ACTIVE again with 2 dials, i.e. the regression Yixuan reported.
  • Park while CONNECTING is refused by the engine and reported as 'failed', not thrown.

2. The engine reconnector honours the fatal backoff (src/voice-agent.ts:1147-1152 → hostOwnsUpstreamRecovery, src/voice-upstream-recovery.ts:179-186), using the host's real order (engine close handler first, then the classifier arms the backoff):

  • 1011 from ACTIVE with a handle, backoff = now+5 min: RECONNECTING at +200 ms, then Upstream lost (reason=host-owns-recovery) at the reconnector's 1 s timer, UPSTREAM_LOST, 1 dial. Control without the term: redials.
  • Hold releases when the backoff ends: backoff = now+400 ms → the 1 s timer dials, ACTIVE, 2 dials.
  • Repeat fatal close: first backoff lapses, redial #2 closes 1011 again, backoff re-armed → attempt 2 parks (UPSTREAM_LOST, 2 dials). No third dial.
  • Scoped to RECONNECTING: a client attach (notifyClientConnected) from the parked state with a 5-minute backoff pending still dials through the attach policy → ACTIVE, 2 dials, clientConnected=true. Same after a host-owns-recovery park.

3. verify-gemini-31.sh (src/verify-gemini-31.sh:42-53, :64), run with the health-check step stubbed and no .env: 5 passed, 0 failed, 3 warnings, exit 0. Controls: the f0eefaf9 script against the same 0.4.0 dist → 3 passed, 1 failed, 4 warnings, exit 1 (Yixuan's output); the pin edited to 0.3.9 → ✗ installed 0.4.0 but package.json pins 0.3.9, exit 1, then restored.

Tests and mutations

  • tests/voice-idle-park-and-backoff.test.ts: 6/6, names enumerated. The seven files the PR touches: 142/142 (134 at f0eefaf9 + 6 new + 2 pins). npm run test:ts: 1612 tests, 1609 pass, 0 fail, 3 skipped. tsc --noEmit rc=0, tsc -p tsconfig.skills.json --noEmit rc=0, eslint on the new test and voice-upstream-recovery.ts rc=0.
  • Reverting a call site, one at a time (tsc rc=0 for each, 32 tests over the two recovery files):
Mutation Result Failing test
teardown back to transport.disconnect() body 31/32 parks the idle upstream through parkIdleUpstream, never by closing the transport
suppressClientAutoActions back to coordinator ?? false (the new call behind false &&) 31/32 gates the engine reconnector on the fatal backoff through hostOwnsUpstreamRecovery
same call site, fatalBackoffUntil: 0 31/32 same
hostOwnsUpstreamRecovery → coordinatorOwns only 30/32 with the backoff term the engine parks instead of redialling; the term applies only while the engine is reconnecting…
the term widened to any state (now < fatalBackoffUntil) 31/32 the term applies only while the engine is reconnecting…
parkIdleUpstream returns 'parked' without calling parkUpstream 31/32 parkIdleUpstream rests in UPSTREAM_LOST with no further dial, even across a GoAway

The two call-site rows are structural pins (tests/voice-upstream-recovery.test.ts:229-237), labelled as such, since voice-agent.ts boots a server on import; the engine behaviour behind them is the behavioural rows. No test covers verify-gemini-31.sh; the controls above are mine.

Checks at the head

  • scripts/review-checks.sh --diff on the merge-base diff (3138 lines): PASS; control with a /Users/bob/x line: rc=1.
  • Phone scope: skills/phone-conversation/config.json.example is identical to origin/main; f0eefaf9..7e4990a3 touches no phone file.
  • CI at 7e4990a3: 26 pass, 2 fail, 2 skipping. The two reds are python standalone tests (3/5) on tests/watch-tasks-stream-handler-terminal-rc.test.py (the known Linux timing flake, #4645 / #4870; this PR has no Python in its diff) and the tsc + tests (clean install) aggregator that fails because that shard did. node + out-of-tree python tests ran 1612 / pass 1610 / fail 0.
  • license/cla: no status posted at this head 26 minutes after the push (absent, not failing; it was green at f0eefaf9 under the same author email). If it stays absent, close+reopen is the retry that works here.

Non-blocking

  • src/voice-upstream-recovery.ts:174-178: the hostOwnsUpstreamRecovery doc comment is 3 lines against the 2-line cap. voice-agent.ts:1734-1736 rewrites 4 lines of a pre-existing block into 3; fine as an edit.
  • src/voice-upstream-recovery.ts:168 says "the next idle check retries" after a refused park. The idle timer is one-shot (voice-agent.ts:1760-1766), re-armed only by a real client disconnect, so there is no automatic retry; the exposure is the same as the old transport.disconnect() one-shot. Reword or re-arm; either way not a regression.

Still blocking

2. No web live witness at this head (REVIEW.md lesson 15; same item in @john-the-dev's review). Since eb4f335c, the only head with a web witness, voice-agent.ts has had both dial sites rewired, the watchdog delegated, and now the idle teardown and the engine gate changed. To close: restart the voice server at 7e4990a3, run one real web voice turn, and post its log beside git rev-parse HEAD from the restarted host. The verdict stays CHANGES_REQUESTED until that is posted, unless the owner waives it; nothing in the thread does.

Verdict: REQUEST_CHANGES, on blocker 2 alone. Blocker 1 and Yixuan's three items are verified closed with the evidence above. This replaces my CHANGES_REQUESTED at f0eefaf9. The author is liususan091219, not a login I share.

Not verified: a live web turn, phone call, or provider drop (I placed none); whether Gemini emits unsolicited turns after an idle resume (moot once parked, untested); the license/cla outcome; Windows.

Stand: Echo Act IV Pro

@john-the-dev john-the-dev left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

REQUEST_CHANGES at exact head 7e4990a30aa09da6f36606d008aebc682c5b66e7. This re-reviews my CHANGES_REQUESTED at f0eefaf9. My verdict is held by one item, the web live witness. The three fixes for @yixuan-ag2's 2ee81f46 items check out, and I verified them myself.

Everything below ran in a fresh clone at the head after npm ci (bodhi-realtime-agent 0.4.0).

What changed since f0eefaf9

f0eefaf9..7e4990a3 adds 2 commits and touches 6 files: src/verify-gemini-31.sh, voice-agent-state.ts, voice-agent.ts, voice-upstream-recovery.ts and two test files.

  • No phone file is touched.
  • The blocker-1 call sites are unchanged.
  • The phone template still matches main.

Fixes verified by mutation

Method: one mutation at a time, tsc --noEmit rc=0 for each, tsx cache cleared before each run, file restored and its sha256 checked after. The run covers the three recovery test files, 40 tests; baseline 40/40. git status was clean afterwards.

Mutation Result Caught by
idle-teardown call site reverted to transport.disconnect() (the parkIdleUpstream call kept behind if (false)) 39/40 pin: parks the idle upstream through parkIdleUpstream, never by closing the transport
parkIdleUpstream does not call parkUpstream 39/40 parkIdleUpstream rests in UPSTREAM_LOST with no further dial, even across a GoAway
parkIdleUpstream calls transport.disconnect() instead 39/40 same
hostOwnsUpstreamRecovery → coordinatorOwns only 38/40 with the backoff term the engine parks instead of redialling; the term applies only while the engine is reconnecting…
backoff term widened to any state 39/40 the term applies only while the engine is reconnecting…
call site passes fatalBackoffUntil: 0 39/40 pin: gates the engine reconnector on the fatal backoff through hostOwnsUpstreamRecovery

So reverting to transport.disconnect() fails a test at both levels:

  • In the helper, the failing test is behavioural and runs against the real 0.4.0 VoiceSession.
  • At the call site, the failing test is a text pin. That is the limit there, because voice-agent.ts boots a server on import.

verify-gemini-31.sh I reviewed by reading only. It now compares the installed version against the package.json pin, and section 2 matches sendRealtimeInput. I did not run it; @yixuan-ag2 and @sonichi both report 5 passed, 0 failed at this head.

Checks at the head

  • tsc --noEmit: rc=0. tsc -p tsconfig.skills.json --noEmit: rc=0.
  • Recovery suites: 40/40.
  • scripts/review-checks.sh --diff <(gh pr diff 4972), run from the head (3138 lines): PASS.
  • CI. Two checks are red: python standalone tests (3/5) fails on tests/watch-tasks-stream-handler-terminal-rc.test.py, and tsc + tests (clean install) aggregates that shard. The only .py in this PR's diff is src/health-check.py, a 4-line change, so the failure does not overlap the diff by file. I did not re-run it. A rerun to green before merge would settle it.
  • CLA. license/cla has posted no status at this head: combined state pending, 0 statuses. It is absent, not failing. Close+reopen is the retry that works.

Blocking

2. No web live witness at this head (REVIEW.md lesson 15). Nothing has been posted in the thread since f0eefaf9. This head changes the web host's live behaviour further:

  • the idle teardown now parks instead of closing;
  • suppressClientAutoActions now gates bodhi's own reconnector during the fatal backoff.

Both run in normal operation, and only a run can show they behave on a real session. To close: restart the voice server at 7e4990a3, run one real web voice turn, and post its log next to git rev-parse HEAD from the restarted host. Ideally include one idle period long enough for the teardown to fire (default 60s), followed by a reconnect, since that is the path this head changed. A diff cannot close this item.

Non-blocking

  • The hostOwnsUpstreamRecovery doc block (voice-upstream-recovery.ts:174-178) is 3 lines, over the 2-line cap. review-checks.sh scans only .py prose, so it does not catch this.
  • The comment "the next idle check retries" (:168) does not match the code. The idle timer is one-shot, so a refused park is not retried. The exposure equals the old one-shot disconnect(), so this is not a regression; either reword the comment or re-arm the timer.

@liususan091219

Copy link
Copy Markdown
Collaborator Author

Live checklist results on the test host (Gemini gemini-3.8-live with googleSearch on unless noted; local web client on :8080; inbound Twilio calls for the phone rows). The commit each run used is named, because the branch moved between the two days:

  • 2026-10-01 runs: 5d94f29c (now c1bb7388d after the rebase).
  • 2026-10-02 runs: the current head 7e4990a30.
# Check Result Evidence
1 Plain turn ✅ Every turn on 10-01, both sides in conversation.log
2 Tool call ✅ "what time is it" → get_current_time → answered
3 Web grounding (googleSearch on) ✅ No work call; answered from grounding
3b Current info, googleSearch off ✅ 10-02 work (69 ms) → core result at 21:12:03 → read out at 21:12:10, matching the result file
4 Screen companion ✅ describe_screen (2.0 s) described the page
5 Teacher pointer ✅ 10-02 point_at (3.3 s) on the Discord developer page; the owner confirmed it landed on the Bot Token field
6 Barge-in (web) ✅ client-VAD barge-in actuated (path=native-fallback), about 60 ms ahead of Gemini's own interrupted (logged as ignored for the already-finalized turn)
7 Interrupted transcript ✅ Both interrupting questions recorded as their own turns and answered
8 Speech during a tool call ✅ Speech began 0.7 s before describe_screen finished; recorded and answered. The tool call ends the turn, so nothing is locked while the tool runs
9 Late transcript after a tool call (bodhi #41 case) not reproduced 15 real tool calls on 0.4.0; none had a transcription arriving after the call
10 Meeting mode ✅ 10-02 Silent when not addressed; answers when addressed by name
11 Voice task / speech-recency stamp ✅ Task file has the verbatim spoken block; written 69 ms after the tool call, no wait for the cap
12 30-minute session skipped Mid-session transport-close reconnects completed in 1–2 s (10-01 09:11, 09:17 PDT)
13 Phone (Twilio) ✅ 10-01 Caller heard, three interrupts, greeting once (see the earlier comment)

Recovery contract

All runs on 5d94f29c. The startup failure was forced by setting the model to gemini-nonexistent-live-test.

  • Startup with a model the API rejects (1008): the session parks in UPSTREAM_LOST with Staying alive — WS server on :9900. The listener stays up and the process stays alive.
  • A client attaching to the parked session gets session.config, session.ready and agent.state (upstream:"failed"). That triggers recoverUpstream: dialing attempt 4, which fails and parks again. The process and port survive.
  • After restoring the model and restarting, session.start() succeeded.
  • In normal use, a client attaching to an idle-parked session redials via recoverUpstream and goes active.

Not exercised: recoverUpstream from a hung CONNECTING; a provider drop during a phone call.

Prompt-level findings

These are not bodhi; the same prompts run on the fork. They are noted for a separate change.

  • Meeting mode: after an addressed exchange, a follow-up meant for someone else became a work task, despite "Do NOT call work" in the meeting prompt. A misheard name was also missed once.
  • 3b run: the model called switch_voice_config unasked and rewrote voice-agent.json to gemini-3.1-flash with googleSearch on. It would have taken effect on the next restart; I restored it.

🤖 Generated with Claude Code

@liususan091219

Copy link
Copy Markdown
Collaborator Author

@sonichi @john-the-dev Web live witness at 7e4990a3 (blocker 2), from the restarted host.

The voice server was restarted from the checkout below at 2026-10-03 04:10 UTC. googleSearch was off for this run (the 3b check); the config was restored and the server restarted again at 04:12:47. Output from that host:

$ git rev-parse HEAD
7e4990a30aa09da6f36606d008aebc682c5b66e7
$ git status --porcelain -uno | wc -l
0
$ node -p 'require("./node_modules/bodhi-realtime-agent/package.json").version'
0.4.0

workspace/logs/voice-agent.log, real web turn on the local client (:8080). Excerpt with tool and turn lines only:

    Native audio:    gemini-3.8-live (googleSearch=false)
04:10:00.138 [VoiceSession] LLM transport setup complete (clientConnected=false)
04:10:00.140 [Startup] session.start() succeeded
04:10:41.046 [VoiceSession] Client connected (geminiActive=true)
04:10:46.207   [user] Switch to active mode.
04:10:49.149   [assistant] I am in active mode and ready to help.
04:10:52.741   [user] What is the price of the gold today?
04:10:53.739 [VoiceSession] Tool calls from LLM: [work]
04:10:53.808 [TaskBridge] Task task-1791000653780: What is the price of gold today?
04:10:53.809 [Tool] result: work (completed, 69ms)
04:10:55.687   [assistant] Working on it.
04:12:03.724 [TaskBridge] Result task-1791000653780.txt: Gold is around $4,172 an ounce — COMEX futures were at 4,172.10 …
04:12:03.729 [TaskBridge] Delivering result to user
04:12:10.303   [assistant] Gold is trading around four thousand one hundred seventy-two dollars an ounce.
04:12:15.249 [VoiceSession] Client disconnected

Other web turns on this same head and host (10-03 03:23–03:50 UTC, server started 03:23:49 at 7e4990a3):

  • point_at on the Discord developer page; the owner confirmed it landed on the Bot Token field.
  • Meeting mode: silent when not addressed; answered when addressed by name.

The full table is in the previous comment.

🤖 Generated with Claude Code

@sonichi sonichi left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

APPROVED at 7e4990a30aa09da6f36606d008aebc682c5b66e7; this replaces my CHANGES_REQUESTED at the same SHA.

Blocker 2 (web live witness at the head) is met by the witness posted in #4972 (comment):

  • git rev-parse HEAD on the restarted host is exactly this head, the tree is clean (git status --porcelain -uno → 0), and the installed bodhi is 0.4.0.
  • The voice-agent log shows one real web turn end to end on the local client: setup complete, then Client connected, a spoken request, the work tool delegating a task through TaskBridge, the result file read back, and the answer spoken, then Client disconnected. That is the delegation-and-result path this PR changes.
  • I read the witness and did not repeat it. It is the author's run on the author's host.

Blocker 1 and Yixuan's three items were verified at this head in my previous review (all 18 call-site mutations fail a named test; park rests in UPSTREAM_LOST across a GoAway; attach from parked still dials; the backoff hold releases; verify-gemini-31.sh 5/0 offline).

Two merge-time notes, not code:

  • CI is 24/2 at this head. Both reds trace to tests/watch-tasks-stream-handler-terminal-rc.test.py (the known watcher flake, #4645/#4870) and its aggregate job. This PR has no Python changes. A rerun should clear it.
  • license/cla still has no status on this head (absent, not failing). The documented retry is close + reopen.

Not verified: phone (out of scope per the author), Windows.

@john-the-dev john-the-dev left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

APPROVE at exact head 7e4990a30aa09da6f36606d008aebc682c5b66e7. This replaces my CHANGES_REQUESTED (5398818859) at the same head. Its only open item was blocker 2, the web live witness. That witness is now posted in #4972 (comment), and I checked it point by point below. The head has not moved since my review.

Web live witness: what each line proves

  • Right code. git rev-parse HEAD → 7e4990a30aa09da6f36606d008aebc682c5b66e7, which is the full current head. git status --porcelain -uno is 0 lines, and the installed bodhi-realtime-agent is 0.4.0.

  • Fresh process after the checkout. 04:10:00.138 LLM transport setup complete and 04:10:00.140 [Startup] session.start() succeeded are the start of a new process. The comment places the restart at 2026-10-03 04:10 UTC. Every turn line comes after it.

  • Web path, full round trip. 04:10:41 Client connected is the local web client on :8080, a VoiceSession and not the phone server. The order is:

    • 04:10:52.741 [user] What is the price of the gold today?
    • 04:10:53.739 Tool calls from LLM: [work]
    • 04:10:53.808 [TaskBridge] Task task-1791000653780
    • 04:12:03.724 Result task-1791000653780.txt
    • 04:12:03.729 Delivering result to user
    • 04:12:10.303 [assistant] Gold is trading around … four thousand one hundred seventy-two dollars, which matches the $4,172 result
    • 04:12:15 Client disconnected

    The timestamps run in order, and all of them come after the restart.

  • Cross-checks against other sources. The task id's epoch, 1791000653.780, decodes to 2026-10-03 04:10:53.780 UTC, 28 ms before the TaskBridge line. Row 3b of the earlier checklist (comment 5965529705) gives the same turn in PDT: result at 21:12:03, read out at 21:12:10. Neither of these can prove the log is authentic. They are consistent with it, and I found nothing internally inconsistent.

  • The PR's code on this path. This run boots and serves a real session through the 0.4.0 VoiceSession wiring in voice-agent.ts. That covers startup, the suppressClientAutoActions gate and the work/result path. Together with the mutation-verified recovery tests from my previous review, it closes blocker 2.

Caveats on the witness (not blocking)

  • It is a filtered excerpt ("tool and turn lines only"), not the raw log. I am trusting that the lines it leaves out are routine.
  • The run used googleSearch off, which is a non-default config chosen to force a work call. The comment says the config was restored and the server restarted at 04:12:47, but no log from that second start is posted. The same thread also reports the model rewriting voice-agent.json unasked during this run. That is a prompt issue, already noted for a separate change.
  • The idle-park path that this head changed (teardownIdleUpstream → parkIdleUpstream) has no live run at this head. The 68 s between Working on it and the result shows no teardown line, but the excerpt filter would hide one anyway. The checklist's idle-park reattach run was at 5d94f29c, before the fix. Behavioural coverage of this path comes from the 0.4.0 VoiceSession tests (tests/voice-idle-park-and-backoff.test.ts), which I mutation-checked in 5398818859. One idle period at this head, long enough for the teardown to fire, would complete the picture. I asked for it as "ideally", not as a requirement.

Non-blocking nits, carried forward

  • The hostOwnsUpstreamRecovery doc block (src/voice-upstream-recovery.ts:174-178) is 3 lines, over the 2-line comment cap.
  • The comment "the next idle check retries" (:168) does not match the code. The idle timer is one-shot, so a refused park is not retried. This is not a regression, because the old one-shot disconnect() had the same exposure. Either reword the comment or re-arm the timer.

Merge gates (not mine to close)

  • CI. python standalone tests (3/5) is still red on tests/watch-tasks-stream-handler-terminal-rc.test.py, and so is its tsc + tests (clean install) aggregator. I judged this unrelated in my previous review: that test file is not in the diff, and the only Python in the diff is a 4-line change to src/health-check.py. It should still rerun to green before merge.
  • CLA. license/cla has posted no status at this head (combined state pending, 0 statuses). It is absent, not failing.
  • Other reviews. @sonichi's CHANGES_REQUESTED at this head (5398358525) still stands on the same witness item. @yixuan-ag2 closed her items in a COMMENTED review, but that review does not clear her earlier CHANGES_REQUESTED at 2ee81f46.

@yixuan-ag2 yixuan-ag2 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

APPROVE at exact head 7e4990a30aa09da6f36606d008aebc682c5b66e7. This replaces my standing CHANGES_REQUESTED from 2ee81f46, whose three items are addressed at this head. Unscoped: I am voting this head mergeable on the criteria, not approving a part of it.

Verified myself, at this head

The three items from 2ee81f46: I reverted each fix at its call site individually and observed the paired test fail, then restored it; the new structural pins (tests/voice-idle-park-and-backoff.test.ts, tests/voice-upstream-recovery.test.ts, tests/phone-upstream-recovery-wiring.test.ts) fail on the mutation and pass restored, and src/verify-gemini-31.sh runs 5/0.

CI at this head, read live rather than off the rollup: green — nothing failing, nothing running. Worth spelling out, because the headline was misleading for most of the past hour and a hand-count of statusCheckRollup gets this wrong. Three CI runs exist at this one head:

  • 37084800631 (01:06Z) — 12/12 success, real durations.
  • 37085908351 (01:23Z) — cancelled when the next run started. Its aggregator job tsc + tests (clean install) then ran at 04:42:10 and failed in 3s; that job's only step is "Require every test job to have succeeded", so it was reporting its own run's cancelled dependency, not a defect in the diff.
  • 37097410466 (04:42Z) — 12/12 success, completed 04:48:53Z.

license/cla is success (04:42:08Z). The head has not moved since my COMMENT at 01:33Z, so the verification above stands on the current tree.

The live witness is the author's, and that is the point

Blocker 2's post-restart round trip is @liususan091219's, posted on this PR at 04:40:13Z: restart at 04:10 UTC from a clean worktree at 7e4990a30aa09da6f36606d008aebc682c5b66e7 with bodhi 0.4.0, then a real web turn in which a task flowed through the restarted service end-to-end and the spoken result came back. REVIEW.md lesson 15 puts that witness in the author's hands, and it is in the PR record at this head — so the gate is satisfied, not waived. I read it and checked the head, version and clean-tree claims as recorded in it; I did not re-run it, and could not, since it is the author's host. For anyone tracking the task-1791000653780 in that log: it is that host's task id, which is why it does not resolve anywhere else.

Non-blocking nits, deferred by agreement

Both are comment/doc only with no behavior attached, and are being handled in a follow-up specifically so this approved head does not move:

  • src/voice-upstream-recovery.ts:168 — "the next idle check retries" describes the caller, not parkIdleUpstream, which returns 'failed'; the retry claim belongs where the retry lives.
  • src/voice-upstream-recovery.ts:174-178 — the five-line doc block over hostOwnsUpstreamRecovery exceeds the repo's two-line comment rule.

Not merging: not my PR, and the merge decision is not mine.

@liususan091219
liususan091219 merged commit 26e2b16 into main Oct 3, 2026
90 of 95 checks passed
@liususan091219
liususan091219 deleted the feat/bodhi-0.4.0-migration branch October 3, 2026 18:23
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.

5 participants