osborn 0.9.72 → 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.
Files changed (2) hide show
  1. package/dist/index.js +94 -0
  2. 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
  *
@@ -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
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "osborn",
3
- "version": "0.9.72",
3
+ "version": "0.9.73",
4
4
  "description": "Voice AI coding assistant - local agent that connects to Osborn frontend",
5
5
  "type": "module",
6
6
  "bin": {