From 390c503b56644d7a03984c5c54cb501db9b1cd6d Mon Sep 17 00:00:00 2001 From: JayMishra-github Date: Mon, 16 Feb 2026 10:15:32 -0800 Subject: [PATCH] feat(voice-call): add configurable stale call reaper Adds a periodic reaper that automatically ends calls older than a configurable threshold. This catches calls stuck in unexpected states, such as notify-mode calls that never receive a terminal webhook from the provider. New config option: staleCallReaperSeconds: number (default: 0 = disabled) When enabled, checks every 30 seconds and ends calls exceeding the max age. Recommended value: 120-300 for production deployments. Co-Authored-By: Claude Opus 4.6 --- extensions/voice-call/src/config.ts | 8 +++++++ extensions/voice-call/src/webhook.ts | 36 ++++++++++++++++++++++++++++ 2 files changed, 44 insertions(+) diff --git a/extensions/voice-call/src/config.ts b/extensions/voice-call/src/config.ts index df7cf57b612..68b197c09bb 100644 --- a/extensions/voice-call/src/config.ts +++ b/extensions/voice-call/src/config.ts @@ -273,6 +273,14 @@ export const VoiceCallConfigSchema = z /** Maximum call duration in seconds */ maxDurationSeconds: z.number().int().positive().default(300), + /** + * Maximum age of a call in seconds before it is automatically reaped. + * Catches calls stuck in unexpected states (e.g., notify-mode calls that + * never receive a terminal webhook). Set to 0 to disable. + * Default: 0 (disabled). Recommended: 120-300 for production. + */ + staleCallReaperSeconds: z.number().int().nonnegative().default(0), + /** Silence timeout for end-of-speech detection (ms) */ silenceTimeoutMs: z.number().int().positive().default(800), diff --git a/extensions/voice-call/src/webhook.ts b/extensions/voice-call/src/webhook.ts index f8920d9a928..5f0b48f55bb 100644 --- a/extensions/voice-call/src/webhook.ts +++ b/extensions/voice-call/src/webhook.ts @@ -28,6 +28,7 @@ export class VoiceCallWebhookServer { private manager: CallManager; private provider: VoiceCallProvider; private coreConfig: CoreConfig | null; + private staleCallReaperInterval: ReturnType | null = null; /** Media stream handler for bidirectional audio (when streaming enabled) */ private mediaStreamHandler: MediaStreamHandler | null = null; @@ -229,14 +230,49 @@ export class VoiceCallWebhookServer { console.log(`[voice-call] Media stream WebSocket on ws://${bind}:${port}${streamPath}`); } resolve(url); + + // Start the stale call reaper if configured + this.startStaleCallReaper(); }); }); } + /** + * Start a periodic reaper that ends calls older than the configured threshold. + * Catches calls stuck in unexpected states (e.g., notify-mode calls that never + * receive a terminal webhook from the provider). + */ + private startStaleCallReaper(): void { + const maxAgeSeconds = this.config.staleCallReaperSeconds; + if (!maxAgeSeconds || maxAgeSeconds <= 0) { + return; + } + + const CHECK_INTERVAL_MS = 30_000; // Check every 30 seconds + const maxAgeMs = maxAgeSeconds * 1000; + + this.staleCallReaperInterval = setInterval(() => { + const now = Date.now(); + for (const call of this.manager.getActiveCalls()) { + const age = now - call.startedAt; + if (age > maxAgeMs) { + console.log( + `[voice-call] Reaping stale call ${call.callId} (age: ${Math.round(age / 1000)}s, state: ${call.state})`, + ); + void this.manager.endCall(call.callId).catch(() => {}); + } + } + }, CHECK_INTERVAL_MS); + } + /** * Stop the webhook server. */ async stop(): Promise { + if (this.staleCallReaperInterval) { + clearInterval(this.staleCallReaperInterval); + this.staleCallReaperInterval = null; + } return new Promise((resolve) => { if (this.server) { this.server.close(() => {