fix(voice-call): skip stale calls on load to prevent stuck concurrent limit

When the gateway restarts, loadActiveCalls() reloads non-terminal calls from
calls.jsonl. However, these calls may have already ended (e.g., Twilio timed
them out) and are now stale. This causes the concurrent call limit to be
reached with phantom calls that can never be cleared.

Fix: Skip loading calls that are older than maxDurationSeconds, as they
cannot possibly still be active.

Fixes calls getting stuck in 'initiated' state after gateway restarts.
This commit is contained in:
Juno 2026-01-27 14:42:37 +00:00
parent 83de980d6c
commit c4ac0d2722

View File

@ -830,6 +830,10 @@ export class CallManager {
const logPath = path.join(this.storePath, "calls.jsonl"); const logPath = path.join(this.storePath, "calls.jsonl");
if (!fs.existsSync(logPath)) return; if (!fs.existsSync(logPath)) return;
// Calls older than maxDurationSeconds are definitely stale
const maxAgeMs = this.config.maxDurationSeconds * 1000;
const now = Date.now();
// Read file synchronously and parse lines // Read file synchronously and parse lines
const content = fs.readFileSync(logPath, "utf-8"); const content = fs.readFileSync(logPath, "utf-8");
const lines = content.split("\n"); const lines = content.split("\n");
@ -847,18 +851,28 @@ export class CallManager {
} }
} }
// Only keep non-terminal calls // Only keep non-terminal calls that aren't stale
for (const [callId, call] of callMap) { for (const [callId, call] of callMap) {
if (!TerminalStates.has(call.state)) { // Skip terminal states
this.activeCalls.set(callId, call); if (TerminalStates.has(call.state)) continue;
// Populate providerCallId mapping for lookups
if (call.providerCallId) { // Skip stale calls (older than max duration)
this.providerCallIdMap.set(call.providerCallId, callId); const callAge = now - call.startedAt;
} if (callAge > maxAgeMs) {
// Populate processed event IDs console.log(
for (const eventId of call.processedEventIds) { `[voice-call] Skipping stale call ${callId} (age: ${Math.round(callAge / 1000)}s, max: ${this.config.maxDurationSeconds}s)`,
this.processedEventIds.add(eventId); );
} continue;
}
this.activeCalls.set(callId, call);
// Populate providerCallId mapping for lookups
if (call.providerCallId) {
this.providerCallIdMap.set(call.providerCallId, callId);
}
// Populate processed event IDs
for (const eventId of call.processedEventIds) {
this.processedEventIds.add(eventId);
} }
} }
} }