thinkpool-pair 0.7.317 → 0.7.319
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 +39 -8
- package/package.json +2 -1
- package/presence.mjs +34 -0
- package/service.mjs +68 -4
- package/supervisor-ready.mjs +57 -0
package/account.mjs
CHANGED
|
@@ -15,7 +15,7 @@ import { createClient } from '@supabase/supabase-js'
|
|
|
15
15
|
import os from 'node:os'
|
|
16
16
|
import { saveAuth, loadAuth, markAuthReconnectRequired, loadDirs, bindDir, loadServed, rememberServed, loadDefaultDir, saveDefaultDir, claudeRecentProjectDir } from './auth-store.mjs'
|
|
17
17
|
import { isSafeToRestart } from './update-gate.mjs'
|
|
18
|
-
import { makeThrottledTrack, presenceSelfEchoObservation } from './presence.mjs'
|
|
18
|
+
import { makeThrottledTrack, presenceRecoveryEligible, presenceSelfEchoObservation, reducePresenceChannelEvidence } from './presence.mjs'
|
|
19
19
|
import { publicKeyB64, announceProviders, listProviders, addProvider, addProviderModel, removeProvider, unseal } from './providers.mjs'
|
|
20
20
|
import { supervisorServes, supervisorRoomsToStop } from './serve-consent.mjs'
|
|
21
21
|
import { resolveServeDir } from './serve-dir.mjs'
|
|
@@ -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,
|
|
@@ -674,16 +679,35 @@ export async function runAccount(SUPABASE_URL, SUPABASE_ANON) {
|
|
|
674
679
|
// and stamps presenceSyncSeenAt. acctSubscribedAt bounds the startup case where
|
|
675
680
|
// SUBSCRIBED fires but the first sync never arrives: that is a broken Realtime
|
|
676
681
|
// channel, not an unknowable state, once the listener was attached before join.
|
|
677
|
-
let
|
|
678
|
-
let
|
|
679
|
-
|
|
682
|
+
let presenceEvidence = reducePresenceChannelEvidence()
|
|
683
|
+
let initialTickDone = false
|
|
684
|
+
let readinessWritten = false
|
|
685
|
+
const publishReadiness = () => {
|
|
686
|
+
if (readinessWritten || !initialTickDone) return
|
|
687
|
+
let present = false
|
|
688
|
+
try { present = supervisorPresenceEchoed(acct.presenceState?.(), machine, BRIDGE_ID, VERSION) } catch { present = false }
|
|
689
|
+
if (!present) return
|
|
690
|
+
readinessWritten = writeSupervisorReady({ version: VERSION, bridgeId: BRIDGE_ID })
|
|
691
|
+
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')
|
|
692
|
+
}
|
|
693
|
+
acct.on('presence', { event: 'sync' }, () => {
|
|
694
|
+
presenceEvidence = reducePresenceChannelEvidence(presenceEvidence, 'sync')
|
|
695
|
+
publishReadiness()
|
|
696
|
+
})
|
|
680
697
|
|
|
681
698
|
// Re-track on EVERY (re)subscribe, not just the first: a realtime reconnect
|
|
682
699
|
// (network blip, or a token swap mid-flight) rejoins the channel and must
|
|
683
700
|
// re-announce, or the dashboard would read "no bridge" until the next restart.
|
|
684
701
|
await new Promise((res) => acct.subscribe((st) => {
|
|
685
|
-
if (st === 'SUBSCRIBED') {
|
|
702
|
+
if (st === 'SUBSCRIBED') {
|
|
703
|
+
presenceEvidence = reducePresenceChannelEvidence(presenceEvidence, st)
|
|
704
|
+
pushPresence(); res()
|
|
705
|
+
}
|
|
686
706
|
else if (st === 'CLOSED' || st === 'CHANNEL_ERROR' || st === 'TIMED_OUT') {
|
|
707
|
+
// Invalidate the old generation before inspecting presenceState again. The
|
|
708
|
+
// channel retains a local self key across failures; accepting that cache is
|
|
709
|
+
// what left the managed service zombified while a fresh foreground run worked.
|
|
710
|
+
presenceEvidence = reducePresenceChannelEvidence(presenceEvidence, st)
|
|
687
711
|
// Realtime dropped (the 2026-07-08 `realtime CLOSED` blip). The socket normally
|
|
688
712
|
// auto-reconnects, but nudge it so presence — and the claim heartbeat that shares
|
|
689
713
|
// this client — recover instead of the loop standing by against its own stale row.
|
|
@@ -1120,6 +1144,8 @@ export async function runAccount(SUPABASE_URL, SUPABASE_ANON) {
|
|
|
1120
1144
|
|
|
1121
1145
|
await tick()
|
|
1122
1146
|
const iv = setInterval(tick, 15000)
|
|
1147
|
+
initialTickDone = true
|
|
1148
|
+
publishReadiness()
|
|
1123
1149
|
releaseStartupAnchor()
|
|
1124
1150
|
const updateTimers = [] // auto-update poll/apply timers — cleared in stop() so none fire post-exit
|
|
1125
1151
|
let stopping = false
|
|
@@ -1160,12 +1186,16 @@ export async function runAccount(SUPABASE_URL, SUPABASE_ANON) {
|
|
|
1160
1186
|
const presenceWatch = setInterval(() => {
|
|
1161
1187
|
if (stopping) return
|
|
1162
1188
|
const now = Date.now()
|
|
1189
|
+
const recoveryEligible = presenceRecoveryEligible({ claimHeld, lastClaimOkAt, now })
|
|
1163
1190
|
let present = false
|
|
1164
1191
|
try { present = !!acct.presenceState?.()[machine] } catch { present = false }
|
|
1165
1192
|
const observation = presenceSelfEchoObservation({
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1193
|
+
// A fresh HTTP claim distinguishes a Realtime-only zombie from a full
|
|
1194
|
+
// connectivity outage. In the latter case, wait in-process; a respawn cannot
|
|
1195
|
+
// restore the network and launchd would otherwise loop indefinitely.
|
|
1196
|
+
tracking: recoveryEligible,
|
|
1197
|
+
subscribedAt: Math.max(presenceEvidence.subscribedAt, lastClaimOkAt),
|
|
1198
|
+
syncSeenAt: presenceEvidence.syncSeenAt,
|
|
1169
1199
|
present,
|
|
1170
1200
|
missingSince: selfEchoMissingSince,
|
|
1171
1201
|
now,
|
|
@@ -1199,6 +1229,7 @@ export async function runAccount(SUPABASE_URL, SUPABASE_ANON) {
|
|
|
1199
1229
|
clearInterval(presenceWatch)
|
|
1200
1230
|
for (const t of updateTimers) { try { clearInterval(t) } catch { /* noop */ } } // clearInterval clears Timeouts too
|
|
1201
1231
|
keepFresh.cancel()
|
|
1232
|
+
clearSupervisorReady({ pid: process.pid })
|
|
1202
1233
|
releaseSingleton()
|
|
1203
1234
|
for (const c of children.values()) { try { c.kill(sig || 'SIGTERM') } catch { /* noop */ } }
|
|
1204
1235
|
pairBus.close()
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "thinkpool-pair",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.319",
|
|
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/presence.mjs
CHANGED
|
@@ -57,6 +57,40 @@ export function makeThrottledTrack(channel, { minMs = 5000, onStatus = null } =
|
|
|
57
57
|
}
|
|
58
58
|
}
|
|
59
59
|
|
|
60
|
+
// A RealtimeChannel keeps its last presenceState() locally. After CHANNEL_ERROR /
|
|
61
|
+
// CLOSED that cache can still contain our own key even though the server (and every
|
|
62
|
+
// dashboard subscriber) has already removed it. Treat every subscribe/error edge as
|
|
63
|
+
// a new evidence generation: only a presence sync delivered in that generation may
|
|
64
|
+
// certify the cached self key.
|
|
65
|
+
export function reducePresenceChannelEvidence(state = {}, event, now = Date.now()) {
|
|
66
|
+
const current = {
|
|
67
|
+
subscribedAt: Number(state.subscribedAt) || 0,
|
|
68
|
+
syncSeenAt: Number(state.syncSeenAt) || 0,
|
|
69
|
+
}
|
|
70
|
+
if (event === 'sync') return { ...current, syncSeenAt: now }
|
|
71
|
+
if (event === 'SUBSCRIBED') {
|
|
72
|
+
// A reconnect loop may report SUBSCRIBED repeatedly without ever delivering
|
|
73
|
+
// presence sync. Preserve the first unsynced boundary so retries cannot keep
|
|
74
|
+
// pushing the watchdog deadline out forever.
|
|
75
|
+
if (current.subscribedAt && !current.syncSeenAt) return current
|
|
76
|
+
return { subscribedAt: now, syncSeenAt: 0 }
|
|
77
|
+
}
|
|
78
|
+
if (event === 'CLOSED' || event === 'CHANNEL_ERROR' || event === 'TIMED_OUT') {
|
|
79
|
+
// Likewise, repeated error callbacks are one failure episode until a fresh
|
|
80
|
+
// sync proves recovery.
|
|
81
|
+
if (current.subscribedAt && !current.syncSeenAt) return current
|
|
82
|
+
return { subscribedAt: now, syncSeenAt: 0 }
|
|
83
|
+
}
|
|
84
|
+
return current
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Recycling a websocket is useful only when plain HTTP is healthy enough to prove
|
|
88
|
+
// this is a Realtime-only wedge. During a full network/Supabase outage, respawning a
|
|
89
|
+
// launchd service cannot help and merely creates a restart storm.
|
|
90
|
+
export function presenceRecoveryEligible({ claimHeld, lastClaimOkAt, now, maxClaimSilenceMs = 40_000 }) {
|
|
91
|
+
return !!claimHeld && Number.isFinite(lastClaimOkAt) && (now - lastClaimOkAt) < maxClaimSilenceMs
|
|
92
|
+
}
|
|
93
|
+
|
|
60
94
|
/* ─────────────────────────────────────────────────────────────
|
|
61
95
|
presenceSelfEchoVerdict — the pure trigger for the account
|
|
62
96
|
supervisor's presence self-echo watchdog (2026-07-08 recurrence).
|
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
|
+
}
|