#6702: fix(voice-call): mark calls as ended when media stream disconnects
channel: voice-call
Cluster:
Voice Call Enhancements and Fixes
## Summary
Fixes a bug where inbound voice calls were not properly cleaned up when disconnected, leaving them stuck in non-terminal states
(like "speaking" or "listening"). This caused the `maxConcurrentCalls` limit to be exhausted, blocking all new calls with "Maximum
concurrent calls reached" errors.
## Root Cause
- For **outbound calls**, `StatusCallback` is set programmatically via the Twilio API, so call completion is properly detected.
- For **inbound calls**, status callbacks must be configured in the Twilio console. If not configured, the `call.ended` event is
never received and calls remain "active" forever.
## Solution
Use media stream disconnect as a reliable fallback signal that the call has ended. When the WebSocket closes, check if the call is
still in an active state and transition it to "completed".
This is safe because:
- `processEvent` has idempotency checks for terminal states
- If a proper status callback arrives first, this event is ignored
- Works for all providers, not just Twilio
## Test Plan
- [x] Tested locally with OpenClaw instance
- [x] Made inbound call, talked, hung up → call marked as completed
- [x] Made subsequent outbound call → succeeded (no "Maximum concurrent calls" error)
- [x] `pnpm tsgo && pnpm format && pnpm lint && pnpm build && pnpm test` - all 208 tests pass
<!-- greptile_comment -->
<h2>Greptile Overview</h2>
<h3>Greptile Summary</h3>
Adds a fallback cleanup path for voice calls: when the media stream WebSocket disconnects, the webhook server looks up the active call and emits a `call.ended` normalized event to move the call into a terminal state. This addresses cases (notably inbound Twilio calls without console-configured status callbacks) where `call.ended` webhooks never arrive, leaving calls stuck in active states and exhausting the `maxConcurrentCalls` limit.
The change fits into the existing architecture by reusing the existing `CallManager.processEvent` pipeline and provider call-id mapping, rather than introducing new provider-specific teardown logic.
<h3>Confidence Score: 3/5</h3>
- Reasonably safe to merge, but the disconnect→ended fallback may prematurely terminate calls in some disconnect scenarios.
- Change is localized and uses existing event-processing flow, but it currently fires `call.ended` on any media stream disconnect without checking call state or accounting for transient WS drops/reconnects, which can cause incorrect terminal transitions depending on provider/network behavior.
- extensions/voice-call/src/webhook.ts
<!-- greptile_other_comments_section -->
<sub>(2/5) Greptile learns from your feedback when you react with thumbs up/down!</sub>
<!-- /greptile_comment -->
Most Similar PRs
#4325: fix(voice-call): verify call status with provider before loading st...
by garnetlyx · 2026-01-30
80.2%
#8297: fix(voice-call): prevent empty TwiML for non-in-progress outbound c...
by vishaltandale00 · 2026-02-03
77.2%
#22285: Voice-call: add onCallEnded lifecycle callback
by MegaPhoenix92 · 2026-02-21
76.8%
#18852: fix: Voice-call state persistence is fire-and-forget, causing silen...
by coygeek · 2026-02-17
75.9%
#7704: fix(voice-call): add authentication to WebSocket media stream endpoint
by coygeek · 2026-02-03
75.5%
#9760: fix(voice-call): discard stale calls on plugin restart
by leszekszpunar · 2026-02-05
75.0%
#19489: fix(voice-call): add echo suppression for TTS playback
by kalichkin · 2026-02-17
75.0%
#7652: fix(voice-call): fix Telnyx transcription (STT) not working
by tturnerdev · 2026-02-03
74.8%
#21566: feat(voice-call): bridge call transcripts to main agent session
by MegaPhoenix92 · 2026-02-20
74.3%
#8251: fix(voice-call): remove redundant transcript from extraSystemPrompt
by geodeterra · 2026-02-03
74.1%