fix(voice-call): preserve live Twilio streams in stale reaper

Co-authored-by: Sahibzada <94376830+sahibzada-allahyar@users.noreply.github.com>

Co-authored-by: clawsweeper[bot] <274271284+clawsweeper[bot]@users.noreply.github.com>
This commit is contained in:
clawsweeper
2026-06-06 05:20:19 +00:00
parent 3c07d3668a
commit 5fee2ff7a1
2 changed files with 53 additions and 3 deletions
@@ -305,6 +305,42 @@ describe("CallManager verification on restore", () => {
expect(hangupCall.reason).toBe("timeout");
});
it.each(["speaking", "listening"] as const)(
"uses call start as max-duration anchor for restored live %s calls without answeredAt",
async (state) => {
vi.useFakeTimers();
const now = new Date("2026-03-17T03:07:00Z").getTime();
vi.setSystemTime(now);
const startedAt = now - 290_000;
const { manager, provider, storePath } = await initializeManager({
callOverrides: {
callId: `call-${state}`,
providerCallId: `provider-${state}`,
state,
startedAt,
answeredAt: undefined,
},
configOverrides: { maxDurationSeconds: 300 },
});
const activeCall = requireSingleActiveCall(manager);
expect(activeCall.state).toBe(state);
expect(activeCall.answeredAt).toBe(startedAt);
expect(
loadActiveCallsFromStore(storePath).activeCalls.get(activeCall.callId)?.answeredAt,
).toBe(startedAt);
await vi.advanceTimersByTimeAsync(9_000);
expect(manager.getActiveCalls()).toHaveLength(1);
expect(provider.hangupCalls).toHaveLength(0);
await vi.advanceTimersByTimeAsync(1_100);
expect(manager.getActiveCalls()).toHaveLength(0);
const hangupCall = requireSingleHangupCall(provider);
expect(hangupCall.reason).toBe("timeout");
},
);
it("restores dedupe keys from terminal persisted calls so replayed webhooks stay ignored", async () => {
const storePath = createTestStorePath();
const persisted = makePersistedCall({
+17 -3
View File
@@ -47,6 +47,13 @@ function incrementRestoreStatusCount(
counts.set(key, (counts.get(key) ?? 0) + 1);
}
function resolveRestoredMaxDurationAnchor(call: CallRecord): number | undefined {
return (
call.answeredAt ??
(call.state === "speaking" || call.state === "listening" ? call.startedAt : undefined)
);
}
function resolveDefaultStoreBase(config: VoiceCallConfig, storePath?: string): string {
const rawOverride = storePath?.trim() || config.store?.trim();
if (rawOverride) {
@@ -126,11 +133,12 @@ export class CallManager {
}
}
// Restart max-duration timers for restored calls that are past the answered state
// Restart max-duration timers for restored calls that are past the answered/live state.
let skippedAlreadyElapsedTimers = 0;
for (const [callId, call] of verified) {
if (call.answeredAt && !TerminalStates.has(call.state)) {
const elapsed = Date.now() - call.answeredAt;
const maxDurationAnchor = resolveRestoredMaxDurationAnchor(call);
if (maxDurationAnchor !== undefined && !TerminalStates.has(call.state)) {
const elapsed = Date.now() - maxDurationAnchor;
const maxDurationMs = resolveVoiceCallSecondsTimerDelayMs(this.config.maxDurationSeconds);
if (elapsed >= maxDurationMs) {
// Already expired — remove instead of keeping
@@ -141,6 +149,12 @@ export class CallManager {
skippedAlreadyElapsedTimers += 1;
continue;
}
if (call.answeredAt === undefined) {
// Twilio streams can restore directly in speaking/listening without an
// answered webhook; anchoring at startedAt preserves bounded duration.
call.answeredAt = maxDurationAnchor;
persistCallRecord(this.storePath, call);
}
startMaxDurationTimer({
ctx: this.getContext(),
callId,