thinkpool-pair 0.7.296 → 0.7.298
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 -40
- package/bridge.mjs +26 -3
- package/package.json +1 -1
- package/service.mjs +36 -5
- package/update-gate.mjs +4 -1
package/account.mjs
CHANGED
|
@@ -501,47 +501,27 @@ export async function runAccount(SUPABASE_URL, SUPABASE_ANON) {
|
|
|
501
501
|
// we do NOT uninstall the service. A plain foreground bridge has no supervisor, so it
|
|
502
502
|
// just stops — restart is meaningful for the always-on bridge. Old bridges (pre-0.7.60)
|
|
503
503
|
// don't subscribe to this event, so the dashboard button is a harmless no-op there.
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
//
|
|
507
|
-
//
|
|
508
|
-
//
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
// The reload IS the bounce; sessions resume from disk on the new version. Best-effort:
|
|
513
|
-
// if there's no service or npm is unreachable, we fall through to the plain bounce, so
|
|
514
|
-
// the button is never dead — and even a failed update degrades to today's behaviour.
|
|
515
|
-
const plainBounce = (delay) => setTimeout(() => {
|
|
516
|
-
try { releaseSingleton() } catch { /* noop */ }
|
|
517
|
-
// Unsupervised FOREGROUND bridge (no launchd/systemd) → re-spawn ourselves DETACHED
|
|
518
|
-
// after releasing the lock, so "Restart" isn't a footgun that never comes back
|
|
519
|
-
// (2026-06-25). A supervised bridge is respawned by its supervisor on exit(1).
|
|
520
|
-
if (!runsAsService()) {
|
|
521
|
-
try { spawn(process.execPath, [process.argv[1]], { detached: true, stdio: 'ignore', env: process.env }).unref() } catch { /* noop */ }
|
|
522
|
-
}
|
|
523
|
-
process.exit(1)
|
|
524
|
-
}, delay)
|
|
504
|
+
let restartPreparationRunning = false
|
|
505
|
+
acct.on('broadcast', { event: 'restart' }, ({ payload } = {}) => {
|
|
506
|
+
// A dashboard restart is an EXPLICIT apply request, but it is not permission
|
|
507
|
+
// to interrupt a live turn or a pending human decision. Stage the immutable
|
|
508
|
+
// runtime while work continues; applyIfIdle() owns the only destructive edge.
|
|
509
|
+
applyRequested = true
|
|
510
|
+
if (restartPreparationRunning || applyingUpdate) return
|
|
511
|
+
restartPreparationRunning = true
|
|
525
512
|
;(async () => {
|
|
526
|
-
let updating = false
|
|
527
513
|
try {
|
|
528
514
|
const svc = await import('./service.mjs')
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
// The updater's bootout replaces us (single clean bounce). Long backstop: if the
|
|
538
|
-
// update never lands (npm down / install failed), bounce the current version so the
|
|
539
|
-
// button isn't dead — the updater normally boots us out well before this fires.
|
|
540
|
-
plainBounce(60000)
|
|
541
|
-
return
|
|
515
|
+
const staged = svc.serviceActive(null) ? svc.stageServiceUpdate(null) : null
|
|
516
|
+
// The dashboard's advertised version is only a fallback marker. The host
|
|
517
|
+
// independently resolves and verifies npm above before applying a service.
|
|
518
|
+
pendingUpdate = staged || (typeof payload?.version === 'string' ? payload.version : VERSION) || 'restart'
|
|
519
|
+
} catch {
|
|
520
|
+
pendingUpdate = (typeof payload?.version === 'string' ? payload.version : VERSION) || 'restart'
|
|
521
|
+
} finally {
|
|
522
|
+
restartPreparationRunning = false
|
|
542
523
|
}
|
|
543
|
-
|
|
544
|
-
process.kill(process.pid, 'SIGTERM')
|
|
524
|
+
void applyIfIdle()
|
|
545
525
|
})()
|
|
546
526
|
})
|
|
547
527
|
// Throttled so a realtime reconnect storm can't machine-gun track() into the
|
|
@@ -907,7 +887,13 @@ export async function runAccount(SUPABASE_URL, SUPABASE_ANON) {
|
|
|
907
887
|
console.log(`child_ready sup=${SUP_ID} room=${room} pid=${child.pid}`)
|
|
908
888
|
pushPresence()
|
|
909
889
|
}
|
|
910
|
-
else if (m.t === 'idle') {
|
|
890
|
+
else if (m.t === 'idle') {
|
|
891
|
+
childIdle.set(room, !!m.idle)
|
|
892
|
+
childBetween.set(room, m.between != null ? !!m.between : !!m.idle)
|
|
893
|
+
childPeer.set(room, !!m.webPeer)
|
|
894
|
+
void applyIfIdle()
|
|
895
|
+
}
|
|
896
|
+
else if (m.t === 'busy') { childIdle.set(room, false); childBetween.set(room, false) }
|
|
911
897
|
else if (m.t === 'apply-update') { applyRequested = true; applyIfIdle() } // user clicked the chip
|
|
912
898
|
// ── Thinkpool Ensemble cross-ROOM routing ──────────────────────────────
|
|
913
899
|
// `room` here is the room that sent the message (this closure is per-child).
|
|
@@ -1134,15 +1120,19 @@ export async function runAccount(SUPABASE_URL, SUPABASE_ANON) {
|
|
|
1134
1120
|
// Restart the account bridge to apply a pending update ONLY when it's safe: every
|
|
1135
1121
|
// child idle (between turns) AND either a user clicked apply OR nobody's watching
|
|
1136
1122
|
// (unattended fallback). The predicate is unit-tested (tests/update-gate.test.mjs).
|
|
1137
|
-
|
|
1123
|
+
async function applyIfIdle() {
|
|
1138
1124
|
if (applyingUpdate || !isSafeToRestart({ pendingUpdate, stopping, childIdle, childBetween, childPeer, requested: applyRequested })) return
|
|
1139
1125
|
applyingUpdate = true
|
|
1140
1126
|
process.stderr.write(`\n ◆ thinkpool-pair ${pendingUpdate} ready (running ${VERSION}) — restarting account bridge to update; sessions resume.\n`)
|
|
1141
1127
|
try {
|
|
1142
1128
|
const svc = await import('./service.mjs')
|
|
1143
|
-
if (svc.serviceActive(null) && svc.
|
|
1129
|
+
if (svc.serviceActive(null) && svc.applyStagedServiceUpdate(null, pendingUpdate) !== false) return
|
|
1144
1130
|
} catch { /* fall back to the legacy supervised restart below */ }
|
|
1145
1131
|
applyingUpdate = false
|
|
1132
|
+
restarting = true
|
|
1133
|
+
if (!runsAsService()) {
|
|
1134
|
+
try { spawn(process.execPath, [process.argv[1]], { detached: true, stdio: 'ignore', env: process.env }).unref() } catch { /* noop */ }
|
|
1135
|
+
}
|
|
1146
1136
|
stop('SIGTERM')
|
|
1147
1137
|
}
|
|
1148
1138
|
|
|
@@ -1180,6 +1170,15 @@ export async function runAccount(SUPABASE_URL, SUPABASE_ANON) {
|
|
|
1180
1170
|
const latest = await fetchLatest()
|
|
1181
1171
|
if (latest && isNewer(latest, VERSION)) {
|
|
1182
1172
|
if (!pendingUpdate) process.stderr.write(`\n ◆ thinkpool-pair ${latest} published — "update ready" surfaced to each room; applies between turns.\n`)
|
|
1173
|
+
// Provision while children continue running. applyIfIdle performs the
|
|
1174
|
+
// final safety check only after this slow network/install work is done.
|
|
1175
|
+
try {
|
|
1176
|
+
const svc = await import('./service.mjs')
|
|
1177
|
+
svc.provisionRuntime(latest)
|
|
1178
|
+
} catch (error) {
|
|
1179
|
+
process.stderr.write(`\n ⚠ bridge ${latest} could not be staged yet: ${error?.message || error}\n`)
|
|
1180
|
+
return
|
|
1181
|
+
}
|
|
1183
1182
|
pendingUpdate = latest
|
|
1184
1183
|
// Tell every served child so its room shows the chip (children never self-update;
|
|
1185
1184
|
// only this supervisor restart re-resolves @latest). Slice 3 nudge.
|
package/bridge.mjs
CHANGED
|
@@ -1038,7 +1038,14 @@ const warmComplete = (e) => {
|
|
|
1038
1038
|
// output or a human driving). Read by the auto-update poll so an update restart
|
|
1039
1039
|
// only happens when idle, never mid-turn.
|
|
1040
1040
|
let lastActivity = Date.now()
|
|
1041
|
-
const markActivity = () => {
|
|
1041
|
+
const markActivity = () => {
|
|
1042
|
+
lastActivity = Date.now()
|
|
1043
|
+
// Do not wait for the 5s idle heartbeat to retract a stale between-turn
|
|
1044
|
+
// report. A dashboard update may be staged concurrently with this input.
|
|
1045
|
+
if (process.send && process.env.THINKPOOL_PAIR_ACCOUNT_CHILD === '1') {
|
|
1046
|
+
try { process.send({ t: 'busy' }) } catch { /* parent is already leaving */ }
|
|
1047
|
+
}
|
|
1048
|
+
}
|
|
1042
1049
|
|
|
1043
1050
|
// ── Slice 3: update-ready nudge state ──────────────────────────────
|
|
1044
1051
|
// The newest published version once the update poll (service tier) or the account
|
|
@@ -1490,9 +1497,25 @@ function pumpDesign(term) {
|
|
|
1490
1497
|
by: next.by,
|
|
1491
1498
|
design: true,
|
|
1492
1499
|
}
|
|
1500
|
+
// Design bypasses the ordinary `code-turn` handler, so it must perform the
|
|
1501
|
+
// same lifecycle handshake itself. Open the revision BEFORE logging the human
|
|
1502
|
+
// line: its stamped `turnRev` lets every room client reconcile stale
|
|
1503
|
+
// `busy:false` immediately, without waiting several seconds for first model
|
|
1504
|
+
// output. A rejected dispatch gets a real terminal boundary below.
|
|
1505
|
+
syncStructuredTurn(lane)
|
|
1506
|
+
let accepted = false
|
|
1507
|
+
try {
|
|
1508
|
+
accepted = lane.session.sendTurn(designPrompt({ record: next.record, request: next.request, by: next.by, restore: !!next.restoreRecord, priorRecord: next.restoreRecord })) !== false
|
|
1509
|
+
} catch { accepted = false }
|
|
1510
|
+
if (accepted) beginStructuredTurn(lane)
|
|
1493
1511
|
stampEvent(visible); pushLog(lane, visible); bcast('code-event', { term, evt: visible })
|
|
1494
|
-
|
|
1495
|
-
|
|
1512
|
+
if (!accepted) {
|
|
1513
|
+
syncStructuredTurn(lane)
|
|
1514
|
+
const failed = { kind: 'error', message: 'The producing lane could not start the edit.', recoverable: true }
|
|
1515
|
+
pushLog(lane, failed); bcast('code-event', { term, evt: failed })
|
|
1516
|
+
finishDesign(term, 'failed', { message: 'The producing lane could not start the edit.' })
|
|
1517
|
+
}
|
|
1518
|
+
announce()
|
|
1496
1519
|
}
|
|
1497
1520
|
// Push one manifest file into the room, attributed to `term`. The owner is
|
|
1498
1521
|
// resolved by the WATCHER, not guessed here — each session/terminal writes to
|
package/package.json
CHANGED
package/service.mjs
CHANGED
|
@@ -487,16 +487,47 @@ export function restartService(room) {
|
|
|
487
487
|
// Resolve the registry version first, then rewrite + reload the service pinned to that
|
|
488
488
|
// exact build. If npm is unavailable, fail without pretending an old-version restart
|
|
489
489
|
// was an update.
|
|
490
|
+
function publishedServiceVersion(exec = execSync) {
|
|
491
|
+
let target = ''
|
|
492
|
+
try {
|
|
493
|
+
target = String(exec('npm view thinkpool-pair version', { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] })).trim()
|
|
494
|
+
} catch { /* reported by caller */ }
|
|
495
|
+
return /^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/.test(target) ? target : null
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
// Download and verify the immutable runtime WITHOUT replacing the live service.
|
|
499
|
+
// The account supervisor can do this while rooms are busy, then re-check its
|
|
500
|
+
// between-turn contract immediately before the now-fast service-manager reload.
|
|
501
|
+
export function stageServiceUpdate(room, { exec = execSync, active = isServiceInstalled, provision = provisionRuntime } = {}) {
|
|
502
|
+
if (!active(room)) {
|
|
503
|
+
process.stderr.write(' ⚠ no background service installed to update — install one first.\n')
|
|
504
|
+
return null
|
|
505
|
+
}
|
|
506
|
+
const target = publishedServiceVersion(exec)
|
|
507
|
+
if (!target) {
|
|
508
|
+
process.stderr.write(" ⚠ couldn't reach npm or resolve the published version — bridge left unchanged; NO update or restart was applied.\n")
|
|
509
|
+
return null
|
|
510
|
+
}
|
|
511
|
+
process.stderr.write(`\n ◆ preparing bridge v${target} in the background; active sessions keep running…\n`)
|
|
512
|
+
try { provision(target); return target } catch (error) {
|
|
513
|
+
process.stderr.write(` ⚠ couldn't stage bridge v${target}: ${error?.message || error}\n`)
|
|
514
|
+
return null
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
export function applyStagedServiceUpdate(room, target, { install = installService, active = isServiceInstalled } = {}) {
|
|
519
|
+
if (!active(room) || !/^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/.test(String(target || ''))) return false
|
|
520
|
+
process.stderr.write(`\n ◆ updating the background service to v${target} and restarting…\n`)
|
|
521
|
+
return install(room, [], { version: target, staleProof: true }) !== false
|
|
522
|
+
}
|
|
523
|
+
|
|
490
524
|
export function updateService(room, { exec = execSync, install = installService, active = isServiceInstalled } = {}) {
|
|
491
525
|
if (!active(room)) {
|
|
492
526
|
process.stderr.write(' ⚠ no background service installed to update — install one first.\n')
|
|
493
527
|
return false
|
|
494
528
|
}
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
target = String(exec('npm view thinkpool-pair version', { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] })).trim()
|
|
498
|
-
} catch { /* reported below */ }
|
|
499
|
-
if (!/^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/.test(target)) {
|
|
529
|
+
const target = publishedServiceVersion(exec)
|
|
530
|
+
if (!target) {
|
|
500
531
|
process.stderr.write(" ⚠ couldn't reach npm or resolve the published version — bridge left unchanged; NO update or restart was applied.\n")
|
|
501
532
|
return false
|
|
502
533
|
}
|
package/update-gate.mjs
CHANGED
|
@@ -33,7 +33,10 @@ export function isSafeToRestart({ pendingUpdate, stopping = false, childIdle, ch
|
|
|
33
33
|
export function turnInFlight(sessions) {
|
|
34
34
|
for (const s of (sessions?.values?.() || [])) {
|
|
35
35
|
const ta = s && (typeof s.session?.turnActive === 'boolean' ? s.session.turnActive : s.turnActive)
|
|
36
|
-
|
|
36
|
+
// A permission / question resolver is process-local. Restarting while one is
|
|
37
|
+
// pending destroys the only promise the eventual durable decision can wake,
|
|
38
|
+
// even when the runtime has temporarily reported turnActive=false.
|
|
39
|
+
if (ta || Number(s?.pending?.size || 0) > 0) return true
|
|
37
40
|
}
|
|
38
41
|
return false
|
|
39
42
|
}
|