osborn 0.9.72 → 0.9.74

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 +104 -1
  2. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -739,7 +739,16 @@ function startApiServer(workingDir, port) {
739
739
  return;
740
740
  }
741
741
  }
742
- const targetWorkDir = url.searchParams.get('targetWorkDir');
742
+ // 0.9.74: default targetWorkDir to this machine's own working directory.
743
+ // Stale sync clients that omit the param used to get source slugs written
744
+ // verbatim — those sessions LIST fine (the scanner walks all slugs) but
745
+ // silently fail to RESUME (the SDK resumes at cwd=workingDir, whose slug
746
+ // they don't match). Confirmed in prod 2026-07-27 after a stale-skill
747
+ // upload. On a cloud machine, remapping into our own workspace is always
748
+ // the right default; a client that genuinely wants source slugs preserved
749
+ // (e.g. laptop→laptop mirroring) must now opt in with preserveSlugs=1.
750
+ const targetWorkDir = url.searchParams.get('targetWorkDir')
751
+ ?? (url.searchParams.get('preserveSlugs') === '1' ? null : workingDir);
743
752
  const tmpDir = mkdtempSync(join(tmpdir(), 'osborn-import-'));
744
753
  const tarProc = spawn('tar', ['-xf', '-', '-C', tmpDir]);
745
754
  // Stream-sniff the first chunk to detect gzip magic bytes (0x1f 0x8b).
@@ -1221,6 +1230,88 @@ async function main() {
1221
1230
  }
1222
1231
  }, ALONE_GRACE_MS);
1223
1232
  };
