Repository navigation
feat(voice): migrate to bodhi-realtime-agent 0.4.0 (npm) with the public recovery contract - #4972
Conversation
Coverage Gate✅ Diff coverage PASSES the 95% bar. Whole-tree (informational): 90%. Diff CoverageDiff: origin/main...HEAD, staged and unstaged changesNo lines with coverage information in this diff. |
sonichi
left a comment
There was a problem hiding this comment.
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.
|
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. |
|
@sonichi Thanks. Each point from your review at Blocker 1: phone calls lose the caller's audio
Blocker 2: web voice input health
Risk: an install that pulls without reinstalling
Mutation controls:
Nits
Local checks
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. |
|
@sonichi Live phone round trip on #4972 at conversation.log for that call (the caller's speech reached Gemini and was answered): 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 What this does and does not show
Could you re-review at 🤖 Generated with Claude Code |
sonichi
left a comment
There was a problem hiding this comment.
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:1836calls the typedcallSession.voiceSession.feedAudioFromClient(pcm16k), with noas any.- Mutation: changing it back to
handleAudioFromClientmakestsc -p tsconfig.skills.jsonfail withTS2551: Property 'handleAudioFromClient' does not exist on type 'VoiceSession'. Did you mean 'feedAudioFromClient'?(rc=2). CI runsnpm run typecheck:skills(ci.yml:182), so the rename is now gated. - Installed prototype:
handleAudioFromClient: undefined,feedAudioFromClient,observeAudioInput,notifyClientConnectedandrecoverUpstreamare all functions,sendGreeting: undefined. - The live call is at the right head. The phone server started 18:44:57Z, after
5d94f29cwas committed at 14:01Z. Your verbatim lines show oneSending greetingand three caller questions answered, which is a post-restart round trip on the changed path. It would be stronger with the server'sgit rev-parse HEADin the same paste, but I accept it. - The two neighbours I flagged are closed in code, by reading only:
- The dead
sendGreetingwrap is gone.reattachGreeting: 'until-first-turn'gatesrunClientAttachPolicy(distreattachGreetingAllowed, line 14568). - The CLOSED-redial override is replaced by a
session.upstreamLosthandler (:936-952). That handler is the open item below.
- The dead
Blocker 2, audio-health ledger: closed.
voice-audio-health.ts:615-621registers throughobserveAudioInput. In 0.4.0,feedAudioFromClient → ingestClientAudionotifies those observers before routing (dist 14622-14642).- Restoring
voice-audio-health.tsfromeb4f335cmakestests/voice-audio-health.test.tsgo 43/44 (1 fail). At the head it is 44/44.
Risk, a pull without a reinstall: closed.
startup.sh:530-531controls:npm ls bodhi-realtime-agentreturns 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 printsnpm error invalid.- The capability read is guarded:
canRecover()invoice-upstream-recovery.tsuses?.()?.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.0pin (lockfileresolvedmatches); the stale npm advice (a grep for fork/.gitinstall advice finds only historical comments); the over-cap comments I named. - CLOSED fallbacks: keeping them as the no-capability path is fine with me.
ipadvisory: no upstream fix, accepted.
Still blocking
- The phone recovery is a third, untested copy of the dial policy.
conversation-server.ts:943callssession.recoverUpstream({ reason: 'human-retry', skipContextInjection: false, holdSyntheticUntilFreshSpeech: true })itself. It does not go throughvoice-upstream-recovery.ts, which this PR created to own exactly that call. No test reaches it (mutation above), and your live note saysupstreamLost → recoverUpstreamwas "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. - 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 afterrecoverUpstreamactivates. bodhi's own resumption reconnect, which happened twice in five minutes in your web log, no longer triggers it. If that matters,session.resumeorsession.stateChange→ ACTIVE is the hook. - Web witness one commit back. The web live witness in the body is at
eb4f335c. I read thevoice-agent.tsdelta in5d94f29cas a behaviour-preserving extraction intoredialUpstream/replaceHungDial. The only change I see is the legacy dial going froms.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.tsadds a 3-line comment; the cap is 2. - Red CI at the head.
python standalone tests (4/5)fails ontests/slack-proactive-claim-requires-owner.test.py, andtsc + 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 main1be758ba5, on a different file (watch-tasks-stream-fallback-receipt-scope.test.py). It looks unrelated; please re-run it.lints + typecheck, eslint andlicense/claare 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
handleAudioFromClienthit outside fixtures sits in the audio-health legacy fallback, its comments or its test doubles. The control at the merge-base finds 5 inconversation-server.ts; at the head there are 0. handleClientConnectedstill exists in 0.4.0 (notifyClientConnectedcalls it).- The other
as anyinternals used (transport,clientTransport,notificationQueue,toolExecutor,transcriptManager,handleClientDisconnected,handleAudioOutput) are all present in the installed dist.
- Every
- 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 sharedsonichilogin, 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
|
Both blocking items are addressed in 1. The phone recovery is no longer a third copy
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); });
2. The call sites are pinnedThe 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:
Re-running your three mutations against
Restored: 16 pass, 0 fail. Full suite Non-blocking
Not verifiedNo live phone call on this head. The round trip in the body exercised the audio path at Unrelated change in the same push
|
john-the-dev
left a comment
There was a problem hiding this comment.
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/andskills/forrecoverUpstream|handleClientConnected|notifyClientConnected|transitionTo\(|handleTransportClose|redialUpstream|replaceHungDial|triggerUpstreamRedial|\.reconnect\(. Every upstream dial in the diff's files now goes throughsrc/voice-upstream-recovery.ts:- phone:
conversation-server.ts:937-949, thecreatePostParkRedialerhandler forsession.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:933isnotifyClientConnectedon attach.voice-agent.ts:1126and:1821are the attach hook and its wrapper. One more site outside this diff callsrecoverUpstreamdirectly:src/voice-silence-recovery-coordinator.ts:279. That is the ACTIVE-silence path, it is unchanged frommain, and it has its own module. I note it only because the commit title says "every". - phone:
-
The module tests discriminate.
tests/voice-upstream-recovery.test.tspasses 16/16. I broke the module on purpose three times, restoring it byte for byte between runs (shasumba626fae…before and after):Mutation in voice-upstream-recovery.tsResult createPostParkRedialerdrops itsisLive()guard (:133)15 pass, 1 fail recoverignoreshold(:30)15 pass, 1 fail With legacyomitted, still dialshandleClientConnected(:61)15 pass, 1 fail Restored 16 pass, 0 fail -
Checks at the head:
tsc --noEmitandtsc -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 theupstreamLostsubscribe, the delay and thehold: true. - A test that the web host's
getSessionresolves the livesessionRef.
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
ff8fbad4is out of scope. It switches the phone template togemini-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
redialUpstreamdoc block atvoice-upstream-recovery.ts:37-39is 3 lines.review-checks.shonly scans prose in.pyfiles, so CI will not catch it. - Small cleanups.
recover(s, opts.reason, opts.origin, opts, opts)at:55passes the same object as both the log and the options.opts.legacy!at:63follows 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
left a comment
There was a problem hiding this comment.
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-949buildscreatePostParkRedialerfromsrc/voice-upstream-recovery.ts. ItsupstreamLosthandler is now onlysetTimeout(redialAfterPark, 1500).- I searched
src/andskills/forrecoverUpstream(. The only call outside the module isvoice-silence-recovery-coordinator.ts:279. That file is byte-identical to the merge-base0c03031b0, so it is outside this PR. - The module tests discriminate. Positive control: removing the
isLive()guard atvoice-upstream-recovery.ts:133gives 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 awirePhoneUpstreamRecovery(session, callSession, activeCalls, deps)that do the subscribe, the delay andhold: true. Then test that emittingsession.upstreamLoston a live call reachesrecoverUpstreamwithholdSyntheticUntilFreshSpeech: true, and that a hung-up call does not. - Do the same for the web host, so that resolving
sessionRefand passingtick.forceClosethrough 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)
2ae78addadds 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 ateb4f335c, two commits before the last change tovoice-agent.ts. - The
ff8fbad4latency table has samples and a median, but no log lines and nogit rev-parse HEADfrom 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.jsonis 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,
loadVoiceConfigfalls back toVOICE_CONFIG_DEFAULTS.model. That is stillgemini-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-retryablemodel_not_foundand 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:68records that 3.1 with search closes with 1011 on a free-tier key. Whether 3.8 does the same is untested here.
- If the template copy fails,
- Documentation and claims:
- The template's
_commentnow 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-155still 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:3shipsgemini-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 onlygemini-3.1-flash-live-preview.gemini-3.8-liveappears 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.
- The template's
- 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 afterrecoverUpstreamactivates, so bodhi's own resumption reconnect does not nudge. I accept this as preserved behaviour for this PR. If it matters later,session.resumeorstateChange→ ACTIVE is the hook. - 3-line test comment: closed.
tests/task-bridge-spoken-block.test.ts:122-123is now 2 lines. TheredialUpstreamdoc block atvoice-upstream-recovery.ts:37-39is still 3 lines of text.review-checks.shscans prose only in.pyfiles, so CI will not catch it. - Web witness one commit back: now two commits back, and
2ae78addchanges 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
|
@john-the-dev @sonichi: the call-site blocker from both REQUEST_CHANGES reviews at What changedPhone. The decisions that used to be written at
The call site is now a single call that passes the server's own
Web. 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: Nits.
Mutation table at
|
| # | 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, andgit rev-parse HEADfrom 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
left a comment
There was a problem hiding this comment.
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, gitHeaddec34e0); the 0.4.0 source at that gitHead for line cites. - Offline reproductions with the real
VoiceSessionandGeminiLiveTransportfrom the installed package. Only@google/genai'sLive.prototype.connectis replaced by a fake session that captures the callbacks and feedssetupComplete,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 atffec0cd(the build #602 was verified against), both read from on-disk installs and, for the idle teardown, run. voice-agent.tsandconversation-server.tsboot 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.0src/transport/gemini-live-transport.ts:863-884) closes the SDK session without advancing the dial generation, so the socket'sonclosepasses the fence (707-708) and reachesVoiceSession.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 withclientConnected=false.hostOwnsRecoveryissuppressClientAutoActions()(voice-session.ts:2021), which this host sets tovoiceRecoveryCoordinator?.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 the1000 '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
handleAudioOutputinstead 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), andnotificationQueue.markAudioReceivedexists 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-initiatedsendContent(..., 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; oneoutputTranscriptionchunk 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 issuescancelResponse; Gemini's server-VADinterruptedstill ends the turn, and the server never sends Twilioclear, 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 withclientSender: { sendAudio: (pcm) => { /* mute check, tee, pcm24kToMulaw8k, 160-byte Twilio frames */ }, sendJson: () => {} }(port/hostare ignored with a sender) and delete the override and the manualmarkAudioReceived; or at least wrap (const orig = sessionAny.handleAudioOutput.bind(session); sessionAny.handleAudioOutput = (data) => { orig(data); ... }; the library'ssendAudioToClientis 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 thelegacybranch ofredialUpstream(src/voice-upstream-recovery.ts:60-66) still redial CLOSED through the privatehandleClientConnected(). On 0.4.0 CLOSED is terminal (session-manager.ts:26);handleClientConnected()from CLOSED marks the client attached, re-sendssession.configand returns at the attach policy (4160-4170), andrecoverUpstream()throws "the session is closing" (probed: no dial). On this branch the web session cannot be CLOSED with the process alive: the onlyclose()(1658) is followed byprocess.exit(0), under'hold'every engine path parks instead, andreplaceHungDial's forced CLOSED runs only withoutrecoverUpstream, 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 (clientConnectedstays true afterclose(), so the tick guard would pass). Suggested:isUpstreamDown= UPSTREAM_LOST only, drop thelegacybranch andlegacyReconnectInFlight, replace theclearStaleResumptionHandleblock withsession.clearResumption()or nothing (the helper clears the legacy alias and the SessionManager copy but not the transport'seffectiveResumptionHandlethe next dial uses,gemini-live-transport.ts:641-643, 858-861), and inreplaceHungDialprefer exiting over forcing CLOSED, since nothing revives it. - startup.sh 529-539. The gate behaves as intended:
npm ls bodhi-realtime-agentexits 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/nullhides npm's reason when it fails;npm ciwould 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 "ornpm run buildfor 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
onSpeechStartedon everyinputTranscriptionchunk and oninterrupted(gemini-live-transport.ts:1695-1700, 1714-1727), whichVoiceSessionrepublishes asspeech.user_startedwith sourceprovider(voice-session.ts:2321-2332). Thec726e1ddist calledhandleUserSpeechEvidencefrom 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,inputBufferaccumulates). On Geminispeech.user_endedcomes 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 theendedhalf. - Private members. Every member the host still reaches through
as anyexists 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 transportonClose,updateTools,updateSystemInstruction,sendContent,sendFile,disconnect,isConnected,session.sendRealtimeInput.handleAudioFromClientis gone and src/voice-audio-health.ts already prefersobserveAudioInput. Public replacements now exist for some casts:getClientSocketHealth()forclientTransport.client.bufferedAmount(1942),closeClientConnection(4000, 'goodbye')for the two goodbye closes (661-663, 823-824),clearResumption()for the continuity helper. transport.sendContent(turns, true)ongemini-3.8-livetakes the realtime text path (shouldUseRealtimeTextForContentmatches^gemini-3(\.\d+)?-.*live), the same as on the 2.5 native-audio preset.health-check.pycheck_bodhi_distreports ok against the 0.4.0 dist (media: { datahas 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.
| inputAudioTranscription: true, | ||
| // A lost upstream parks the session in UPSTREAM_LOST with the WS | ||
| // listener up; the redial paths below call recoverUpstream(). | ||
| upstreamLossPolicy: 'hold', |
There was a problem hiding this comment.
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.
| // 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. |
There was a problem hiding this comment.
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.
| 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 |
There was a problem hiding this comment.
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.
| 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" |
There was a problem hiding this comment.
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.
| 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`." |
There was a problem hiding this comment.
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) => { | |||
There was a problem hiding this comment.
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'; |
There was a problem hiding this comment.
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.
|
|
||
| # 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 |
There was a problem hiding this comment.
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.
2ee81f4 to
5a3ae3a
Compare
|
Update at Phone is out of scope for this PR, per the owner. So:
Checks at Still open:
The call-site blocker from both REQUEST_CHANGES reviews was addressed at — Sutando (susans-MacBook-Pro) |
…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>
5a3ae3a to
f0eefaf
Compare
john-the-dev
left a comment
There was a problem hiding this comment.
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}gives1cd17c7bfor both, andgit diff 5a3ae3ac f0eefaf9is empty. Only authorship changed. -
What changed since my review.
git diff 2ae78add f0eefaf9touches 8 files (+255/−26). Phone wiring moved intoskills/phone-conversation/scripts/upstream-recovery-wiring.ts(phoneCallIsLive,wirePhoneUpstreamRecovery). The web CONNECTING watchdog is nowconnectingWatchdogTickinsrc/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
tscconfig, 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 true0 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 statuswas 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.exampleis byte-identical toorigin/main.2ee81f46..f0eefaf9touches only that template and one comment inconversation-server.ts, 4/4 lines.- No phone wiring is left half-done.
upstream-recovery-wiring.tsis imported at its only call site.tsc -p tsconfig.skills.json --noEmitgives 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.tsandtests/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/clais 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:tsat 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
left a comment
There was a problem hiding this comment.
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 give1cd17c7b;git diff 5a3ae3ac f0eefaf9is empty. git diff 2ae78add f0eefaf9: 8 files, +255/−26. The phone wiring is nowskills/phone-conversation/scripts/upstream-recovery-wiring.ts(phoneCallIsLive,wirePhoneUpstreamRecovery), called once atconversation-server.ts:937-944with the server's ownsession/callSession/activeCalls. The web watchdog isconnectingWatchdogTickinsrc/voice-connect-watchdog.ts:87-102, called atvoice-agent.ts:2029-2035; the redialer is built at:960-968fromsessionRef, assigned at:1274.- Baseline:
tests/voice-upstream-recovery.test.ts+tests/phone-upstream-recovery-wiring.test.ts= 32 tests, 32 pass.tsc --noEmitrc=0,tsc -p tsconfig.skills.json --noEmitrc=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-base0c03031b0, none isff8fbad4;git diff origin/main HEAD -- skills/phone-conversation/config.json.exampleis empty;2ee81f46..f0eefaf9touches only that template and one comment inconversation-server.ts(4/4 lines). - 3-line comment: closed. My scan of every added
.tscomment block in the merge-base diff finds 0 blocks over the 2-line cap; as a control, the same scan at2ae78addfinds 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/xdiff: rc=1. Note it scans prose only in.pyfiles, hence the separate.tsscan 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>
|
@cla-assistant check |
yixuan-ag2
left a comment
There was a problem hiding this comment.
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 ontests/watch-tasks-stream-handler-terminal-rc.test.py("restart: the restarted watcher publishes a terminal failure"), which cascadestsc + 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/clashows no status at all on7e4990a3(0 statuses, state "pending") — per the thread, 3 commits are authored asxliu127@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
left a comment
There was a problem hiding this comment.
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. AgoAwayfed to the parked session: stillUPSTREAM_LOST, 1 dial. The socketonclose(1000)that follows a GoAway: stillUPSTREAM_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 →ACTIVEagain 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:
RECONNECTINGat +200 ms, thenUpstream 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 ahost-owns-recoverypark.
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 atf0eefaf9+ 6 new + 2 pins).npm run test:ts: 1612 tests, 1609 pass, 0 fail, 3 skipped.tsc --noEmitrc=0,tsc -p tsconfig.skills.json --noEmitrc=0,eslinton the new test andvoice-upstream-recovery.tsrc=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 --diffon the merge-base diff (3138 lines): PASS; control with a/Users/bob/xline: rc=1.- Phone scope:
skills/phone-conversation/config.json.exampleis identical toorigin/main;f0eefaf9..7e4990a3touches no phone file. - CI at
7e4990a3: 26 pass, 2 fail, 2 skipping. The two reds arepython standalone tests (3/5)ontests/watch-tasks-stream-handler-terminal-rc.test.py(the known Linux timing flake, #4645 / #4870; this PR has no Python in its diff) and thetsc + tests (clean install)aggregator that fails because that shard did.node + out-of-tree python testsran 1612 / pass 1610 / fail 0. license/cla: no status posted at this head 26 minutes after the push (absent, not failing; it was green atf0eefaf9under 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: thehostOwnsUpstreamRecoverydoc comment is 3 lines against the 2-line cap.voice-agent.ts:1734-1736rewrites 4 lines of a pre-existing block into 3; fine as an edit.src/voice-upstream-recovery.ts:168says "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 oldtransport.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
left a comment
There was a problem hiding this comment.
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.tsboots 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 ontests/watch-tasks-stream-handler-terminal-rc.test.py, andtsc + tests (clean install)aggregates that shard. The only.pyin this PR's diff issrc/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/clahas posted no status at this head: combined statepending, 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;
suppressClientAutoActionsnow 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
hostOwnsUpstreamRecoverydoc block (voice-upstream-recovery.ts:174-178) is 3 lines, over the 2-line cap.review-checks.shscans only.pyprose, 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-shotdisconnect(), so this is not a regression; either reword the comment or re-arm the timer.
|
Live checklist results on the test host (Gemini
Recovery contract All runs on
Not exercised: Prompt-level findings These are not bodhi; the same prompts run on the fork. They are noted for a separate change.
🤖 Generated with Claude Code |
|
@sonichi @john-the-dev Web live witness at 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:
Other web turns on this same head and host (10-03 03:23–03:50 UTC, server started 03:23:49 at
The full table is in the previous comment. 🤖 Generated with Claude Code |
sonichi
left a comment
There was a problem hiding this comment.
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 HEADon 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, theworktool delegating a task through TaskBridge, the result file read back, and the answer spoken, thenClient 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/clastill 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
left a comment
There was a problem hiding this comment.
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 -unois 0 lines, and the installedbodhi-realtime-agentis0.4.0. -
Fresh process after the checkout.
04:10:00.138 LLM transport setup completeand04:10:00.140 [Startup] session.start() succeededare 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 connectedis the local web client on :8080, aVoiceSessionand 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-179100065378004:12:03.724 Result task-1791000653780.txt04:12:03.729 Delivering result to user04:12:10.303 [assistant] Gold is trading around … four thousand one hundred seventy-two dollars, which matches the$4,172result04: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
VoiceSessionwiring invoice-agent.ts. That covers startup, thesuppressClientAutoActionsgate 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
workcall. 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 rewritingvoice-agent.jsonunasked 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 betweenWorking on itand the result shows no teardown line, but the excerpt filter would hide one anyway. The checklist's idle-park reattach run was at5d94f29c, before the fix. Behavioural coverage of this path comes from the 0.4.0VoiceSessiontests (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
hostOwnsUpstreamRecoverydoc 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-shotdisconnect()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 ontests/watch-tasks-stream-handler-terminal-rc.test.py, and so is itstsc + 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 tosrc/health-check.py. It should still rerun to green before merge. - CLA.
license/clahas posted no status at this head (combined statepending, 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
left a comment
There was a problem hiding this comment.
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 jobtsc + 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, notparkIdleUpstream, which returns'failed'; the retry claim belongs where the retry lives.src/voice-upstream-recovery.ts:174-178— the five-line doc block overhostOwnsUpstreamRecoveryexceeds the repo's two-line comment rule.
Not merging: not my PR, and the merge decision is not mine.
What changed, and why
Moves
bodhi-realtime-agentfrom the sonichi fork pin (0.1.5,github:sonichi/bodhi_realtime_agent#c726e1d) to the npm release^0.4.0(published 2026-09-30, gitHeaddec34e0=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:
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.handleUserSpeechEvidencehook 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 reshapedhandleInput.Changes
src/voice-agent.tsVoiceSessionruns withupstreamLossPolicy: 'hold': a lost upstream parks the session inUPSTREAM_LOSTwith the WS listener up. The forcedCONNECTING→CLOSEDtransition after a failedstart()is removed.triggerUpstreamRedial(): fromUPSTREAM_LOSTit callsrecoverUpstream()(gated ongetRecoveryCapabilities().recoverUpstream), fromCLOSEDit 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).recoverUpstream()(allowed from CONNECTING per the 0.4.0 contract) and falls back to forcing CLOSED only when the capability is absent.speech.user_started/speech.user_endedevents.src/voice-redial-scheduler.ts—isUpstreamDown();shouldEventDialacceptsUPSTREAM_LOSTas well asCLOSED.src/voice-agent-state.ts— comment only:UPSTREAM_LOSTfalls into the existing down branch (backoffwith a client attached, elseidle), now stated and pinned by a test vector.tests/task-bridge-spoken-block.test.ts— the canary asserts the exportedTranscriptManager's behaviour (inputBufferaccumulates onhandleInput, drains onflushInput) 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-thinkingwithout a thinking level → close 1007), so the first dial fails deterministically.Before —
main@0c03031b0+ bodhi 0.4.0:After — this branch @
eb4f335c2: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:The client saw
agent.stateframes alternatingconnecting/backoffwithclientAttached: true.Tests
main+ bodhi 0.4.0 (dependency bump only),tsx --test 'tests/**/*.test.ts':(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:
tsc --noEmitclean;eslint src/voice-agent.ts17 warnings (main: 18, all pre-existingno-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
eb4f335c2was started as the owner's live voice-agent from a worktree, with the workspace and memory pinned to the real ones, modelgemini-3.8-live, Search grounding on. Startup:Owner spoke through the web client; rows from
data/conversation.sqlite(voicetable), 9 user turns and 6 tool calls in the session: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:No
Upstream lost, noUPSTREAM_LOST, norecoverUpstreamcall 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.mdentries 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 anyremoved). Durable record inworkspace/data/conversation.sqlite, session #603, callCAfe36061a0cc1b60e0aa3b463dffe1e63: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 anygone, reintroducing the old method name failstypecheck:skills, which CI runs — a unittest could not have caught the original break, and this does.
Two secondary observations from the same call, neither load-bearing:
agentgreeting row above). A mid-call upstream reconnectoccurred and did not re-greet, but the server log for that call is not retained (
/tmp, truncatedby the next restart), so the greeting-once claim rests on the sqlite row and the no-re-greet note
is an observation only.
upstream once. That is upstream watchdog behaviour, not introduced here.
Added in
ff8fbad4: the phone preset moves to gemini-3.8-liveScope note, since this was not in the original diff.
skills/phone-conversation/config.json.examplestill named
gemini-2.5-flash-native-audio-preview-12-2025while the web presets had moved to the3.8 live family and bodhi 0.4.0's own
DEFAULT_GEMINI_LIVE_MODELis a 3.x live model — the phonewas 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:
gemini-2.5-flash-native-audio-preview-12-2025gemini-3.8-liveSo 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.jsonand is unaffected. Notest reads this file (
tests/voice-config-session-tuning.test.tsreads the websrc/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
TranscriptManagerreplay that omittedthe
flush()a live turn always performs. Across 12 real tool calls on 0.4.0 the inputtranscription lands before the tool call (by 650 ms - 1 s), and the lock window between
msg.toolCalland the nextserverContent.turnCompleteis 1-20 ms with nothing in it. #41 isclosed; details there.
Rollback
Revert the dependency bump (
package.json+ lockfile) and thevoice-agent.tshunks together; the scheduler/state changes are additive and safe to keep on the old pin.🤖 Generated with Claude Code