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:
2026-04-15 06:53:23 +05:30
parent 4eb8cb80b2
commit 2acba59963

View File

@@ -14,9 +14,15 @@ export type AgentEventType =
| 'ACW_START' | 'ACW_START'
| 'ACW_END'; | 'ACW_END';
type PendingStart = { // Separate pending slots per event category. Call + ACW overlap (agent
eventType: 'PAUSE' | 'CALL_START' | 'ACW_START'; // enters ACW before the CALL_END arrives), so a single shared slot would
at: number; // ms timestamp // 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. // ozonetelAgentId → Agent entity UUID. Loaded at startup.
private readonly agentUuidByOzonetelId = new Map<string, string>(); private readonly agentUuidByOzonetelId = new Map<string, string>();
// agentId → most recent "start" event that's awaiting a paired "end", // agentId → map of pending start events per category, used to compute
// used to compute durationSec on the END event. // durationSec on the matching END event.
private readonly pendingStartByAgent = new Map<string, PendingStart>(); private readonly pendingStartsByAgent = new Map<string, PendingStarts>();
constructor(private readonly platform: PlatformGraphqlService) {} constructor(private readonly platform: PlatformGraphqlService) {}
@@ -117,24 +123,34 @@ export class AgentHistoryService implements OnModuleInit {
return; return;
} }
// Compute duration when closing out a pending start. Ozonetel // Pair START → END events by category. CALL and ACW can overlap
// emits a single "release" action for both post-pause and post-ACW, // (agent enters ACW before CALL_END arrives), so each lives in its
// so a READY event closes whatever was open. Specific close events // own slot. READY is a fallback close — supervisor.service already
// (RESUME / ACW_END / CALL_END) also pair to their explicit start. // 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; let durationSec: number | null = null;
if (this.closesAnyOpenStart(params.eventType)) { const endSlot = this.slotForEnd(params.eventType);
const pending = this.pendingStartByAgent.get(params.ozonetelAgentId); const startSlot = this.slotForStart(params.eventType);
if (pending) { const eventMs = new Date(params.eventAt).getTime();
durationSec = Math.max(0, Math.round((new Date(params.eventAt).getTime() - pending.at) / 1000));
this.pendingStartByAgent.delete(params.ozonetelAgentId); 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 } else if (startSlot) {
// doesn't nest states, so a new start implicitly ends the previous. const existing = this.pendingStartsByAgent.get(params.ozonetelAgentId) ?? {};
this.pendingStartByAgent.set(params.ozonetelAgentId, { existing[startSlot] = eventMs;
eventType: params.eventType as PendingStart['eventType'], this.pendingStartsByAgent.set(params.ozonetelAgentId, existing);
at: new Date(params.eventAt).getTime(), } 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> = { const data: Record<string, any> = {
@@ -172,17 +188,18 @@ export class AgentHistoryService implements OnModuleInit {
|| msg.includes('AgentEventCreateInput') || msg.includes('AgentSessionCreateInput'); || msg.includes('AgentEventCreateInput') || msg.includes('AgentSessionCreateInput');
} }
private closesAnyOpenStart(endType: AgentEventType): boolean { private slotForStart(eventType: AgentEventType): PendingSlot | null {
// READY, RESUME, CALL_END, ACW_END all close whatever start was open. if (eventType === 'PAUSE') return 'pause';
// LOGIN/LOGOUT don't — they're session boundaries. if (eventType === 'CALL_START') return 'call';
return endType === 'READY' if (eventType === 'ACW_START') return 'acw';
|| endType === 'RESUME' return null;
|| endType === 'CALL_END'
|| endType === 'ACW_END';
} }
private isStartEvent(eventType: AgentEventType): boolean { private slotForEnd(eventType: AgentEventType): PendingSlot | null {
return eventType === 'PAUSE' || eventType === 'CALL_START' || eventType === 'ACW_START'; if (eventType === 'RESUME') return 'pause';
if (eventType === 'CALL_END') return 'call';
if (eventType === 'ACW_END') return 'acw';
return null;
} }
/** /**