fix(voice-call): terminate expired restored calls

This commit is contained in:
Peter Steinberger
2026-04-25 03:54:57 +01:00
parent fe930b987e
commit 5381625f45
4 changed files with 76 additions and 7 deletions

View File

@@ -76,6 +76,7 @@ Docs: https://docs.openclaw.ai
- Providers/OpenAI-compatible: forward `prompt_cache_key` on Completions requests only for providers that opt in with `compat.supportsPromptCacheKey`, keeping default proxy payloads unchanged. Fixes #69272.
- Providers/ElevenLabs: omit the MP3-only `Accept` header for PCM telephony synthesis, so Voice Call requests for `pcm_22050` no longer receive MP3 audio. Fixes #67340. Thanks @marcchabot.
- Plugins/Voice Call: reap stale pre-answer calls by default, honor configured TTS timeouts for Twilio media-stream playback, and fail empty telephony audio instead of completing as silence. Fixes #42071; supersedes #60957. Thanks @Ryce and @sliekens.
- Plugins/Voice Call: terminate expired restored call sessions with the provider and restart restored max-duration timers with only the remaining duration, preventing stale outbound retry loops after Gateway restarts. Fixes #48739. Thanks @mira-solari.
- Skills: honor legacy `metadata.clawdbot` requirements and installer hints when `metadata.openclaw` is absent, so older skills no longer appear ready when required binaries are missing. Fixes #71323. Thanks @chen-zhang-cs-code.
- Browser/config: expand `~` in `browser.executablePath` before Chromium launch, so home-relative custom browser paths no longer fail with `ENOENT`. Fixes #67264. Thanks @Quratulain-bilal.
- Telegram/streaming: hide tool-progress status updates by default while keeping explicit `streaming.preview.toolProgress` opt-in support for edited preview messages. Fixes #71320. Thanks @neeravmakwana.

View File

