mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-25 03:45:46 -06:00
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:
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user