Skip to content

Commit 5fee2ff

Browse files
fix(voice-call): preserve live Twilio streams in stale reaper
Co-authored-by: Sahibzada <[email protected]> Co-authored-by: clawsweeper[bot] <274271284+clawsweeper[bot]@users.noreply.github.com>
1 parent 3c07d36 commit 5fee2ff

2 files changed

Lines changed: 53 additions & 3 deletions

File tree

extensions/voice-call/src/manager.restore.test.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -305,6 +305,42 @@ describe("CallManager verification on restore", () => {
305305
expect(hangupCall.reason).toBe("timeout");
306306
});
307307

308+
it.each(["speaking", "listening"] as const)(
309+
"uses call start as max-duration anchor for restored live %s calls without answeredAt",
310+
async (state) => {
311+
vi.useFakeTimers();
312+
const now = new Date("2026-03-17T03:07:00Z").getTime();
313+
vi.setSystemTime(now);
314+
const startedAt = now - 290_000;
315+
const { manager, provider, storePath } = await initializeManager({
316+
callOverrides: {
317+
callId: `call-${state}`,
318+
providerCallId: `provider-${state}`,
319+
state,
320+
startedAt,
321+
answeredAt: undefined,
322+
},
323+
configOverrides: { maxDurationSeconds: 300 },
324+
});
325+
326+
const activeCall = requireSingleActiveCall(manager);
327+
expect(activeCall.state).toBe(state);
328+
expect(activeCall.answeredAt).toBe(startedAt);
329+
expect(
330+
loadActiveCallsFromStore(storePath).activeCalls.get(activeCall.callId)?.answeredAt,
331+
).toBe(startedAt);
332+
333+
await vi.advanceTimersByTimeAsync(9_000);
334+
expect(manager.getActiveCalls()).toHaveLength(1);
335+
expect(provider.hangupCalls).toHaveLength(0);
336+
337+
await vi.advanceTimersByTimeAsync(1_100);
338+
expect(manager.getActiveCalls()).toHaveLength(0);
339+
const hangupCall = requireSingleHangupCall(provider);
340+
expect(hangupCall.reason).toBe("timeout");
341+
},
342+
);
343+
308344
it("restores dedupe keys from terminal persisted calls so replayed webhooks stay ignored", async () => {
309345
const storePath = createTestStorePath();
310346
const persisted = makePersistedCall({

extensions/voice-call/src/manager.ts

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,13 @@ function incrementRestoreStatusCount(
4747
counts.set(key, (counts.get(key) ?? 0) + 1);
4848
}
4949

50+
function resolveRestoredMaxDurationAnchor(call: CallRecord): number | undefined {
51+
return (
52+
call.answeredAt ??
53+
(call.state === "speaking" || call.state === "listening" ? call.startedAt : undefined)
54+
);
55+
}
56+
5057
function resolveDefaultStoreBase(config: VoiceCallConfig, storePath?: string): string {
5158
const rawOverride = storePath?.trim() || config.store?.trim();
5259
if (rawOverride) {
@@ -126,11 +133,12 @@ export class CallManager {
126133
}
127134
}
128135

129-
// Restart max-duration timers for restored calls that are past the answered state
136+
// Restart max-duration timers for restored calls that are past the answered/live state.
130137
let skippedAlreadyElapsedTimers = 0;
131138
for (const [callId, call] of verified) {
132-
if (call.answeredAt && !TerminalStates.has(call.state)) {
133-
const elapsed = Date.now() - call.answeredAt;
139+
const maxDurationAnchor = resolveRestoredMaxDurationAnchor(call);
140+
if (maxDurationAnchor !== undefined && !TerminalStates.has(call.state)) {
141+
const elapsed = Date.now() - maxDurationAnchor;
134142
const maxDurationMs = resolveVoiceCallSecondsTimerDelayMs(this.config.maxDurationSeconds);
135143
if (elapsed >= maxDurationMs) {
136144
// Already expired — remove instead of keeping
@@ -141,6 +149,12 @@ export class CallManager {
141149
skippedAlreadyElapsedTimers += 1;
142150
continue;
143151
}
152+
if (call.answeredAt === undefined) {
153+
// Twilio streams can restore directly in speaking/listening without an
154+
// answered webhook; anchoring at startedAt preserves bounded duration.
155+
call.answeredAt = maxDurationAnchor;
156+
persistCallRecord(this.storePath, call);
157+
}
144158
startMaxDurationTimer({
145159
ctx: this.getContext(),
146160
callId,

0 commit comments

Comments
 (0)