thinkpool-pair 0.7.295 → 0.7.297
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 +29 -5
- package/package.json +2 -1
- package/providers.mjs +8 -0
- package/service.mjs +36 -5
- package/terminal-row-reconcile.mjs +65 -0
- 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
|
@@ -39,6 +39,7 @@ import { randomUUID } from 'node:crypto'
|
|
|
39
39
|
import { createClient } from '@supabase/supabase-js'
|
|
40
40
|
import { serveDecision, fetchServeRow, refusalMessage, gateFailAction } from './serve-consent.mjs'
|
|
41
41
|
import { reapTerminalRow as _reapTerminalRow } from './reap-terminal.mjs'
|
|
42
|
+
import { reconcileTerminalRows as _reconcileTerminalRows } from './terminal-row-reconcile.mjs'
|
|
42
43
|
import { cancelDurableDispatchPermissions, cancelDurablePermissions } from './dispatch-permission-cleanup.mjs'
|
|
43
44
|
// Slice 3 — the pure decision layer for agent push events (turn-done / needs-input).
|
|
44
45
|
// Unit-tested in bridge/agent-notify.test.mjs; everything with I/O stays here.
|
|
@@ -46,7 +47,7 @@ import { createPermNotifier, shouldNotifyTurnDone, permissionSummary, clipSummar
|
|
|
46
47
|
// resolveProviderEnv(id) → {ANTHROPIC_BASE_URL,ANTHROPIC_AUTH_TOKEN,ANTHROPIC_MODEL} for a
|
|
47
48
|
// registered custom provider, or null for the built-in/unknown (leave the default env intact).
|
|
48
49
|
// Multi-provider BYOK slice 1: a lane spawned with a `provider` id runs on that endpoint.
|
|
49
|
-
import { resolveProviderEnv, resolveProviderRef, providerNameMap, publicKeyB64, announceProviders, listProviders, addProvider, removeProvider, unseal, effectiveLaneModel, sameProviderEnv, providerModel } from './providers.mjs'
|
|
50
|
+
import { resolveProviderEnv, resolveProviderRef, providerNameMap, publicKeyB64, bridgeHostId, announceProviders, listProviders, addProvider, removeProvider, unseal, effectiveLaneModel, sameProviderEnv, providerModel } from './providers.mjs'
|
|
50
51
|
import { validateProviderSwitch, providerSwitchPlan, BUILTIN_PROVIDER } from './switch-provider.mjs'
|
|
51
52
|
import { createSdkMcpServer, tool } from '@anthropic-ai/claude-agent-sdk'
|
|
52
53
|
import { z } from 'zod'
|
|
@@ -595,6 +596,7 @@ const BRIDGE_STARTED_AT = Date.now()
|
|
|
595
596
|
// box. Distinct from `name` (the driver's username). Announced additively —
|
|
596
597
|
// top-level only (one bridge per announce); older clients ignore the unknown key.
|
|
597
598
|
const host = (os.hostname() || 'host').split('.')[0].slice(0, 24)
|
|
599
|
+
const hostId = bridgeHostId()
|
|
598
600
|
|
|
599
601
|
// Repo awareness — the room shows which project this machine is sharing.
|
|
600
602
|
// Cheap reads, no subprocess: directory name + .git/HEAD.
|
|
@@ -718,6 +720,20 @@ function reapTerminalRow(id) {
|
|
|
718
720
|
onFailure: ({ error, status }) => process.stderr.write(`\n ⚠ terminal row reap failed (${String(id).slice(0, 8)}${status ? `, HTTP ${status}` : ''}): ${error || 'unknown error'}\n`),
|
|
719
721
|
})
|
|
720
722
|
}
|
|
723
|
+
|
|
724
|
+
// Crash/abort counterpart to the clean-close reap above. Called only after the
|
|
725
|
+
// subscribed bridge has restored every on-disk session into `terms`/`sessions`.
|
|
726
|
+
// The stable host filter is load-bearing: another machine's dormant lanes are
|
|
727
|
+
// durable product state, while this machine's missing rows are stale ghosts.
|
|
728
|
+
function reconcileTerminalRows() {
|
|
729
|
+
return _reconcileTerminalRows({
|
|
730
|
+
room, hostId, liveIds: [...terms.keys(), ...sessions.keys()], token: codeAuthToken,
|
|
731
|
+
supabaseUrl: SUPABASE_URL, anonKey: SUPABASE_ANON, shuttingDown,
|
|
732
|
+
onFailure: ({ error, status }) => process.stderr.write(`\n ⚠ terminal row reconcile failed${status ? ` (HTTP ${status})` : ''}: ${error || 'unknown error'}\n`),
|
|
733
|
+
}).then(({ reaped }) => {
|
|
734
|
+
if (reaped.length) process.stderr.write(`\n ◆ removed ${reaped.length} stale terminal row${reaped.length === 1 ? '' : 's'} after restore.\n`)
|
|
735
|
+
})
|
|
736
|
+
}
|
|
721
737
|
// Auth: is the sender a PARTICIPANT of THIS room (owner OR granted OR joined)? Room mode
|
|
722
738
|
// serves the room, so RLS returns participants; we confirm the jwt's user is one of them.
|
|
723
739
|
// Pattern (per #218/#219): getUser + uid → REST existence check on code_sessions
|
|
@@ -1022,7 +1038,14 @@ const warmComplete = (e) => {
|
|
|
1022
1038
|
// output or a human driving). Read by the auto-update poll so an update restart
|
|
1023
1039
|
// only happens when idle, never mid-turn.
|
|
1024
1040
|
let lastActivity = Date.now()
|
|
1025
|
-
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
|
+
}
|
|
1026
1049
|
|
|
1027
1050
|
// ── Slice 3: update-ready nudge state ──────────────────────────────
|
|
1028
1051
|
// The newest published version once the update poll (service tier) or the account
|
|
@@ -1130,9 +1153,9 @@ const announce = () => {
|
|
|
1130
1153
|
cwd, version: VERSION, promptBundle: THINKPOOL_PROMPT_BUNDLE,
|
|
1131
1154
|
// host: short machine label (see const `host`) so the room shows which box
|
|
1132
1155
|
// currently serves it + attributes dormant terminals to their home machine.
|
|
1133
|
-
// Additive top-level
|
|
1134
|
-
//
|
|
1135
|
-
host,
|
|
1156
|
+
// Additive top-level fields consumed by src/pages/code/room.jsx onAnnounce.
|
|
1157
|
+
// Older clients ignore them.
|
|
1158
|
+
host, hostId,
|
|
1136
1159
|
// uid: the authed user id whose bridge is serving this room (owner or grantee).
|
|
1137
1160
|
// Additive field (older clients ignore it). A grantee bridge watches peer `bridge`
|
|
1138
1161
|
// announces for uid === owner_id to know the owner's bridge reclaimed the room and
|
|
@@ -4460,6 +4483,7 @@ channel
|
|
|
4460
4483
|
}
|
|
4461
4484
|
}
|
|
4462
4485
|
await announce()
|
|
4486
|
+
void reconcileTerminalRows()
|
|
4463
4487
|
// Account presence must distinguish a spawned child from a usable room.
|
|
4464
4488
|
// Signal only after realtime subscribed, durable sessions restored, and the
|
|
4465
4489
|
// first authoritative roster was announced. The dashboard keeps the bridge
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "thinkpool-pair",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.297",
|
|
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": {
|
|
@@ -83,6 +83,7 @@
|
|
|
83
83
|
"serve-dir.mjs",
|
|
84
84
|
"serve-consent.mjs",
|
|
85
85
|
"reap-terminal.mjs",
|
|
86
|
+
"terminal-row-reconcile.mjs",
|
|
86
87
|
"switch-provider.mjs",
|
|
87
88
|
"service.mjs",
|
|
88
89
|
"presence.mjs",
|
package/providers.mjs
CHANGED
|
@@ -100,6 +100,14 @@ export function ensureKeypair() {
|
|
|
100
100
|
/** SPKI base64 public key for the announce (`providerPubKey`). */
|
|
101
101
|
export function publicKeyB64() { return ensureKeypair().publicKeyB64 }
|
|
102
102
|
|
|
103
|
+
// Stable, public machine identity for terminal ownership. `host` is a display
|
|
104
|
+
// label and can be "localhost" on multiple computers; this fingerprint is tied
|
|
105
|
+
// to the bridge's existing keypair without exposing private material. Accepting
|
|
106
|
+
// an explicit public key keeps the identity primitive directly testable.
|
|
107
|
+
export function bridgeHostId(pubKey = publicKeyB64()) {
|
|
108
|
+
return crypto.createHash('sha256').update(pubKey).digest('hex').slice(0, 32)
|
|
109
|
+
}
|
|
110
|
+
|
|
103
111
|
// ── seal / unseal (hybrid RSA-OAEP + AES-256-GCM) ───────────────────────
|
|
104
112
|
// seal() is here so the UNIT can drive both ends node-side; the real seal
|
|
105
113
|
// happens in the browser dashboard via WebCrypto against the same envelope.
|
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
|
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/* Reconcile durable terminal rows after a bridge has restored its on-disk roster.
|
|
2
|
+
|
|
3
|
+
A clean close is reaped by reap-terminal.mjs. This module closes the other
|
|
4
|
+
lifecycle boundary: a crash/abort can remove the live session file without a
|
|
5
|
+
clean-close event, leaving a permanent dormant DB row. A stable public host id
|
|
6
|
+
makes the bridge authoritative only for its own rows; foreign/null-host rows are
|
|
7
|
+
deliberately preserved for handover and mixed-version compatibility. */
|
|
8
|
+
|
|
9
|
+
const outcome = (extra = {}) => ({ reaped: [], ...extra })
|
|
10
|
+
|
|
11
|
+
export async function reconcileTerminalRows ({
|
|
12
|
+
room,
|
|
13
|
+
hostId,
|
|
14
|
+
liveIds = [],
|
|
15
|
+
token,
|
|
16
|
+
supabaseUrl,
|
|
17
|
+
anonKey,
|
|
18
|
+
shuttingDown = false,
|
|
19
|
+
fetchImpl = fetch,
|
|
20
|
+
onFailure,
|
|
21
|
+
} = {}) {
|
|
22
|
+
if (shuttingDown) return outcome({ reason: 'shutdown' })
|
|
23
|
+
if (!token) return outcome({ reason: 'anon' })
|
|
24
|
+
if (!room) return outcome({ reason: 'no-room' })
|
|
25
|
+
if (!hostId) return outcome({ reason: 'no-host-id' })
|
|
26
|
+
|
|
27
|
+
const fail = (result) => {
|
|
28
|
+
try { onFailure?.(result) } catch { /* observability cannot break restore */ }
|
|
29
|
+
return outcome(result)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
try {
|
|
33
|
+
const query = `${supabaseUrl}/rest/v1/code_terminals?select=id,host_id` +
|
|
34
|
+
`&session_code=eq.${encodeURIComponent(room)}` +
|
|
35
|
+
`&host_id=eq.${encodeURIComponent(hostId)}`
|
|
36
|
+
const response = await fetchImpl(query, {
|
|
37
|
+
headers: { apikey: anonKey, Authorization: `Bearer ${token}` },
|
|
38
|
+
})
|
|
39
|
+
if (!response?.ok) {
|
|
40
|
+
return fail({ reason: 'error', error: `list HTTP ${response?.status ?? 'unknown'}`, status: response?.status })
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const rows = await response.json()
|
|
44
|
+
const live = new Set(liveIds || [])
|
|
45
|
+
const stale = (Array.isArray(rows) ? rows : [])
|
|
46
|
+
.filter((row) => row?.id && row.host_id === hostId && !live.has(row.id))
|
|
47
|
+
.map((row) => row.id)
|
|
48
|
+
|
|
49
|
+
const reaped = []
|
|
50
|
+
for (const id of stale) {
|
|
51
|
+
const del = await fetchImpl(
|
|
52
|
+
`${supabaseUrl}/rest/v1/code_terminals?id=eq.${encodeURIComponent(id)}` +
|
|
53
|
+
`&host_id=eq.${encodeURIComponent(hostId)}`,
|
|
54
|
+
{ method: 'DELETE', headers: { apikey: anonKey, Authorization: `Bearer ${token}` } },
|
|
55
|
+
)
|
|
56
|
+
if (!del?.ok) {
|
|
57
|
+
return fail({ reason: 'error', error: `delete HTTP ${del?.status ?? 'unknown'}`, status: del?.status, reaped })
|
|
58
|
+
}
|
|
59
|
+
reaped.push(id)
|
|
60
|
+
}
|
|
61
|
+
return { reason: 'ok', reaped }
|
|
62
|
+
} catch (error) {
|
|
63
|
+
return fail({ reason: 'error', error: error?.message || String(error) })
|
|
64
|
+
}
|
|
65
|
+
}
|
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
|
}
|