fix: dedupe voice call lifecycle cleanup

This commit is contained in:
Peter Steinberger 2026-03-28 03:18:40 +00:00
parent 0825ff9619
commit 07d386c2bb
7 changed files with 124 additions and 75 deletions

View File

@ -116,7 +116,7 @@ export class CallManager {
ctx: this.getContext(),
callId,
onTimeout: async (id) => {
await endCallWithContext(this.getContext(), id);
await endCallWithContext(this.getContext(), id, { reason: "timeout" });
},
});
console.log(`[voice-call] Restarted max-duration timer for restored call ${callId}`);

View File

@ -2,16 +2,12 @@ import crypto from "node:crypto";
import { isAllowlistedCaller, normalizePhoneNumber } from "../allowlist.js";
import type { CallRecord, CallState, NormalizedEvent } from "../types.js";
import type { CallManagerContext } from "./context.js";
import { finalizeCall } from "./lifecycle.js";
import { findCall } from "./lookup.js";
import { endCall } from "./outbound.js";
import { addTranscriptEntry, transitionState } from "./state.js";
import { persistCallRecord } from "./store.js";
import {
clearMaxDurationTimer,
rejectTranscriptWaiter,
resolveTranscriptWaiter,
startMaxDurationTimer,
} from "./timers.js";
import { resolveTranscriptWaiter, startMaxDurationTimer } from "./timers.js";
type EventContext = Pick<
CallManagerContext,
@ -193,7 +189,7 @@ export function processEvent(ctx: EventContext, event: NormalizedEvent): void {
ctx,
callId: call.callId,
onTimeout: async (callId) => {
await endCall(ctx, callId);
await endCall(ctx, callId, { reason: "timeout" });
},
});
ctx.onCallAnswered?.(call);
@ -228,28 +224,24 @@ export function processEvent(ctx: EventContext, event: NormalizedEvent): void {
break;
case "call.ended":
call.endedAt = event.timestamp;
call.endReason = event.reason;
transitionState(call, event.reason as CallState);
clearMaxDurationTimer(ctx, call.callId);
rejectTranscriptWaiter(ctx, call.callId, `Call ended: ${event.reason}`);
ctx.activeCalls.delete(call.callId);
if (call.providerCallId) {
ctx.providerCallIdMap.delete(call.providerCallId);
}
break;
finalizeCall({
ctx,
call,
endReason: event.reason,
endedAt: event.timestamp,
});
return;
case "call.error":
if (!event.retryable) {
call.endedAt = event.timestamp;
call.endReason = "error";
transitionState(call, "error");
clearMaxDurationTimer(ctx, call.callId);
rejectTranscriptWaiter(ctx, call.callId, `Call error: ${event.error}`);
ctx.activeCalls.delete(call.callId);
if (call.providerCallId) {
ctx.providerCallIdMap.delete(call.providerCallId);
}
finalizeCall({
ctx,
call,
endReason: "error",
endedAt: event.timestamp,
transcriptRejectReason: `Call error: ${event.error}`,
});
return;
}
break;
}

View File

@ -0,0 +1,53 @@
import type { CallRecord, EndReason } from "../types.js";
import type { CallManagerContext } from "./context.js";
import { transitionState } from "./state.js";
import { persistCallRecord } from "./store.js";
import { clearMaxDurationTimer, rejectTranscriptWaiter } from "./timers.js";
type CallLifecycleContext = Pick<
CallManagerContext,
"activeCalls" | "providerCallIdMap" | "storePath"
> &
Partial<Pick<CallManagerContext, "transcriptWaiters" | "maxDurationTimers">>;
function removeProviderCallMapping(
providerCallIdMap: Map<string, string>,
call: Pick<CallRecord, "callId" | "providerCallId">,
): void {
if (!call.providerCallId) {
return;
}
const mappedCallId = providerCallIdMap.get(call.providerCallId);
if (mappedCallId === call.callId) {
providerCallIdMap.delete(call.providerCallId);
}
}
export function finalizeCall(params: {
ctx: CallLifecycleContext;
call: CallRecord;
endReason: EndReason;
endedAt?: number;
transcriptRejectReason?: string;
}): void {
const { ctx, call, endReason } = params;
call.endedAt = params.endedAt ?? Date.now();
call.endReason = endReason;
transitionState(call, endReason);
persistCallRecord(ctx.storePath, call);
if (ctx.maxDurationTimers) {
clearMaxDurationTimer({ maxDurationTimers: ctx.maxDurationTimers }, call.callId);
}
if (ctx.transcriptWaiters) {
rejectTranscriptWaiter(
{ transcriptWaiters: ctx.transcriptWaiters },
call.callId,
params.transcriptRejectReason ?? `Call ended: ${endReason}`,
);
}
ctx.activeCalls.delete(call.callId);
removeProviderCallMapping(ctx.providerCallIdMap, call);
}

