osborn 0.9.71 → 0.9.73
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 +97 -3
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -1221,6 +1221,88 @@ async function main() {
|
|
|
1221
1221
|
}
|
|
1222
1222
|
}, ALONE_GRACE_MS);
|
|
1223
1223
|
};
|
|
1224
|
+
// ── 0.9.73: Idle machine self-stop (the $123 Fly bill fix) ──
|
|
1225
|
+
// After an intentional alone-leave the agent used to sit "idle, awaiting
|
|
1226
|
+
// /connect-room" forever — process alive, Fly machine `started`, billing
|
|
1227
|
+
// performance-2x 24/7 (~$62/mo). Confirmed twice: room osborn-3h6htr held
|
|
1228
|
+
// a zombie WS Jun 18→22 (5,700+ min "In progress" on the LiveKit dashboard),
|
|
1229
|
+
// and the July invoice ($123.61) showed two 4GB machines running the whole
|
|
1230
|
+
// cycle. The machine's restart policy is `on-failure`, so `process.exit(0)`
|
|
1231
|
+
// cleanly STOPS the machine (billing stops, volume + JSONL persist) while
|
|
1232
|
+
// crashes still auto-restart. The frontend's startSandbox() boots a stopped
|
|
1233
|
+
// machine on the next Resume, so warm-idle buys nothing but cost.
|
|
1234
|
+
//
|
|
1235
|
+
// Armed when livekitState.status flips to 'idle' (intentional leave), and by
|
|
1236
|
+
// the zombie watchdog below. Cancelled implicitly: before exiting we re-check
|
|
1237
|
+
// that we're still idle/disconnected — any /connect-room in the window aborts.
|
|
1238
|
+
// Local dev (no FLY_APP_NAME) never exits. Override with OSBORN_IDLE_EXIT=0.
|
|
1239
|
+
const IDLE_EXIT_GRACE_MS = 10 * 60 * 1000; // 10 min: rides out a page refresh + re-auth
|
|
1240
|
+
let idleExitTimer = null;
|
|
1241
|
+
const armIdleExitTimer = (reason) => {
|
|
1242
|
+
if (!process.env.FLY_APP_NAME || process.env.OSBORN_IDLE_EXIT === '0')
|
|
1243
|
+
return;
|
|
1244
|
+
if (idleExitTimer)
|
|
1245
|
+
clearTimeout(idleExitTimer);
|
|
1246
|
+
console.log(`⏻ [IDLE-EXIT] armed (${IDLE_EXIT_GRACE_MS / 60000} min) — reason: ${reason}`);
|
|
1247
|
+
idleExitTimer = setTimeout(() => {
|
|
1248
|
+
idleExitTimer = null;
|
|
1249
|
+
const stillIdle = livekitState.status === 'idle' || livekitState.status === 'failed';
|
|
1250
|
+
const empty = room.remoteParticipants.size === 0;
|
|
1251
|
+
if (stillIdle && empty) {
|
|
1252
|
+
console.log(`⏻ [IDLE-EXIT] still idle after grace — exiting 0 so Fly stops the machine (billing stops; next Resume boots it)`);
|
|
1253
|
+
// Give the log tee a beat to flush, then stop the machine.
|
|
1254
|
+
setTimeout(() => process.exit(0), 2000);
|
|
1255
|
+
}
|
|
1256
|
+
else {
|
|
1257
|
+
console.log(`⏻ [IDLE-EXIT] aborted — status=${livekitState.status} participants=${room.remoteParticipants.size}`);
|
|
1258
|
+
}
|
|
1259
|
+
}, IDLE_EXIT_GRACE_MS);
|
|
1260
|
+
};
|
|
1261
|
+
const cancelIdleExitTimer = () => {
|
|
1262
|
+
if (idleExitTimer) {
|
|
1263
|
+
clearTimeout(idleExitTimer);
|
|
1264
|
+
idleExitTimer = null;
|
|
1265
|
+
console.log('⏻ [IDLE-EXIT] cancelled — activity resumed');
|
|
1266
|
+
}
|
|
1267
|
+
};
|
|
1268
|
+
// ── 0.9.73: Zombie-presence watchdog (poll-based) ──
|
|
1269
|
+
// The Jun 18 zombie proved the event-driven alone-timer has a blind spot:
|
|
1270
|
+
// when the LiveKit WS goes half-dead, RoomEvent callbacks stop firing entirely
|
|
1271
|
+
// (agent log went silent for 4 days while the dashboard logged "Participant
|
|
1272
|
+
// resumed" on every network blip). A poll can't be starved by a dead event
|
|
1273
|
+
// stream. Every 60s: if we believe we're connected but the room has no remote
|
|
1274
|
+
// participants, count it; after ALONE_GRACE_MS worth of consecutive empty
|
|
1275
|
+
// polls, force the same intentional-leave path the timer uses. If the
|
|
1276
|
+
// disconnect itself hangs (zombie WS), the idle-exit timer armed alongside
|
|
1277
|
+
// still stops the machine.
|
|
1278
|
+
const WATCHDOG_POLL_MS = 60 * 1000;
|
|
1279
|
+
let emptyPolls = 0;
|
|
1280
|
+
setInterval(() => {
|
|
1281
|
+
try {
|
|
1282
|
+
if (livekitState.status !== 'connected') {
|
|
1283
|
+
emptyPolls = 0;
|
|
1284
|
+
return;
|
|
1285
|
+
}
|
|
1286
|
+
if (room.remoteParticipants.size > 0) {
|
|
1287
|
+
emptyPolls = 0;
|
|
1288
|
+
return;
|
|
1289
|
+
}
|
|
1290
|
+
emptyPolls++;
|
|
1291
|
+
const emptyForMs = emptyPolls * WATCHDOG_POLL_MS;
|
|
1292
|
+
if (emptyForMs >= ALONE_GRACE_MS) {
|
|
1293
|
+
console.log(`🧟 [ZOMBIE-WATCHDOG] room empty for ${emptyPolls} consecutive polls (${emptyForMs / 1000}s) — forcing leave`);
|
|
1294
|
+
emptyPolls = 0;
|
|
1295
|
+
intentionalLeave = true;
|
|
1296
|
+
room.disconnect().catch((e) => console.error('watchdog room.disconnect failed:', e));
|
|
1297
|
+
// Belt-and-suspenders: if the WS is zombied the Disconnected event may
|
|
1298
|
+
// never fire (so the idle-exit arm in that handler won't run). Arm here too.
|
|
1299
|
+
armIdleExitTimer('zombie-watchdog forced leave');
|
|
1300
|
+
}
|
|
1301
|
+
}
|
|
1302
|
+
catch (err) {
|
|
1303
|
+
console.error('🧟 [ZOMBIE-WATCHDOG] poll error:', err instanceof Error ? err.message : err);
|
|
1304
|
+
}
|
|
1305
|
+
}, WATCHDOG_POLL_MS);
|
|
1224
1306
|
/**
|
|
1225
1307
|
* Hard-kill the in-flight Claude SDK query AND the persistent subprocess.
|
|
1226
1308
|
*
|
|
@@ -2170,9 +2252,9 @@ async function main() {
|
|
|
2170
2252
|
// a full 3s window to keep talking before deciding it was false and
|
|
2171
2253
|
// resuming. Other two knobs left at SDK defaults.
|
|
2172
2254
|
interruption: {
|
|
2173
|
-
minDuration:
|
|
2174
|
-
minWords:
|
|
2175
|
-
falseInterruptionTimeout:
|
|
2255
|
+
minDuration: 1500, // default 500 — require 1.5s sustained speech (faster barge-in than 2500)
|
|
2256
|
+
minWords: 2, // default 0 — require ≥2 transcript words
|
|
2257
|
+
falseInterruptionTimeout: 3500, // default 2000 — 3.5s false-interrupt window (belt-and-suspenders since minDuration was loosened)
|
|
2176
2258
|
// resumeFalseInterruption: true, // default true (unchanged)
|
|
2177
2259
|
// discardAudioIfUninterruptible: true,// default true (unchanged)
|
|
2178
2260
|
},
|
|
@@ -2884,6 +2966,9 @@ async function main() {
|
|
|
2884
2966
|
livekitState.error = null;
|
|
2885
2967
|
livekitState.errorCode = null;
|
|
2886
2968
|
console.log('🕊️ Left LiveKit room intentionally — idle, awaiting /connect-room (no auto-rejoin)');
|
|
2969
|
+
// 0.9.73: idle machines must not bill forever — stop the Fly machine
|
|
2970
|
+
// after the grace window unless a /connect-room revives us first.
|
|
2971
|
+
armIdleExitTimer('intentional leave → idle');
|
|
2887
2972
|
return;
|
|
2888
2973
|
}
|
|
2889
2974
|
livekitState.status = 'retrying';
|
|
@@ -2901,6 +2986,8 @@ async function main() {
|
|
|
2901
2986
|
clearTimeout(aloneTimer);
|
|
2902
2987
|
aloneTimer = null;
|
|
2903
2988
|
}
|
|
2989
|
+
// 0.9.73: and any pending idle machine self-stop.
|
|
2990
|
+
cancelIdleExitTimer();
|
|
2904
2991
|
// Wait for previous session's byte stream handler to fully deregister.
|
|
2905
2992
|
// Quick reconnects (< ~6s) crash with "byte stream handler already set" without this.
|
|
2906
2993
|
if (pendingSessionClose) {
|
|
@@ -4622,11 +4709,17 @@ async function main() {
|
|
|
4622
4709
|
clearTimeout(aloneTimer);
|
|
4623
4710
|
aloneTimer = null;
|
|
4624
4711
|
}
|
|
4712
|
+
// 0.9.73: a user is on the way — cancel any pending idle machine self-stop.
|
|
4713
|
+
cancelIdleExitTimer();
|
|
4625
4714
|
if (livekitState.status === 'connected')
|
|
4626
4715
|
return; // already in the room — no-op
|
|
4627
4716
|
console.log('🔌 /connect-room — joining LiveKit for incoming user');
|
|
4628
4717
|
await connectWithRetry();
|
|
4629
4718
|
};
|
|
4719
|
+
// 0.9.73: explicit frontend leave should also start the idle-exit countdown —
|
|
4720
|
+
// the user is done; the machine shouldn't idle-bill waiting for a maybe-return.
|
|
4721
|
+
// (leaveRoomHook sets intentionalLeave → Disconnected handler arms it, but
|
|
4722
|
+
// arm here too in case the disconnect event is swallowed by a zombie WS.)
|
|
4630
4723
|
leaveRoomHook = async (reason) => {
|
|
4631
4724
|
if (aloneTimer) {
|
|
4632
4725
|
clearTimeout(aloneTimer);
|
|
@@ -4636,6 +4729,7 @@ async function main() {
|
|
|
4636
4729
|
return; // already out — no-op
|
|
4637
4730
|
intentionalLeave = true;
|
|
4638
4731
|
console.log(`🚪 Leaving LiveKit room (${reason}) — stops connection-minute burn`);
|
|
4732
|
+
armIdleExitTimer(`explicit leave (${reason})`);
|
|
4639
4733
|
try {
|
|
4640
4734
|
await room.disconnect();
|
|
4641
4735
|
}
|