thinkpool-pair 0.7.357 → 0.7.359
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 +3 -1
- package/bridge.mjs +394 -70
- package/codex-session.mjs +25 -5
- package/package.json +3 -1
- package/scheduled-run-admission.mjs +364 -0
- package/scheduled-runs.mjs +268 -0
- package/service.mjs +10 -4
- package/session-store.mjs +117 -0
- package/thinkpool-capabilities.json +6 -4
package/codex-session.mjs
CHANGED
|
@@ -1153,7 +1153,11 @@ export function startCodexSession({ cwd, model, effort: initialEffort = 'high',
|
|
|
1153
1153
|
// synchronously after sendTurn(), so this is the edge that makes Thinking,
|
|
1154
1154
|
// Stop, and the tab spinner appear at dispatch time rather than at the
|
|
1155
1155
|
// first provider event many seconds later.
|
|
1156
|
-
|
|
1156
|
+
// Native compaction already owns that public busy edge. A human turn
|
|
1157
|
+
// accepted during it stays queued without flipping the ordinary-turn
|
|
1158
|
+
// latch; otherwise compactContext's post-bootstrap recheck mistakes the
|
|
1159
|
+
// queued turn for an active one and abandons a compaction it already owns.
|
|
1160
|
+
if (!turnActive && !compactActive) {
|
|
1157
1161
|
aborted = false
|
|
1158
1162
|
armTurnLiveness()
|
|
1159
1163
|
}
|
|
@@ -1229,13 +1233,20 @@ export function startCodexSession({ cwd, model, effort: initialEffort = 'high',
|
|
|
1229
1233
|
},
|
|
1230
1234
|
async compactContext() {
|
|
1231
1235
|
if (turnActive || compactActive) return false
|
|
1232
|
-
|
|
1233
|
-
//
|
|
1234
|
-
//
|
|
1235
|
-
|
|
1236
|
+
// A Stop latch belongs to the turn it cancelled. sendTurn clears it when
|
|
1237
|
+
// accepting new work, but /compact enters through this direct control path.
|
|
1238
|
+
// Leaving it set makes appServerNotification discard the real
|
|
1239
|
+
// turn/started event, then the five-second waiter falsely declares failure
|
|
1240
|
+
// while Codex continues compacting the old thread in the background.
|
|
1241
|
+
aborted = false
|
|
1236
1242
|
compactActive = true
|
|
1237
1243
|
let startTimer = null
|
|
1244
|
+
let compactAccepted = false
|
|
1238
1245
|
try {
|
|
1246
|
+
const readyAppServer = await ensureAppServer()
|
|
1247
|
+
// compactActive claims the lane before bootstrap, so sendTurn queues
|
|
1248
|
+
// behind us. An independently active ordinary turn still wins safely.
|
|
1249
|
+
if (!readyAppServer || turnActive) return false
|
|
1239
1250
|
const compactTurn = new Promise((resolve, reject) => {
|
|
1240
1251
|
startTimer = setTimeout(() => {
|
|
1241
1252
|
compactStartWaiter = null
|
|
@@ -1244,6 +1255,7 @@ export function startCodexSession({ cwd, model, effort: initialEffort = 'high',
|
|
|
1244
1255
|
compactStartWaiter = { resolve, reject }
|
|
1245
1256
|
})
|
|
1246
1257
|
await appServer.compact({ threadId: sessionId })
|
|
1258
|
+
compactAccepted = true
|
|
1247
1259
|
const turnId = await compactTurn
|
|
1248
1260
|
if (startTimer) clearTimeout(startTimer)
|
|
1249
1261
|
const completed = await appServer.waitForTurn(turnId)
|
|
@@ -1252,6 +1264,14 @@ export function startCodexSession({ cwd, model, effort: initialEffort = 'high',
|
|
|
1252
1264
|
if (status === 'completed') return true
|
|
1253
1265
|
throw new Error(completed?.turn?.error?.message || `compaction ${status || 'failed'}`)
|
|
1254
1266
|
} catch (error) {
|
|
1267
|
+
// Once thread/compact/start was accepted, replaying via a bounded-recap
|
|
1268
|
+
// reset is unsafe: the native compaction may still complete (the exact
|
|
1269
|
+
// production failure this guard closes). Preserve the existing thread
|
|
1270
|
+
// and report uncertainty instead of executing a second compaction path.
|
|
1271
|
+
if (compactAccepted || !['rejected', 'not_sent'].includes(error?.delivery)) {
|
|
1272
|
+
note(`Native Codex compaction status is uncertain; existing context was preserved: ${error?.message || error}`)
|
|
1273
|
+
return null
|
|
1274
|
+
}
|
|
1255
1275
|
note(`Native Codex compaction unavailable; using bounded recap fallback: ${error?.message || error}`)
|
|
1256
1276
|
return false
|
|
1257
1277
|
} finally {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "thinkpool-pair",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.359",
|
|
4
4
|
"description": "Connect Claude Code, Codex, or Hermes on your computer to a Thinkpool Code room.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -63,6 +63,8 @@
|
|
|
63
63
|
"evidence-citations.mjs",
|
|
64
64
|
"error-recovery.mjs",
|
|
65
65
|
"pair-control-authority.mjs",
|
|
66
|
+
"scheduled-runs.mjs",
|
|
67
|
+
"scheduled-run-admission.mjs",
|
|
66
68
|
"event-id.mjs",
|
|
67
69
|
"event-bounds.mjs",
|
|
68
70
|
"replay-transport.mjs",
|
|
@@ -0,0 +1,364 @@
|
|
|
1
|
+
// Host-wide scheduled-run admission for account-served room bridge children.
|
|
2
|
+
//
|
|
3
|
+
// Every room bridge is a separate process, so a process-local count/window cannot
|
|
4
|
+
// enforce the laptop safety boundary. This ledger serializes admissions under
|
|
5
|
+
// TP_PAIR_ROOT. It stores only hashes and millisecond timestamps: never room codes,
|
|
6
|
+
// prompts, provider/model names, credentials, endpoints, or host paths.
|
|
7
|
+
|
|
8
|
+
import fs from 'node:fs'
|
|
9
|
+
import os from 'node:os'
|
|
10
|
+
import path from 'node:path'
|
|
11
|
+
import { createHash, randomUUID } from 'node:crypto'
|
|
12
|
+
import { SPAWN } from './cross-terminal.mjs'
|
|
13
|
+
|
|
14
|
+
const VERSION = 1
|
|
15
|
+
const LEDGER_NAME = 'scheduled-run-admission.json'
|
|
16
|
+
const LOCK_NAME = '.scheduled-run-admission.lock'
|
|
17
|
+
const MAX_LEDGER_BYTES = 128 * 1024
|
|
18
|
+
const DEFAULT_LOCK_WAIT_MS = 1_000
|
|
19
|
+
const DEFAULT_STALE_LOCK_MS = 30_000
|
|
20
|
+
const DEFAULT_RETRY_MS = 12
|
|
21
|
+
const DEFAULT_MAX_LEASE_MS = 31 * 60 * 1_000
|
|
22
|
+
const SAFE_ID = /^[A-Za-z0-9][A-Za-z0-9._:@-]{0,255}$/
|
|
23
|
+
|
|
24
|
+
const rootPath = (root) => path.resolve(root || process.env.TP_PAIR_ROOT || path.join(os.homedir(), '.thinkpool-pair'))
|
|
25
|
+
const pathsFor = (root) => {
|
|
26
|
+
const dir = rootPath(root)
|
|
27
|
+
return {
|
|
28
|
+
dir,
|
|
29
|
+
ledger: path.join(dir, LEDGER_NAME),
|
|
30
|
+
lock: path.join(dir, LOCK_NAME),
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
const digest = (value) => createHash('sha256').update(String(value)).digest('hex')
|
|
34
|
+
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
|
|
35
|
+
const finiteInt = (value) => Number.isSafeInteger(value) && value >= 0
|
|
36
|
+
const exactKeys = (value, expected) =>
|
|
37
|
+
value && typeof value === 'object' &&
|
|
38
|
+
Object.keys(value).sort().join('\0') === [...expected].sort().join('\0')
|
|
39
|
+
|
|
40
|
+
function normalizeLimits (limits = {}) {
|
|
41
|
+
const machineMax = Number.isSafeInteger(limits.machineMax) && limits.machineMax > 0 ? limits.machineMax : SPAWN.machineMax
|
|
42
|
+
const perWindowCap = Number.isSafeInteger(limits.perWindowCap) && limits.perWindowCap > 0 ? limits.perWindowCap : SPAWN.perWindowCap
|
|
43
|
+
const windowMs = Number.isSafeInteger(limits.windowMs) && limits.windowMs > 0 ? limits.windowMs : SPAWN.windowMs
|
|
44
|
+
const maxLeaseMs = Number.isSafeInteger(limits.maxLeaseMs) && limits.maxLeaseMs > 0 ? limits.maxLeaseMs : DEFAULT_MAX_LEASE_MS
|
|
45
|
+
return Object.freeze({ machineMax, perWindowCap, windowMs, maxLeaseMs })
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function emptyLedger () {
|
|
49
|
+
return { version: VERSION, leases: [], spawnTimes: [] }
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function validLease (lease) {
|
|
53
|
+
return Boolean(exactKeys(lease, ['runKey', 'terminalKey', 'tokenHash', 'acquiredAt', 'expiresAt']) &&
|
|
54
|
+
/^[a-f0-9]{64}$/.test(lease.runKey) &&
|
|
55
|
+
/^[a-f0-9]{64}$/.test(lease.terminalKey) &&
|
|
56
|
+
/^[a-f0-9]{64}$/.test(lease.tokenHash) &&
|
|
57
|
+
finiteInt(lease.acquiredAt) &&
|
|
58
|
+
finiteInt(lease.expiresAt) &&
|
|
59
|
+
lease.expiresAt > lease.acquiredAt)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function readLedger (file, limits) {
|
|
63
|
+
let fd = null
|
|
64
|
+
let raw
|
|
65
|
+
try {
|
|
66
|
+
fd = fs.openSync(file, fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW || 0))
|
|
67
|
+
const stat = fs.fstatSync(fd)
|
|
68
|
+
if (!stat.isFile() || stat.size > MAX_LEDGER_BYTES) throw new Error('invalid_admission_ledger')
|
|
69
|
+
raw = fs.readFileSync(fd, 'utf8')
|
|
70
|
+
} catch (error) {
|
|
71
|
+
if (error?.code === 'ENOENT') return emptyLedger()
|
|
72
|
+
throw error
|
|
73
|
+
} finally {
|
|
74
|
+
if (fd != null) fs.closeSync(fd)
|
|
75
|
+
}
|
|
76
|
+
const parsed = JSON.parse(raw)
|
|
77
|
+
if (!exactKeys(parsed, ['version', 'leases', 'spawnTimes']) || parsed.version !== VERSION || !Array.isArray(parsed.leases) || !Array.isArray(parsed.spawnTimes)) throw new Error('invalid_admission_ledger')
|
|
78
|
+
// Generous parse bounds let a future lower configured cap prune an older
|
|
79
|
+
// snapshot, while still refusing attacker-sized or corrupted structures.
|
|
80
|
+
if (parsed.leases.length > Math.max(64, limits.machineMax * 4) || parsed.spawnTimes.length > Math.max(128, limits.perWindowCap * 8)) throw new Error('unbounded_admission_ledger')
|
|
81
|
+
if (!parsed.leases.every(validLease) || !parsed.spawnTimes.every(finiteInt)) throw new Error('invalid_admission_ledger')
|
|
82
|
+
if (new Set(parsed.leases.map((lease) => lease.runKey)).size !== parsed.leases.length ||
|
|
83
|
+
new Set(parsed.leases.map((lease) => lease.terminalKey)).size !== parsed.leases.length) throw new Error('duplicate_admission_ledger')
|
|
84
|
+
return parsed
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function pruneLedger (ledger, now, limits) {
|
|
88
|
+
// Never truncate still-active leases merely because a later process starts
|
|
89
|
+
// with a lower cap. Every active lease continues to consume capacity until
|
|
90
|
+
// exact release or expiry.
|
|
91
|
+
const leases = ledger.leases.filter((lease) => lease.expiresAt > now)
|
|
92
|
+
const spawnTimes = ledger.spawnTimes
|
|
93
|
+
.filter((at) => at > now - limits.windowMs && at <= now)
|
|
94
|
+
.sort((a, b) => a - b)
|
|
95
|
+
return {
|
|
96
|
+
ledger: { version: VERSION, leases, spawnTimes },
|
|
97
|
+
changed: leases.length !== ledger.leases.length || spawnTimes.length !== ledger.spawnTimes.length,
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
let tempSerial = 0
|
|
102
|
+
function atomicWriteLedger (file, ledger) {
|
|
103
|
+
const serialized = `${JSON.stringify(ledger)}\n`
|
|
104
|
+
if (Buffer.byteLength(serialized) > MAX_LEDGER_BYTES) throw new Error('unbounded_admission_ledger')
|
|
105
|
+
const tmp = path.join(path.dirname(file), `.${path.basename(file)}.tmp.${process.pid}.${Date.now()}.${tempSerial++}`)
|
|
106
|
+
let fd = null
|
|
107
|
+
try {
|
|
108
|
+
fd = fs.openSync(tmp, 'wx', 0o600)
|
|
109
|
+
const bytes = Buffer.from(serialized)
|
|
110
|
+
for (let offset = 0; offset < bytes.length;) {
|
|
111
|
+
const written = fs.writeSync(fd, bytes, offset, bytes.length - offset)
|
|
112
|
+
if (written <= 0) throw new Error('short_admission_write')
|
|
113
|
+
offset += written
|
|
114
|
+
}
|
|
115
|
+
fs.fsyncSync(fd)
|
|
116
|
+
fs.closeSync(fd)
|
|
117
|
+
fd = null
|
|
118
|
+
fs.renameSync(tmp, file)
|
|
119
|
+
fs.chmodSync(file, 0o600)
|
|
120
|
+
// Persist the directory entry where the host filesystem supports it.
|
|
121
|
+
let dirFd = null
|
|
122
|
+
try {
|
|
123
|
+
dirFd = fs.openSync(path.dirname(file), 'r')
|
|
124
|
+
fs.fsyncSync(dirFd)
|
|
125
|
+
} catch {
|
|
126
|
+
// Windows and some network filesystems reject directory fsync. The
|
|
127
|
+
// replacement file itself was still fsynced before the atomic rename.
|
|
128
|
+
} finally {
|
|
129
|
+
if (dirFd != null) fs.closeSync(dirFd)
|
|
130
|
+
}
|
|
131
|
+
} catch (error) {
|
|
132
|
+
try { if (fd != null) fs.closeSync(fd) } catch { /* best effort */ }
|
|
133
|
+
try { fs.rmSync(tmp, { force: true }) } catch { /* best effort */ }
|
|
134
|
+
throw error
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function ensureRoot (dir) {
|
|
139
|
+
fs.mkdirSync(dir, { recursive: true, mode: 0o700 })
|
|
140
|
+
const stat = fs.lstatSync(dir)
|
|
141
|
+
if (!stat.isDirectory() || stat.isSymbolicLink()) throw new Error('invalid_pair_root')
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function readLockOwner (lock) {
|
|
145
|
+
const file = path.join(lock, 'owner')
|
|
146
|
+
const stat = fs.lstatSync(file)
|
|
147
|
+
if (!stat.isFile() || stat.isSymbolicLink() || stat.size > 1_024) return null
|
|
148
|
+
const owner = JSON.parse(fs.readFileSync(file, 'utf8'))
|
|
149
|
+
if (!exactKeys(owner, ['pid', 'token', 'createdAt']) ||
|
|
150
|
+
!Number.isSafeInteger(owner.pid) || owner.pid < 1 ||
|
|
151
|
+
typeof owner.token !== 'string' || !/^[a-f0-9-]{36}$/.test(owner.token) ||
|
|
152
|
+
!finiteInt(owner.createdAt)) return null
|
|
153
|
+
return owner
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function ownerProcessState (pid) {
|
|
157
|
+
try {
|
|
158
|
+
process.kill(pid, 0)
|
|
159
|
+
return 'live'
|
|
160
|
+
} catch (error) {
|
|
161
|
+
if (error?.code === 'ESRCH') return 'dead'
|
|
162
|
+
// EPERM and every unknown platform error fail closed.
|
|
163
|
+
return 'unknown'
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function recoverStaleLock (lock, staleLockMs, now) {
|
|
168
|
+
const stat = fs.lstatSync(lock)
|
|
169
|
+
if (!stat.isDirectory() || stat.isSymbolicLink() || now - stat.mtimeMs <= staleLockMs) return false
|
|
170
|
+
let owner = null
|
|
171
|
+
try { owner = readLockOwner(lock) } catch { return false }
|
|
172
|
+
if (!owner || ownerProcessState(owner.pid) !== 'dead') return false
|
|
173
|
+
// Rename is the recovery claim. Only the process that atomically moves this
|
|
174
|
+
// exact stale directory may remove it; contenders see ENOENT and retry. A
|
|
175
|
+
// live, permission-unknown, or malformed owner is never stolen.
|
|
176
|
+
const stale = `${lock}.stale.${process.pid}.${randomUUID()}`
|
|
177
|
+
fs.renameSync(lock, stale)
|
|
178
|
+
fs.rmSync(stale, { recursive: true, force: true })
|
|
179
|
+
return true
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
async function acquireLock ({ dir, lock }, { lockWaitMs = DEFAULT_LOCK_WAIT_MS, staleLockMs = DEFAULT_STALE_LOCK_MS, retryMs = DEFAULT_RETRY_MS } = {}) {
|
|
183
|
+
ensureRoot(dir)
|
|
184
|
+
const startedAt = Date.now()
|
|
185
|
+
while (Date.now() - startedAt <= lockWaitMs) {
|
|
186
|
+
const owner = Object.freeze({ pid: process.pid, token: randomUUID(), createdAt: Date.now() })
|
|
187
|
+
try {
|
|
188
|
+
fs.mkdirSync(lock, { mode: 0o700 })
|
|
189
|
+
fs.writeFileSync(path.join(lock, 'owner'), JSON.stringify(owner), { flag: 'wx', mode: 0o600 })
|
|
190
|
+
return owner
|
|
191
|
+
} catch (error) {
|
|
192
|
+
if (error?.code !== 'EEXIST') {
|
|
193
|
+
// If mkdir succeeded but writing the ownership marker failed, remove
|
|
194
|
+
// only the markerless lock we just created.
|
|
195
|
+
try {
|
|
196
|
+
if (fs.existsSync(lock) && !fs.existsSync(path.join(lock, 'owner'))) fs.rmdirSync(lock)
|
|
197
|
+
} catch { /* a contender/stale recovery may own the path now */ }
|
|
198
|
+
throw error
|
|
199
|
+
}
|
|
200
|
+
try {
|
|
201
|
+
if (recoverStaleLock(lock, staleLockMs, Date.now())) continue
|
|
202
|
+
} catch (recoveryError) {
|
|
203
|
+
if (!['ENOENT', 'EEXIST'].includes(recoveryError?.code)) throw recoveryError
|
|
204
|
+
}
|
|
205
|
+
await sleep(Math.max(1, retryMs))
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
throw new Error('admission_lock_timeout')
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
async function withLedgerLock (options, operation) {
|
|
212
|
+
const paths = pathsFor(options?.root)
|
|
213
|
+
let lockOwner = null
|
|
214
|
+
try {
|
|
215
|
+
lockOwner = await acquireLock(paths, options)
|
|
216
|
+
return await operation(paths)
|
|
217
|
+
} finally {
|
|
218
|
+
if (lockOwner) {
|
|
219
|
+
try {
|
|
220
|
+
// A paused process may resume after its stale lock was recovered. Never
|
|
221
|
+
// let that old holder remove the new holder's lock.
|
|
222
|
+
const ownerFile = path.join(paths.lock, 'owner')
|
|
223
|
+
const currentOwner = readLockOwner(paths.lock)
|
|
224
|
+
if (currentOwner?.pid === lockOwner.pid && currentOwner?.token === lockOwner.token) {
|
|
225
|
+
fs.rmSync(ownerFile, { force: true })
|
|
226
|
+
fs.rmdirSync(paths.lock)
|
|
227
|
+
}
|
|
228
|
+
} catch { /* stale recovery/host cleanup owns it */ }
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function invalidIdentity (runId, terminalId) {
|
|
234
|
+
return typeof runId !== 'string' || !SAFE_ID.test(runId) || typeof terminalId !== 'string' || !SAFE_ID.test(terminalId)
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// Acquire before the database claim and local spawn. `runId` may be the
|
|
238
|
+
// deterministic occurrence key (schedule-id + next-run-at) because the database
|
|
239
|
+
// UUID does not exist until claim_due_code_schedule succeeds.
|
|
240
|
+
export async function acquireScheduledRunAdmission ({
|
|
241
|
+
runId,
|
|
242
|
+
terminalId,
|
|
243
|
+
deadlineAt,
|
|
244
|
+
now = Date.now(),
|
|
245
|
+
root,
|
|
246
|
+
limits,
|
|
247
|
+
lockWaitMs,
|
|
248
|
+
staleLockMs,
|
|
249
|
+
retryMs,
|
|
250
|
+
} = {}) {
|
|
251
|
+
const policy = normalizeLimits(limits)
|
|
252
|
+
if (invalidIdentity(runId, terminalId) || !finiteInt(now) || !finiteInt(deadlineAt) || deadlineAt <= now || deadlineAt - now > policy.maxLeaseMs) {
|
|
253
|
+
return Object.freeze({ ok: false, code: 'invalid_admission_request' })
|
|
254
|
+
}
|
|
255
|
+
const token = randomUUID()
|
|
256
|
+
const runKey = digest(runId)
|
|
257
|
+
const terminalKey = digest(terminalId)
|
|
258
|
+
try {
|
|
259
|
+
return await withLedgerLock({ root, lockWaitMs, staleLockMs, retryMs }, ({ ledger: file }) => {
|
|
260
|
+
const loaded = readLedger(file, policy)
|
|
261
|
+
const { ledger, changed } = pruneLedger(loaded, now, policy)
|
|
262
|
+
const sameRun = ledger.leases.find((lease) => lease.runKey === runKey)
|
|
263
|
+
const sameTerminal = ledger.leases.find((lease) => lease.terminalKey === terminalKey)
|
|
264
|
+
if (sameRun || sameTerminal) {
|
|
265
|
+
if (changed) atomicWriteLedger(file, ledger)
|
|
266
|
+
return Object.freeze({
|
|
267
|
+
ok: false,
|
|
268
|
+
code: sameRun && sameTerminal && sameRun === sameTerminal
|
|
269
|
+
? 'duplicate_lease'
|
|
270
|
+
: sameRun ? 'duplicate_run' : 'duplicate_terminal',
|
|
271
|
+
})
|
|
272
|
+
}
|
|
273
|
+
if (ledger.leases.length >= policy.machineMax) {
|
|
274
|
+
if (changed) atomicWriteLedger(file, ledger)
|
|
275
|
+
return Object.freeze({ ok: false, code: 'machine_cap' })
|
|
276
|
+
}
|
|
277
|
+
if (ledger.spawnTimes.length >= policy.perWindowCap) {
|
|
278
|
+
if (changed) atomicWriteLedger(file, ledger)
|
|
279
|
+
return Object.freeze({ ok: false, code: 'burst_cap', retryAt: ledger.spawnTimes[0] + policy.windowMs })
|
|
280
|
+
}
|
|
281
|
+
ledger.leases.push({ runKey, terminalKey, tokenHash: digest(token), acquiredAt: now, expiresAt: deadlineAt })
|
|
282
|
+
ledger.spawnTimes.push(now)
|
|
283
|
+
atomicWriteLedger(file, ledger)
|
|
284
|
+
return Object.freeze({
|
|
285
|
+
ok: true,
|
|
286
|
+
code: 'admitted',
|
|
287
|
+
lease: Object.freeze({ runId, terminalId, token, expiresAt: deadlineAt }),
|
|
288
|
+
})
|
|
289
|
+
})
|
|
290
|
+
} catch {
|
|
291
|
+
return Object.freeze({ ok: false, code: 'admission_unavailable' })
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// Exact-pair + one-time token release makes outcome/close cleanup idempotent
|
|
296
|
+
// without allowing one room child to free another child's active lease.
|
|
297
|
+
export async function releaseScheduledRunAdmission ({
|
|
298
|
+
runId,
|
|
299
|
+
terminalId,
|
|
300
|
+
token,
|
|
301
|
+
now = Date.now(),
|
|
302
|
+
root,
|
|
303
|
+
limits,
|
|
304
|
+
lockWaitMs,
|
|
305
|
+
staleLockMs,
|
|
306
|
+
retryMs,
|
|
307
|
+
} = {}) {
|
|
308
|
+
const policy = normalizeLimits(limits)
|
|
309
|
+
if (invalidIdentity(runId, terminalId) || typeof token !== 'string' || token.length < 16 || token.length > 128 || !finiteInt(now)) {
|
|
310
|
+
return Object.freeze({ ok: false, code: 'invalid_release_request' })
|
|
311
|
+
}
|
|
312
|
+
const runKey = digest(runId)
|
|
313
|
+
const terminalKey = digest(terminalId)
|
|
314
|
+
const tokenHash = digest(token)
|
|
315
|
+
try {
|
|
316
|
+
return await withLedgerLock({ root, lockWaitMs, staleLockMs, retryMs }, ({ ledger: file }) => {
|
|
317
|
+
const loaded = readLedger(file, policy)
|
|
318
|
+
const { ledger, changed } = pruneLedger(loaded, now, policy)
|
|
319
|
+
const index = ledger.leases.findIndex((lease) => lease.runKey === runKey && lease.terminalKey === terminalKey)
|
|
320
|
+
if (index < 0) {
|
|
321
|
+
if (ledger.leases.some((lease) => lease.runKey === runKey || lease.terminalKey === terminalKey)) {
|
|
322
|
+
return Object.freeze({ ok: false, code: 'lease_mismatch' })
|
|
323
|
+
}
|
|
324
|
+
if (changed) atomicWriteLedger(file, ledger)
|
|
325
|
+
return Object.freeze({ ok: true, code: 'already_released' })
|
|
326
|
+
}
|
|
327
|
+
if (ledger.leases[index].tokenHash !== tokenHash) return Object.freeze({ ok: false, code: 'lease_mismatch' })
|
|
328
|
+
ledger.leases.splice(index, 1)
|
|
329
|
+
atomicWriteLedger(file, ledger)
|
|
330
|
+
return Object.freeze({ ok: true, code: 'released' })
|
|
331
|
+
})
|
|
332
|
+
} catch {
|
|
333
|
+
return Object.freeze({ ok: false, code: 'admission_unavailable' })
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
// Secret-free diagnostics/tests: counts and expiry only.
|
|
338
|
+
export async function inspectScheduledRunAdmissions ({
|
|
339
|
+
now = Date.now(),
|
|
340
|
+
root,
|
|
341
|
+
limits,
|
|
342
|
+
lockWaitMs,
|
|
343
|
+
staleLockMs,
|
|
344
|
+
retryMs,
|
|
345
|
+
} = {}) {
|
|
346
|
+
const policy = normalizeLimits(limits)
|
|
347
|
+
if (!finiteInt(now)) return Object.freeze({ ok: false, code: 'invalid_inspection_request' })
|
|
348
|
+
try {
|
|
349
|
+
return await withLedgerLock({ root, lockWaitMs, staleLockMs, retryMs }, ({ ledger: file }) => {
|
|
350
|
+
const loaded = readLedger(file, policy)
|
|
351
|
+
const { ledger, changed } = pruneLedger(loaded, now, policy)
|
|
352
|
+
if (changed) atomicWriteLedger(file, ledger)
|
|
353
|
+
return Object.freeze({
|
|
354
|
+
ok: true,
|
|
355
|
+
code: 'inspected',
|
|
356
|
+
active: ledger.leases.length,
|
|
357
|
+
burst: ledger.spawnTimes.length,
|
|
358
|
+
nextExpiryAt: ledger.leases.length ? Math.min(...ledger.leases.map((lease) => lease.expiresAt)) : null,
|
|
359
|
+
})
|
|
360
|
+
})
|
|
361
|
+
} catch {
|
|
362
|
+
return Object.freeze({ ok: false, code: 'admission_unavailable' })
|
|
363
|
+
}
|
|
364
|
+
}
|