thinkpool-pair 0.7.316 → 0.7.318
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 +19 -1
- package/bridge.mjs +14 -1
- package/package.json +2 -1
- package/service.mjs +68 -4
- package/supervisor-ready.mjs +57 -0
package/account.mjs
CHANGED
|
@@ -23,6 +23,7 @@ import { pairKeyFor, pairTopic, CROSSROOM_BUS } from './cross-terminal.mjs'
|
|
|
23
23
|
import { installedAgentCommands } from './agent-detect.mjs'
|
|
24
24
|
import { hostMemoryAdmission } from './host-memory.mjs'
|
|
25
25
|
import { createPairBusBroker, mergePairRoomRoster, pairBusStatusFromRealtime, PAIR_BUS_STATUS } from './pair-bus.mjs'
|
|
26
|
+
import { clearSupervisorReady, supervisorPresenceEchoed, writeSupervisorReady } from './supervisor-ready.mjs'
|
|
26
27
|
|
|
27
28
|
const VERSION = (() => { try { return JSON.parse(fs.readFileSync(new URL('./package.json', import.meta.url), 'utf8')).version } catch { return null } })()
|
|
28
29
|
|
|
@@ -410,6 +411,10 @@ export async function runAccount(SUPABASE_URL, SUPABASE_ANON) {
|
|
|
410
411
|
console.error('\n ◇ An account bridge is already running on this machine\n (~/.thinkpool-pair/account.lock). Not starting a second — two would race\n your saved login. Stop the other one first, or share a single room with\n `npx thinkpool-pair <ROOM>`.\n')
|
|
411
412
|
process.exit(0)
|
|
412
413
|
}
|
|
414
|
+
// We own account.lock now, so any previous readiness receipt is stale. A fresh
|
|
415
|
+
// receipt is written only after the first reconciliation tick AND a server-side
|
|
416
|
+
// presence self-echo prove that this exact supervisor finished bootstrapping.
|
|
417
|
+
clearSupervisorReady()
|
|
413
418
|
const releaseStartupAnchor = createStartupAnchor()
|
|
414
419
|
// F2: NEVER exit(1) on a refresh failure. The old code exited 1 here, and launchd
|
|
415
420
|
// KeepAlive respawned us within seconds → refresh again → the 16× crash storm (and,
|
|
@@ -676,7 +681,17 @@ export async function runAccount(SUPABASE_URL, SUPABASE_ANON) {
|
|
|
676
681
|
// channel, not an unknowable state, once the listener was attached before join.
|
|
677
682
|
let presenceSyncSeenAt = 0
|
|
678
683
|
let acctSubscribedAt = 0
|
|
679
|
-
|
|
684
|
+
let initialTickDone = false
|
|
685
|
+
let readinessWritten = false
|
|
686
|
+
const publishReadiness = () => {
|
|
687
|
+
if (readinessWritten || !initialTickDone) return
|
|
688
|
+
let present = false
|
|
689
|
+
try { present = supervisorPresenceEchoed(acct.presenceState?.(), machine, BRIDGE_ID, VERSION) } catch { present = false }
|
|
690
|
+
if (!present) return
|
|
691
|
+
readinessWritten = writeSupervisorReady({ version: VERSION, bridgeId: BRIDGE_ID })
|
|
692
|
+
if (!readinessWritten) process.stderr.write('\n ◇ Could not record local supervisor readiness; the bridge remains running but installers will not claim it was confirmed.\n')
|
|
693
|
+
}
|
|
694
|
+
acct.on('presence', { event: 'sync' }, () => { presenceSyncSeenAt = Date.now(); publishReadiness() })
|
|
680
695
|
|
|
681
696
|
// Re-track on EVERY (re)subscribe, not just the first: a realtime reconnect
|
|
682
697
|
// (network blip, or a token swap mid-flight) rejoins the channel and must
|
|
@@ -1120,6 +1135,8 @@ export async function runAccount(SUPABASE_URL, SUPABASE_ANON) {
|
|
|
1120
1135
|
|
|
1121
1136
|
await tick()
|
|
1122
1137
|
const iv = setInterval(tick, 15000)
|
|
1138
|
+
initialTickDone = true
|
|
1139
|
+
publishReadiness()
|
|
1123
1140
|
releaseStartupAnchor()
|
|
1124
1141
|
const updateTimers = [] // auto-update poll/apply timers — cleared in stop() so none fire post-exit
|
|
1125
1142
|
let stopping = false
|
|
@@ -1199,6 +1216,7 @@ export async function runAccount(SUPABASE_URL, SUPABASE_ANON) {
|
|
|
1199
1216
|
clearInterval(presenceWatch)
|
|
1200
1217
|
for (const t of updateTimers) { try { clearInterval(t) } catch { /* noop */ } } // clearInterval clears Timeouts too
|
|
1201
1218
|
keepFresh.cancel()
|
|
1219
|
+
clearSupervisorReady({ pid: process.pid })
|
|
1202
1220
|
releaseSingleton()
|
|
1203
1221
|
for (const c of children.values()) { try { c.kill(sig || 'SIGTERM') } catch { /* noop */ } }
|
|
1204
1222
|
pairBus.close()
|
package/bridge.mjs
CHANGED
|
@@ -982,6 +982,8 @@ function syncStructuredTurn(entry, now = Date.now()) {
|
|
|
982
982
|
if (busy) {
|
|
983
983
|
entry._turnRev = (Number(entry._turnRev) || 0) + 1
|
|
984
984
|
entry._turnStart = now
|
|
985
|
+
entry._settledTurnRev = null
|
|
986
|
+
entry._turnSettledAt = null
|
|
985
987
|
}
|
|
986
988
|
entry._busyAnn = busy
|
|
987
989
|
return true
|
|
@@ -992,6 +994,8 @@ function beginStructuredTurn(entry, now = Date.now()) {
|
|
|
992
994
|
entry._turnRev = (Number(entry._turnRev) || 0) + 1
|
|
993
995
|
entry._turnStart = now
|
|
994
996
|
entry._busyAnn = true
|
|
997
|
+
entry._settledTurnRev = null
|
|
998
|
+
entry._turnSettledAt = null
|
|
995
999
|
return true
|
|
996
1000
|
}
|
|
997
1001
|
|
|
@@ -999,6 +1003,8 @@ function advanceStructuredTurn(entry, now = Date.now()) {
|
|
|
999
1003
|
entry._turnRev = (Number(entry._turnRev) || 0) + 1
|
|
1000
1004
|
entry._turnStart = now
|
|
1001
1005
|
entry._busyAnn = true
|
|
1006
|
+
entry._settledTurnRev = null
|
|
1007
|
+
entry._turnSettledAt = null
|
|
1002
1008
|
return true
|
|
1003
1009
|
}
|
|
1004
1010
|
|
|
@@ -1281,7 +1287,7 @@ const announce = () => {
|
|
|
1281
1287
|
// laneStatusOf: authoritative busy/idle + last-action timestamp/age +
|
|
1282
1288
|
// STUCK/BLOCKED alert. The bridge owns the turn and permission state, so
|
|
1283
1289
|
// every roster consumer reads one status instead of reconstructing it.
|
|
1284
|
-
...[...sessions.entries()].map(([id, s]) => ({ id, cmd: s.cmd, kind: 'structured', runtime: s.runtime || 'claude', alive: true, ...laneStatusOf(s), turnRev: Number(s._turnRev) || 0, ...(laneBusyOf(s) && s._turnStart ? { turnStartedAt: s._turnStart } : {}), hasTranscript: s.log.length > 0, commands: s.commands, mode: s.mode || undefined, effort: s.effort || undefined, name: termNames[id] || undefined, model: s.model || undefined, capabilities: { ...structuredRuntimeMetadata(s.runtime || 'claude'), modes: structuredModesForLane(s.runtime || 'claude', s) }, ...(s.runtime === 'codex' ? { approvalPolicy: codexConfigForMode(s.mode).approvalPolicy, models: s.models || [], canSteer: s.session?.canSteer ?? false } : s.runtime === 'hermes' ? { models: s.models || [], canSteer: s.session?.canSteer ?? false } : {}), ...(s.archiveOldestSeq != null ? { oldestSeq: s.archiveOldestSeq } : {}), ...(s.spawnedBy ? { spawned: true, spawnedBy: s.spawnedBy } : {}), ...(s.sideParent ? { sideParent: s.sideParent, sideTask: s.sideTask || undefined, sideHandback: !!s.sideHandback } : {}), ...(s.flowSessionId ? { flowId: s.flowSessionId, flowRole: s.flowTaskKey ? 'lane' : 'conductor' } : {}),
|
|
1290
|
+
...[...sessions.entries()].map(([id, s]) => ({ id, cmd: s.cmd, kind: 'structured', runtime: s.runtime || 'claude', alive: true, ...laneStatusOf(s), turnRev: Number(s._turnRev) || 0, ...(laneBusyOf(s) && s._turnStart ? { turnStartedAt: s._turnStart } : {}), ...(!laneBusyOf(s) && Number(s._settledTurnRev) > 0 && Number(s._settledTurnRev) === Number(s._turnRev) ? { settledTurnRev: Number(s._settledTurnRev), turnSettledAt: Number(s._turnSettledAt) || undefined } : {}), hasTranscript: s.log.length > 0, commands: s.commands, mode: s.mode || undefined, effort: s.effort || undefined, name: termNames[id] || undefined, model: s.model || undefined, capabilities: { ...structuredRuntimeMetadata(s.runtime || 'claude'), modes: structuredModesForLane(s.runtime || 'claude', s) }, ...(s.runtime === 'codex' ? { approvalPolicy: codexConfigForMode(s.mode).approvalPolicy, models: s.models || [], canSteer: s.session?.canSteer ?? false } : s.runtime === 'hermes' ? { models: s.models || [], canSteer: s.session?.canSteer ?? false } : {}), ...(s.archiveOldestSeq != null ? { oldestSeq: s.archiveOldestSeq } : {}), ...(s.spawnedBy ? { spawned: true, spawnedBy: s.spawnedBy } : {}), ...(s.sideParent ? { sideParent: s.sideParent, sideTask: s.sideTask || undefined, sideHandback: !!s.sideHandback } : {}), ...(s.flowSessionId ? { flowId: s.flowSessionId, flowRole: s.flowTaskKey ? 'lane' : 'conductor' } : {}),
|
|
1285
1291
|
// provider: the registered LLM-provider this lane runs on, NAME-ONLY {id,name}
|
|
1286
1292
|
// (NEVER the key or baseUrl). Additive; older clients ignore it. Omitted for the
|
|
1287
1293
|
// built-in/default Claude path (no badge). Makes the lane's provider badge +
|
|
@@ -2167,6 +2173,9 @@ function openStructured({ id, runtime = 'claude', model, models, effort, resume,
|
|
|
2167
2173
|
// first transcript event ts, so even the first post-fix restart is ordered right.
|
|
2168
2174
|
openedAt: openedAt || (Array.isArray(log) ? (log.find((e) => e?.ts)?.ts || 0) : 0) || Date.now() }
|
|
2169
2175
|
entry._turnRev = entry.log.reduce((max, event) => Math.max(max, Number(event?.turnRev) || 0), 0)
|
|
2176
|
+
const restoredBoundary = [...entry.log].reverse().find((event) => event?.kind === 'result' && Number(event?.turnRev) > 0)
|
|
2177
|
+
entry._settledTurnRev = Number(restoredBoundary?.turnRev) || null
|
|
2178
|
+
entry._turnSettledAt = Number(restoredBoundary?.ts) || null
|
|
2170
2179
|
entry.interruptedRecap = restoredTurnOpen(entry.log) ? buildRecapFromLog(entry.log, RECAP_CAP) : null
|
|
2171
2180
|
// Slice 3 — a permission card left unanswered past the grace window pushes
|
|
2172
2181
|
// "<lane> — needs you: <what>"; answering it anywhere retracts the banner
|
|
@@ -3244,6 +3253,10 @@ function openStructured({ id, runtime = 'claude', model, models, effort, resume,
|
|
|
3244
3253
|
const mockupDeliveryBoundary = isMockupDeliveryBoundary(evt, { turnActive: entry.session?.turnActive === true })
|
|
3245
3254
|
stampStructuredTurn(entry, evt)
|
|
3246
3255
|
stampEvent(evt)
|
|
3256
|
+
if (terminalBoundary) {
|
|
3257
|
+
entry._settledTurnRev = Number(evt.turnRev) || Number(entry._turnRev) || null
|
|
3258
|
+
entry._turnSettledAt = Number(evt.ts) || Date.now()
|
|
3259
|
+
}
|
|
3247
3260
|
const stalledChanged = evt.kind === 'stalled' ? !entry.stalled : !!entry.stalled
|
|
3248
3261
|
entry.stalled = evt.kind === 'stalled'
|
|
3249
3262
|
entry.lastEvent = evt
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "thinkpool-pair",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.318",
|
|
4
4
|
"description": "Share a local coding-agent CLI (Claude Code, Codex, Gemini, Aider, …) into a ThinkPool Code room, live.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -89,6 +89,7 @@
|
|
|
89
89
|
"terminal-row-reconcile.mjs",
|
|
90
90
|
"switch-provider.mjs",
|
|
91
91
|
"service.mjs",
|
|
92
|
+
"supervisor-ready.mjs",
|
|
92
93
|
"presence.mjs",
|
|
93
94
|
"account.mjs",
|
|
94
95
|
"auth-store.mjs",
|
package/service.mjs
CHANGED
|
@@ -23,6 +23,7 @@ import fs from 'node:fs'
|
|
|
23
23
|
import path from 'node:path'
|
|
24
24
|
import { execSync } from 'node:child_process'
|
|
25
25
|
import { hostMemoryAdmission } from './host-memory.mjs'
|
|
26
|
+
import { readSupervisorReady, supervisorReadyMatches } from './supervisor-ready.mjs'
|
|
26
27
|
|
|
27
28
|
// Service identity. Account mode has no room → a single stable id so there's
|
|
28
29
|
// exactly one account service per machine (a second install replaces it).
|
|
@@ -306,6 +307,8 @@ export function buildDarwinReloadHandoff({ targetLabel, targetFile, stagedFile,
|
|
|
306
307
|
`status=${shq(statusFile)}`,
|
|
307
308
|
'tmp="$status.tmp.$$"',
|
|
308
309
|
'ok=0',
|
|
310
|
+
'live_pid=""',
|
|
311
|
+
'stable=0',
|
|
309
312
|
'had_previous=0',
|
|
310
313
|
'if [ -f "$target_file" ]; then cp "$target_file" "$backup_file" && had_previous=1; fi',
|
|
311
314
|
'launchctl bootout "$dom/$target" 2>/dev/null || true',
|
|
@@ -313,7 +316,7 @@ export function buildDarwinReloadHandoff({ targetLabel, targetFile, stagedFile,
|
|
|
313
316
|
'cp "$staged_file" "$target_file"',
|
|
314
317
|
'launchctl enable "$dom/$target" 2>/dev/null || true',
|
|
315
318
|
'for i in $(seq 1 30); do launchctl bootstrap "$dom" "$target_file" 2>/dev/null && break; sleep 0.3; done',
|
|
316
|
-
'for i in $(seq 1 60); do
|
|
319
|
+
'for i in $(seq 1 60); do snapshot="$(launchctl print "$dom/$target" 2>/dev/null || true)"; pid="$(printf "%s\\n" "$snapshot" | sed -n "s/^[[:space:]]*pid = \\([0-9][0-9]*\\)[[:space:]]*$/\\1/p" | head -1)"; if [ -n "$pid" ] && printf "%s\\n" "$snapshot" | grep -F -- "$expected" >/dev/null && printf "%s\\n" "$snapshot" | grep -F "state = running" >/dev/null; then if [ "$pid" = "$live_pid" ]; then stable=$((stable+1)); else live_pid="$pid"; stable=1; fi; if [ "$stable" -ge 5 ]; then ok=1; break; fi; else live_pid=""; stable=0; fi; sleep 0.5; done',
|
|
317
320
|
`if [ "$ok" = 1 ]; then printf '%s\\n' '${JSON.stringify({ ok: true, version: String(version), target: String(targetLabel) })}' > "$tmp"; else rollback=0; if [ "$had_previous" = 1 ]; then launchctl bootout "$dom/$target" 2>/dev/null || true; for i in $(seq 1 40); do launchctl print "$dom/$target" >/dev/null 2>&1 || break; sleep 0.2; done; cp "$backup_file" "$target_file"; launchctl enable "$dom/$target" 2>/dev/null || true; for i in $(seq 1 30); do if launchctl bootstrap "$dom" "$target_file" 2>/dev/null; then rollback=1; break; fi; sleep 0.3; done; fi; if [ "$rollback" = 1 ]; then printf '%s\\n' '${JSON.stringify({ ok: false, version: String(version), target: String(targetLabel), error: 'new runtime not confirmed; previous service restored', rolledBack: true })}' > "$tmp"; else printf '%s\\n' '${JSON.stringify({ ok: false, version: String(version), target: String(targetLabel), error: 'new runtime not confirmed and rollback failed', rolledBack: false })}' > "$tmp"; fi; fi`,
|
|
318
321
|
'mv "$tmp" "$status"',
|
|
319
322
|
'rm -f "$staged_file" "$backup_file" "$helper_file"',
|
|
@@ -332,6 +335,53 @@ export function buildDarwinReloadHandoff({ targetLabel, targetFile, stagedFile,
|
|
|
332
335
|
return { helperLabel, helperFile, backupFile, statusFile, logFile, script, content }
|
|
333
336
|
}
|
|
334
337
|
|
|
338
|
+
const accountLockFile = () => path.join(os.homedir(), '.thinkpool-pair', 'account.lock')
|
|
339
|
+
const processAlive = (pid) => {
|
|
340
|
+
try { process.kill(pid, 0); return true } catch (error) { return error?.code === 'EPERM' }
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
// A first background install can race a foreground/orphaned ThinkPool bridge that
|
|
344
|
+
// still owns account.lock. The launchd child then exits 0, satisfying a momentary
|
|
345
|
+
// `state = running` probe while KeepAlive correctly declines to restart it. Retire
|
|
346
|
+
// only a PID whose command is demonstrably a packaged ThinkPool bridge; fail closed
|
|
347
|
+
// on PID reuse or an unrelated process.
|
|
348
|
+
export async function retireUnmanagedBridgeLock({
|
|
349
|
+
lockFile = accountLockFile(),
|
|
350
|
+
readLock = () => fs.readFileSync(lockFile, 'utf8'),
|
|
351
|
+
removeLock = () => fs.rmSync(lockFile, { force: true }),
|
|
352
|
+
commandForPid = (pid) => String(execSync(`ps -p ${pid} -o command=`, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] })),
|
|
353
|
+
alive = processAlive,
|
|
354
|
+
signal = (pid, sig) => process.kill(pid, sig),
|
|
355
|
+
sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
|
|
356
|
+
waitMs = 2500,
|
|
357
|
+
pollMs = 100,
|
|
358
|
+
stderr = process.stderr,
|
|
359
|
+
} = {}) {
|
|
360
|
+
let pid = 0
|
|
361
|
+
try { pid = parseInt(String(readLock()).trim(), 10) || 0 } catch { return true }
|
|
362
|
+
const removeIfSameAndDead = () => {
|
|
363
|
+
if (alive(pid)) return false
|
|
364
|
+
try { if ((parseInt(String(readLock()).trim(), 10) || 0) === pid) removeLock() } catch { /* already gone */ }
|
|
365
|
+
return true
|
|
366
|
+
}
|
|
367
|
+
if (!pid || pid === process.pid || removeIfSameAndDead()) return true
|
|
368
|
+
let command = ''
|
|
369
|
+
try { command = commandForPid(pid) } catch { return removeIfSameAndDead() }
|
|
370
|
+
const packagedBridge = /(?:node_modules[\\/]thinkpool-pair|\.npm[\\/]_npx|\.thinkpool-pair[\\/]runtimes)[\\/][^\n]*bridge\.mjs(?:\s|$)/.test(command)
|
|
371
|
+
if (!packagedBridge) {
|
|
372
|
+
stderr.write(` ⚠ account.lock belongs to PID ${pid}, but it is not a verified thinkpool-pair bridge; refusing to signal an unrelated process.\n`)
|
|
373
|
+
return false
|
|
374
|
+
}
|
|
375
|
+
stderr.write(` ◆ stopping previous thinkpool-pair bridge (PID ${pid}) before installing the background service…\n`)
|
|
376
|
+
try { signal(pid, 'SIGTERM') } catch { if (removeIfSameAndDead()) return true }
|
|
377
|
+
const polls = Math.max(1, Math.ceil(Math.max(0, Number(waitMs) || 0) / Math.max(1, Number(pollMs) || 1)))
|
|
378
|
+
for (let i = 0; i < polls; i++) { if (removeIfSameAndDead()) return true; await sleep(Math.max(1, Number(pollMs) || 1)) }
|
|
379
|
+
try { signal(pid, 'SIGKILL') } catch { /* checked below */ }
|
|
380
|
+
for (let i = 0; i < 5; i++) { if (removeIfSameAndDead()) return true; await sleep(Math.max(1, Number(pollMs) || 1)) }
|
|
381
|
+
stderr.write(` ⚠ previous thinkpool-pair bridge PID ${pid} did not stop; background install aborted to avoid two competing bridges.\n`)
|
|
382
|
+
return false
|
|
383
|
+
}
|
|
384
|
+
|
|
335
385
|
function startDarwinReloadHandoff(a, version) {
|
|
336
386
|
const stagedFile = path.join(a.logDir, `service-update-${process.pid}-${Date.now()}.plist`)
|
|
337
387
|
const h = buildDarwinReloadHandoff({ targetLabel: labelFromFile(a.file), targetFile: a.file, stagedFile, expectedRuntime: a.expectedRuntime, version })
|
|
@@ -467,6 +517,8 @@ export async function installAndConfirmService(room, cmdArgs = [], {
|
|
|
467
517
|
install = installService,
|
|
468
518
|
exec = execSync,
|
|
469
519
|
snapshot = serviceRuntimeSnapshot,
|
|
520
|
+
prepareAccountHandoff = retireUnmanagedBridgeLock,
|
|
521
|
+
readReady = () => readSupervisorReady(),
|
|
470
522
|
readStatus = () => {
|
|
471
523
|
try { return JSON.parse(fs.readFileSync(path.join(os.homedir(), '.thinkpool-pair', 'update-status.json'), 'utf8')) } catch { return null }
|
|
472
524
|
},
|
|
@@ -476,11 +528,20 @@ export async function installAndConfirmService(room, cmdArgs = [], {
|
|
|
476
528
|
pollMs = 250,
|
|
477
529
|
stderr = process.stderr,
|
|
478
530
|
} = {}) {
|
|
531
|
+
const before = snapshot(room, { platform, exec })
|
|
532
|
+
if (!room && platform !== 'win32' && !before) {
|
|
533
|
+
if (!(await prepareAccountHandoff())) {
|
|
534
|
+
stderr.write(' ⚠ background install aborted: the previous bridge could not be handed off safely.\n')
|
|
535
|
+
return false
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
const confirmationStartedAt = now()
|
|
479
539
|
if (install(room, cmdArgs, { version, staleProof }) === false) return false
|
|
480
540
|
|
|
481
541
|
const confirmation = () => {
|
|
482
542
|
const live = snapshot(room, { platform, exec })
|
|
483
|
-
|
|
543
|
+
const ready = !!room || platform !== 'darwin' || supervisorReadyMatches(readReady(), live, version, { notBefore: confirmationStartedAt })
|
|
544
|
+
if (live?.version === version && ready) {
|
|
484
545
|
stderr.write(` ✓ bridge install confirmed — running v${version}.\n`)
|
|
485
546
|
return true
|
|
486
547
|
}
|
|
@@ -508,7 +569,7 @@ export async function installAndConfirmService(room, cmdArgs = [], {
|
|
|
508
569
|
if (status.ok === true && confirmation()) return true
|
|
509
570
|
}
|
|
510
571
|
if (now() >= deadline) {
|
|
511
|
-
stderr.write(` ⚠ install v${version} timed out waiting for
|
|
572
|
+
stderr.write(` ⚠ install v${version} timed out waiting for supervisor readiness; no connected account bridge was proven.\n`)
|
|
512
573
|
return false
|
|
513
574
|
}
|
|
514
575
|
await sleep(Math.max(1, Number(pollMs) || 1))
|
|
@@ -609,6 +670,7 @@ export async function updateAndConfirmService(room, {
|
|
|
609
670
|
install = installService,
|
|
610
671
|
active = isServiceInstalled,
|
|
611
672
|
snapshot = serviceRuntimeSnapshot,
|
|
673
|
+
readReady = () => readSupervisorReady(),
|
|
612
674
|
readStatus = () => {
|
|
613
675
|
try { return JSON.parse(fs.readFileSync(path.join(os.homedir(), '.thinkpool-pair', 'update-status.json'), 'utf8')) } catch { return null }
|
|
614
676
|
},
|
|
@@ -619,6 +681,7 @@ export async function updateAndConfirmService(room, {
|
|
|
619
681
|
stderr = process.stderr,
|
|
620
682
|
} = {}) {
|
|
621
683
|
const before = snapshot(room, { platform, exec })
|
|
684
|
+
const confirmationStartedAt = now()
|
|
622
685
|
let target = null
|
|
623
686
|
const captureTarget = (...args) => {
|
|
624
687
|
const result = exec(...args)
|
|
@@ -636,7 +699,8 @@ export async function updateAndConfirmService(room, {
|
|
|
636
699
|
const live = snapshot(room, { platform, exec })
|
|
637
700
|
const versionChanged = !!before && before.version !== target
|
|
638
701
|
const processChanged = !!before?.pid && !!live?.pid && before.pid !== live.pid
|
|
639
|
-
|
|
702
|
+
const ready = !!room || platform !== 'darwin' || supervisorReadyMatches(readReady(), live, target, { notBefore: confirmationStartedAt })
|
|
703
|
+
if (live?.version === target && (versionChanged || processChanged) && ready) {
|
|
640
704
|
stderr.write(` ✓ bridge restart confirmed — running v${target}.\n`)
|
|
641
705
|
return { ok: true, live }
|
|
642
706
|
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import fs from 'node:fs'
|
|
2
|
+
import os from 'node:os'
|
|
3
|
+
import path from 'node:path'
|
|
4
|
+
|
|
5
|
+
export const ACCOUNT_READY_FILE = path.join(os.homedir(), '.thinkpool-pair', 'account-ready.json')
|
|
6
|
+
|
|
7
|
+
export function readSupervisorReady(file = ACCOUNT_READY_FILE, { fsMod = fs } = {}) {
|
|
8
|
+
try {
|
|
9
|
+
const value = JSON.parse(fsMod.readFileSync(file, 'utf8'))
|
|
10
|
+
if (!value || typeof value !== 'object') return null
|
|
11
|
+
return value
|
|
12
|
+
} catch { return null }
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function supervisorReadyMatches(receipt, live, version, { notBefore = 0 } = {}) {
|
|
16
|
+
return !!receipt && !!live?.pid &&
|
|
17
|
+
String(receipt.pid) === String(live.pid) &&
|
|
18
|
+
receipt.version === version &&
|
|
19
|
+
typeof receipt.bridgeId === 'string' && receipt.bridgeId.length > 0 &&
|
|
20
|
+
Number.isFinite(receipt.readyAt) && receipt.readyAt >= notBefore
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// Supabase presence is keyed by hostname, so the key can outlive a crashed
|
|
24
|
+
// process briefly. Require the unique process bridgeId inside the echoed metas;
|
|
25
|
+
// hostname existence alone can certify the previous supervisor's stale presence.
|
|
26
|
+
export function supervisorPresenceEchoed(state, machine, bridgeId, version) {
|
|
27
|
+
if (!state || typeof state !== 'object' || typeof machine !== 'string' || !machine || typeof bridgeId !== 'string' || !bridgeId) return false
|
|
28
|
+
const metas = Array.isArray(state[machine]) ? state[machine] : []
|
|
29
|
+
return metas.some((meta) => meta?.bridgeId === bridgeId && meta?.version === version)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function writeSupervisorReady({ pid = process.pid, version, bridgeId, readyAt = Date.now() }, file = ACCOUNT_READY_FILE, { fsMod = fs } = {}) {
|
|
33
|
+
if (!Number.isInteger(Number(pid)) || Number(pid) <= 0 || typeof version !== 'string' || !version || typeof bridgeId !== 'string' || !bridgeId) return false
|
|
34
|
+
const tmp = `${file}.tmp.${process.pid}.${Date.now()}`
|
|
35
|
+
try {
|
|
36
|
+
fsMod.mkdirSync(path.dirname(file), { recursive: true })
|
|
37
|
+
fsMod.writeFileSync(tmp, `${JSON.stringify({ pid: Number(pid), version, bridgeId, readyAt })}\n`, { mode: 0o600 })
|
|
38
|
+
fsMod.renameSync(tmp, file)
|
|
39
|
+
return true
|
|
40
|
+
} catch {
|
|
41
|
+
try { fsMod.rmSync(tmp, { force: true }) } catch { /* noop */ }
|
|
42
|
+
return false
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Remove an owned receipt on shutdown. With no pid, clear stale startup evidence
|
|
47
|
+
// after this process has acquired the singleton lock and therefore owns account mode.
|
|
48
|
+
export function clearSupervisorReady({ pid = null, file = ACCOUNT_READY_FILE, fsMod = fs } = {}) {
|
|
49
|
+
try {
|
|
50
|
+
if (pid !== null) {
|
|
51
|
+
const receipt = readSupervisorReady(file, { fsMod })
|
|
52
|
+
if (!receipt || String(receipt.pid) !== String(pid)) return false
|
|
53
|
+
}
|
|
54
|
+
fsMod.rmSync(file, { force: true })
|
|
55
|
+
return true
|
|
56
|
+
} catch { return false }
|
|
57
|
+
}
|