1233
+ // ── 0.9.73: Idle machine self-stop (the $123 Fly bill fix) ──
1234
+ // After an intentional alone-leave the agent used to sit "idle, awaiting
1235
+ // /connect-room" forever — process alive, Fly machine `started`, billing
1236
+ // performance-2x 24/7 (~$62/mo). Confirmed twice: room osborn-3h6htr held
1237
+ // a zombie WS Jun 18→22 (5,700+ min "In progress" on the LiveKit dashboard),
1238
+ // and the July invoice ($123.61) showed two 4GB machines running the whole
1239
+ // cycle. The machine's restart policy is `on-failure`, so `process.exit(0)`
1240
+ // cleanly STOPS the machine (billing stops, volume + JSONL persist) while
1241
+ // crashes still auto-restart. The frontend's startSandbox() boots a stopped
1242
+ // machine on the next Resume, so warm-idle buys nothing but cost.
1243
+ //
1244
+ // Armed when livekitState.status flips to 'idle' (intentional leave), and by
1245
+ // the zombie watchdog below. Cancelled implicitly: before exiting we re-check
1246
+ // that we're still idle/disconnected — any /connect-room in the window aborts.
1247
+ // Local dev (no FLY_APP_NAME) never exits. Override with OSBORN_IDLE_EXIT=0.
1248
+ const IDLE_EXIT_GRACE_MS = 10 * 60 * 1000; // 10 min: rides out a page refresh + re-auth
1249
+ let idleExitTimer = null;
1250
+ const armIdleExitTimer = (reason) => {
1251
+ if (!process.env.FLY_APP_NAME || process.env.OSBORN_IDLE_EXIT === '0')
1252
+ return;
1253
+ if (idleExitTimer)
1254
+ clearTimeout(idleExitTimer);
1255
+ console.log(`⏻ [IDLE-EXIT] armed (${IDLE_EXIT_GRACE_MS / 60000} min) — reason: ${reason}`);
1256
+ idleExitTimer = setTimeout(() => {
1257
+ idleExitTimer = null;
1258
+ const stillIdle = livekitState.status === 'idle' || livekitState.status === 'failed';
1259
+ const empty = room.remoteParticipants.size === 0;
1260
+ if (stillIdle && empty) {
1261
+ console.log(`⏻ [IDLE-EXIT] still idle after grace — exiting 0 so Fly stops the machine (billing stops; next Resume boots it)`);
1262
+ // Give the log tee a beat to flush, then stop the machine.
1263
+ setTimeout(() => process.exit(0), 2000);
1264
+ }
1265
+ else {
1266
+ console.log(`⏻ [IDLE-EXIT] aborted — status=${livekitState.status} participants=${room.remoteParticipants.size}`);
1267
+ }
1268
+ }, IDLE_EXIT_GRACE_MS);
1269
+ };
1270
+ const cancelIdleExitTimer = () => {
1271
+ if (idleExitTimer) {
1272
+ clearTimeout(idleExitTimer);
1273
+ idleExitTimer = null;
1274
+ console.log('⏻ [IDLE-EXIT] cancelled — activity resumed');
1275
+ }
1276
+ };
1277
+ // ── 0.9.73: Zombie-presence watchdog (poll-based) ──
1278
+ // The Jun 18 zombie proved the event-driven alone-timer has a blind spot:
1279
+ // when the LiveKit WS goes half-dead, RoomEvent callbacks stop firing entirely
1280
+ // (agent log went silent for 4 days while the dashboard logged "Participant
1281
+ // resumed" on every network blip). A poll can't be starved by a dead event
1282
+ // stream. Every 60s: if we believe we're connected but the room has no remote
1283
+ // participants, count it; after ALONE_GRACE_MS worth of consecutive empty
1284
+ // polls, force the same intentional-leave path the timer uses. If the
1285
+ // disconnect itself hangs (zombie WS), the idle-exit timer armed alongside
1286
+ // still stops the machine.
1287
+ const WATCHDOG_POLL_MS = 60 * 1000;
1288
+ let emptyPolls = 0;
1289
+ setInterval(() => {
1290
+ try {
1291
+ if (livekitState.status !== 'connected') {
1292
+ emptyPolls = 0;
1293
+ return;
1294
+ }
1295
+ if (room.remoteParticipants.size > 0) {
1296
+ emptyPolls = 0;
1297
+ return;
1298
+ }
1299
+ emptyPolls++;
1300
+ const emptyForMs = emptyPolls * WATCHDOG_POLL_MS;
1301
+ if (emptyForMs >= ALONE_GRACE_MS) {
1302
+ console.log(`🧟 [ZOMBIE-WATCHDOG] room empty for ${emptyPolls} consecutive polls (${emptyForMs / 1000}s) — forcing leave`);
1303
+ emptyPolls = 0;
1304
+ intentionalLeave = true;
1305
+ room.disconnect().catch((e) => console.error('watchdog room.disconnect failed:', e));
1306
+ // Belt-and-suspenders: if the WS is zombied the Disconnected event may
1307
+ // never fire (so the idle-exit arm in that handler won't run). Arm here too.
1308
+ armIdleExitTimer('zombie-watchdog forced leave');
1309
+ }
1310
+ }
1311
+ catch (err) {
1312
+ console.error('🧟 [ZOMBIE-WATCHDOG] poll error:', err instanceof Error ? err.message : err);
1313
+ }
1314
+ }, WATCHDOG_POLL_MS);
1224
1315
  /**
1225
1316
  * Hard-kill the in-flight Claude SDK query AND the persistent subprocess.
1226
1317
  *
@@ -2884,6 +2975,9 @@ async function main() {
2884
2975
  livekitState.error = null;
2885
2976
  livekitState.errorCode = null;
2886
2977
  console.log('🕊️ Left LiveKit room intentionally — idle, awaiting /connect-room (no auto-rejoin)');
2978
+ // 0.9.73: idle machines must not bill forever — stop the Fly machine
2979
+ // after the grace window unless a /connect-room revives us first.
2980
+ armIdleExitTimer('intentional leave → idle');
2887
2981
  return;
2888
2982
  }
2889
2983
  livekitState.status = 'retrying';
@@ -2901,6 +2995,8 @@ async function main() {
2901
2995
  clearTimeout(aloneTimer);
2902
2996
  aloneTimer = null;
2903
2997
  }
2998
+ // 0.9.73: and any pending idle machine self-stop.
2999
+ cancelIdleExitTimer();
2904
3000
  // Wait for previous session's byte stream handler to fully deregister.
2905
3001
  // Quick reconnects (< ~6s) crash with "byte stream handler already set" without this.
2906
3002
  if (pendingSessionClose) {
@@ -4622,11 +4718,17 @@ async function main() {
4622
4718
  clearTimeout(aloneTimer);
4623
4719
  aloneTimer = null;
4624
4720
  }
4721
+ // 0.9.73: a user is on the way — cancel any pending idle machine self-stop.
4722
+ cancelIdleExitTimer();
4625
4723
  if (livekitState.status === 'connected')
4626
4724
  return; // already in the room — no-op
4627
4725
  console.log('🔌 /connect-room — joining LiveKit for incoming user');
4628
4726
  await connectWithRetry();
4629
4727
  };
4728
+ // 0.9.73: explicit frontend leave should also start the idle-exit countdown —
4729
+ // the user is done; the machine shouldn't idle-bill waiting for a maybe-return.
4730
+ // (leaveRoomHook sets intentionalLeave → Disconnected handler arms it, but
4731
+ // arm here too in case the disconnect event is swallowed by a zombie WS.)
4630
4732
  leaveRoomHook = async (reason) => {
4631
4733
  if (aloneTimer) {
4632
4734
  clearTimeout(aloneTimer);
@@ -4636,6 +4738,7 @@ async function main() {
4636
4738
  return; // already out — no-op
4637
4739
  intentionalLeave = true;
4638
4740
  console.log(`🚪 Leaving LiveKit room (${reason}) — stops connection-minute burn`);
4741
+ armIdleExitTimer(`explicit leave (${reason})`);
4639
4742
  try {
4640
4743
  await room.disconnect();
4641
4744
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "osborn",
3
- "version": "0.9.72",
3
+ "version": "0.9.74",
4
4
  "description": "Voice AI coding assistant - local agent that connects to Osborn frontend",
5
5
  "type": "module",
6
6
  "bin": {