thinkpool-pair 0.7.172 → 0.7.174
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 +67 -8
- package/launcher.mjs +31 -48
- package/package.json +1 -1
- package/presence.mjs +44 -3
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, loadDirs, bindDir, loadServed, rememberServed, loadDefaultDir, saveDefaultDir, claudeRecentProjectDir } from './auth-store.mjs'
|
|
17
17
|
import { isSafeToRestart } from './update-gate.mjs'
|
|
18
|
-
import { makeThrottledTrack } from './presence.mjs'
|
|
18
|
+
import { makeThrottledTrack, presenceSelfEchoVerdict } from './presence.mjs'
|
|
19
19
|
import { publicKeyB64, announceProviders, listProviders, addProvider, removeProvider, unseal } from './providers.mjs'
|
|
20
20
|
import { supervisorServes } from './serve-consent.mjs'
|
|
21
21
|
import { resolveServeDir } from './serve-dir.mjs'
|
|
@@ -386,9 +386,21 @@ export async function runAccount(SUPABASE_URL, SUPABASE_ANON) {
|
|
|
386
386
|
// ThinkPool nothing; there's no cost reason to refuse a free host.
|
|
387
387
|
|
|
388
388
|
const DEFAULT_DIR = process.cwd() // unbound sessions auto-serve here; `bind` overrides per session
|
|
389
|
+
// Ground truth for "am I the managed service": ask the service manager, not the
|
|
390
|
+
// THINKPOOL_PAIR_AUTOUPDATE env proxy. Since pinned installs became the default,
|
|
391
|
+
// the plist/unit sets AUTOUPDATE only when --auto-update was passed — the env var
|
|
392
|
+
// now means "track @latest", NOT "I'm the service". Testing the env alone made a
|
|
393
|
+
// pinned managed service (a) announce service:false → dashboard says "foreground",
|
|
394
|
+
// and (b) skip the uninstall on a dashboard Kill → launchd respawns the "killed"
|
|
395
|
+
// bridge ~2s later (the 2026-06-18 zombie-toggle, reintroduced by pinning).
|
|
396
|
+
let isManagedService = false
|
|
397
|
+
try { const svc = await import('./service.mjs'); isManagedService = svc.serviceActive(null) } catch { /* not installed */ }
|
|
398
|
+
const runsAsService = () => process.env.THINKPOOL_PAIR_AUTOUPDATE === '1' || isManagedService
|
|
389
399
|
const svcHint = process.env.THINKPOOL_PAIR_AUTOUPDATE === '1'
|
|
390
400
|
? ' (auto-updating background service — survives reboot, tracks @latest)\n'
|
|
391
|
-
:
|
|
401
|
+
: isManagedService
|
|
402
|
+
? ' (background service — survives reboot, restarts on crash, pinned to this version)\n'
|
|
403
|
+
: ' (run hands-off + auto-updating: npx thinkpool-pair install-service)\n'
|
|
392
404
|
const _effDefault = loadDefaultDir() || claudeRecentProjectDir() || DEFAULT_DIR
|
|
393
405
|
process.stderr.write(`\n ◆ thinkpool-pair — account mode · ${email}\n New sessions auto-serve from ${_effDefault}${loadDefaultDir() ? ' (your default)' : (claudeRecentProjectDir() ? ' (your most-recent Claude Code project)' : '')}\n (change it: npx thinkpool-pair set-default-dir <dir> · one room: npx thinkpool-pair bind <code> <dir>)\n${svcHint}`)
|
|
394
406
|
|
|
@@ -463,7 +475,7 @@ export async function runAccount(SUPABASE_URL, SUPABASE_ANON) {
|
|
|
463
475
|
// restart), force the process down and release the lock so a fresh bridge
|
|
464
476
|
// can take over. NOT unref'd: it must keep the loop alive until it fires.
|
|
465
477
|
setTimeout(() => { try { releaseSingleton() } catch { /* noop */ } process.exit(0) }, 2500)
|
|
466
|
-
if (
|
|
478
|
+
if (runsAsService()) {
|
|
467
479
|
try { const svc = await import('./service.mjs'); svc.uninstallService(null) } catch { /* noop */ }
|
|
468
480
|
}
|
|
469
481
|
process.kill(process.pid, 'SIGTERM')
|
|
@@ -493,7 +505,7 @@ export async function runAccount(SUPABASE_URL, SUPABASE_ANON) {
|
|
|
493
505
|
// Unsupervised FOREGROUND bridge (no launchd/systemd) → re-spawn ourselves DETACHED
|
|
494
506
|
// after releasing the lock, so "Restart" isn't a footgun that never comes back
|
|
495
507
|
// (2026-06-25). A supervised bridge is respawned by its supervisor on exit(1).
|
|
496
|
-
if (
|
|
508
|
+
if (!runsAsService()) {
|
|
497
509
|
try { spawn(process.execPath, [process.argv[1]], { detached: true, stdio: 'ignore', env: process.env }).unref() } catch { /* noop */ }
|
|
498
510
|
}
|
|
499
511
|
process.exit(1)
|
|
@@ -525,11 +537,13 @@ export async function runAccount(SUPABASE_URL, SUPABASE_ANON) {
|
|
|
525
537
|
// per-client presence rate limit (ClientPresenceRateLimitReached → channel closed
|
|
526
538
|
// → dashboard reads "no bridge" while sessions run; 2026-06-17/-22/-24). The 15s
|
|
527
539
|
// tick + token-refresh callers are unaffected (their interval exceeds the window).
|
|
528
|
-
|
|
540
|
+
// lastTrackStatus retains the most recent track()'s server verdict ('ok'|'timed out'|
|
|
541
|
+
// 'error') so the presence self-echo watchdog can log WHY presence went dark.
|
|
542
|
+
let lastTrackStatus = 'init'
|
|
543
|
+
const trackPresence = makeThrottledTrack(acct, { minMs: 5000, onStatus: (s) => { lastTrackStatus = s } })
|
|
529
544
|
// `service` lets the dashboard show whether this bridge is the durable installed
|
|
530
545
|
// service (always-on, survives reboot) vs a foreground `npx` run that dies with its
|
|
531
|
-
// terminal
|
|
532
|
-
// (service.mjs), so it's the reliable proxy for "running as the managed service".
|
|
546
|
+
// terminal — runsAsService() above (service-manager ground truth, not the env proxy).
|
|
533
547
|
// providerPubKey: the bridge's RSA-OAEP public key (SPKI base64), generated +
|
|
534
548
|
// persisted 0600 on first read. The dashboard imports it to SEAL a provider's API
|
|
535
549
|
// key so it reaches the bridge without ever crossing the wire in the clear. Computed
|
|
@@ -538,7 +552,7 @@ export async function runAccount(SUPABASE_URL, SUPABASE_ANON) {
|
|
|
538
552
|
// to spawn against — re-read each announce so an add/remove reflects immediately.
|
|
539
553
|
// Both are additive; older clients ignore them (feature: multi-provider BYOK, slice 1).
|
|
540
554
|
const PROVIDER_PUBKEY = publicKeyB64()
|
|
541
|
-
const pushPresence = () => trackPresence({ name: machine, version: VERSION, rooms: [...children.keys()], refused: [...refused].map(([code, reason]) => ({ code, reason })), service:
|
|
555
|
+
const pushPresence = () => trackPresence({ name: machine, version: VERSION, rooms: [...children.keys()], refused: [...refused].map(([code, reason]) => ({ code, reason })), service: runsAsService(), providerPubKey: PROVIDER_PUBKEY, providers: announceProviders(), ts: Date.now() })
|
|
542
556
|
|
|
543
557
|
// ── Provider registry — the multi-BYOK wire contract (slice 1). ─────────────
|
|
544
558
|
// AUTH: the account channel `tpacct:<uid>` is created WITHOUT config.private:true,
|
|
@@ -625,6 +639,12 @@ export async function runAccount(SUPABASE_URL, SUPABASE_ANON) {
|
|
|
625
639
|
// detect a wedged loop and self-heal — the 2026-07-08 outage stood by against its OWN
|
|
626
640
|
// 564s-stale row because the tick had frozen and the heartbeat simply stopped.
|
|
627
641
|
let lastClaimOkAt = Date.now()
|
|
642
|
+
// Presence self-echo watchdog state (2026-07-08 recurrence). claim_bridge rides HTTP and
|
|
643
|
+
// stays healthy, so claimLoopWedged is blind to a zombie realtime channel where track()
|
|
644
|
+
// lands nowhere. The server's presence SYNC echoing OUR OWN `machine` key back into
|
|
645
|
+
// acct.presenceState() is the only proof a track() actually reached the server.
|
|
646
|
+
let selfEchoMissingSince = 0 // wall-clock we first saw our own key missing (0 = present/ok)
|
|
647
|
+
let presenceRecycledForMissing = 0 // de-dupe: the missing-since value we already forced a recycle for
|
|
628
648
|
const refreshClaim = async () => {
|
|
629
649
|
try {
|
|
630
650
|
// Time-box the RPC: a half-open realtime socket must not freeze the heartbeat (and
|
|
@@ -981,6 +1001,44 @@ export async function runAccount(SUPABASE_URL, SUPABASE_ANON) {
|
|
|
981
1001
|
try { await refreshClaim(); pushPresence() } catch { /* the next tick retries */ }
|
|
982
1002
|
}, 20_000)
|
|
983
1003
|
heartbeatWatch.unref?.()
|
|
1004
|
+
|
|
1005
|
+
// ── presence self-echo watchdog (2026-07-08) ───────────────────────────────
|
|
1006
|
+
// The claim heartbeat rides claim_bridge (plain HTTP) so it recovers even while the
|
|
1007
|
+
// realtime socket is a zombie — which is exactly why claimLoopWedged can't see the case
|
|
1008
|
+
// where the socket went CLOSED-then-half-open and channel.track() lands nowhere FOREVER,
|
|
1009
|
+
// leaving the dashboard on "No bridge connected" while children serve rooms. The server's
|
|
1010
|
+
// presence SYNC is the only proof: if our own `machine` key is absent from
|
|
1011
|
+
// acct.presenceState() while we hold the claim, the track never reached the server. Runs on
|
|
1012
|
+
// its OWN interval (like the claim watchdog) so a wedged tick can't starve it. Two-stage
|
|
1013
|
+
// escalation via the pure predicate:
|
|
1014
|
+
// ≥~60s missing → recycle the realtime socket (full disconnect+connect — the connect()-only
|
|
1015
|
+
// nudge already in the subscribe/CLOSED handlers provably didn't recover it)
|
|
1016
|
+
// ≥~90s more → exit(1); launchd KeepAlive / systemd reruns clean, children persist and
|
|
1017
|
+
// resume from the session-store (the same invisible bounce the dashboard
|
|
1018
|
+
// Restart button already relies on). Guarded: never while stopping, only
|
|
1019
|
+
// while claimHeld (a standby bridge doesn't track).
|
|
1020
|
+
const presenceWatch = setInterval(() => {
|
|
1021
|
+
if (stopping) return
|
|
1022
|
+
const now = Date.now()
|
|
1023
|
+
let present = false
|
|
1024
|
+
try { present = !!acct.presenceState?.()[machine] } catch { present = false }
|
|
1025
|
+
if (present) { selfEchoMissingSince = 0; presenceRecycledForMissing = 0; return }
|
|
1026
|
+
if (!claimHeld) { selfEchoMissingSince = 0; return } // a standby bridge doesn't announce — never escalate
|
|
1027
|
+
if (!selfEchoMissingSince) selfEchoMissingSince = now
|
|
1028
|
+
const verdict = presenceSelfEchoVerdict({ tracking: claimHeld, selfEchoMissingSince, now })
|
|
1029
|
+
if (verdict === 'recycle') {
|
|
1030
|
+
if (presenceRecycledForMissing === selfEchoMissingSince) return // already recycled this episode — wait out the respawn window
|
|
1031
|
+
presenceRecycledForMissing = selfEchoMissingSince
|
|
1032
|
+
console.log(`presence_watchdog sup=${SUP_ID} action=recycle missing_ms=${now - selfEchoMissingSince} track=${lastTrackStatus} — full realtime disconnect+reconnect`)
|
|
1033
|
+
try { sb.realtime?.disconnect?.() } catch { /* noop */ }
|
|
1034
|
+
try { sb.realtime?.connect?.() } catch { /* noop */ }
|
|
1035
|
+
try { pushPresence() } catch { /* re-announced on the next SUBSCRIBED too */ }
|
|
1036
|
+
} else if (verdict === 'respawn') {
|
|
1037
|
+
console.log(`presence_watchdog sup=${SUP_ID} action=respawn missing_ms=${now - selfEchoMissingSince} track=${lastTrackStatus} — recycle failed, exiting for a clean supervisor respawn`)
|
|
1038
|
+
process.exit(1)
|
|
1039
|
+
}
|
|
1040
|
+
}, 15_000)
|
|
1041
|
+
presenceWatch.unref?.()
|
|
984
1042
|
// restart-vs-stop is the ONLY difference in exit code: a web "restart" sets this so
|
|
985
1043
|
// we exit non-zero (every supervisor respawns); a normal stop/shutdown exits 0.
|
|
986
1044
|
const stop = (sig) => {
|
|
@@ -991,6 +1049,7 @@ export async function runAccount(SUPABASE_URL, SUPABASE_ANON) {
|
|
|
991
1049
|
console.log(`supervisor_stop sup=${SUP_ID} reason=${sig || 'unknown'} restarting=${restarting} children=${children.size}`)
|
|
992
1050
|
clearInterval(iv)
|
|
993
1051
|
clearInterval(heartbeatWatch)
|
|
1052
|
+
clearInterval(presenceWatch)
|
|
994
1053
|
for (const t of updateTimers) { try { clearInterval(t) } catch { /* noop */ } } // clearInterval clears Timeouts too
|
|
995
1054
|
keepFresh.cancel()
|
|
996
1055
|
releaseSingleton()
|
package/launcher.mjs
CHANGED
|
@@ -119,6 +119,8 @@ export async function runLauncher({ actions, io, state = detectState(), refresh
|
|
|
119
119
|
io.print(' ' + C.dim('└' + '─'.repeat(54)) + '\n')
|
|
120
120
|
}
|
|
121
121
|
|
|
122
|
+
// KEPT for the `npx thinkpool-pair <ROOM>` argv path (bridge.mjs) — no longer a main-menu
|
|
123
|
+
// item (single-session serve was retired), but the helper stays so nothing else breaks.
|
|
122
124
|
const pairRoom = async () => {
|
|
123
125
|
const room = (await io.ask(`\n Room code ${C.dim('(from /code in the web app)')} ${C.cyan('▸')} `)).toUpperCase().trim()
|
|
124
126
|
if (!room) { io.print(' ' + C.yellow('no room — back to menu')); return }
|
|
@@ -135,39 +137,15 @@ export async function runLauncher({ actions, io, state = detectState(), refresh
|
|
|
135
137
|
await actions.pairRoom({ room, agentCmd: agent.cmd, resumeArgs: cont ? agent.resume : [], headless })
|
|
136
138
|
}
|
|
137
139
|
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
{ label: 'Here in this terminal (foreground)', hint: 'stops when you close it' },
|
|
148
|
-
{ label: '← Back to main menu', hint: '' },
|
|
149
|
-
])
|
|
150
|
-
if (how === 2) return
|
|
151
|
-
if (how === 1) { await actions.serveAccountForeground(); return }
|
|
152
|
-
// how === 0 — always-on durable OS service (launchd / systemd / Windows Startup).
|
|
153
|
-
await actions.installService({ room: null }); resync()
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
const serviceMenu = async () => {
|
|
157
|
-
const what = await askChoice('install for', [
|
|
158
|
-
{ label: 'All my sessions (account)', hint: state.loggedIn ? '' : 'needs login first' },
|
|
159
|
-
{ label: 'One specific room', hint: 'no login needed' },
|
|
160
|
-
{ label: '← Back to main menu', hint: '' },
|
|
161
|
-
])
|
|
162
|
-
if (what === 2) return
|
|
163
|
-
if (what === 0) {
|
|
164
|
-
if (!state.loggedIn) { io.print('\n ' + C.yellow('Account service needs a login first (pick “Serve all my sessions”).')); return }
|
|
165
|
-
await actions.installService({ room: null }); resync(); return
|
|
166
|
-
}
|
|
167
|
-
const room = (await io.ask(`\n Room code ${C.dim('(blank to go back)')} ${C.cyan('▸')} `)).toUpperCase().trim()
|
|
168
|
-
if (!room) { io.print(' ' + C.yellow('no room — back to menu')); return }
|
|
169
|
-
const agent = state.agents[0] || { cmd: 'claude' }
|
|
170
|
-
await actions.installService({ room, agentCmd: agent.cmd }); resync()
|
|
140
|
+
// Login gate shared by both "serve" paths (foreground + install service). Both serve
|
|
141
|
+
// EVERY hosted session, so both require an account link first. Returns true only once
|
|
142
|
+
// logged in — offers to link on the spot, re-reads disk, and refuses to serve if still not.
|
|
143
|
+
const ensureLoggedIn = async () => {
|
|
144
|
+
if (state.loggedIn) return true
|
|
145
|
+
io.print('\n ' + C.yellow('You are not linked to a ThinkPool account yet.'))
|
|
146
|
+
if (await askYesNo(' Link this device now?', true)) { await actions.login(); resync() }
|
|
147
|
+
if (!state.loggedIn) { io.print(' ' + C.yellow('login needed to serve your sessions — back to menu')); return false }
|
|
148
|
+
return true
|
|
171
149
|
}
|
|
172
150
|
|
|
173
151
|
const providerMenu = async () => {
|
|
@@ -249,29 +227,34 @@ export async function runLauncher({ actions, io, state = detectState(), refresh
|
|
|
249
227
|
}
|
|
250
228
|
|
|
251
229
|
// ── menu loop ──
|
|
252
|
-
//
|
|
253
|
-
//
|
|
254
|
-
//
|
|
255
|
-
//
|
|
256
|
-
//
|
|
230
|
+
// account-foreground runs forever, so that choice never returns here. Everything else
|
|
231
|
+
// (install / restart / uninstall / settings / provider) does its work, prints its notes,
|
|
232
|
+
// and RETURNS — so the loop always drops you back at the menu. The trailing "press Enter"
|
|
233
|
+
// lands on every returning path. Slots 1+2 are the ONLY two things this device does:
|
|
234
|
+
// before a service exists → the two ways to start serving all your sessions; once the
|
|
235
|
+
// service exists → maintain it (update/restart or remove). No single-room / per-room
|
|
236
|
+
// serve is offered anymore (that was `npx thinkpool-pair <ROOM>`, still on the argv path).
|
|
257
237
|
for (;;) {
|
|
258
238
|
header()
|
|
259
239
|
io.print(' ' + C.bold('What do you want to do?') + '\n')
|
|
260
|
-
//
|
|
261
|
-
const items =
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
240
|
+
// Slots 1+2 swap on whether the always-on service is already installed.
|
|
241
|
+
const items = state.accountSvc
|
|
242
|
+
? [
|
|
243
|
+
{ key: 'restart', label: 'Restart & update the bridge', hint: 'pull the newest version + restart — sessions resume' },
|
|
244
|
+
{ key: 'uninstall', label: 'Remove background service', hint: 'stop serving your sessions on this device' },
|
|
245
|
+
]
|
|
246
|
+
: [
|
|
247
|
+
{ key: 'serve', label: 'Serve all my sessions', hint: 'runs here, Ctrl-C stops it' },
|
|
248
|
+
{ key: 'service', label: 'Always-on background service', hint: 'recommended — survives reboot, restarts on crash' },
|
|
249
|
+
]
|
|
267
250
|
items.push({ key: 'settings', label: 'Settings', hint: 'provider · account · restart · remove service' })
|
|
268
251
|
items.push({ key: 'quit', label: 'Quit', hint: '' })
|
|
269
252
|
const pick = items[await askChoice('choose', items)]
|
|
270
253
|
if (pick.key === 'quit') return
|
|
271
|
-
if (pick.key === '
|
|
272
|
-
else if (pick.key === '
|
|
273
|
-
else if (pick.key === 'service') await serviceMenu()
|
|
254
|
+
if (pick.key === 'serve') { if (await ensureLoggedIn()) await actions.serveAccountForeground() }
|
|
255
|
+
else if (pick.key === 'service') { if (await ensureLoggedIn()) { await actions.installService({ room: null }); resync() } }
|
|
274
256
|
else if (pick.key === 'restart') { await actions.restartUpdateService({ room: null }); resync() }
|
|
257
|
+
else if (pick.key === 'uninstall') { await actions.uninstallService({ room: null }); resync() }
|
|
275
258
|
else if (pick.key === 'settings') await settingsMenu()
|
|
276
259
|
await io.ask('\n ' + C.dim('press Enter to return to the menu…'))
|
|
277
260
|
}
|
package/package.json
CHANGED
package/presence.mjs
CHANGED
|
@@ -27,16 +27,23 @@
|
|
|
27
27
|
// channel: a supabase RealtimeChannel (stable across reconnects — supabase-js
|
|
28
28
|
// rejoins the same object, so channel.track stays valid). Returns a function you
|
|
29
29
|
// call with the presence payload anywhere you'd have called channel.track(payload).
|
|
30
|
-
|
|
30
|
+
// onStatus (optional): called with each track()'s server status ('ok' | 'timed out' |
|
|
31
|
+
// 'error') so a caller can retain the last one for a postmortem log line. Best-effort —
|
|
32
|
+
// a track that throws reports 'error' and never breaks the caller.
|
|
33
|
+
export function makeThrottledTrack(channel, { minMs = 5000, onStatus = null } = {}) {
|
|
31
34
|
let last = 0
|
|
32
35
|
let timer = null
|
|
33
36
|
let pending = null
|
|
34
|
-
const fire = () => {
|
|
37
|
+
const fire = async () => {
|
|
35
38
|
timer = null
|
|
36
39
|
last = Date.now()
|
|
37
40
|
const payload = pending
|
|
38
41
|
pending = null
|
|
39
|
-
|
|
42
|
+
// Await the track so we can retain its status for the next postmortem. fire() is never
|
|
43
|
+
// awaited by callers, so this stays fire-and-forget — the await only sequences the
|
|
44
|
+
// onStatus callback; it never blocks a caller and a rejection can't escape (caught here).
|
|
45
|
+
try { const status = await channel.track(payload); onStatus?.(status ?? 'ok') }
|
|
46
|
+
catch { onStatus?.('error') /* presence track is best-effort */ }
|
|
40
47
|
}
|
|
41
48
|
return (payload) => {
|
|
42
49
|
pending = payload
|
|
@@ -49,3 +56,37 @@ export function makeThrottledTrack(channel, { minMs = 5000 } = {}) {
|
|
|
49
56
|
}
|
|
50
57
|
}
|
|
51
58
|
}
|
|
59
|
+
|
|
60
|
+
/* ─────────────────────────────────────────────────────────────
|
|
61
|
+
presenceSelfEchoVerdict — the pure trigger for the account
|
|
62
|
+
supervisor's presence self-echo watchdog (2026-07-08 recurrence).
|
|
63
|
+
|
|
64
|
+
The failure it catches: the supervisor is alive, its claim is held
|
|
65
|
+
(claim_bridge is a plain HTTP RPC, so it stays healthy), children
|
|
66
|
+
serve rooms — yet an anonymous probe of tpacct shows ZERO presence
|
|
67
|
+
keys. After a `realtime CLOSED` blip the channel goes zombie and
|
|
68
|
+
channel.track() lands nowhere, forever. The claim watchdog
|
|
69
|
+
(claimLoopWedged) can't see this — the claim rides HTTP, not the
|
|
70
|
+
socket. The server's presence SYNC is the only proof: if OUR OWN
|
|
71
|
+
key is absent from acct.presenceState() while we're tracking and
|
|
72
|
+
hold the claim, the track never reached the server.
|
|
73
|
+
|
|
74
|
+
Given how long our key has been missing (now - selfEchoMissingSince),
|
|
75
|
+
escalate in two stages:
|
|
76
|
+
missing < graceMs → 'ok' (normal; a fresh track takes a beat to echo)
|
|
77
|
+
graceMs ≤ missing < graceMs+respawnMs → 'recycle' (force a full realtime disconnect+reconnect)
|
|
78
|
+
missing ≥ graceMs + respawnMs → 'respawn' (recycle didn't take → exit; supervisor reruns clean)
|
|
79
|
+
|
|
80
|
+
selfEchoMissingSince is 0/null whenever our key is present OR we're
|
|
81
|
+
not tracking; the caller stamps it the first tick the key goes
|
|
82
|
+
missing and clears it the first tick it returns. Extracted + exported
|
|
83
|
+
so the watchdog's trigger is unit-testable without a live socket
|
|
84
|
+
(mirrors claimLoopWedged). Deterministic in `now` — no Date.now here.
|
|
85
|
+
───────────────────────────────────────────────────────────── */
|
|
86
|
+
export function presenceSelfEchoVerdict({ tracking, selfEchoMissingSince, now, graceMs = 60_000, respawnMs = 90_000 }) {
|
|
87
|
+
if (!tracking || !selfEchoMissingSince) return 'ok'
|
|
88
|
+
const missingMs = now - selfEchoMissingSince
|
|
89
|
+
if (missingMs >= graceMs + respawnMs) return 'respawn'
|
|
90
|
+
if (missingMs >= graceMs) return 'recycle'
|
|
91
|
+
return 'ok'
|
|
92
|
+
}
|