thinkpool-pair 0.7.33 → 0.7.34
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/account.mjs +4 -6
- package/bridge.mjs +12 -19
- package/package.json +1 -1
- package/update-gate.mjs +3 -11
package/account.mjs
CHANGED
|
@@ -208,8 +208,7 @@ export async function runAccount(SUPABASE_URL, SUPABASE_ANON) {
|
|
|
208
208
|
process.stderr.write(`\n ◆ thinkpool-pair — account mode · ${email}\n Auto-serving your sessions from ${DEFAULT_DIR}\n (point one at another repo: npx thinkpool-pair bind <code> <dir>)\n${svcHint}`)
|
|
209
209
|
|
|
210
210
|
const children = new Map() // room -> child process
|
|
211
|
-
const childIdle = new Map()
|
|
212
|
-
const childBetween = new Map() // room -> bool (between turns now — safe to restart per Contract #1)
|
|
211
|
+
const childIdle = new Map() // room -> bool (last idle report; unknown = not idle)
|
|
213
212
|
const childPeer = new Map() // room -> bool (a web client is watching this room)
|
|
214
213
|
let applyRequested = false // a user clicked "apply" in some room (Slice 3)
|
|
215
214
|
let pendingUpdate = null // newest published version once the poll sees it
|
|
@@ -283,14 +282,13 @@ export async function runAccount(SUPABASE_URL, SUPABASE_ANON) {
|
|
|
283
282
|
const child = spawn(process.execPath, [BRIDGE, room, '--headless', '--auto=claude'], { cwd: dir, stdio: ['inherit', 'inherit', 'inherit', 'ipc'], env })
|
|
284
283
|
children.set(room, child)
|
|
285
284
|
childIdle.set(room, false) // unknown until it reports → never restart a fresh child
|
|
286
|
-
childBetween.set(room, false)
|
|
287
285
|
childPeer.set(room, false)
|
|
288
286
|
child.on('message', (m) => {
|
|
289
287
|
if (!m) return
|
|
290
|
-
if (m.t === 'idle') { childIdle.set(room, !!m.idle);
|
|
288
|
+
if (m.t === 'idle') { childIdle.set(room, !!m.idle); childPeer.set(room, !!m.webPeer) }
|
|
291
289
|
else if (m.t === 'apply-update') { applyRequested = true; applyIfIdle() } // user clicked the chip
|
|
292
290
|
})
|
|
293
|
-
child.on('exit', () => { children.delete(room); childIdle.delete(room);
|
|
291
|
+
child.on('exit', () => { children.delete(room); childIdle.delete(room); childPeer.delete(room) }) // re-served on the next tick
|
|
294
292
|
// If an update is already pending, tell the freshly-served child immediately so
|
|
295
293
|
// its room shows the "update ready" chip without waiting for the next poll.
|
|
296
294
|
if (pendingUpdate) { try { child.send({ t: 'update-available', version: pendingUpdate }) } catch { /* child not ready */ } }
|
|
@@ -319,7 +317,7 @@ export async function runAccount(SUPABASE_URL, SUPABASE_ANON) {
|
|
|
319
317
|
// child idle (between turns) AND either a user clicked apply OR nobody's watching
|
|
320
318
|
// (unattended fallback). The predicate is unit-tested (tests/update-gate.test.mjs).
|
|
321
319
|
const applyIfIdle = () => {
|
|
322
|
-
if (!isSafeToRestart({ pendingUpdate, stopping, childIdle,
|
|
320
|
+
if (!isSafeToRestart({ pendingUpdate, stopping, childIdle, childPeer, requested: applyRequested })) return
|
|
323
321
|
process.stderr.write(`\n ◆ thinkpool-pair ${pendingUpdate} ready (running ${VERSION}) — restarting account bridge to update; sessions resume.\n`)
|
|
324
322
|
stop('SIGTERM') // graceful: children persist + leave presence; exit 0 → service reruns @latest
|
|
325
323
|
}
|
package/bridge.mjs
CHANGED
|
@@ -1059,15 +1059,12 @@ if (process.env.THINKPOOL_PAIR_AUTOUPDATE === '1' && VERSION) {
|
|
|
1059
1059
|
const POLL_MS = Math.max(60, parseInt(process.env.THINKPOOL_PAIR_UPDATE_INTERVAL, 10) || 1800) * 1000
|
|
1060
1060
|
const IDLE_MS = Math.max(10, parseInt(process.env.THINKPOOL_PAIR_UPDATE_IDLE, 10) || 90) * 1000
|
|
1061
1061
|
// pendingUpdate is module-level now (shared with surfaceUpdate + the room chip).
|
|
1062
|
-
|
|
1063
|
-
// mid-flush). Idle = that PLUS the quiet window — the extra conservatism only the
|
|
1064
|
-
// unattended auto-apply needs; an explicit click restarts on between-turns alone.
|
|
1065
|
-
const betweenTurns = () => {
|
|
1062
|
+
const isIdle = () => {
|
|
1066
1063
|
if (turnInFlight(sessions)) return false // a structured turn is live
|
|
1064
|
+
if (Date.now() - lastActivity < IDLE_MS) return false
|
|
1067
1065
|
for (const t of terms.values()) if (t.buf) return false // bytes mid-flush
|
|
1068
1066
|
return true
|
|
1069
1067
|
}
|
|
1070
|
-
const isIdle = () => betweenTurns() && (Date.now() - lastActivity >= IDLE_MS)
|
|
1071
1068
|
// Newer? Compare the numeric x.y.z core; ignore prerelease tails to stay safe.
|
|
1072
1069
|
const isNewer = (latest, cur) => {
|
|
1073
1070
|
const core = (v) => String(v).split('-')[0].split('.').map((n) => parseInt(n, 10) || 0)
|
|
@@ -1086,13 +1083,11 @@ if (process.env.THINKPOOL_PAIR_AUTOUPDATE === '1' && VERSION) {
|
|
|
1086
1083
|
} catch { return null } finally { clearTimeout(to) }
|
|
1087
1084
|
}
|
|
1088
1085
|
const applyIfIdle = () => {
|
|
1089
|
-
if (!pendingUpdate) return
|
|
1090
|
-
//
|
|
1091
|
-
//
|
|
1092
|
-
//
|
|
1093
|
-
|
|
1094
|
-
if (applyRequested) { if (!betweenTurns()) return }
|
|
1095
|
-
else if (!isIdle() || webPeerPresent()) return
|
|
1086
|
+
if (!pendingUpdate || !isIdle()) return
|
|
1087
|
+
// Slice 3: when a web client is watching, wait for their "apply" click instead
|
|
1088
|
+
// of restarting under them. With nobody present (cloud VM / closed tab) the
|
|
1089
|
+
// unattended fallback still auto-applies at idle so headless boxes self-heal.
|
|
1090
|
+
if (!applyRequested && webPeerPresent()) return
|
|
1096
1091
|
process.stderr.write(`\n ◆ thinkpool-pair ${pendingUpdate} ready (running ${VERSION}) — restarting to update; the session resumes.\n`)
|
|
1097
1092
|
shutdown(0, false) // clean teardown (no farewell banner); service reruns @latest, session-store resumes
|
|
1098
1093
|
}
|
|
@@ -1113,19 +1108,17 @@ if (process.env.THINKPOOL_PAIR_AUTOUPDATE === '1' && VERSION) {
|
|
|
1113
1108
|
// supervisor restart re-resolves @latest. Instead we report our idle state so
|
|
1114
1109
|
// the supervisor can pick a moment when EVERY child is idle to restart-update.
|
|
1115
1110
|
const IDLE_MS = Math.max(10, parseInt(process.env.THINKPOOL_PAIR_UPDATE_IDLE, 10) || 90) * 1000
|
|
1116
|
-
const
|
|
1111
|
+
const idleNow = () => {
|
|
1117
1112
|
if (turnInFlight(sessions)) return false // a structured turn is live
|
|
1113
|
+
if (Date.now() - lastActivity < IDLE_MS) return false
|
|
1118
1114
|
for (const t of terms.values()) if (t.buf) return false // bytes mid-flush
|
|
1119
1115
|
return true
|
|
1120
1116
|
}
|
|
1121
|
-
const idleNow = () => betweenTurns() && (Date.now() - lastActivity >= IDLE_MS)
|
|
1122
1117
|
// The supervisor owns updates (children never self-update). It IPCs us when a newer
|
|
1123
|
-
// version is out → surface the chip; we report
|
|
1124
|
-
//
|
|
1125
|
-
// web peer is present, so the supervisor restarts on an explicit click between turns
|
|
1126
|
-
// and otherwise waits for true idle with nobody watching (Slice 3).
|
|
1118
|
+
// version is out → surface the chip; we report idle + whether a web peer is present
|
|
1119
|
+
// so it restarts only between turns and only when nobody is mid-click (Slice 3).
|
|
1127
1120
|
process.on('message', (m) => { if (m && m.t === 'update-available') surfaceUpdate(m.version) })
|
|
1128
|
-
setInterval(() => { try { process.send({ t: 'idle', idle: idleNow(),
|
|
1121
|
+
setInterval(() => { try { process.send({ t: 'idle', idle: idleNow(), webPeer: webPeerPresent() }) } catch { /* parent gone */ } }, 5000).unref()
|
|
1129
1122
|
} else if (VERSION) {
|
|
1130
1123
|
// Foreground run (not the supervised service): it can't self-restart, but
|
|
1131
1124
|
// nudge once if a newer version is out so a relaunch with @latest picks it up.
|
package/package.json
CHANGED
package/update-gate.mjs
CHANGED
|
@@ -11,18 +11,10 @@
|
|
|
11
11
|
// child is idle (a child reports idle=false while a turn is in flight — that's what
|
|
12
12
|
// keeps Contract #1), AND either a user requested apply (clicked the chip) OR — the
|
|
13
13
|
// unattended fallback — no child has a web peer present (nobody is there to click).
|
|
14
|
-
export function isSafeToRestart({ pendingUpdate, stopping = false, childIdle,
|
|
14
|
+
export function isSafeToRestart({ pendingUpdate, stopping = false, childIdle, childPeer, requested = false }) {
|
|
15
15
|
if (!pendingUpdate || stopping) return false
|
|
16
|
-
if (
|
|
17
|
-
|
|
18
|
-
// NOT the full quiet window — in an active room that 90s of silence never comes,
|
|
19
|
-
// so the click would otherwise do nothing. Fall back to childIdle when a child
|
|
20
|
-
// doesn't report `between` (older child during the mixed-version update itself).
|
|
21
|
-
const gate = childBetween instanceof Map ? childBetween : childIdle
|
|
22
|
-
for (const ready of gate.values()) if (!ready) return false // any busy child → wait (Contract #1)
|
|
23
|
-
return true
|
|
24
|
-
}
|
|
25
|
-
for (const idle of childIdle.values()) if (!idle) return false // unattended: full idle window
|
|
16
|
+
for (const idle of childIdle.values()) if (!idle) return false // any busy child → wait (between-turns)
|
|
17
|
+
if (requested) return true // user clicked apply + all idle → go
|
|
26
18
|
for (const present of childPeer.values()) if (present) return false // someone's watching, no click → wait
|
|
27
19
|
return true // unattended: idle + nobody watching → auto-apply
|
|
28
20
|
}
|