fix(supervisor): sweep stale activeCalls before returning to Live Monitor

Bug 560: Live Call Monitor showed ghost calls with runaway timers when
the agent wasn't on a call. Cause — activeCalls Map only added on
'Answered' and deleted on 'Disconnect'; a missed Disconnect (sidecar
restart, Ozonetel subscription hiccup, network blip) left the entry
lingering forever.

getActiveCalls() now sweeps stale entries before returning:
 - drop if startTime is older than 30 minutes
 - drop if the mapped agent is currently ready / offline / paused
   (agent can't be on a call in any of those states)

Each sweep logs the reason so we can track how often this fires.
This commit is contained in:
2026-04-16 05:38:52 +05:30
parent 6adb3985cb
commit a1413aae40

View File

@@ -257,7 +257,52 @@ export class SupervisorService implements OnModuleInit {
this.agentStateSubject.next({ agentId, state: 'force-logout' as any, timestamp: new Date().toISOString() }); this.agentStateSubject.next({ agentId, state: 'force-logout' as any, timestamp: new Date().toISOString() });
} }
// Max plausible call length before the entry is treated as orphaned.
// Real Ozonetel calls cap out far short of this — 30 minutes is a safe
// ceiling for a hospital call-center context. If a genuinely longer
// call existed, losing it from Live Monitor is preferable to the ghost
// state (supervisors lose trust in the dashboard otherwise).
private static readonly MAX_ACTIVE_CALL_AGE_MS = 30 * 60 * 1000;
// Agent states that are incompatible with having an active call. If the
// mapped agent is currently in one of these, the activeCalls entry is
// definitely stale (e.g. Disconnect webhook was dropped).
private static readonly NON_CALL_AGENT_STATES = new Set(['ready', 'offline', 'paused']);
getActiveCalls(): ActiveCall[] { getActiveCalls(): ActiveCall[] {
// Sweep stale entries before returning. The activeCalls Map is a
// best-effort in-memory projection of Ozonetel call events — if
// Ozonetel drops a Disconnect (network blip, subscription hiccup,
// sidecar restart mid-call), the entry lingers forever and the
// Live Call Monitor shows a ghost call with a runaway timer.
//
// Two signals identify staleness:
// 1. The associated agent is not in a busy state (ready, offline,
// paused — they can't be on a call).
// 2. startTime is older than MAX_ACTIVE_CALL_AGE_MS (hard ceiling
// regardless of agent-state signal).
const now = Date.now();
const toDelete: string[] = [];
for (const [ucid, call] of this.activeCalls.entries()) {
const ageMs = now - new Date(call.startTime).getTime();
if (isNaN(ageMs)) continue;
if (ageMs > SupervisorService.MAX_ACTIVE_CALL_AGE_MS) {
toDelete.push(ucid);
this.logger.warn(`[ACTIVE-CALLS] Sweep: dropping ${ucid} (age ${Math.round(ageMs / 60000)}m, exceeds ${SupervisorService.MAX_ACTIVE_CALL_AGE_MS / 60000}m cap)`);
continue;
}
const agentState = this.agentStates.get(call.agentId)?.state;
if (agentState && SupervisorService.NON_CALL_AGENT_STATES.has(agentState)) {
toDelete.push(ucid);
this.logger.warn(`[ACTIVE-CALLS] Sweep: dropping ${ucid} — agent ${call.agentId} is ${agentState}`);
}
}
for (const ucid of toDelete) this.activeCalls.delete(ucid);
return Array.from(this.activeCalls.values()); return Array.from(this.activeCalls.values());
} }