mirror of
https://dev.azure.com/globalhealthx/EMR/_git/helix-engage-server
synced 2026-05-18 20:08:19 +00:00
fix(supervisor): separate pending slots per event category to pair CALL/ACW correctly
CALL and ACW overlap: an agent enters ACW before the CALL_END webhook arrives. With a single shared pending slot, ACW_START would clobber the pending CALL_START and CALL_END would compute 0-second duration against the ACW_START timestamp. Verified in production data — 4/4 CALL_END rows on Global had durationS=0. Fix: one slot per category (pause/call/acw). Each END reads and clears its own slot. READY and LOGOUT defensively flush all slots to avoid leaking state across sessions. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -14,9 +14,15 @@ export type AgentEventType =
|
||||
| 'ACW_START'
|
||||
| 'ACW_END';
|
||||
|
||||
type PendingStart = {
|
||||
eventType: 'PAUSE' | 'CALL_START' | 'ACW_START';
|
||||
at: number; // ms timestamp
|
||||
// Separate pending slots per event category. Call + ACW overlap (agent
|
||||
// enters ACW before the CALL_END arrives), so a single shared slot would
|
||||
// let ACW_START clobber pending CALL_START and produce 0-second call
|
||||
// durations. Keep one slot per category so each END event pairs cleanly.
|
||||
type PendingSlot = 'pause' | 'call' | 'acw';
|
||||
type PendingStarts = {
|
||||
pause?: number; // PAUSE eventAt ms
|
||||
call?: number; // CALL_START eventAt ms
|
||||
acw?: number; // ACW_START eventAt ms
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -36,9 +42,9 @@ export class AgentHistoryService implements OnModuleInit {
|
||||
// ozonetelAgentId → Agent entity UUID. Loaded at startup.
|
||||
private readonly agentUuidByOzonetelId = new Map<string, string>();
|
||||
|
||||
// agentId → most recent "start" event that's awaiting a paired "end",
|
||||
// used to compute durationSec on the END event.
|
||||
private readonly pendingStartByAgent = new Map<string, PendingStart>();
|
||||
// agentId → map of pending start events per category, used to compute
|
||||
// durationSec on the matching END event.
|
||||
private readonly pendingStartsByAgent = new Map<string, PendingStarts>();
|
||||
|
||||
constructor(private readonly platform: PlatformGraphqlService) {}
|
||||
|
||||
@@ -117,24 +123,34 @@ export class AgentHistoryService implements OnModuleInit {
|
||||
return;
|
||||
}
|
||||
|
||||
// Compute duration when closing out a pending start. Ozonetel
|
||||
// emits a single "release" action for both post-pause and post-ACW,
|
||||
// so a READY event closes whatever was open. Specific close events
|
||||
// (RESUME / ACW_END / CALL_END) also pair to their explicit start.
|
||||
// Pair START → END events by category. CALL and ACW can overlap
|
||||
// (agent enters ACW before CALL_END arrives), so each lives in its
|
||||
// own slot. READY is a fallback close — supervisor.service already
|
||||
// maps 'release'/'IDLE' to RESUME / ACW_END when it knows the prior
|
||||
// state; READY only fires when that disambiguation failed, so it
|
||||
// clears anything dangling.
|
||||
let durationSec: number | null = null;
|
||||
if (this.closesAnyOpenStart(params.eventType)) {
|
||||
const pending = this.pendingStartByAgent.get(params.ozonetelAgentId);
|
||||
if (pending) {
|
||||
durationSec = Math.max(0, Math.round((new Date(params.eventAt).getTime() - pending.at) / 1000));
|
||||
this.pendingStartByAgent.delete(params.ozonetelAgentId);
|
||||
const endSlot = this.slotForEnd(params.eventType);
|
||||
const startSlot = this.slotForStart(params.eventType);
|
||||
const eventMs = new Date(params.eventAt).getTime();
|
||||
|
||||
if (endSlot) {
|
||||
const pending = this.pendingStartsByAgent.get(params.ozonetelAgentId);
|
||||
const at = pending?.[endSlot];
|
||||
if (at !== undefined) {
|
||||
durationSec = Math.max(0, Math.round((eventMs - at) / 1000));
|
||||
delete pending![endSlot];
|
||||
if (!pending!.pause && !pending!.call && !pending!.acw) {
|
||||
this.pendingStartsByAgent.delete(params.ozonetelAgentId);
|
||||
}
|
||||
} else if (this.isStartEvent(params.eventType)) {
|
||||
// Overwrite any existing pending start for this agent. Ozonetel
|
||||
// doesn't nest states, so a new start implicitly ends the previous.
|
||||
this.pendingStartByAgent.set(params.ozonetelAgentId, {
|
||||
eventType: params.eventType as PendingStart['eventType'],
|
||||
at: new Date(params.eventAt).getTime(),
|
||||
});
|
||||
}
|
||||
} else if (startSlot) {
|
||||
const existing = this.pendingStartsByAgent.get(params.ozonetelAgentId) ?? {};
|
||||
existing[startSlot] = eventMs;
|
||||
this.pendingStartsByAgent.set(params.ozonetelAgentId, existing);
|
||||
} else if (params.eventType === 'READY' || params.eventType === 'LOGOUT') {
|
||||
// Defensive flush of any lingering slots on session boundaries.
|
||||
this.pendingStartsByAgent.delete(params.ozonetelAgentId);
|
||||
}
|
||||
|
||||
const data: Record<string, any> = {
|
||||
@@ -172,17 +188,18 @@ export class AgentHistoryService implements OnModuleInit {
|
||||
|| msg.includes('AgentEventCreateInput') || msg.includes('AgentSessionCreateInput');
|
||||
}
|
||||
|
||||
private closesAnyOpenStart(endType: AgentEventType): boolean {
|
||||
// READY, RESUME, CALL_END, ACW_END all close whatever start was open.
|
||||
// LOGIN/LOGOUT don't — they're session boundaries.
|
||||
return endType === 'READY'
|
||||
|| endType === 'RESUME'
|
||||
|| endType === 'CALL_END'
|
||||
|| endType === 'ACW_END';
|
||||
private slotForStart(eventType: AgentEventType): PendingSlot | null {
|
||||
if (eventType === 'PAUSE') return 'pause';
|
||||
if (eventType === 'CALL_START') return 'call';
|
||||
if (eventType === 'ACW_START') return 'acw';
|
||||
return null;
|
||||
}
|
||||
|
||||
private isStartEvent(eventType: AgentEventType): boolean {
|
||||
return eventType === 'PAUSE' || eventType === 'CALL_START' || eventType === 'ACW_START';
|
||||
private slotForEnd(eventType: AgentEventType): PendingSlot | null {
|
||||
if (eventType === 'RESUME') return 'pause';
|
||||
if (eventType === 'CALL_END') return 'call';
|
||||
if (eventType === 'ACW_END') return 'acw';
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user