osborn 0.9.82 → 0.9.83
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.js +408 -320
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -313,23 +313,39 @@ function startApiServer(workingDir, port) {
|
|
|
313
313
|
});
|
|
314
314
|
return;
|
|
315
315
|
}
|
|
316
|
+
// GET /room-code — LEGACY (0.9.83). Returns the LAST-CREATED full room NAME
|
|
317
|
+
// (not a short code anymore) so old frontends that fetch this before joining
|
|
318
|
+
// keep working during rollout. New frontends should instead call
|
|
319
|
+
// POST /connect-room and use the returned { roomName }. Kept as a fallback.
|
|
316
320
|
if (req.method === 'GET' && url.pathname === '/room-code') {
|
|
317
321
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
318
322
|
res.end(JSON.stringify({ roomCode: currentRoomCode }));
|
|
319
323
|
return;
|
|
320
324
|
}
|
|
321
325
|
// POST /connect-room — the frontend connect flow calls this so the agent
|
|
322
|
-
// joins LiveKit for an incoming user.
|
|
323
|
-
//
|
|
324
|
-
//
|
|
325
|
-
//
|
|
326
|
-
//
|
|
326
|
+
// joins LiveKit for an incoming user. 0.9.83: creates a FRESH, UNIQUE room
|
|
327
|
+
// per session and AWAITS the join so we can return the room NAME the
|
|
328
|
+
// frontend must mint its token against — eliminating the /room-code fetch
|
|
329
|
+
// race. Response: { ok, roomName }. The frontend should bind to roomName
|
|
330
|
+
// directly (mint its LiveKit token for it) rather than fetching /room-code.
|
|
331
|
+
// The agent is in the room by the time this responds, so the user's join
|
|
332
|
+
// fires ParticipantConnected (or is caught by the adopt-sweep either way).
|
|
327
333
|
if (req.method === 'POST' && url.pathname === '/connect-room') {
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
334
|
+
if (!connectRoomHook) {
|
|
335
|
+
res.writeHead(503, { 'Content-Type': 'application/json' });
|
|
336
|
+
res.end(JSON.stringify({ ok: false, error: 'agent not ready' }));
|
|
337
|
+
return;
|
|
332
338
|
}
|
|
339
|
+
connectRoomHook()
|
|
340
|
+
.then((roomName) => {
|
|
341
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
342
|
+
res.end(JSON.stringify({ ok: true, roomName, status: livekitState.status }));
|
|
343
|
+
})
|
|
344
|
+
.catch((e) => {
|
|
345
|
+
console.error('❌ /connect-room hook failed:', e);
|
|
346
|
+
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
347
|
+
res.end(JSON.stringify({ ok: false, error: e instanceof Error ? e.message : String(e), status: livekitState.status }));
|
|
348
|
+
});
|
|
333
349
|
return;
|
|
334
350
|
}
|
|
335
351
|
// POST /leave-room — the frontend's explicit "leave"/disconnect leaves the
|
|
@@ -1197,43 +1213,59 @@ async function main() {
|
|
|
1197
1213
|
console.warn('⚠️ could not persist room code (rotation race protection disabled):', e instanceof Error ? e.message : e);
|
|
1198
1214
|
}
|
|
1199
1215
|
}
|
|
1200
|
-
|
|
1201
|
-
|
|
1216
|
+
// `roomCode` is the STABLE, identity-derived PREFIX (per machine/user). Under
|
|
1217
|
+
// the temporary-rooms architecture (0.9.83) we no longer join ONE fixed room
|
|
1218
|
+
// for the machine's life — each user session gets a FRESH, unique room name
|
|
1219
|
+
// built by appending a base36 timestamp to this prefix:
|
|
1220
|
+
// osborn-${roomCode}-${Date.now().toString(36)}
|
|
1221
|
+
// The stable prefix is preserved purely for log forensics (grep by user).
|
|
1222
|
+
// currentRoomCode holds the LAST-CREATED full room NAME so the legacy
|
|
1223
|
+
// GET /room-code endpoint keeps working for old frontends during rollout
|
|
1224
|
+
// (see note in createRoomSession + the /room-code handler).
|
|
1225
|
+
const buildRoomName = () => `osborn-${roomCode}-${Date.now().toString(36)}`;
|
|
1202
1226
|
if (cliArgs.roomCode) {
|
|
1203
|
-
console.log(`🔗
|
|
1227
|
+
console.log(`🔗 Room code prefix: ${roomCode}`);
|
|
1204
1228
|
}
|
|
1205
1229
|
else {
|
|
1206
|
-
console.log(`\n✨
|
|
1207
|
-
console.log(`\n📋 Share this with the frontend or run:`);
|
|
1208
|
-
console.log(` Open: https://osborn.app?room=${roomCode}`);
|
|
1209
|
-
console.log(` Or enter code "${roomCode}" in the frontend\n`);
|
|
1230
|
+
console.log(`\n✨ Room code prefix: ${roomCode} — each session mints a fresh room osborn-${roomCode}-<ts>\n`);
|
|
1210
1231
|
}
|
|
1211
1232
|
// Start HTTP API server for frontend session browsing
|
|
1212
1233
|
const apiPort = parseInt(process.env.OSBORN_API_PORT || '8741', 10);
|
|
1213
1234
|
startApiServer(workingDir, apiPort);
|
|
1214
1235
|
// ============================================================
|
|
1215
|
-
//
|
|
1236
|
+
// Agent access token — minted FRESH per room session (0.9.83)
|
|
1216
1237
|
// ============================================================
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1238
|
+
// A LiveKit JWT grant is scoped to a specific room name. Since every user
|
|
1239
|
+
// session now joins a UNIQUE temporary room (buildRoomName), the token must
|
|
1240
|
+
// be minted per room, not once at boot. mintAgentToken(roomName) returns a
|
|
1241
|
+
// fresh JWT grant for the given room.
|
|
1242
|
+
const mintAgentToken = async (targetRoom) => {
|
|
1243
|
+
const token = new AccessToken(apiKey, apiSecret, {
|
|
1244
|
+
identity: 'osborn-agent',
|
|
1245
|
+
name: 'Osborn AI',
|
|
1246
|
+
metadata: JSON.stringify({ type: 'agent', version: '0.3.0' }),
|
|
1247
|
+
});
|
|
1248
|
+
token.addGrant({
|
|
1249
|
+
roomJoin: true,
|
|
1250
|
+
room: targetRoom,
|
|
1251
|
+
canPublish: true,
|
|
1252
|
+
canSubscribe: true,
|
|
1253
|
+
canPublishData: true,
|
|
1254
|
+
});
|
|
1255
|
+
return token.toJwt();
|
|
1256
|
+
};
|
|
1231
1257
|
// ============================================================
|
|
1232
|
-
//
|
|
1258
|
+
// Room session (0.9.83): FRESH Room instance per user session
|
|
1233
1259
|
// ============================================================
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1260
|
+
// rtc-node's Room is single-use: Room.disconnect() ends with
|
|
1261
|
+
// removeAllListeners() (room.js:584), wiping EVERY room.on() handler, and its
|
|
1262
|
+
// signal-less waitFor + hasCleanedUp reset make a reused Room deaf after any
|
|
1263
|
+
// leave/rejoin cycle. So we NEVER reuse a Room after disconnect — instead we
|
|
1264
|
+
// create a brand-new Room (with full handler wiring) per session via
|
|
1265
|
+
// createRoomSession(), and discard it via destroyRoomSession(). activeRoom is
|
|
1266
|
+
// null while the agent is idle (between sessions).
|
|
1267
|
+
let activeRoom = null;
|
|
1268
|
+
let activeRoomName = null; // the temporary room name currently joined
|
|
1237
1269
|
// Track state
|
|
1238
1270
|
let pendingSessionClose = null; // Tracks async session close for reconnect safety
|
|
1239
1271
|
let currentSession = null;
|
|
@@ -1259,17 +1291,46 @@ async function main() {
|
|
|
1259
1291
|
if (aloneTimer)
|
|
1260
1292
|
clearTimeout(aloneTimer);
|
|
1261
1293
|
aloneTimer = null;
|
|
1262
|
-
if (
|
|
1294
|
+
if (!activeRoom || activeRoom.remoteParticipants.size > 0)
|
|
1263
1295
|
return;
|
|
1264
1296
|
aloneTimer = setTimeout(() => {
|
|
1265
1297
|
aloneTimer = null;
|
|
1266
|
-
if (
|
|
1267
|
-
console.log(`🕊️ Alone in room ${ALONE_GRACE_MS / 1000}s —
|
|
1298
|
+
if (activeRoom && activeRoom.remoteParticipants.size === 0 && livekitState.status === 'connected') {
|
|
1299
|
+
console.log(`🕊️ Alone in room ${ALONE_GRACE_MS / 1000}s — destroying room session to stop connection-minute burn`);
|
|
1268
1300
|
intentionalLeave = true;
|
|
1269
|
-
|
|
1301
|
+
// Fresh-Room-per-session (0.9.83): tear down + discard the instance
|
|
1302
|
+
// rather than room.disconnect() on a reused Room.
|
|
1303
|
+
destroyRoomSession('alone-timer').catch((e) => console.error('alone-leave destroyRoomSession failed:', e));
|
|
1270
1304
|
}
|
|
1271
1305
|
}, ALONE_GRACE_MS);
|
|
1272
1306
|
};
|
|
1307
|
+
// 0.9.83: Fast tab-close leave. When a real session ENDS (user left / closed
|
|
1308
|
+
// the tab), LiveKit has already dropped that participant server-side, so we
|
|
1309
|
+
// don't need the full 3-min alone grace — free the room in ~20s. A short
|
|
1310
|
+
// grace still rides out a quick refresh/rejoin. This shrinks the "ghost
|
|
1311
|
+
// participant" window that compounds room churn; the 3-min alone-timer
|
|
1312
|
+
// remains for the woken-but-never-joined case (armed on connect).
|
|
1313
|
+
let fastLeaveTimer = null;
|
|
1314
|
+
const POST_LEAVE_GRACE_MS = 20 * 1000;
|
|
1315
|
+
const armFastLeaveTimer = () => {
|
|
1316
|
+
if (fastLeaveTimer)
|
|
1317
|
+
clearTimeout(fastLeaveTimer);
|
|
1318
|
+
fastLeaveTimer = null;
|
|
1319
|
+
if (!activeRoom || activeRoom.remoteParticipants.size > 0)
|
|
1320
|
+
return;
|
|
1321
|
+
fastLeaveTimer = setTimeout(() => {
|
|
1322
|
+
fastLeaveTimer = null;
|
|
1323
|
+
if (activeRoom && activeRoom.remoteParticipants.size === 0 && livekitState.status === 'connected') {
|
|
1324
|
+
console.log(`🚪 Room empty ${POST_LEAVE_GRACE_MS / 1000}s after session end (tab close / leave) — destroying room session`);
|
|
1325
|
+
intentionalLeave = true;
|
|
1326
|
+
destroyRoomSession('fast-leave').catch((e) => console.error('fast-leave destroyRoomSession failed:', e));
|
|
1327
|
+
}
|
|
1328
|
+
}, POST_LEAVE_GRACE_MS);
|
|
1329
|
+
};
|
|
1330
|
+
const cancelFastLeaveTimer = () => { if (fastLeaveTimer) {
|
|
1331
|
+
clearTimeout(fastLeaveTimer);
|
|
1332
|
+
fastLeaveTimer = null;
|
|
1333
|
+
} };
|
|
1273
1334
|
// ── 0.9.73: Idle machine self-stop (the $123 Fly bill fix) ──
|
|
1274
1335
|
// After an intentional alone-leave the agent used to sit "idle, awaiting
|
|
1275
1336
|
// /connect-room" forever — process alive, Fly machine `started`, billing
|
|
@@ -1296,14 +1357,14 @@ async function main() {
|
|
|
1296
1357
|
idleExitTimer = setTimeout(() => {
|
|
1297
1358
|
idleExitTimer = null;
|
|
1298
1359
|
const stillIdle = livekitState.status === 'idle' || livekitState.status === 'failed';
|
|
1299
|
-
const empty =
|
|
1360
|
+
const empty = !activeRoom || activeRoom.remoteParticipants.size === 0;
|
|
1300
1361
|
if (stillIdle && empty) {
|
|
1301
1362
|
console.log(`⏻ [IDLE-EXIT] still idle after grace — exiting 0 so Fly stops the machine (billing stops; next Resume boots it)`);
|
|
1302
1363
|
// Give the log tee a beat to flush, then stop the machine.
|
|
1303
1364
|
setTimeout(() => process.exit(0), 2000);
|
|
1304
1365
|
}
|
|
1305
1366
|
else {
|
|
1306
|
-
console.log(`⏻ [IDLE-EXIT] aborted — status=${livekitState.status} participants=${
|
|
1367
|
+
console.log(`⏻ [IDLE-EXIT] aborted — status=${livekitState.status} participants=${activeRoom?.remoteParticipants.size ?? 0}`);
|
|
1307
1368
|
}
|
|
1308
1369
|
}, IDLE_EXIT_GRACE_MS);
|
|
1309
1370
|
};
|
|
@@ -1314,85 +1375,20 @@ async function main() {
|
|
|
1314
1375
|
console.log('⏻ [IDLE-EXIT] cancelled — activity resumed');
|
|
1315
1376
|
}
|
|
1316
1377
|
};
|
|
1317
|
-
// ── 0.9.
|
|
1318
|
-
//
|
|
1319
|
-
//
|
|
1320
|
-
// (
|
|
1321
|
-
//
|
|
1322
|
-
//
|
|
1323
|
-
//
|
|
1324
|
-
//
|
|
1325
|
-
//
|
|
1326
|
-
//
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
if (livekitState.status !== 'connected') {
|
|
1332
|
-
emptyPolls = 0;
|
|
1333
|
-
return;
|
|
1334
|
-
}
|
|
1335
|
-
if (room.remoteParticipants.size > 0) {
|
|
1336
|
-
emptyPolls = 0;
|
|
1337
|
-
return;
|
|
1338
|
-
}
|
|
1339
|
-
emptyPolls++;
|
|
1340
|
-
const emptyForMs = emptyPolls * WATCHDOG_POLL_MS;
|
|
1341
|
-
if (emptyForMs >= ALONE_GRACE_MS) {
|
|
1342
|
-
console.log(`🧟 [ZOMBIE-WATCHDOG] room empty for ${emptyPolls} consecutive polls (${emptyForMs / 1000}s) — forcing leave`);
|
|
1343
|
-
emptyPolls = 0;
|
|
1344
|
-
intentionalLeave = true;
|
|
1345
|
-
// Update state FIRST, don't wait for the Disconnected event — a zombie
|
|
1346
|
-
// WS is precisely the case where that event never arrives. Observed
|
|
1347
|
-
// 2026-07-27: after a forced leave the status stayed 'connected', so
|
|
1348
|
-
// /health lied, /connect-room no-op'd, and the next user joined a room
|
|
1349
|
-
// the agent wasn't in (UI stuck at "Connecting..."). Setting idle here
|
|
1350
|
-
// makes the next /connect-room actually rejoin. If the Disconnected
|
|
1351
|
-
// event does fire later, its handler sets the same state — idempotent.
|
|
1352
|
-
livekitState.status = 'idle';
|
|
1353
|
-
const disconnectWatch = setTimeout(() => console.error('🧟 [ZOMBIE-WATCHDOG] room.disconnect() still pending after 10s — WS likely zombied; idle-exit timer is the backstop'), 10_000);
|
|
1354
|
-
room.disconnect()
|
|
1355
|
-
.then(() => { clearTimeout(disconnectWatch); console.log('🧟 [ZOMBIE-WATCHDOG] forced leave completed'); })
|
|
1356
|
-
.catch((e) => { clearTimeout(disconnectWatch); console.error('watchdog room.disconnect failed:', e); });
|
|
1357
|
-
// Belt-and-suspenders: if the WS is zombied the Disconnected event may
|
|
1358
|
-
// never fire (so the idle-exit arm in that handler won't run). Arm here too.
|
|
1359
|
-
armIdleExitTimer('zombie-watchdog forced leave');
|
|
1360
|
-
}
|
|
1361
|
-
}
|
|
1362
|
-
catch (err) {
|
|
1363
|
-
console.error('🧟 [ZOMBIE-WATCHDOG] poll error:', err instanceof Error ? err.message : err);
|
|
1364
|
-
}
|
|
1365
|
-
}, WATCHDOG_POLL_MS);
|
|
1366
|
-
// ── 0.9.80: Event-loss adopter (poll-based) ──
|
|
1367
|
-
// After a leave/rejoin cycle the same Room instance can stop delivering
|
|
1368
|
-
// ParticipantConnected entirely — observed 2026-07-28: the leave-room guard
|
|
1369
|
-
// saw "1 participant(s) still in room" while the agent sat at "Waiting for
|
|
1370
|
-
// user to connect" (state knew, events never fired; user stuck on
|
|
1371
|
-
// "Connecting..." forever). Same blind-spot family as the zombie watchdog:
|
|
1372
|
-
// never trust the event stream alone. Every 5s: participant present but no
|
|
1373
|
-
// voice session → re-emit ParticipantConnected (60s per-identity cooldown
|
|
1374
|
-
// so a slow in-flight session creation isn't double-fired).
|
|
1375
|
-
const adoptAttempts = new Map();
|
|
1376
|
-
setInterval(() => {
|
|
1377
|
-
try {
|
|
1378
|
-
if (livekitState.status !== 'connected')
|
|
1379
|
-
return;
|
|
1380
|
-
if (currentSession)
|
|
1381
|
-
return;
|
|
1382
|
-
const p = room.remoteParticipants.values().next().value;
|
|
1383
|
-
if (!p)
|
|
1384
|
-
return;
|
|
1385
|
-
const last = adoptAttempts.get(p.identity) ?? 0;
|
|
1386
|
-
if (Date.now() - last < 60_000)
|
|
1387
|
-
return;
|
|
1388
|
-
adoptAttempts.set(p.identity, Date.now());
|
|
1389
|
-
console.log(`📥 [ADOPT-POLL] ${p.identity} present with no session — invoking ParticipantConnected handler directly`);
|
|
1390
|
-
participantConnectedHandler?.(p).catch((e) => console.error('📥 [ADOPT-POLL] handler error:', e instanceof Error ? e.message : e));
|
|
1391
|
-
}
|
|
1392
|
-
catch (err) {
|
|
1393
|
-
console.error('📥 [ADOPT-POLL] error:', err instanceof Error ? err.message : err);
|
|
1394
|
-
}
|
|
1395
|
-
}, 5_000);
|
|
1378
|
+
// ── 0.9.83: Zombie-presence watchdog + event-loss adopter DELETED ──
|
|
1379
|
+
// Both poll-based layers existed solely to compensate for Room-reuse damage:
|
|
1380
|
+
// • zombie-watchdog: RoomEvent callbacks stopped firing after a leave/rejoin
|
|
1381
|
+
// cycle because Room.disconnect() had already run removeAllListeners()
|
|
1382
|
+
// (room.js:584), so even real disconnects went unobserved.
|
|
1383
|
+
// • adopt-poll: ParticipantConnected stopped being delivered on a reused
|
|
1384
|
+
// Room for the same reason.
|
|
1385
|
+
// Under fresh-Room-per-session (createRoomSession / destroyRoomSession) the
|
|
1386
|
+
// event stream is trustworthy again — every session gets a Room with freshly
|
|
1387
|
+
// wired handlers, and we NEVER reuse a Room after disconnect. The legitimate
|
|
1388
|
+
// race the adopt-poll also covered (a participant already in the room at join
|
|
1389
|
+
// time, who fires no ParticipantConnected event) is now handled once, at the
|
|
1390
|
+
// right moment, by the adopt-sweep inside createRoomSession(). The idle-exit
|
|
1391
|
+
// timer (above) remains as the billing backstop.
|
|
1396
1392
|
/**
|
|
1397
1393
|
* Hard-kill the in-flight Claude SDK query AND the persistent subprocess.
|
|
1398
1394
|
*
|
|
@@ -2903,16 +2899,27 @@ async function main() {
|
|
|
2903
2899
|
return { session, agent };
|
|
2904
2900
|
}
|
|
2905
2901
|
// ============================================================
|
|
2906
|
-
// Room Event Handlers
|
|
2902
|
+
// Room Event Handlers (0.9.83: registered per-session via wireRoomHandlers)
|
|
2907
2903
|
// ============================================================
|
|
2908
|
-
|
|
2904
|
+
//
|
|
2905
|
+
// Every handler BODY lives here in main()'s scope (closing over currentSession,
|
|
2906
|
+
// sendToFrontend, etc.). The actual `room.on(...)` REGISTRATION happens inside
|
|
2907
|
+
// wireRoomHandlers(room) — called once per fresh Room in createRoomSession().
|
|
2908
|
+
// We never re-use a Room after disconnect (rtc-node's Room.disconnect() runs
|
|
2909
|
+
// removeAllListeners() at room.js:584, wiping every listener), so handlers are
|
|
2910
|
+
// wired fresh onto each new instance.
|
|
2911
|
+
// Post-connect setup. RoomEvent.Connected is NEVER emitted by rtc-node (grep
|
|
2912
|
+
// of dist finds zero emit("connected") — only connectionStateChanged), so this
|
|
2913
|
+
// body — which used to live in a dead RoomEvent.Connected handler — is now
|
|
2914
|
+
// called directly from createRoomSession() after connect() resolves.
|
|
2915
|
+
const postConnectSetup = (room, connectedRoomName) => {
|
|
2909
2916
|
// 0.9.68: log Room SID + name PROMINENTLY so we can cross-reference
|
|
2910
2917
|
// this specific session in LiveKit Cloud dashboard → Sessions tab.
|
|
2911
2918
|
// @livekit/rtc-node Room exposes SID via async getSid() (it's resolved
|
|
2912
2919
|
// after WebRTC handshake), so we fetch it asynchronously and log when ready.
|
|
2913
|
-
console.log(`✅ Connected to room: ${
|
|
2920
|
+
console.log(`✅ Connected to room: ${connectedRoomName} | t=${new Date().toISOString()}`);
|
|
2914
2921
|
room.getSid().then((sid) => {
|
|
2915
|
-
console.log(`🔗 [LIVEKIT-DASHBOARD] room sid=${sid} name=${
|
|
2922
|
+
console.log(`🔗 [LIVEKIT-DASHBOARD] room sid=${sid} name=${connectedRoomName} — search at https://cloud.livekit.io/projects → Sessions → "${sid}"`);
|
|
2916
2923
|
}).catch((err) => {
|
|
2917
2924
|
console.log(`⚠️ [LIVEKIT-DASHBOARD] failed to fetch room SID: ${err instanceof Error ? err.message : String(err)}`);
|
|
2918
2925
|
});
|
|
@@ -2950,7 +2957,7 @@ async function main() {
|
|
|
2950
2957
|
// window (e.g. machine woken then abandoned mid-handshake), leave the room
|
|
2951
2958
|
// rather than hold it indefinitely. Cancelled in ParticipantConnected.
|
|
2952
2959
|
armAloneTimer();
|
|
2953
|
-
}
|
|
2960
|
+
};
|
|
2954
2961
|
// NOTE: previously this section also had a RoomEvent.ActiveSpeakersChanged
|
|
2955
2962
|
// handler that interrupted TTS on any sustained audio activity (~50ms after
|
|
2956
2963
|
// mic onset). That fired too eagerly — coughs, paper rustles, the agent's
|
|
@@ -2961,48 +2968,14 @@ async function main() {
|
|
|
2961
2968
|
// Flux STT's speech-vs-noise classification: slower (~100-300ms) but
|
|
2962
2969
|
// confidence-aware. The latency tradeoff is worth eliminating the false
|
|
2963
2970
|
// interrupts at the root.
|
|
2964
|
-
// 0.9.
|
|
2965
|
-
//
|
|
2966
|
-
//
|
|
2967
|
-
|
|
2968
|
-
|
|
2969
|
-
|
|
2970
|
-
console.log(`🎙️ [ROOM-SPEAKERS] count=${ids.length} ids=${JSON.stringify(ids)} t=${new Date().toISOString()}`);
|
|
2971
|
-
}
|
|
2972
|
-
catch { }
|
|
2973
|
-
});
|
|
2974
|
-
room.on(RoomEvent.ConnectionQualityChanged, (quality, participant) => {
|
|
2975
|
-
try {
|
|
2976
|
-
console.log(`📶 [ROOM-QUALITY] participant=${participant?.identity} quality=${quality} t=${new Date().toISOString()}`);
|
|
2977
|
-
}
|
|
2978
|
-
catch { }
|
|
2979
|
-
});
|
|
2980
|
-
room.on(RoomEvent.TrackMuted, (publication, participant) => {
|
|
2981
|
-
try {
|
|
2982
|
-
console.log(`🔇 [ROOM-TRACK-MUTED] participant=${participant?.identity} kind=${publication?.kind} source=${publication?.source} sid=${publication?.sid} t=${new Date().toISOString()}`);
|
|
2983
|
-
}
|
|
2984
|
-
catch { }
|
|
2985
|
-
});
|
|
2986
|
-
room.on(RoomEvent.TrackUnmuted, (publication, participant) => {
|
|
2987
|
-
try {
|
|
2988
|
-
console.log(`🔊 [ROOM-TRACK-UNMUTED] participant=${participant?.identity} kind=${publication?.kind} source=${publication?.source} sid=${publication?.sid} t=${new Date().toISOString()}`);
|
|
2989
|
-
}
|
|
2990
|
-
catch { }
|
|
2991
|
-
});
|
|
2992
|
-
room.on(RoomEvent.TrackSubscribed, (track, publication, participant) => {
|
|
2993
|
-
try {
|
|
2994
|
-
console.log(`📥 [ROOM-TRACK-SUBSCRIBED] participant=${participant?.identity} kind=${track?.kind} source=${publication?.source} sid=${publication?.sid} t=${new Date().toISOString()}`);
|
|
2995
|
-
}
|
|
2996
|
-
catch { }
|
|
2997
|
-
});
|
|
2998
|
-
room.on(RoomEvent.TrackUnsubscribed, (track, publication, participant) => {
|
|
2999
|
-
try {
|
|
3000
|
-
console.log(`📤 [ROOM-TRACK-UNSUBSCRIBED] participant=${participant?.identity} kind=${track?.kind} source=${publication?.source} sid=${publication?.sid} t=${new Date().toISOString()}`);
|
|
3001
|
-
}
|
|
3002
|
-
catch { }
|
|
3003
|
-
});
|
|
3004
|
-
room.on(RoomEvent.Disconnected, () => {
|
|
2971
|
+
// Disconnected handler body (0.9.83: reworked for fresh-Room-per-session).
|
|
2972
|
+
// The Room instance that just fired this is ALREADY dead (rtc-node ran
|
|
2973
|
+
// cleanupOnDisconnect + removeAllListeners). We NEVER reconnect it. If the
|
|
2974
|
+
// disconnect was involuntary mid-session, we create a BRAND-NEW Room joining
|
|
2975
|
+
// the SAME LiveKit room name so any in-flight frontend token stays valid.
|
|
2976
|
+
const handleRoomDisconnected = () => {
|
|
3005
2977
|
console.log('👋 Disconnected from room');
|
|
2978
|
+
const disconnectedRoomName = activeRoomName;
|
|
3006
2979
|
// Clean up active research and voice queue
|
|
3007
2980
|
voiceQueue.length = 0;
|
|
3008
2981
|
isProcessingQueue = false;
|
|
@@ -3027,54 +3000,98 @@ async function main() {
|
|
|
3027
3000
|
currentLLM = null;
|
|
3028
3001
|
clearFastBrainSession();
|
|
3029
3002
|
clearPipelineFastBrainSession();
|
|
3030
|
-
// ──
|
|
3031
|
-
// When LiveKit Cloud evicts our WebSocket (idle, network blip, or quota window),
|
|
3032
|
-
// the previous code stopped here — agent process kept running but no longer in
|
|
3033
|
-
// any room. /health continued returning "livekit.status:connected" because the
|
|
3034
|
-
// status was never written back. Frontend's checkOsbornHealth only validates
|
|
3035
|
-
// HTTP 200, so the ghost state was invisible. Users got stuck in "Connecting..."
|
|
3036
|
-
// forever because their LiveKit-token-minted room had no agent in it.
|
|
3037
|
-
//
|
|
3038
|
-
// Fix: re-arm the retry loop. connectWithRetry() will try to reconnect with
|
|
3039
|
-
// the same room name (so the room code stays stable for any in-flight frontend
|
|
3040
|
-
// token requests), backing off 5s → 60s. If the disconnect was permanent
|
|
3041
|
-
// (e.g. JWT expired — they're 24h), the retry will fail and surface
|
|
3042
|
-
// livekit.status=failed, which the (also-fixed) frontend health check will
|
|
3043
|
-
// see and trigger restartService.
|
|
3044
|
-
//
|
|
3045
|
-
// Note: we mark status='retrying' immediately so /health reflects the real
|
|
3046
|
-
// state — closing the lie window between Disconnected and the next attempt.
|
|
3047
|
-
// ── Voluntary-leave guard (2026-06-09) ──
|
|
3003
|
+
// ── Voluntary-leave guard ──
|
|
3048
3004
|
// If we left the room ON PURPOSE (user clicked leave → /leave-room, or the
|
|
3049
3005
|
// agent-side alone timer fired), do NOT auto-rejoin — rejoining would
|
|
3050
|
-
// recreate the connection-minute burn we just stopped.
|
|
3051
|
-
//
|
|
3052
|
-
//
|
|
3006
|
+
// recreate the connection-minute burn we just stopped. The
|
|
3007
|
+
// destroyRoomSession() path already set intentionalLeave and will null out
|
|
3008
|
+
// activeRoom + arm idle-exit; here we just do bookkeeping and bail. Reset
|
|
3009
|
+
// the flag so a later involuntary drop still rejoins.
|
|
3053
3010
|
if (intentionalLeave) {
|
|
3054
3011
|
intentionalLeave = false;
|
|
3055
3012
|
livekitState.status = 'idle';
|
|
3056
3013
|
livekitState.error = null;
|
|
3057
3014
|
livekitState.errorCode = null;
|
|
3015
|
+
// The Room instance is dead. Drop our reference so nothing reuses it.
|
|
3016
|
+
activeRoom = null;
|
|
3017
|
+
activeRoomName = null;
|
|
3058
3018
|
console.log('🕊️ Left LiveKit room intentionally — idle, awaiting /connect-room (no auto-rejoin)');
|
|
3059
3019
|
// 0.9.73: idle machines must not bill forever — stop the Fly machine
|
|
3060
3020
|
// after the grace window unless a /connect-room revives us first.
|
|
3061
3021
|
armIdleExitTimer('intentional leave → idle');
|
|
3062
3022
|
return;
|
|
3063
3023
|
}
|
|
3024
|
+
// ── Involuntary drop (LiveKit evicted our WS: idle, network blip, quota) ──
|
|
3025
|
+
// The old code called room.connect() on the SAME (now-listener-less) Room —
|
|
3026
|
+
// which "succeeded" but produced a deaf agent every time (no events reached
|
|
3027
|
+
// the wiped listeners). Fix: discard the dead instance and create a FRESH
|
|
3028
|
+
// Room rejoining the SAME LiveKit room name so any in-flight frontend token
|
|
3029
|
+
// stays valid. status='retrying' closes the lie window until reconnect.
|
|
3030
|
+
activeRoom = null;
|
|
3031
|
+
activeRoomName = null;
|
|
3064
3032
|
livekitState.status = 'retrying';
|
|
3065
3033
|
livekitState.error = 'LiveKit room disconnected; attempting to rejoin';
|
|
3066
3034
|
livekitState.errorCode = 'disconnected';
|
|
3067
|
-
console.log(
|
|
3068
|
-
|
|
3069
|
-
console.error('❌ Reconnect
|
|
3035
|
+
console.log(`🔄 Rejoining LiveKit room after involuntary disconnect (fresh Room, same name: ${disconnectedRoomName})...`);
|
|
3036
|
+
createRoomSession(disconnectedRoomName || buildRoomName()).catch(err => {
|
|
3037
|
+
console.error('❌ Reconnect createRoomSession threw:', err);
|
|
3038
|
+
livekitState.status = 'failed';
|
|
3039
|
+
livekitState.error = err instanceof Error ? err.message : String(err);
|
|
3070
3040
|
});
|
|
3071
|
-
}
|
|
3072
|
-
//
|
|
3073
|
-
//
|
|
3074
|
-
//
|
|
3075
|
-
|
|
3076
|
-
|
|
3077
|
-
|
|
3041
|
+
};
|
|
3042
|
+
// wireRoomHandlers(room): register EVERY room.on(...) listener onto a fresh
|
|
3043
|
+
// Room instance. Called once per session from createRoomSession(). The bodies
|
|
3044
|
+
// close over main()-scope state; `room` is the fresh instance passed in.
|
|
3045
|
+
const wireRoomHandlers = (room) => {
|
|
3046
|
+
// 0.9.71: Room-level audio observability — observe-only logs so we can
|
|
3047
|
+
// cross-reference user mic mute/quality changes against TTS cutoffs without
|
|
3048
|
+
// re-introducing the over-eager ActiveSpeakers interrupt.
|
|
3049
|
+
room.on(RoomEvent.ActiveSpeakersChanged, (speakers) => {
|
|
3050
|
+
try {
|
|
3051
|
+
const ids = (speakers || []).map((s) => s?.identity).filter(Boolean);
|
|
3052
|
+
console.log(`🎙️ [ROOM-SPEAKERS] count=${ids.length} ids=${JSON.stringify(ids)} t=${new Date().toISOString()}`);
|
|
3053
|
+
}
|
|
3054
|
+
catch { }
|
|
3055
|
+
});
|
|
3056
|
+
room.on(RoomEvent.ConnectionQualityChanged, (quality, participant) => {
|
|
3057
|
+
try {
|
|
3058
|
+
console.log(`📶 [ROOM-QUALITY] participant=${participant?.identity} quality=${quality} t=${new Date().toISOString()}`);
|
|
3059
|
+
}
|
|
3060
|
+
catch { }
|
|
3061
|
+
});
|
|
3062
|
+
room.on(RoomEvent.TrackMuted, (publication, participant) => {
|
|
3063
|
+
try {
|
|
3064
|
+
console.log(`🔇 [ROOM-TRACK-MUTED] participant=${participant?.identity} kind=${publication?.kind} source=${publication?.source} sid=${publication?.sid} t=${new Date().toISOString()}`);
|
|
3065
|
+
}
|
|
3066
|
+
catch { }
|
|
3067
|
+
});
|
|
3068
|
+
room.on(RoomEvent.TrackUnmuted, (publication, participant) => {
|
|
3069
|
+
try {
|
|
3070
|
+
console.log(`🔊 [ROOM-TRACK-UNMUTED] participant=${participant?.identity} kind=${publication?.kind} source=${publication?.source} sid=${publication?.sid} t=${new Date().toISOString()}`);
|
|
3071
|
+
}
|
|
3072
|
+
catch { }
|
|
3073
|
+
});
|
|
3074
|
+
room.on(RoomEvent.TrackSubscribed, (track, publication, participant) => {
|
|
3075
|
+
try {
|
|
3076
|
+
console.log(`📥 [ROOM-TRACK-SUBSCRIBED] participant=${participant?.identity} kind=${track?.kind} source=${publication?.source} sid=${publication?.sid} t=${new Date().toISOString()}`);
|
|
3077
|
+
}
|
|
3078
|
+
catch { }
|
|
3079
|
+
});
|
|
3080
|
+
room.on(RoomEvent.TrackUnsubscribed, (track, publication, participant) => {
|
|
3081
|
+
try {
|
|
3082
|
+
console.log(`📤 [ROOM-TRACK-UNSUBSCRIBED] participant=${participant?.identity} kind=${track?.kind} source=${publication?.source} sid=${publication?.sid} t=${new Date().toISOString()}`);
|
|
3083
|
+
}
|
|
3084
|
+
catch { }
|
|
3085
|
+
});
|
|
3086
|
+
room.on(RoomEvent.Disconnected, handleRoomDisconnected);
|
|
3087
|
+
room.on(RoomEvent.ParticipantConnected, (p) => { participantConnectedHandler?.(p); });
|
|
3088
|
+
room.on(RoomEvent.ParticipantDisconnected, handleParticipantDisconnected);
|
|
3089
|
+
room.on(RoomEvent.DataReceived, handleDataReceived);
|
|
3090
|
+
};
|
|
3091
|
+
// 0.9.83: handler kept as a named reference so createRoomSession's adopt-sweep
|
|
3092
|
+
// can CALL it directly for participants already in the room at join time
|
|
3093
|
+
// (they fire no ParticipantConnected event — rtc-node populates them from the
|
|
3094
|
+
// connect callback, not via events).
|
|
3078
3095
|
participantConnectedHandler = async (participant) => {
|
|
3079
3096
|
console.log(`\n👤 User joined: ${participant.identity}`);
|
|
3080
3097
|
// A user is present — cancel any pending agent-side "alone" leave.
|
|
@@ -3082,6 +3099,8 @@ async function main() {
|
|
|
3082
3099
|
clearTimeout(aloneTimer);
|
|
3083
3100
|
aloneTimer = null;
|
|
3084
3101
|
}
|
|
3102
|
+
// 0.9.83: and the fast tab-close leave (user rejoined within the grace).
|
|
3103
|
+
cancelFastLeaveTimer();
|
|
3085
3104
|
// 0.9.73: and any pending idle machine self-stop.
|
|
3086
3105
|
cancelIdleExitTimer();
|
|
3087
3106
|
// Wait for previous session's byte stream handler to fully deregister.
|
|
@@ -3608,7 +3627,7 @@ async function main() {
|
|
|
3608
3627
|
console.log(`⚠️ Recovery too frequent — scheduling retry in ${MIN_RECOVERY_INTERVAL}ms`);
|
|
3609
3628
|
setTimeout(async () => {
|
|
3610
3629
|
// Re-check: if session was already recovered or user left, skip
|
|
3611
|
-
if (currentSession || !
|
|
3630
|
+
if (currentSession || !activeRoom || !activeRoom.remoteParticipants.size)
|
|
3612
3631
|
return;
|
|
3613
3632
|
console.log('🔄 Retrying direct mode recovery after guard interval...');
|
|
3614
3633
|
// Trigger recovery by emitting a synthetic close
|
|
@@ -3688,7 +3707,7 @@ async function main() {
|
|
|
3688
3707
|
currentAgent = newAgent;
|
|
3689
3708
|
// Re-wire event listeners on the new session
|
|
3690
3709
|
wireSessionEvents(newSession, newAgent);
|
|
3691
|
-
await newSession.start({ agent: newAgent, room });
|
|
3710
|
+
await newSession.start({ agent: newAgent, room: activeRoom });
|
|
3692
3711
|
// Sync state
|
|
3693
3712
|
agentState = 'listening';
|
|
3694
3713
|
sendToFrontend({ type: 'agent_state', state: 'listening' });
|
|
@@ -3767,7 +3786,7 @@ async function main() {
|
|
|
3767
3786
|
currentAgent = newAgent;
|
|
3768
3787
|
// Re-wire event listeners on the new session
|
|
3769
3788
|
wireSessionEvents(newSession, newAgent);
|
|
3770
|
-
await newSession.start({ agent: newAgent, room });
|
|
3789
|
+
await newSession.start({ agent: newAgent, room: activeRoom });
|
|
3771
3790
|
// Sync state
|
|
3772
3791
|
agentState = 'listening';
|
|
3773
3792
|
sendToFrontend({ type: 'agent_state', state: 'listening' });
|
|
@@ -3807,7 +3826,7 @@ async function main() {
|
|
|
3807
3826
|
// Start voice session
|
|
3808
3827
|
console.log('🎬 Starting voice session...');
|
|
3809
3828
|
try {
|
|
3810
|
-
await session.start({ agent, room });
|
|
3829
|
+
await session.start({ agent, room: activeRoom });
|
|
3811
3830
|
console.log('✅ Voice session started!');
|
|
3812
3831
|
console.log('🎤 Ready - speak to begin!\n');
|
|
3813
3832
|
// Workspace is created later in the session_id event handler (when SDK assigns real ID)
|
|
@@ -3938,8 +3957,9 @@ async function main() {
|
|
|
3938
3957
|
console.error('❌ Failed to start session:', err);
|
|
3939
3958
|
}
|
|
3940
3959
|
};
|
|
3941
|
-
|
|
3942
|
-
|
|
3960
|
+
// ParticipantConnected is registered in wireRoomHandlers (delegates to
|
|
3961
|
+
// participantConnectedHandler above).
|
|
3962
|
+
const handleParticipantDisconnected = (participant) => {
|
|
3943
3963
|
console.log(`👋 User left: ${participant.identity}`);
|
|
3944
3964
|
// Full cleanup — stop all background work to avoid accumulating API usage
|
|
3945
3965
|
voiceQueue.length = 0;
|
|
@@ -3992,13 +4012,14 @@ async function main() {
|
|
|
3992
4012
|
activeMeetingBotId = null;
|
|
3993
4013
|
}
|
|
3994
4014
|
}
|
|
3995
|
-
//
|
|
3996
|
-
//
|
|
3997
|
-
//
|
|
3998
|
-
|
|
4015
|
+
// 0.9.83: a real session just ended → use the FAST leave (~20s), not the
|
|
4016
|
+
// 3-min alone grace. Runs on the agent, so it fires even on an abrupt tab
|
|
4017
|
+
// close. Cancelled if a user rejoins within the grace (handled where the
|
|
4018
|
+
// participant-join cancels timers).
|
|
4019
|
+
armFastLeaveTimer();
|
|
3999
4020
|
console.log('⏳ Waiting for new user...\n');
|
|
4000
|
-
}
|
|
4001
|
-
|
|
4021
|
+
};
|
|
4022
|
+
const handleDataReceived = async (payload, participant, kind, topic) => {
|
|
4002
4023
|
if (topic !== 'user-input')
|
|
4003
4024
|
return;
|
|
4004
4025
|
try {
|
|
@@ -4752,133 +4773,199 @@ async function main() {
|
|
|
4752
4773
|
}
|
|
4753
4774
|
}
|
|
4754
4775
|
catch { }
|
|
4755
|
-
}
|
|
4776
|
+
};
|
|
4756
4777
|
// ============================================================
|
|
4757
|
-
//
|
|
4778
|
+
// Room session lifecycle (0.9.83): fresh Room per user session
|
|
4758
4779
|
// ============================================================
|
|
4759
|
-
//
|
|
4760
|
-
//
|
|
4761
|
-
//
|
|
4762
|
-
//
|
|
4763
|
-
// (a) hit the same 429 / auth error every ~30s, (b) burned the LiveKit
|
|
4764
|
-
// quota's retry budget, and (c) hit Fly's max-restart-count (3) and killed
|
|
4765
|
-
// the machine — at which point the frontend's /api/sandbox probe saw the
|
|
4766
|
-
// sandbox as failed and bounced the user back to the dashboard with no
|
|
4767
|
-
// useful error.
|
|
4780
|
+
// createRoomSession(roomName): mint a FRESH Room, wire ALL handlers onto it,
|
|
4781
|
+
// mint a room-scoped JWT, connect with a bounded retry, run the post-connect
|
|
4782
|
+
// setup + adopt-sweep, and mark status='connected'. The caller AWAITS this so
|
|
4783
|
+
// /connect-room can respond only once the agent is actually in the room.
|
|
4768
4784
|
//
|
|
4769
|
-
//
|
|
4770
|
-
//
|
|
4771
|
-
//
|
|
4772
|
-
//
|
|
4773
|
-
//
|
|
4774
|
-
|
|
4775
|
-
|
|
4776
|
-
|
|
4777
|
-
|
|
4778
|
-
|
|
4779
|
-
|
|
4780
|
-
|
|
4781
|
-
|
|
4782
|
-
|
|
4783
|
-
|
|
4784
|
-
|
|
4785
|
-
|
|
4786
|
-
try {
|
|
4787
|
-
await room.connect(livekitUrl, jwt, {
|
|
4788
|
-
autoSubscribe: true,
|
|
4789
|
-
dynacast: true,
|
|
4790
|
-
});
|
|
4791
|
-
localParticipant = room.localParticipant;
|
|
4792
|
-
livekitState.status = 'connected';
|
|
4793
|
-
livekitState.error = null;
|
|
4794
|
-
livekitState.errorCode = null;
|
|
4795
|
-
backoffIdx = 0;
|
|
4796
|
-
console.log('✅ Connected to room:', roomName);
|
|
4797
|
-
console.log('\n⏳ Waiting for user to connect...');
|
|
4798
|
-
console.log(` Room: ${roomCode}\n`);
|
|
4799
|
-
return;
|
|
4785
|
+
// Bounded retry (3 attempts, backoff 5s→10s→20s): the caller is awaiting, so
|
|
4786
|
+
// we can't loop forever like the old boot-time connectWithRetry did. If all
|
|
4787
|
+
// attempts fail we surface status='failed' and rethrow so the caller sees it.
|
|
4788
|
+
// A single transient blip is absorbed; a persistent failure (quota/auth) is
|
|
4789
|
+
// reported promptly rather than hanging the connect flow.
|
|
4790
|
+
let creatingRoomSession = false;
|
|
4791
|
+
const createRoomSession = async (targetRoomName) => {
|
|
4792
|
+
if (creatingRoomSession) {
|
|
4793
|
+
console.log('⏳ createRoomSession already in progress — skipping duplicate');
|
|
4794
|
+
return;
|
|
4795
|
+
}
|
|
4796
|
+
creatingRoomSession = true;
|
|
4797
|
+
try {
|
|
4798
|
+
// Discard any prior (dead/stale) instance without reuse.
|
|
4799
|
+
if (activeRoom) {
|
|
4800
|
+
console.warn('⚠️ createRoomSession called with an existing activeRoom — discarding it (no reuse)');
|
|
4801
|
+
activeRoom = null;
|
|
4800
4802
|
}
|
|
4801
|
-
|
|
4802
|
-
|
|
4803
|
-
|
|
4804
|
-
|
|
4805
|
-
|
|
4806
|
-
|
|
4807
|
-
|
|
4808
|
-
|
|
4809
|
-
|
|
4810
|
-
|
|
4811
|
-
|
|
4812
|
-
|
|
4813
|
-
|
|
4814
|
-
|
|
4815
|
-
|
|
4816
|
-
|
|
4817
|
-
|
|
4818
|
-
|
|
4819
|
-
|
|
4820
|
-
|
|
4821
|
-
|
|
4822
|
-
|
|
4823
|
-
|
|
4803
|
+
const room = new Room();
|
|
4804
|
+
room.setMaxListeners(50); // Prevent MaxListenersExceeded warnings
|
|
4805
|
+
wireRoomHandlers(room);
|
|
4806
|
+
const backoffSchedule = [5_000, 10_000, 20_000];
|
|
4807
|
+
let lastErr = null;
|
|
4808
|
+
for (let attempt = 0; attempt < 3; attempt++) {
|
|
4809
|
+
livekitState.status = livekitState.attemptCount === 0 ? 'connecting' : 'retrying';
|
|
4810
|
+
livekitState.lastAttemptAt = Date.now();
|
|
4811
|
+
livekitState.attemptCount += 1;
|
|
4812
|
+
try {
|
|
4813
|
+
const jwt = await mintAgentToken(targetRoomName);
|
|
4814
|
+
await room.connect(livekitUrl, jwt, {
|
|
4815
|
+
autoSubscribe: true,
|
|
4816
|
+
dynacast: true,
|
|
4817
|
+
});
|
|
4818
|
+
// Success — adopt this instance as the active room.
|
|
4819
|
+
activeRoom = room;
|
|
4820
|
+
activeRoomName = targetRoomName;
|
|
4821
|
+
currentRoomCode = targetRoomName; // legacy /room-code returns last-created NAME
|
|
4822
|
+
localParticipant = room.localParticipant;
|
|
4823
|
+
livekitState.status = 'connected';
|
|
4824
|
+
livekitState.error = null;
|
|
4825
|
+
livekitState.errorCode = null;
|
|
4826
|
+
console.log('✅ Connected to room:', targetRoomName);
|
|
4827
|
+
// Post-connect setup (re-homed from the dead RoomEvent.Connected handler).
|
|
4828
|
+
postConnectSetup(room, targetRoomName);
|
|
4829
|
+
// Adopt-sweep: ParticipantConnected only fires for participants who join
|
|
4830
|
+
// AFTER us. Anyone already in the room at connect time is populated by
|
|
4831
|
+
// rtc-node from the connect callback (no event) — invoke the handler
|
|
4832
|
+
// directly so their voice session is created. Runs exactly once per
|
|
4833
|
+
// connect (replaces the old adopt-poll + connectRoomHook adopt loop).
|
|
4834
|
+
for (const p of room.remoteParticipants.values()) {
|
|
4835
|
+
console.log(`👥 Participant already in room at join: ${p.identity} — adopting (invoking handler directly)`);
|
|
4836
|
+
participantConnectedHandler?.(p).catch((e) => console.error('👥 adopt handler error:', e instanceof Error ? e.message : e));
|
|
4837
|
+
}
|
|
4838
|
+
console.log('\n⏳ Waiting for user to connect...');
|
|
4839
|
+
console.log(` Room: ${targetRoomName}\n`);
|
|
4840
|
+
return;
|
|
4841
|
+
}
|
|
4842
|
+
catch (err) {
|
|
4843
|
+
lastErr = err;
|
|
4844
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
4845
|
+
// Categorize the error so the frontend can show specific guidance.
|
|
4846
|
+
let errorCode;
|
|
4847
|
+
if (/429|connection minutes limit/i.test(msg))
|
|
4848
|
+
errorCode = 'quota_exceeded';
|
|
4849
|
+
else if (/401|403|unauthorized|invalid/i.test(msg))
|
|
4850
|
+
errorCode = 'auth';
|
|
4851
|
+
else if (/ENOTFOUND|ECONNREFUSED|ETIMEDOUT|network/i.test(msg))
|
|
4852
|
+
errorCode = 'network';
|
|
4853
|
+
else
|
|
4854
|
+
errorCode = 'unknown';
|
|
4855
|
+
livekitState.status = 'failed';
|
|
4856
|
+
livekitState.error = msg;
|
|
4857
|
+
livekitState.errorCode = errorCode;
|
|
4858
|
+
console.error(`❌ LiveKit connect failed (${errorCode}, attempt ${attempt + 1}/3): ${msg.substring(0, 200)}`);
|
|
4859
|
+
if (attempt < 2) {
|
|
4860
|
+
const waitMs = backoffSchedule[attempt];
|
|
4861
|
+
console.error(` Retrying in ${waitMs / 1000}s...`);
|
|
4862
|
+
await new Promise(r => setTimeout(r, waitMs));
|
|
4863
|
+
}
|
|
4864
|
+
}
|
|
4824
4865
|
}
|
|
4866
|
+
// All attempts failed. Drop the dead instance; leave status='failed' so
|
|
4867
|
+
// /health surfaces it and the frontend can retry /connect-room.
|
|
4868
|
+
activeRoom = null;
|
|
4869
|
+
activeRoomName = null;
|
|
4870
|
+
console.error('❌ createRoomSession: all 3 connect attempts failed — giving up (status=failed)');
|
|
4871
|
+
throw lastErr instanceof Error ? lastErr : new Error(String(lastErr));
|
|
4872
|
+
}
|
|
4873
|
+
finally {
|
|
4874
|
+
creatingRoomSession = false;
|
|
4825
4875
|
}
|
|
4826
4876
|
};
|
|
4827
|
-
//
|
|
4828
|
-
//
|
|
4829
|
-
//
|
|
4830
|
-
|
|
4831
|
-
|
|
4877
|
+
// destroyRoomSession(reason): tear down the active room session and DISCARD
|
|
4878
|
+
// the Room instance (never reused). A hung rtc-node disconnect() (signal-less
|
|
4879
|
+
// waitFor at room.js:579) is made harmless by racing it against a 10s timeout:
|
|
4880
|
+
// on timeout we just log and drop the instance — nothing reuses it, so a
|
|
4881
|
+
// zombie WS at the Rust layer can't wedge the next session. Idempotent.
|
|
4882
|
+
let destroyingRoomSession = false;
|
|
4883
|
+
const destroyRoomSession = async (reason) => {
|
|
4884
|
+
if (destroyingRoomSession)
|
|
4885
|
+
return;
|
|
4886
|
+
if (!activeRoom) {
|
|
4887
|
+
// Already idle — still ensure billing backstop is armed.
|
|
4888
|
+
livekitState.status = 'idle';
|
|
4889
|
+
armIdleExitTimer(`destroyRoomSession(${reason}) — already idle`);
|
|
4890
|
+
return;
|
|
4891
|
+
}
|
|
4892
|
+
destroyingRoomSession = true;
|
|
4893
|
+
const room = activeRoom;
|
|
4894
|
+
// Drop the reference immediately so nothing (handlers, sweeps) touches it.
|
|
4895
|
+
activeRoom = null;
|
|
4896
|
+
activeRoomName = null;
|
|
4832
4897
|
if (aloneTimer) {
|
|
4833
4898
|
clearTimeout(aloneTimer);
|
|
4834
4899
|
aloneTimer = null;
|
|
4835
4900
|
}
|
|
4836
|
-
|
|
4901
|
+
cancelFastLeaveTimer();
|
|
4902
|
+
intentionalLeave = true; // suppress the Disconnected handler's auto-rejoin
|
|
4903
|
+
console.log(`🚪 Destroying room session (${reason}) — disconnecting + discarding Room instance`);
|
|
4904
|
+
try {
|
|
4905
|
+
const timeout = new Promise((resolve) => setTimeout(() => resolve('timeout'), 10_000));
|
|
4906
|
+
const result = await Promise.race([room.disconnect().then(() => 'ok'), timeout]);
|
|
4907
|
+
if (result === 'timeout') {
|
|
4908
|
+
console.error('⚠️ room.disconnect() still pending after 10s (zombie WS) — abandoning the instance');
|
|
4909
|
+
try {
|
|
4910
|
+
room.ffiHandle?.dispose?.();
|
|
4911
|
+
}
|
|
4912
|
+
catch { }
|
|
4913
|
+
}
|
|
4914
|
+
}
|
|
4915
|
+
catch (e) {
|
|
4916
|
+
console.error('destroyRoomSession room.disconnect failed:', e);
|
|
4917
|
+
}
|
|
4918
|
+
livekitState.status = 'idle';
|
|
4919
|
+
livekitState.error = null;
|
|
4920
|
+
livekitState.errorCode = null;
|
|
4921
|
+
armIdleExitTimer(`destroyRoomSession(${reason})`);
|
|
4922
|
+
destroyingRoomSession = false;
|
|
4923
|
+
};
|
|
4924
|
+
// Wire the module-level HTTP control hooks now that the room-session lifecycle
|
|
4925
|
+
// functions exist. The /connect-room and /leave-room endpoints call these.
|
|
4926
|
+
//
|
|
4927
|
+
// NOTE (frontend migration): ChatSessionProvider should later call
|
|
4928
|
+
// POST /connect-room FIRST and mint its LiveKit token from the returned
|
|
4929
|
+
// { roomName } (which connectRoomHook resolves to). During rollout, the legacy
|
|
4930
|
+
// GET /room-code endpoint keeps returning the last-created room NAME so old
|
|
4931
|
+
// frontends still function.
|
|
4932
|
+
connectRoomHook = async () => {
|
|
4933
|
+
intentionalLeave = false;
|
|
4837
4934
|
cancelIdleExitTimer();
|
|
4838
|
-
|
|
4839
|
-
|
|
4840
|
-
|
|
4841
|
-
|
|
4842
|
-
|
|
4843
|
-
|
|
4844
|
-
|
|
4845
|
-
|
|
4846
|
-
|
|
4847
|
-
|
|
4848
|
-
for (const p of room.remoteParticipants.values()) {
|
|
4849
|
-
console.log(`👥 Participant already in room at join: ${p.identity} — adopting (invoking handler directly)`);
|
|
4850
|
-
participantConnectedHandler?.(p).catch((e) => console.error('👥 adopt handler error:', e instanceof Error ? e.message : e));
|
|
4935
|
+
// Idempotency: an active session with a user present is reused (return its
|
|
4936
|
+
// room name). An active-but-empty session (agent joined, user never showed,
|
|
4937
|
+
// or a stale race) is torn down and replaced with a fresh one.
|
|
4938
|
+
if (activeRoom && activeRoomName) {
|
|
4939
|
+
if (activeRoom.remoteParticipants.size > 0) {
|
|
4940
|
+
console.log(`🔌 /connect-room — already connected with a user present, reusing ${activeRoomName}`);
|
|
4941
|
+
return activeRoomName;
|
|
4942
|
+
}
|
|
4943
|
+
console.log('🔌 /connect-room — active room is empty; tearing down + creating fresh');
|
|
4944
|
+
await destroyRoomSession('connect-room replacing empty session');
|
|
4851
4945
|
}
|
|
4946
|
+
const targetRoomName = buildRoomName();
|
|
4947
|
+
console.log(`🔌 /connect-room — creating fresh room session: ${targetRoomName}`);
|
|
4948
|
+
await createRoomSession(targetRoomName);
|
|
4949
|
+
return targetRoomName;
|
|
4852
4950
|
};
|
|
4853
|
-
// 0.9.73: explicit frontend leave should also start the idle-exit countdown —
|
|
4854
|
-
// the user is done; the machine shouldn't idle-bill waiting for a maybe-return.
|
|
4855
|
-
// (leaveRoomHook sets intentionalLeave → Disconnected handler arms it, but
|
|
4856
|
-
// arm here too in case the disconnect event is swallowed by a zombie WS.)
|
|
4857
4951
|
leaveRoomHook = async (reason) => {
|
|
4858
4952
|
if (aloneTimer) {
|
|
4859
4953
|
clearTimeout(aloneTimer);
|
|
4860
4954
|
aloneTimer = null;
|
|
4861
4955
|
}
|
|
4862
|
-
if (
|
|
4956
|
+
if (!activeRoom)
|
|
4863
4957
|
return; // already out — no-op
|
|
4864
4958
|
// Never abandon a user who is CURRENTLY in the room. Stale/racing
|
|
4865
4959
|
// leave-room calls (a previous client's teardown landing after a new
|
|
4866
4960
|
// client joined) were kicking the agent out mid-adopt, stranding the new
|
|
4867
4961
|
// user on "Connecting..." (observed 2026-07-28). The alone-timer handles
|
|
4868
4962
|
// the real departure when they actually leave.
|
|
4869
|
-
if (
|
|
4870
|
-
console.log(`🛑 /leave-room ignored (${reason}) — ${
|
|
4963
|
+
if (activeRoom.remoteParticipants.size > 0) {
|
|
4964
|
+
console.log(`🛑 /leave-room ignored (${reason}) — ${activeRoom.remoteParticipants.size} participant(s) still in room`);
|
|
4871
4965
|
return;
|
|
4872
4966
|
}
|
|
4873
|
-
|
|
4874
|
-
|
|
4875
|
-
armIdleExitTimer(`explicit leave (${reason})`);
|
|
4876
|
-
try {
|
|
4877
|
-
await room.disconnect();
|
|
4878
|
-
}
|
|
4879
|
-
catch (e) {
|
|
4880
|
-
console.error('leave-room room.disconnect failed:', e);
|
|
4881
|
-
}
|
|
4967
|
+
console.log(`🚪 /leave-room (${reason}) — destroying room session`);
|
|
4968
|
+
await destroyRoomSession(`explicit leave (${reason})`);
|
|
4882
4969
|
};
|
|
4883
4970
|
// bug-reporter skill hook — forwards a validated bug payload to the frontend
|
|
4884
4971
|
// via the LiveKit data channel. Frontend (which holds the Supabase keys for
|
|
@@ -4914,18 +5001,19 @@ async function main() {
|
|
|
4914
5001
|
},
|
|
4915
5002
|
}).catch((e) => console.error('❌ bugReportHook sendToFrontend failed:', e));
|
|
4916
5003
|
};
|
|
4917
|
-
//
|
|
4918
|
-
//
|
|
4919
|
-
//
|
|
4920
|
-
//
|
|
4921
|
-
connectWithRetry().
|
|
4922
|
-
|
|
4923
|
-
|
|
4924
|
-
|
|
4925
|
-
|
|
4926
|
-
|
|
5004
|
+
// 0.9.83: boot IDLE — do NOT eager-connect to LiveKit at startup. Under the
|
|
5005
|
+
// fresh-Room-per-session model there is no persistent room to join at boot;
|
|
5006
|
+
// the agent only joins when a user arrives via POST /connect-room (which
|
|
5007
|
+
// awaits createRoomSession). This also removes the old boot-time
|
|
5008
|
+
// connectWithRetry() eager connect entirely. The idle-exit timer is armed so
|
|
5009
|
+
// a machine that boots but never receives a /connect-room still stops itself
|
|
5010
|
+
// (billing protection) instead of idling forever.
|
|
5011
|
+
livekitState.status = 'idle';
|
|
5012
|
+
console.log('🟢 Agent booted — idle, awaiting POST /connect-room to join LiveKit');
|
|
5013
|
+
armIdleExitTimer('boot idle (no session yet)');
|
|
4927
5014
|
// Keep main() alive forever — without this the await chain ends and Node
|
|
4928
|
-
// exits 0, which Fly treats as a clean shutdown.
|
|
5015
|
+
// exits 0, which Fly treats as a clean shutdown. The HTTP API server + timers
|
|
5016
|
+
// keep the process responsive.
|
|
4929
5017
|
await new Promise(() => { });
|
|
4930
5018
|
}
|
|
4931
5019
|
// Run
|