mirror of
https://dev.azure.com/globalhealthx/EMR/_git/helix-engage-server
synced 2026-05-18 20:08:19 +00:00
fix: server-side ACW auto-dispose (Layer 3) — 30s timeout safety net
When Ozonetel sends an ACW event, starts a 30-second timer. If no /api/ozonetel/dispose call arrives within that window (frontend crashed, tab closed, page refreshed), auto-disposes with "General Enquiry" + autoRelease:true. Agent exits ACW automatically. Timer is cancelled when: - Frontend submits disposition normally (cancelAcwTimer in controller) - Agent transitions to Ready or Offline - Agent logs out Wiring: OzonetelAgentModule now imports SupervisorModule (forwardRef for circular dep), controller injects SupervisorService to cancel the timer on successful dispose. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -20,11 +20,20 @@ type AgentStateEntry = {
|
||||
timestamp: string;
|
||||
};
|
||||
|
||||
// ACW auto-dispose: if an agent has been in ACW for longer than this
|
||||
// without the frontend calling /api/ozonetel/dispose, the server
|
||||
// auto-disposes with a default disposition + autoRelease. This is the
|
||||
// Layer 3 safety net — covers browser crash, tab close, page refresh
|
||||
// where sendBeacon didn't fire, or any other frontend failure.
|
||||
const ACW_TIMEOUT_MS = 30_000; // 30 seconds
|
||||
const ACW_DEFAULT_DISPOSITION = 'General Enquiry';
|
||||
|
||||
@Injectable()
|
||||
export class SupervisorService implements OnModuleInit {
|
||||
private readonly logger = new Logger(SupervisorService.name);
|
||||
private readonly activeCalls = new Map<string, ActiveCall>();
|
||||
private readonly agentStates = new Map<string, AgentStateEntry>();
|
||||
private readonly acwTimers = new Map<string, NodeJS.Timeout>();
|
||||
readonly agentStateSubject = new Subject<{ agentId: string; state: AgentOzonetelState; timestamp: string }>();
|
||||
|
||||
constructor(
|
||||
@@ -37,6 +46,17 @@ export class SupervisorService implements OnModuleInit {
|
||||
this.logger.log('Supervisor service initialized');
|
||||
}
|
||||
|
||||
// Called by the dispose endpoint to cancel the ACW timer
|
||||
// (agent submitted disposition before the timeout)
|
||||
cancelAcwTimer(agentId: string) {
|
||||
const timer = this.acwTimers.get(agentId);
|
||||
if (timer) {
|
||||
clearTimeout(timer);
|
||||
this.acwTimers.delete(agentId);
|
||||
this.logger.log(`[ACW-TIMER] Cancelled for ${agentId} (disposition received)`);
|
||||
}
|
||||
}
|
||||
|
||||
handleCallEvent(event: any) {
|
||||
const action = event.action;
|
||||
const ucid = event.ucid ?? event.monitorUCID;
|
||||
@@ -71,6 +91,46 @@ export class SupervisorService implements OnModuleInit {
|
||||
this.agentStates.set(agentId, { state: mapped, timestamp: eventTime });
|
||||
this.agentStateSubject.next({ agentId, state: mapped, timestamp: eventTime });
|
||||
this.logger.log(`[AGENT-STATE] Emitted: ${agentId} → ${mapped}`);
|
||||
|
||||
// Layer 3: ACW auto-dispose safety net
|
||||
if (mapped === 'acw') {
|
||||
// Find the most recent UCID for this agent
|
||||
const lastCall = Array.from(this.activeCalls.values())
|
||||
.filter(c => c.agentId === agentId)
|
||||
.pop();
|
||||
const ucid = lastCall?.ucid;
|
||||
|
||||
this.cancelAcwTimer(agentId); // clear any existing timer
|
||||
const timer = setTimeout(async () => {
|
||||
// Check if agent is STILL in ACW (they might have disposed by now)
|
||||
const current = this.agentStates.get(agentId);
|
||||
if (current?.state !== 'acw') {
|
||||
this.logger.log(`[ACW-TIMER] ${agentId} no longer in ACW — skipping auto-dispose`);
|
||||
return;
|
||||
}
|
||||
this.logger.warn(`[ACW-TIMER] ${agentId} stuck in ACW for ${ACW_TIMEOUT_MS / 1000}s — auto-disposing${ucid ? ` (UCID ${ucid})` : ''}`);
|
||||
try {
|
||||
if (ucid) {
|
||||
await this.ozonetel.setDisposition({ agentId, ucid, disposition: ACW_DEFAULT_DISPOSITION });
|
||||
} else {
|
||||
await this.ozonetel.changeAgentState({ agentId, state: 'Ready' });
|
||||
}
|
||||
this.logger.log(`[ACW-TIMER] Auto-dispose successful for ${agentId}`);
|
||||
} catch (err: any) {
|
||||
this.logger.error(`[ACW-TIMER] Auto-dispose failed for ${agentId}: ${err.message}`);
|
||||
// Last resort: try force-ready
|
||||
try {
|
||||
await this.ozonetel.changeAgentState({ agentId, state: 'Ready' });
|
||||
} catch {}
|
||||
}
|
||||
this.acwTimers.delete(agentId);
|
||||
}, ACW_TIMEOUT_MS);
|
||||
this.acwTimers.set(agentId, timer);
|
||||
this.logger.log(`[ACW-TIMER] Started ${ACW_TIMEOUT_MS / 1000}s timer for ${agentId}`);
|
||||
} else if (mapped === 'ready' || mapped === 'offline') {
|
||||
// Agent left ACW normally — cancel the timer
|
||||
this.cancelAcwTimer(agentId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user