thinkpool-pair 0.7.261 → 0.7.263
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/bridge.mjs +23 -13
- package/dispatch-permission-cleanup.mjs +20 -11
- package/package.json +1 -1
package/bridge.mjs
CHANGED
|
@@ -39,7 +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 { cancelDurableDispatchPermissions } from './dispatch-permission-cleanup.mjs'
|
|
42
|
+
import { cancelDurableDispatchPermissions, cancelDurablePermissions } from './dispatch-permission-cleanup.mjs'
|
|
43
43
|
// Slice 3 — the pure decision layer for agent push events (turn-done / needs-input).
|
|
44
44
|
// Unit-tested in bridge/agent-notify.test.mjs; everything with I/O stays here.
|
|
45
45
|
import { createPermNotifier, shouldNotifyTurnDone, permissionSummary, clipSummary, isUserFacingLane, DEFAULT_MIN_TURN_MS } from './agent-notify.mjs'
|
|
@@ -3091,30 +3091,36 @@ function pendingResolution(pending, payload = {}) {
|
|
|
3091
3091
|
// The bridge is the only component that knows when the SDK promise it created
|
|
3092
3092
|
// has died. Persist that lifecycle boundary so a card cannot outlive its
|
|
3093
3093
|
// executable intent after a steer, stop, close, or restart.
|
|
3094
|
-
function
|
|
3094
|
+
function cancelPendingDurablePermissions(s, permissionIds = null, reason = 'permission_aborted', dispatchOnly = false) {
|
|
3095
3095
|
const ids = permissionIds || [...(s?.pending?.entries?.() || [])]
|
|
3096
|
-
.filter(([, pending]) => pending?.payload?.answerFormat === 'dispatch')
|
|
3097
3096
|
.map(([id]) => id)
|
|
3098
3097
|
if (!ids.length || !s?.id) return
|
|
3099
|
-
|
|
3098
|
+
const cancel = dispatchOnly ? cancelDurableDispatchPermissions : cancelDurablePermissions
|
|
3099
|
+
void cancel({
|
|
3100
3100
|
supabaseUrl: SUPABASE_URL, anonKey: SUPABASE_ANON, token: codeAuthToken, roomCode: room,
|
|
3101
3101
|
bridgeId: BRIDGE_ID, terminalId: s.id, permissionIds: ids, reason,
|
|
3102
3102
|
}).then((result) => {
|
|
3103
|
-
if (!result.ok && process.env.TP_DEBUG) process.stderr.write(`\n ◇ durable
|
|
3103
|
+
if (!result.ok && process.env.TP_DEBUG) process.stderr.write(`\n ◇ durable permission cleanup incomplete (${result.code})\n`)
|
|
3104
3104
|
})
|
|
3105
3105
|
}
|
|
3106
3106
|
|
|
3107
|
-
|
|
3108
|
-
|
|
3109
|
-
|
|
3110
|
-
|
|
3107
|
+
const restoredPermissionCleanupPending = new Set()
|
|
3108
|
+
|
|
3109
|
+
async function cancelRestoredDurablePermissions(terminalIds = []) {
|
|
3110
|
+
for (const terminalId of terminalIds) if (terminalId) restoredPermissionCleanupPending.add(terminalId)
|
|
3111
|
+
const terminals = [...restoredPermissionCleanupPending]
|
|
3112
|
+
const outcomes = await Promise.all(terminals.map(async (terminalId) => {
|
|
3113
|
+
const result = await cancelDurablePermissions({
|
|
3111
3114
|
supabaseUrl: SUPABASE_URL, anonKey: SUPABASE_ANON, token: codeAuthToken, roomCode: room,
|
|
3112
3115
|
// New bridge process, old durable bridge authority: filter by the
|
|
3113
3116
|
// restored terminal and let the cleanup read each historical binding.
|
|
3114
3117
|
terminalId, reason: 'bridge_restarted',
|
|
3115
3118
|
})
|
|
3116
|
-
if (!result.ok && process.env.TP_DEBUG) process.stderr.write(`\n ◇ restored
|
|
3119
|
+
if (!result.ok && process.env.TP_DEBUG) process.stderr.write(`\n ◇ restored permission cleanup incomplete (${result.code})\n`)
|
|
3120
|
+
return { terminalId, ok: result.ok }
|
|
3117
3121
|
}))
|
|
3122
|
+
for (const outcome of outcomes) if (outcome.ok) restoredPermissionCleanupPending.delete(outcome.terminalId)
|
|
3123
|
+
return { ok: restoredPermissionCleanupPending.size === 0, pending: [...restoredPermissionCleanupPending] }
|
|
3118
3124
|
}
|
|
3119
3125
|
|
|
3120
3126
|
function drainPending(s) {
|
|
@@ -3123,7 +3129,7 @@ function drainPending(s) {
|
|
|
3123
3129
|
// was already approved but is still re-reading durable authority must also
|
|
3124
3130
|
// fail closed when this terminal stops or its turn settles.
|
|
3125
3131
|
supersedeDispatchLease(s)
|
|
3126
|
-
|
|
3132
|
+
cancelPendingDurablePermissions(s, null, 'permission_aborted')
|
|
3127
3133
|
for (const [, p] of s.pending) {
|
|
3128
3134
|
if (p?.timer) clearTimeout(p.timer)
|
|
3129
3135
|
try { p.resolve(pendingResolution(p, { decision: 'deny' })) } catch { /* noop */ }
|
|
@@ -3137,7 +3143,7 @@ function supersedePendingDispatch(s) {
|
|
|
3137
3143
|
const lease = supersedeDispatchLease(s)
|
|
3138
3144
|
if (!lease) return null
|
|
3139
3145
|
const pending = s.pending?.get(lease.permissionId)
|
|
3140
|
-
|
|
3146
|
+
cancelPendingDurablePermissions(s, [lease.permissionId], 'dispatch_superseded', true)
|
|
3141
3147
|
if (pending?.timer) clearTimeout(pending.timer)
|
|
3142
3148
|
s.pending?.delete(lease.permissionId)
|
|
3143
3149
|
s.permNotifier?.resolve(lease.permissionId)
|
|
@@ -4059,7 +4065,7 @@ channel
|
|
|
4059
4065
|
// Cancel their durable dispatch rows BEFORE opening a restored SDK
|
|
4060
4066
|
// session, so a new permission raised during resume can never be
|
|
4061
4067
|
// mistaken for a pre-restart card from the same terminal.
|
|
4062
|
-
await
|
|
4068
|
+
await cancelRestoredDurablePermissions(all.map((rec) => rec.id))
|
|
4063
4069
|
if (all.length) for (const rec of all) {
|
|
4064
4070
|
const wasInterrupted = restoredTurnOpen(rec.log || [])
|
|
4065
4071
|
// Codex can persist a thread id before its rollout receives the
|
|
@@ -4601,6 +4607,10 @@ if (process.env.THINKPOOL_PAIR_AUTOUPDATE === '1' && VERSION) {
|
|
|
4601
4607
|
if (!ok && codeAuthToken) process.stderr.write('\n ⚠ realtime auth refresh failed; private bridge features may need a reconnect.\n')
|
|
4602
4608
|
})
|
|
4603
4609
|
refreshOwnerPlan()
|
|
4610
|
+
// A restart sweep may have raced an expiring owner JWT. Keep failed
|
|
4611
|
+
// terminal ids until the supervisor hands us its next fresh token, then
|
|
4612
|
+
// retry the exact cleanup instead of leaving dead question cards forever.
|
|
4613
|
+
if (restoredPermissionCleanupPending.size) void cancelRestoredDurablePermissions()
|
|
4604
4614
|
}
|
|
4605
4615
|
})
|
|
4606
4616
|
setInterval(() => { try { process.send({ t: 'idle', idle: idleNow(), between: betweenTurns(), webPeer: webPeerPresent() }) } catch { /* parent gone */ } }, 5000).unref()
|
|
@@ -1,25 +1,30 @@
|
|
|
1
|
-
// Durable cleanup for
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
1
|
+
// Durable cleanup for approvals that no longer have a live bridge promise behind
|
|
2
|
+
// them. A row is human-resolvable only while its originating local intent is live.
|
|
3
|
+
// When that intent is replaced, aborted, or restarted, cancel the exact pending
|
|
4
|
+
// row through the normal authenticated RPC rather than leaving a non-executable
|
|
5
|
+
// card/lifecycle blocker.
|
|
6
6
|
|
|
7
7
|
const PENDING = new Set(['pending'])
|
|
8
8
|
const SETTLED = new Set(['denied', 'idempotent_replay', 'already_resolved', 'stale_or_already_resolved', 'item_expired'])
|
|
9
9
|
|
|
10
10
|
const safeString = (value) => typeof value === 'string' && value.length > 0
|
|
11
11
|
|
|
12
|
-
export function
|
|
12
|
+
export function pendingDurablePermissionItems (items, { bridgeId = null, terminalId, permissionIds = null, actionKinds = ['approval', 'dispatch'] } = {}) {
|
|
13
13
|
if ((bridgeId != null && !safeString(bridgeId)) || !safeString(terminalId)) return []
|
|
14
14
|
const requested = permissionIds == null ? null : new Set(permissionIds.filter(safeString))
|
|
15
|
+
const actions = new Set(actionKinds)
|
|
15
16
|
return (Array.isArray(items) ? items : []).filter((item) => {
|
|
16
17
|
const permissionId = item?.request_context?.permission_id
|
|
17
|
-
return PENDING.has(item?.status) && item?.item_kind === 'approval' && item?.action_kind
|
|
18
|
+
return PENDING.has(item?.status) && item?.item_kind === 'approval' && actions.has(item?.action_kind) &&
|
|
18
19
|
(!bridgeId || item?.bridge_authority_id === bridgeId) && item?.local_authority_id === terminalId &&
|
|
19
20
|
safeString(permissionId) && (!requested || requested.has(permissionId))
|
|
20
21
|
})
|
|
21
22
|
}
|
|
22
23
|
|
|
24
|
+
export function pendingDispatchItems (items, options = {}) {
|
|
25
|
+
return pendingDurablePermissionItems(items, { ...options, actionKinds: ['dispatch'] })
|
|
26
|
+
}
|
|
27
|
+
|
|
23
28
|
const rpc = async ({ fetchImpl, supabaseUrl, headers, name, body }) => {
|
|
24
29
|
const response = await fetchImpl(`${String(supabaseUrl).replace(/\/$/, '')}/rest/v1/rpc/${name}`, {
|
|
25
30
|
method: 'POST', headers, body: JSON.stringify(body),
|
|
@@ -30,9 +35,9 @@ const rpc = async ({ fetchImpl, supabaseUrl, headers, name, body }) => {
|
|
|
30
35
|
|
|
31
36
|
// Fetch through list_code_pair_controls so RLS applies to the bridge's current
|
|
32
37
|
// room-member identity. Never accept an id supplied by a public broadcast.
|
|
33
|
-
export async function
|
|
38
|
+
export async function cancelDurablePermissions ({
|
|
34
39
|
fetchImpl = globalThis.fetch, supabaseUrl, anonKey, token, roomCode, bridgeId,
|
|
35
|
-
terminalId, permissionIds = null, reason = '
|
|
40
|
+
terminalId, permissionIds = null, reason = 'permission_aborted', actionKinds = ['approval', 'dispatch'],
|
|
36
41
|
} = {}) {
|
|
37
42
|
if (typeof fetchImpl !== 'function' || !safeString(supabaseUrl) || !safeString(anonKey) ||
|
|
38
43
|
!safeString(token) || !safeString(roomCode) || (bridgeId != null && !safeString(bridgeId)) || !safeString(terminalId)) {
|
|
@@ -46,7 +51,7 @@ export async function cancelDurableDispatchPermissions ({
|
|
|
46
51
|
const listed = await rpc({ fetchImpl, supabaseUrl, headers, name: 'list_code_pair_controls', body: {
|
|
47
52
|
p_session_code: String(roomCode).toUpperCase(), p_status: null, p_limit: 100,
|
|
48
53
|
} })
|
|
49
|
-
const candidates =
|
|
54
|
+
const candidates = pendingDurablePermissionItems(listed, { bridgeId, terminalId, permissionIds, actionKinds })
|
|
50
55
|
const outcomes = await Promise.all(candidates.map(async (item) => {
|
|
51
56
|
const result = await rpc({ fetchImpl, supabaseUrl, headers, name: 'resolve_code_pair_control', body: {
|
|
52
57
|
p_item_id: item.id,
|
|
@@ -59,7 +64,7 @@ export async function cancelDurableDispatchPermissions ({
|
|
|
59
64
|
// value after the owner-authenticated RLS list—not a guessed current ID.
|
|
60
65
|
p_bridge_authority_id: item.bridge_authority_id,
|
|
61
66
|
p_local_authority_id: terminalId,
|
|
62
|
-
p_idempotency_key: `cancel-
|
|
67
|
+
p_idempotency_key: `cancel-permission:${item.id}:${reason}`.slice(0, 160),
|
|
63
68
|
} })
|
|
64
69
|
return { id: item.id, code: result?.code || 'invalid_response', ok: Boolean(result?.ok) && SETTLED.has(result.code) }
|
|
65
70
|
}))
|
|
@@ -69,3 +74,7 @@ export async function cancelDurableDispatchPermissions ({
|
|
|
69
74
|
return Object.freeze({ ok: false, code: 'cleanup_failed', canceled: 0 })
|
|
70
75
|
}
|
|
71
76
|
}
|
|
77
|
+
|
|
78
|
+
export function cancelDurableDispatchPermissions (options = {}) {
|
|
79
|
+
return cancelDurablePermissions({ ...options, actionKinds: ['dispatch'] })
|
|
80
|
+
}
|