Skip to content

Commit 98f64d2

Browse files
shreyas-lyzrclaude
andcommitted
fix: auto-refresh Realtime session before 60-min cap, recover on expiry
OpenAI Realtime sessions are terminated by the server at 60 minutes. Once the session dies, every further WS message from the browser returns "server had an error while processing your request", which persisted in the UI as an unrecoverable failure. This adds two fixes to OpenAIRealtimeAdapter: 1. Proactive refresh — on session.created, start a 55-minute timer that tears down the WS and opens a fresh one with the same instructions, tools, and voice. The user sees a sub-second pause instead of a hard failure. 2. Reactive recovery — when the server sends an error whose message contains "maximum duration" or code is "session_expired", trigger the same reconnect flow instead of surfacing the error. Both paths reuse the existing connectWs + sendSessionUpdateOn logic, including the ephemeral-token fallback for WebContainer-style hosts. Bumps version to 1.4.3. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 4d697c3 commit 98f64d2

2 files changed

Lines changed: 75 additions & 1 deletion

File tree

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "gitclaw",
3-
"version": "1.4.2",
3+
"version": "1.4.3",
44
"description": "A universal git-native multimodal always learning AI Agent (TinyHuman)",
55
"author": "shreyaskapale",
66
"license": "MIT",

src/voice/openai-realtime.ts

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,13 @@ export class OpenAIRealtimeAdapter implements MultimodalAdapter {
1818
private toolHandler: ((query: string) => Promise<string>) | null = null;
1919
private interrupted = false;
2020

21+
// Session-refresh state
22+
private refreshTimer: NodeJS.Timeout | null = null;
23+
private refreshing = false;
24+
private disposed = false;
25+
// Refresh 5 minutes before OpenAI Realtime's 60-min hard cap
26+
private static readonly REFRESH_AFTER_MS = 55 * 60 * 1000;
27+
2128
constructor(config: MultimodalAdapterConfig) {
2229
this.config = config;
2330
}
@@ -249,12 +256,64 @@ export class OpenAIRealtimeAdapter implements MultimodalAdapter {
249256
}
250257

251258
async disconnect(): Promise<void> {
259+
this.disposed = true;
260+
if (this.refreshTimer) { clearTimeout(this.refreshTimer); this.refreshTimer = null; }
252261
if (this.ws) {
253262
this.ws.close();
254263
this.ws = null;
255264
}
256265
}
257266

267+
/**
268+
* Tear down and reopen the Realtime WS before (or right after) OpenAI's
269+
* 60-minute hard cap expires. Re-sends the stored session.update so the
270+
* agent picks up where it left off without the user noticing.
271+
*/
272+
private async refreshSession(reason: string): Promise<void> {
273+
if (this.refreshing || this.disposed) return;
274+
this.refreshing = true;
275+
console.log(dim(`[voice] Refreshing Realtime session (${reason})`));
276+
try {
277+
// Close the old WS without disposing the adapter
278+
if (this.refreshTimer) { clearTimeout(this.refreshTimer); this.refreshTimer = null; }
279+
if (this.ws) { try { this.ws.close(); } catch {} this.ws = null; }
280+
281+
const model = this.config.model || "gpt-realtime-2025-08-28";
282+
const url = `wss://api.openai.com/v1/realtime?model=${model}`;
283+
284+
try {
285+
await this.connectWs(url, {
286+
headers: {
287+
Authorization: `Bearer ${this.config.apiKey}`,
288+
"OpenAI-Beta": "realtime=v1",
289+
},
290+
});
291+
} catch (err: any) {
292+
const msg = err?.message || "";
293+
if (!msg.includes("authentication") && !msg.includes("401")) throw err;
294+
// Ephemeral token fallback (matches connect() path)
295+
const sessionResp = await fetch("https://api.openai.com/v1/realtime/sessions", {
296+
method: "POST",
297+
headers: { Authorization: `Bearer ${this.config.apiKey}`, "Content-Type": "application/json" },
298+
body: JSON.stringify({ model }),
299+
});
300+
if (!sessionResp.ok) throw new Error(`refresh ephemeral token: ${sessionResp.status}`);
301+
const session = (await sessionResp.json()) as { client_secret?: { value?: string } };
302+
const ephemeralKey = session.client_secret?.value;
303+
if (!ephemeralKey) throw new Error("No ephemeral key on refresh");
304+
await this.connectWs(url, {
305+
headers: { Authorization: `Bearer ${ephemeralKey}`, "OpenAI-Beta": "realtime=v1" },
306+
});
307+
}
308+
console.log(dim("[voice] Session refreshed"));
309+
} catch (err: any) {
310+
console.error(dim(`[voice] Session refresh failed: ${err.message}`));
311+
this.emit({ type: "error", message: `Voice session refresh failed: ${err.message}` });
312+
} finally {
313+
this.refreshing = false;
314+
}
315+
}
316+
258317
private emit(msg: ServerMessage): void {
259318
this.onMessage?.(msg);
260319
}
@@ -295,6 +354,10 @@ export class OpenAIRealtimeAdapter implements MultimodalAdapter {
295354
switch (event.type) {
296355
case "session.created":
297356
console.log(dim("[voice] Session created"));
357+
if (this.refreshTimer) clearTimeout(this.refreshTimer);
358+
this.refreshTimer = setTimeout(() => {
359+
this.refreshSession("proactive refresh before 60-min cap").catch(() => {});
360+
}, OpenAIRealtimeAdapter.REFRESH_AFTER_MS);
298361
break;
299362

300363
case "session.updated":
@@ -347,9 +410,20 @@ export class OpenAIRealtimeAdapter implements MultimodalAdapter {
347410

348411
case "error": {
349412
const errMsg = event.error?.message || "Unknown OpenAI error";
413+
const code = event.error?.code || "";
350414
console.error(dim(`[voice] Error: ${JSON.stringify(event.error)}`));
351415
// Don't surface cancellation errors — they happen when user interrupts with no active response
352416
if (errMsg.toLowerCase().includes("cancellation failed")) break;
417+
// Session expired (60-min cap) — silently reconnect instead of surfacing
418+
const lower = errMsg.toLowerCase();
419+
if (
420+
lower.includes("maximum duration") ||
421+
lower.includes("session_expired") ||
422+
code === "session_expired"
423+
) {
424+
this.refreshSession("session expired").catch(() => {});
425+
break;
426+
}
353427
this.emit({ type: "error", message: errMsg });
354428
break;
355429
}

0 commit comments

Comments
 (0)