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 <noreply@anthropic.com>
This commit is contained in:
JayMishra-github
2026-02-16 10:15:32 -08:00
committed by Peter Steinberger
parent 47f8c9209f
commit 390c503b56
2 changed files with 44 additions and 0 deletions

View File

@@ -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),

View File

@@ -28,6 +28,7 @@ export class VoiceCallWebhookServer {
private manager: CallManager;
private provider: VoiceCallProvider;
private coreConfig: CoreConfig | null;
private staleCallReaperInterval: ReturnType<typeof setInterval> | 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<void> {
if (this.staleCallReaperInterval) {
clearInterval(this.staleCallReaperInterval);
this.staleCallReaperInterval = null;
}
return new Promise((resolve) => {
if (this.server) {
this.server.close(() => {