diff --git a/CHANGELOG.md b/CHANGELOG.md index 00c755f0b09..5f840d8f0db 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/extensions/voice-call/src/manager.restore.test.ts b/extensions/voice-call/src/manager.restore.test.ts index 712eff72dc4..4e70d31e713 100644 --- a/extensions/voice-call/src/manager.restore.test.ts +++ b/extensions/voice-call/src/manager.restore.test.ts @@ -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[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({ diff --git a/extensions/voice-call/src/manager.ts b/extensions/voice-call/src/manager.ts index 33ff00abdcc..93ab5dae7e5 100644 --- a/extensions/voice-call/src/manager.ts +++ b/extensions/voice-call/src/manager.ts @@ -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)`, diff --git a/extensions/voice-call/src/manager/timers.ts b/extensions/voice-call/src/manager/timers.ts index 595ddb993f4..b086e0dec9e 100644 --- a/extensions/voice-call/src/manager/timers.ts +++ b/extensions/voice-call/src/manager/timers.ts @@ -27,12 +27,13 @@ export function startMaxDurationTimer(params: { ctx: MaxDurationTimerContext; callId: CallId; onTimeout: (callId: CallId) => Promise; + 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);