View File

@ -231,10 +231,11 @@ describe("voice-call outbound helpers", () => {
});
expect(call).toEqual(
expect.objectContaining({
state: "hangup-bot",
endReason: "hangup-bot",
endedAt: expect.any(Number),
}),
);
expect(transitionStateMock).toHaveBeenCalledWith(call, "hangup-bot");
expect(clearMaxDurationTimerMock).toHaveBeenCalledWith(ctx, "call-1");
expect(rejectTranscriptWaiterMock).toHaveBeenCalledWith(
ctx,
@ -245,6 +246,36 @@ describe("voice-call outbound helpers", () => {
expect(ctx.providerCallIdMap.size).toBe(0);
});
it("preserves timeout reasons when ending timed out calls", async () => {
const call = { callId: "call-1", providerCallId: "provider-1", state: "active" };
const hangupCall = vi.fn(async () => {});
const ctx = {
activeCalls: new Map([["call-1", call]]),
providerCallIdMap: new Map([["provider-1", "call-1"]]),
provider: { hangupCall },
storePath: "/tmp/voice-call.json",
transcriptWaiters: new Map(),
maxDurationTimers: new Map(),
};
await expect(endCall(ctx as never, "call-1", { reason: "timeout" })).resolves.toEqual({
success: true,
});
expect(hangupCall).toHaveBeenCalledWith({
callId: "call-1",
providerCallId: "provider-1",
reason: "timeout",
});
expect(call).toEqual(
expect.objectContaining({
endReason: "timeout",
endedAt: expect.any(Number),
}),
);
expect(transitionStateMock).toHaveBeenCalledWith(call, "timeout");
expect(rejectTranscriptWaiterMock).toHaveBeenCalledWith(ctx, "call-1", "Call ended: timeout");
});
it("handles missing, disconnected, and already-ended calls", async () => {
await expect(
speak(

View File

@ -1,6 +1,7 @@
import crypto from "node:crypto";
import type { CallMode } from "../config.js";
import {
type EndReason,
TerminalStates,
type CallId,
type CallRecord,
@ -8,15 +9,11 @@ import {
} from "../types.js";
import { mapVoiceToPolly } from "../voice-mapping.js";
import type { CallManagerContext } from "./context.js";
import { finalizeCall } from "./lifecycle.js";
import { getCallByProviderCallId } from "./lookup.js";
import { addTranscriptEntry, transitionState } from "./state.js";
import { persistCallRecord } from "./store.js";
import {
clearMaxDurationTimer,
clearTranscriptWaiter,
rejectTranscriptWaiter,
waitForFinalTranscript,
} from "./timers.js";
import { clearTranscriptWaiter, waitForFinalTranscript } from "./timers.js";
import { generateNotifyTwiml } from "./twiml.js";
type InitiateContext = Pick<
@ -186,14 +183,11 @@ export async function initiateCall(
return { callId, success: true };
} catch (err) {
callRecord.state = "failed";
callRecord.endedAt = Date.now();
callRecord.endReason = "failed";
persistCallRecord(ctx.storePath, callRecord);
ctx.activeCalls.delete(callId);
if (callRecord.providerCallId) {
ctx.providerCallIdMap.delete(callRecord.providerCallId);
}
finalizeCall({
ctx,
call: callRecord,
endReason: "failed",
});
return {
callId,
@ -369,6 +363,7 @@ export async function continueCall(
export async function endCall(
ctx: EndCallContext,
callId: CallId,
options?: { reason?: EndReason },
): Promise<{ success: boolean; error?: string }> {
const lookup = lookupConnectedCall(ctx, callId);
if (lookup.kind === "error") {
@ -378,24 +373,20 @@ export async function endCall(
return { success: true };
}
const { call, providerCallId, provider } = lookup;
const reason = options?.reason ?? "hangup-bot";
try {
await provider.hangupCall({
callId,
providerCallId,
reason: "hangup-bot",
reason,
});
call.state = "hangup-bot";
call.endedAt = Date.now();
call.endReason = "hangup-bot";
persistCallRecord(ctx.storePath, call);
clearMaxDurationTimer(ctx, callId);
rejectTranscriptWaiter(ctx, callId, "Call ended: hangup-bot");
ctx.activeCalls.delete(callId);
ctx.providerCallIdMap.delete(providerCallId);
finalizeCall({
ctx,
call,
endReason: reason,
});
return { success: true };
} catch (err) {

View File

@ -8,14 +8,6 @@ import {
waitForFinalTranscript,
} from "./timers.js";
const { persistCallRecordMock } = vi.hoisted(() => ({
persistCallRecordMock: vi.fn(),
}));
vi.mock("./store.js", () => ({
persistCallRecord: persistCallRecordMock,
}));
describe("voice-call manager timers", () => {
beforeEach(() => {
vi.useFakeTimers();
@ -26,13 +18,12 @@ describe("voice-call manager timers", () => {
vi.useRealTimers();
});
it("starts and clears max duration timers, persisting timed out active calls", async () => {
it("starts and clears max duration timers, delegating timeout handling", async () => {
const call = { id: "call-1", state: "active" };
const ctx = {
activeCalls: new Map([["call-1", call]]),
maxDurationTimers: new Map(),
config: { maxDurationSeconds: 5 },
storePath: "/tmp/voice-call.json",
};
const onTimeout = vi.fn(async () => {});
@ -46,8 +37,7 @@ describe("voice-call manager timers", () => {
await vi.advanceTimersByTimeAsync(5_000);
expect(call).toEqual({ id: "call-1", state: "active", endReason: "timeout" });
expect(persistCallRecordMock).toHaveBeenCalledWith("/tmp/voice-call.json", call);
expect(call).toEqual({ id: "call-1", state: "active" });
expect(onTimeout).toHaveBeenCalledWith("call-1");
expect(ctx.maxDurationTimers.has("call-1")).toBe(false);
@ -65,7 +55,6 @@ describe("voice-call manager timers", () => {
activeCalls: new Map([["call-1", { id: "call-1", state: "completed" }]]),
maxDurationTimers: new Map(),
config: { maxDurationSeconds: 5 },
storePath: "/tmp/voice-call.json",
};
const onTimeout = vi.fn(async () => {});
@ -77,7 +66,6 @@ describe("voice-call manager timers", () => {
await vi.advanceTimersByTimeAsync(5_000);
expect(persistCallRecordMock).not.toHaveBeenCalled();
expect(onTimeout).not.toHaveBeenCalled();
});

View File

@ -1,15 +1,11 @@
import { TerminalStates, type CallId } from "../types.js";
import type { CallManagerContext } from "./context.js";
import { persistCallRecord } from "./store.js";
type TimerContext = Pick<
CallManagerContext,
"activeCalls" | "maxDurationTimers" | "config" | "storePath" | "transcriptWaiters"
>;
type MaxDurationTimerContext = Pick<
TimerContext,
"activeCalls" | "maxDurationTimers" | "config" | "storePath"
"activeCalls" | "maxDurationTimers" | "config" | "transcriptWaiters"
>;
type MaxDurationTimerContext = Pick<TimerContext, "activeCalls" | "maxDurationTimers" | "config">;
type TranscriptWaiterContext = Pick<TimerContext, "transcriptWaiters">;
export function clearMaxDurationTimer(
@ -42,8 +38,6 @@ export function startMaxDurationTimer(params: {
console.log(
`[voice-call] Max duration reached (${params.ctx.config.maxDurationSeconds}s), ending call ${params.callId}`,
);
call.endReason = "timeout";
persistCallRecord(params.ctx.storePath, call);
await params.onTimeout(params.callId);
}
}, maxDurationMs);