@@ -1,4 +1,4 @@
import { describe, expect, it } from "vitest";
import { afterEach, describe, expect, it, vi } from "vitest";
import { VoiceCallConfigSchema } from "./config.js";
import { CallManager } from "./manager.js";
import {
@@ -7,6 +7,7 @@ import {
makePersistedCall,
writeCallsToStore,
} from "./manager.test-harness.js";
import { flushPendingCallRecordWritesForTest, loadActiveCallsFromStore } from "./manager/store.js";
function requireSingleActiveCall(manager: CallManager) {
const activeCalls = manager.getActiveCalls();
@@ -19,6 +20,10 @@ function requireSingleActiveCall(manager: CallManager) {
}
describe("CallManager verification on restore", () => {
afterEach(() => {
vi.useRealTimers();
});
async function initializeManager(params?: {
callOverrides?: Parameters<typeof makePersistedCall>[0];
providerResult?: FakeProvider["getCallStatusResult"];
@@ -44,7 +49,7 @@ describe("CallManager verification on restore", () => {
const manager = new CallManager(config, storePath);
await manager.initialize(provider, "https://example.com/voice/webhook");
return { call, manager };
return { call, manager, provider, storePath };
}
it("skips stale calls reported terminal by provider", async () => {
@@ -75,7 +80,7 @@ describe("CallManager verification on restore", () => {
});
it("skips calls older than maxDurationSeconds", async () => {
const { manager } = await initializeManager({
const { manager, provider, storePath } = await initializeManager({
callOverrides: {
startedAt: Date.now() - 600_000,
answeredAt: Date.now() - 590_000,
@@ -84,6 +89,14 @@ describe("CallManager verification on restore", () => {
});
expect(manager.getActiveCalls()).toHaveLength(0);
expect(provider.hangupCalls).toEqual([
expect.objectContaining({
reason: "timeout",
}),
]);
await flushPendingCallRecordWritesForTest();
expect(loadActiveCallsFromStore(storePath).activeCalls.size).toBe(0);
});
it("skips calls without providerCallId", async () => {
@@ -108,6 +121,33 @@ describe("CallManager verification on restore", () => {
expect(activeCall.state).toBe(call.state);
});
it("uses only remaining max duration for restored answered calls", async () => {
vi.useFakeTimers();
const now = new Date("2026-03-17T03:07:00Z");
vi.setSystemTime(now);
const { manager, provider } = await initializeManager({
callOverrides: {
startedAt: now.getTime() - 290_000,
answeredAt: now.getTime() - 290_000,
state: "answered",
},
configOverrides: { maxDurationSeconds: 300 },
});
expect(manager.getActiveCalls()).toHaveLength(1);
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);
expect(provider.hangupCalls).toEqual([
expect.objectContaining({
reason: "timeout",
}),
]);
});
it("restores dedupe keys from terminal persisted calls so replayed webhooks stay ignored", async () => {
const storePath = createTestStorePath();
const persisted = makePersistedCall({

View File

@@ -14,7 +14,11 @@ import {
speak as speakWithContext,
speakInitialMessage as speakInitialMessageWithContext,
} from "./manager/outbound.js";
import { getCallHistoryFromStore, loadActiveCallsFromStore } from "./manager/store.js";
import {
getCallHistoryFromStore,
loadActiveCallsFromStore,
persistCallRecord,
} from "./manager/store.js";
import { startMaxDurationTimer } from "./manager/timers.js";
import type { VoiceCallProvider } from "./providers/base.js";
import {
@@ -26,6 +30,12 @@ import {
} from "./types.js";
import { resolveUserPath } from "./utils.js";
function markRestoredCallSkipped(call: CallRecord, endReason: "completed" | "timeout"): void {
call.endedAt = Date.now();
call.endReason = endReason;
call.state = endReason;
}
function resolveDefaultStoreBase(config: VoiceCallConfig, storePath?: string): string {
const rawOverride = storePath?.trim() || config.store?.trim();
if (rawOverride) {
@@ -117,6 +127,7 @@ export class CallManager {
startMaxDurationTimer({
ctx: this.getContext(),
callId,
timeoutMs: maxDurationMs - elapsed,
onTimeout: async (id) => {
await endCallWithContext(this.getContext(), id, { reason: "timeout" });
},
@@ -160,6 +171,20 @@ export class CallManager {
console.log(
`[voice-call] Skipping restored call ${callId} (older than maxDurationSeconds)`,
);
markRestoredCallSkipped(call, "timeout");
persistCallRecord(this.storePath, call);
await provider
.hangupCall({
callId,
providerCallId: call.providerCallId,
reason: "timeout",
})
.catch((err) => {
console.warn(
`[voice-call] Failed to hang up expired restored call ${callId}:`,
err instanceof Error ? err.message : String(err),
);
});
continue;
}
@@ -173,6 +198,8 @@ export class CallManager {
console.log(
`[voice-call] Skipping restored call ${callId} (provider status: ${result.status})`,
);
markRestoredCallSkipped(call, "completed");
persistCallRecord(this.storePath, call);
} else if (result.isUnknown) {
console.log(
`[voice-call] Keeping restored call ${callId} (provider status unknown, relying on timer)`,

View File

@@ -27,12 +27,13 @@ export function startMaxDurationTimer(params: {
ctx: MaxDurationTimerContext;
callId: CallId;
onTimeout: (callId: CallId) => Promise<void>;
timeoutMs?: number;
}): void {
clearMaxDurationTimer(params.ctx, params.callId);
const maxDurationMs = params.ctx.config.maxDurationSeconds * 1000;
const maxDurationMs = params.timeoutMs ?? params.ctx.config.maxDurationSeconds * 1000;
console.log(
`[voice-call] Starting max duration timer (${params.ctx.config.maxDurationSeconds}s) for call ${params.callId}`,
`[voice-call] Starting max duration timer (${Math.ceil(maxDurationMs / 1000)}s) for call ${params.callId}`,
);
const timer = setTimeout(async () => {
@@ -40,7 +41,7 @@ export function startMaxDurationTimer(params: {
const call = params.ctx.activeCalls.get(params.callId);
if (call && !TerminalStates.has(call.state)) {
console.log(
`[voice-call] Max duration reached (${params.ctx.config.maxDurationSeconds}s), ending call ${params.callId}`,
`[voice-call] Max duration reached (${Math.ceil(maxDurationMs / 1000)}s), ending call ${params.callId}`,
);
call.endReason = "timeout";
persistCallRecord(params.ctx.storePath, call);