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.
- package/dist/index.js +104 -1
- 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
|
-
|
|
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
|
}
|