diff --git a/src/supervisor/supervisor.service.ts b/src/supervisor/supervisor.service.ts index 1f85527..0a88f2e 100644 --- a/src/supervisor/supervisor.service.ts +++ b/src/supervisor/supervisor.service.ts @@ -257,7 +257,52 @@ export class SupervisorService implements OnModuleInit { 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[] { + // 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()); }