@gotcos/glasses-server 6.27.13 → 6.28.0

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.
@@ -0,0 +1,1225 @@
1
+ // Durable storage for native thread bindings — the part `agent-session-binding-store.ts`
2
+ // deliberately does not have.
3
+ //
4
+ // That file holds the VALUE TYPE and its pure transitions, and says so in its own
5
+ // header: "there is no persistence here yet, and the durable per-target epoch
6
+ // high-water mark that the replay defense depends on does not exist." This file is
7
+ // that missing half. Without it, three things are lost on every restart:
8
+ //
9
+ // 1. `pinnedJobs` — so a job running across a restart no longer holds its lease.
10
+ // 2. The lease itself — so an attached Chat silently becomes unattached.
11
+ // 3. **The epoch.** This is the dangerous one.
12
+ //
13
+ // WHY THE EPOCH HIGH-WATER MARK IS THE WHOLE POINT OF THIS FILE.
14
+ //
15
+ // `createBinding` takes `priorEpoch` as an INPUT. If a caller reads that from the
16
+ // current in-memory binding, then after detach + eviction (or a restart) the next
17
+ // attach to the same target restarts at epoch 1. The prompt queue is CLIENT-owned:
18
+ // up to five prompts can sit on the phone holding no server state. A row composed
19
+ // against the FIRST attach at epoch 1 then matches the SECOND attach exactly, and
20
+ // executes against a conversation the user never intended. The epoch exists to make
21
+ // that row rejectable by value, and it can only do that if the floor is durable and
22
+ // monotonic. So `epochHighWater` is written on every create, is folded upward from
23
+ // every surviving binding at hydration, is NEVER removed by reaping, and never
24
+ // decreases. A binding is disposable state; its epoch floor is not.
25
+ //
26
+ // THE HYDRATION RULE, WHICH IS THE FAIL-CLOSED DECISION THAT MATTERS.
27
+ //
28
+ // "I could not read the store" is NOT "the store is empty". Starting empty at epoch
29
+ // 0 after a corrupt or unreadable file reopens exactly the replay window above, and
30
+ // does it silently. So:
31
+ //
32
+ // file absent -> `fresh`. First boot. The only permissive outcome, and
33
+ // it is permissive because there is positive
34
+ // evidence nothing was ever written.
35
+ // file unreadable -> `degraded`. Distinct reason from corrupt: one is a
36
+ // file unparseable -> `degraded` permission/IO fault, the other is content.
37
+ // unknown version -> `degraded`. A newer writer owns this file; clobbering it
38
+ // would destroy epoch floors we cannot read.
39
+ // epoch ledger invalid -> `degraded`. The anti-replay state itself is untrustworthy.
40
+ // two blocking owners -> `degraded`. The one-writer-per-target invariant is broken.
41
+ //
42
+ // A degraded registry refuses EVERY mutation and resolves EVERY lookup to nothing.
43
+ // It is not a soft warning; nothing attaches and nothing runs until a human resolves
44
+ // it. And degradation is deliberately STICKY — this file does NOT quarantine the bad
45
+ // file the way `loadJsonOrQuarantine` does, because renaming it away would make the
46
+ // very next boot read `fresh` and restart at epoch 0. A boot loop into the replay
47
+ // window is worse than a hard stop. Recovery is an explicit operator action, and the
48
+ // operator needs to understand that moving the file aside resets the floors.
49
+ //
50
+ // A single malformed RECORD is different and is dropped rather than degrading: its
51
+ // epoch contribution is already covered by the ledger (both are written in the same
52
+ // atomic commit), so dropping it loses a lease — which fails closed, every prompt
53
+ // naming it gets `unknown_binding` — without lowering any floor. That distinction is
54
+ // the reason the ledger is a separate top-level field instead of being derived from
55
+ // the records at read time.
56
+ //
57
+ // NOT USED: `loadJsonOrQuarantine` from atomic-fs. It collapses an unreadable file
58
+ // into `{status:'missing'}` (see its `readFileSync` catch), which is precisely the
59
+ // fail-open this module exists to prevent. Its WRITE half is mandatory and is used.
60
+ //
61
+ // CONCURRENCY. Callers never hand a `NativeBinding` back in. Every mutator takes a
62
+ // `bindingId` and re-reads the authoritative record at call time, so the classic
63
+ // read-modify-write loss — two pins computed from the same stale snapshot, one
64
+ // silently overwriting the other — is unrepresentable through this API. Every
65
+ // mutation body is fully synchronous with no `await` and no user callback before the
66
+ // commit, so within this single-threaded process a mutation cannot interleave with
67
+ // another. A re-entrancy latch backs that up for the one place user code DOES run
68
+ // inside a mutation (`onWarn`) and for any future refactor that introduces an await.
69
+ // Returned bindings are frozen, so a caller cannot mutate one and hope it sticks.
70
+
71
+ import { existsSync, lstatSync, mkdirSync, readFileSync } from 'node:fs'
72
+ import { dirname, resolve } from 'node:path'
73
+
74
+ import { durableAtomicWriteFileSync } from './atomic-fs.js'
75
+ import { dataPath } from './data-dir.js'
76
+ import { isValidNativeThreadId } from './native-thread-id.js'
77
+ import {
78
+ BINDING_ID_RE,
79
+ activate as activateBinding,
80
+ beginDetach as beginDetachBinding,
81
+ boundToMarker,
82
+ checkQueuedPrompt as checkQueuedPromptPure,
83
+ createBinding,
84
+ detach as detachBinding,
85
+ forceDetach as forceDetachBinding,
86
+ isBindableProvider,
87
+ isExpired,
88
+ isPinned,
89
+ isTerminal,
90
+ pin as pinBinding,
91
+ renew as renewBinding,
92
+ targetKey as makeTargetKey,
93
+ unpin as unpinBinding,
94
+ verifyBoundTo as verifyBoundToPure,
95
+ type BindingRejection,
96
+ type BindingState,
97
+ type NativeBinding,
98
+ type QueuedPromptClaim,
99
+ } from './agent-session-binding-store.js'
100
+
101
+ export const BINDING_STORE_VERSION = 1
102
+
103
+ /** Retained bindings, including terminal ones kept for reason reporting. */
104
+ export const DEFAULT_MAX_RETAINED_BINDINGS = 512
105
+
106
+ /**
107
+ * Distinct targets whose epoch floor is remembered.
108
+ *
109
+ * At the cap a NEW target is REFUSED rather than evicted. Evicting a floor is the
110
+ * one operation that can silently lower it, which is the replay bug. There is no
111
+ * safe TTL either: a client-queued prompt can sit on a phone indefinitely, so no
112
+ * age proves a floor is retired. Refusing is honest and loud; evicting is quiet
113
+ * and wrong.
114
+ */
115
+ export const DEFAULT_MAX_EPOCH_TARGETS = 10_000
116
+
117
+ /** Concurrent pins on one binding. Reaching this means a caller is leaking pins. */
118
+ export const DEFAULT_MAX_PINS_PER_BINDING = 64
119
+
120
+ /** How long a dead binding is retained so its rejection reason stays specific. */
121
+ export const DEFAULT_TERMINAL_RETENTION_MS = 24 * 60 * 60 * 1000
122
+
123
+ /**
124
+ * A pin older than this is treated as leaked.
125
+ *
126
+ * Must stay far above the provider deadline (21 min default) so a slow but live
127
+ * job is never mistaken for a dead one.
128
+ */
129
+ /**
130
+ * Completed turns remembered per binding.
131
+ *
132
+ * Bounded because a binding is long-lived and the ledger is rewritten on every
133
+ * commit. Oldest are dropped first, so the newest turn - the one a client is most
134
+ * likely to retry - is the last to age out.
135
+ */
136
+ export const MAX_TURNS_PER_BINDING = 64
137
+
138
+ /** A client turn id must be safe to use as a map key and to log. */
139
+ export const TURN_ID_RE = /^[A-Za-z0-9][A-Za-z0-9_-]{7,127}$/
140
+
141
+ export const DEFAULT_STALE_PIN_MS = 24 * 60 * 60 * 1000
142
+
143
+ /**
144
+ * A job id becomes a JSON object key in this store's persisted `pinnedAt` map and
145
+ * is logged. Bounded and conservative; real ids are `randomUUID()`.
146
+ * Deliberately excludes `/` and a leading dot.
147
+ */
148
+ export const PIN_JOB_ID_RE = /^[A-Za-z0-9][A-Za-z0-9._:@-]{0,127}$/
149
+
150
+ export type RegistryRejection =
151
+ | BindingRejection
152
+ | 'store_unavailable'
153
+ | 'persist_failed'
154
+ | 'reentrant_mutation'
155
+ | 'target_busy'
156
+ | 'binding_id_in_use'
157
+ | 'invalid_job_id'
158
+ | 'too_many_pins'
159
+ | 'registry_full'
160
+ | 'epoch_ledger_full'
161
+ | 'caller_supplied_epoch'
162
+
163
+ export interface RegistryCheck {
164
+ ok: boolean
165
+ reason: RegistryRejection | null
166
+ }
167
+
168
+ export type RegistryResult =
169
+ | { binding: NativeBinding; reason: null }
170
+ | { binding: null; reason: RegistryRejection }
171
+
172
+ const noCheck = (reason: RegistryRejection): RegistryCheck => ({ ok: false, reason })
173
+ const reject = (reason: RegistryRejection): RegistryResult => ({ binding: null, reason })
174
+
175
+ export type HydrationStatus = 'fresh' | 'loaded' | 'degraded'
176
+
177
+ export type DegradedReason =
178
+ | 'store_unreadable'
179
+ | 'store_corrupt'
180
+ | 'unsupported_version'
181
+ | 'invalid_epoch_ledger'
182
+ | 'duplicate_target'
183
+
184
+ export interface HydrationReport {
185
+ /**
186
+ * `fresh` and `loaded` are NOT the same answer and must not be collapsed.
187
+ * `fresh` means the mechanism has never written here; `loaded` means it ran and
188
+ * found what it found. A `fresh` report on a host that has been attaching for
189
+ * weeks means the data directory moved, and the epoch floors moved with it.
190
+ */
191
+ status: HydrationStatus
192
+ degradedReason: DegradedReason | null
193
+ path: string
194
+ loadedBindings: number
195
+ /** Records rejected by validation. Non-zero deserves an alarm, not a shrug. */
196
+ droppedRecords: number
197
+ epochTargets: number
198
+ }
199
+
200
+ export interface BindingRecord {
201
+ binding: NativeBinding
202
+ createdAt: number
203
+ updatedAt: number
204
+ /** When the binding first reached a terminal state. */
205
+ terminalAt: number | null
206
+ /**
207
+ * When a pin was last actually removed.
208
+ *
209
+ * Retention is measured from when a binding became INERT, not from when its TTL
210
+ * lapsed, and those are different instants for a binding that was pinned across
211
+ * its own expiry. Without this, a job that finishes an hour after the lease
212
+ * expired would see its binding removed in the very next reap, and the client
213
+ * whose queued row drains a second later gets `unknown_binding` instead of the
214
+ * actionable `binding_expired`. Expiry fires no event, so the moment a binding
215
+ * stops being claimed can only be reconstructed from the last unpin.
216
+ */
217
+ lastUnpinAt: number | null
218
+ /**
219
+ * Per-pin age, which `NativeBinding.pinnedJobs` cannot express.
220
+ *
221
+ * The value type's own header names the gap: a job that dies without unpinning
222
+ * makes the binding immortal, and "this shape cannot express" a per-pin age. It
223
+ * is carried HERE, in the registry's envelope, so the gap can be closed without
224
+ * changing a type this module does not own.
225
+ */
226
+ pinnedAt: Readonly<Record<string, number>>
227
+ }
228
+
229
+ export interface ReapReport {
230
+ removed: string[]
231
+ pinsDropped: number
232
+ retained: number
233
+ }
234
+
235
+ export interface CreateBindingRequest {
236
+ bindingId: string
237
+ cosSessionId: string
238
+ provider: string
239
+ nativeThreadId: string
240
+ workspaceFingerprint: string
241
+ sourceFingerprint: string
242
+ nativeHeadAtAttach?: string | null
243
+ ttlMs: number
244
+ now: number
245
+ }
246
+
247
+ export interface BindingRegistryOptions {
248
+ /** Defaults to `$COS_AGENT_BINDING_STORE_FILE` then `<DATA_DIR>/agent-session-bindings.json`. */
249
+ filePath?: string
250
+ onWarn?: (message: string, detail?: unknown) => void
251
+ now?: number
252
+ terminalRetentionMs?: number
253
+ stalePinMs?: number
254
+ maxRetainedBindings?: number
255
+ maxEpochTargets?: number
256
+ maxPinsPerBinding?: number
257
+ }
258
+
259
+ function isPlainObject(value: unknown): value is Record<string, unknown> {
260
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
261
+ }
262
+
263
+ function positiveIntOption(value: number | undefined, fallback: number): number {
264
+ return Number.isInteger(value) && (value as number) > 0 ? (value as number) : fallback
265
+ }
266
+
267
+ function nonNegativeMsOption(value: number | undefined, fallback: number): number {
268
+ return Number.isFinite(value) && (value as number) >= 0 ? (value as number) : fallback
269
+ }
270
+
271
+ /**
272
+ * Parse a length-prefixed `targetKey` back into its parts, or null.
273
+ *
274
+ * The point is not decoding — nothing needs the parts. It is that a ledger KEY can
275
+ * be checked for well-formedness at hydration, so a hand-edited or corrupted key
276
+ * cannot quietly become a namespace of its own. A key only counts as well formed
277
+ * when both halves round-trip AND both are values that could have produced it.
278
+ */
279
+ export function parseTargetKey(key: unknown): { provider: string; threadId: string } | null {
280
+ if (typeof key !== 'string' || key.length === 0) return null
281
+
282
+ const firstColon = key.indexOf(':')
283
+ if (firstColon <= 0) return null
284
+ const providerLenRaw = key.slice(0, firstColon)
285
+ if (!/^\d+$/.test(providerLenRaw)) return null
286
+ const providerLen = Number(providerLenRaw)
287
+ const provider = key.slice(firstColon + 1, firstColon + 1 + providerLen)
288
+ if (provider.length !== providerLen) return null
289
+
290
+ const afterProvider = key.slice(firstColon + 1 + providerLen)
291
+ if (!afterProvider.startsWith(':')) return null
292
+ const rest = afterProvider.slice(1)
293
+
294
+ const secondColon = rest.indexOf(':')
295
+ if (secondColon <= 0) return null
296
+ const idLenRaw = rest.slice(0, secondColon)
297
+ if (!/^\d+$/.test(idLenRaw)) return null
298
+ const idLen = Number(idLenRaw)
299
+ const threadId = rest.slice(secondColon + 1)
300
+ if (threadId.length !== idLen) return null
301
+
302
+ // A key that no legal (provider, threadId) pair could have produced is corrupt,
303
+ // not merely unfamiliar.
304
+ if (!isBindableProvider(provider) || !isValidNativeThreadId(threadId)) return null
305
+ if (makeTargetKey(provider, threadId) !== key) return null
306
+
307
+ return { provider, threadId }
308
+ }
309
+
310
+ const BINDING_STATES: readonly BindingState[] = ['staging', 'active', 'detaching', 'detached']
311
+
312
+ function freezeBinding(binding: NativeBinding): NativeBinding {
313
+ const frozen: NativeBinding = {
314
+ ...binding,
315
+ pinnedJobs: Object.freeze([...binding.pinnedJobs]),
316
+ }
317
+ return Object.freeze(frozen)
318
+ }
319
+
320
+ /** Structural validation of one persisted binding. Anything unexpected is null. */
321
+ function validateBinding(value: unknown): NativeBinding | null {
322
+ if (!isPlainObject(value)) return null
323
+
324
+ const {
325
+ bindingId, cosSessionId, provider, nativeThreadId, targetKey,
326
+ workspaceFingerprint, sourceFingerprint, nativeHeadAtAttach,
327
+ epoch, state, expiresAt, pinnedJobs,
328
+ } = value
329
+
330
+ if (typeof bindingId !== 'string' || !BINDING_ID_RE.test(bindingId)) return null
331
+ if (typeof cosSessionId !== 'string') return null
332
+ if (!isBindableProvider(provider)) return null
333
+ if (!isValidNativeThreadId(nativeThreadId)) return null
334
+ // The key is DERIVED, so a stored key that disagrees with its own parts is a
335
+ // forged or corrupted row — it would alias a target the row does not name.
336
+ if (typeof targetKey !== 'string' || targetKey !== makeTargetKey(provider, nativeThreadId)) return null
337
+ if (typeof workspaceFingerprint !== 'string' || typeof sourceFingerprint !== 'string') return null
338
+ if (nativeHeadAtAttach !== null && typeof nativeHeadAtAttach !== 'string') return null
339
+ if (!Number.isInteger(epoch) || (epoch as number) < 1) return null
340
+ if (typeof state !== 'string' || !BINDING_STATES.includes(state as BindingState)) return null
341
+ if (!Number.isFinite(expiresAt)) return null
342
+ if (!Array.isArray(pinnedJobs)) return null
343
+
344
+ const pins: string[] = []
345
+ for (const job of pinnedJobs) {
346
+ if (typeof job !== 'string' || !PIN_JOB_ID_RE.test(job)) return null
347
+ if (!pins.includes(job)) pins.push(job)
348
+ }
349
+
350
+ return freezeBinding({
351
+ bindingId,
352
+ cosSessionId,
353
+ provider,
354
+ nativeThreadId,
355
+ targetKey,
356
+ workspaceFingerprint,
357
+ sourceFingerprint,
358
+ nativeHeadAtAttach: (nativeHeadAtAttach ?? null) as string | null,
359
+ epoch: epoch as number,
360
+ state: state as BindingState,
361
+ expiresAt: expiresAt as number,
362
+ pinnedJobs: pins,
363
+ })
364
+ }
365
+
366
+ export function defaultBindingStorePath(): string {
367
+ const override = process.env.COS_AGENT_BINDING_STORE_FILE
368
+ return resolve(override && override.trim().length > 0 ? override : dataPath('agent-session-bindings.json'))
369
+ }
370
+
371
+ interface LoadedState {
372
+ records: Map<string, BindingRecord>
373
+ targets: Map<string, string>
374
+ floors: Map<string, number>
375
+ /** bindingId -> completed turns, oldest first. See TURN LEDGER below. */
376
+ turns: Map<string, TurnRecord[]>
377
+ }
378
+
379
+ /**
380
+ * A turn that already reached a terminal state, remembered so a repeat of the same
381
+ * client turn id replays the answer instead of delivering the prompt again.
382
+ *
383
+ * THE TURN LEDGER, and why it is durable. Measured on 2026-08-16: two byte-identical
384
+ * POSTs both returned `completed`, and the user's real transcript ended up with TWO
385
+ * copies of the turn. The client cannot tell "delivered, but the 200 was lost" from
386
+ * "never arrived" — a dropped connection, a backgrounded phone, a proxy timeout all
387
+ * look the same — so retrying is CORRECT client behavior, and the server is the only
388
+ * side that can make it safe.
389
+ *
390
+ * It lives in the binding store rather than in memory because the same review found
391
+ * the process-local fence re-opened on restart and delivered a second copy. Binding
392
+ * records, epoch floors and pins were all durable; the one piece of state whose loss
393
+ * writes twice into a human's conversation was not.
394
+ */
395
+ export interface TurnRecord {
396
+ /** Client-supplied idempotency key. */
397
+ turnId: string
398
+ /** The exact terminal response body, replayed verbatim on a repeat. */
399
+ result: unknown
400
+ /** Epoch ms, for bounded retention. */
401
+ at: number
402
+ }
403
+
404
+ export class AgentSessionBindingRegistry {
405
+ readonly path: string
406
+ readonly hydration: HydrationReport
407
+
408
+ private records: Map<string, BindingRecord>
409
+ private targets: Map<string, string>
410
+ private floors: Map<string, number>
411
+ private turns: Map<string, TurnRecord[]>
412
+
413
+ private readonly warn: (message: string, detail?: unknown) => void
414
+ private readonly terminalRetentionMs: number
415
+ private readonly stalePinMs: number
416
+ private readonly maxRetainedBindings: number
417
+ private readonly maxEpochTargets: number
418
+ private readonly maxPinsPerBinding: number
419
+ private mutating = false
420
+
421
+ private constructor(path: string, hydration: HydrationReport, state: LoadedState, options: BindingRegistryOptions) {
422
+ this.path = path
423
+ this.hydration = Object.freeze(hydration)
424
+ this.records = state.records
425
+ this.targets = state.targets
426
+ this.floors = state.floors
427
+ this.turns = state.turns
428
+ this.warn = options.onWarn ?? ((message, detail) => console.warn(`[binding-registry] ${message}`, detail ?? ''))
429
+ this.terminalRetentionMs = nonNegativeMsOption(options.terminalRetentionMs, DEFAULT_TERMINAL_RETENTION_MS)
430
+ this.stalePinMs = nonNegativeMsOption(options.stalePinMs, DEFAULT_STALE_PIN_MS)
431
+ this.maxRetainedBindings = positiveIntOption(options.maxRetainedBindings, DEFAULT_MAX_RETAINED_BINDINGS)
432
+ this.maxEpochTargets = positiveIntOption(options.maxEpochTargets, DEFAULT_MAX_EPOCH_TARGETS)
433
+ this.maxPinsPerBinding = positiveIntOption(options.maxPinsPerBinding, DEFAULT_MAX_PINS_PER_BINDING)
434
+ }
435
+
436
+ /** Hydrate from disk. Never throws: an unusable store yields a degraded registry. */
437
+ static open(options: BindingRegistryOptions = {}): AgentSessionBindingRegistry {
438
+ const path = resolve(options.filePath ?? defaultBindingStorePath())
439
+ const warn = options.onWarn ?? ((message: string, detail?: unknown) =>
440
+ console.warn(`[binding-registry] ${message}`, detail ?? ''))
441
+ const now = Number.isFinite(options.now) ? (options.now as number) : Date.now()
442
+
443
+ const empty = (): LoadedState => ({ records: new Map(), targets: new Map(), floors: new Map(), turns: new Map() })
444
+ const degraded = (reason: DegradedReason): AgentSessionBindingRegistry => {
445
+ warn(
446
+ `REFUSING ALL BINDINGS — durable store at ${path} is unusable (${reason}). ` +
447
+ 'The file is left in place on purpose: renaming it away would make the next boot ' +
448
+ 'look like a first boot and restart every epoch at 1, which re-opens the queued-prompt ' +
449
+ 'replay window. Resolve it deliberately.',
450
+ )
451
+ return new AgentSessionBindingRegistry(
452
+ path,
453
+ { status: 'degraded', degradedReason: reason, path, loadedBindings: 0, droppedRecords: 0, epochTargets: 0 },
454
+ empty(),
455
+ options,
456
+ )
457
+ }
458
+
459
+ // `existsSync` collapses "definitely absent" with "cannot see it" — EACCES,
460
+ // ELOOP, EIO, a dangling symlink. `fresh` is the ONLY permissive outcome, and
461
+ // this file's header justifies it as "positive evidence nothing was ever
462
+ // written"; existsSync is not that evidence. Measured: a dangling symlink at
463
+ // the store path hydrated `fresh` with epochFloor 0 while the real floor on
464
+ // disk was 9, and the next attach minted epoch 1 — so a prompt queued on the
465
+ // phone from the earlier epoch-1 attach matches by value again. Only ENOENT
466
+ // may mean fresh.
467
+ let storeAbsent: boolean
468
+ try {
469
+ lstatSync(path)
470
+ storeAbsent = false
471
+ } catch (error: any) {
472
+ if (error?.code !== 'ENOENT') return degraded('store_unreadable')
473
+ storeAbsent = true
474
+ }
475
+ if (storeAbsent) {
476
+ return new AgentSessionBindingRegistry(
477
+ path,
478
+ { status: 'fresh', degradedReason: null, path, loadedBindings: 0, droppedRecords: 0, epochTargets: 0 },
479
+ empty(),
480
+ options,
481
+ )
482
+ }
483
+
484
+ let raw: string
485
+ try {
486
+ raw = readFileSync(path, 'utf-8')
487
+ } catch (err) {
488
+ // Distinct from corrupt on purpose. A permission or IO fault is an operator
489
+ // problem with a different fix than bad content — and collapsing it into
490
+ // "missing" is the exact fail-open in `loadJsonOrQuarantine`.
491
+ warn(`store unreadable: ${path}`, err)
492
+ return degraded('store_unreadable')
493
+ }
494
+
495
+ let parsed: unknown
496
+ try {
497
+ parsed = JSON.parse(raw)
498
+ } catch (err) {
499
+ warn(`store unparseable: ${path}`, err)
500
+ return degraded('store_corrupt')
501
+ }
502
+
503
+ if (!isPlainObject(parsed)) return degraded('store_corrupt')
504
+ const root: Record<string, unknown> = parsed
505
+ if (root.version !== BINDING_STORE_VERSION) return degraded('unsupported_version')
506
+ const rawRecords = root.records
507
+ if (!Array.isArray(rawRecords)) return degraded('store_corrupt')
508
+ const rawLedger = root.epochHighWater
509
+ if (!isPlainObject(rawLedger)) return degraded('invalid_epoch_ledger')
510
+
511
+ // The ledger is the anti-replay state itself. It is all-or-nothing: a single
512
+ // entry we cannot trust means we cannot prove any floor, so partial acceptance
513
+ // would be a guess dressed as a guarantee.
514
+ const floors = new Map<string, number>()
515
+ for (const [key, value] of Object.entries(rawLedger)) {
516
+ if (parseTargetKey(key) === null) {
517
+ warn(`epoch ledger holds a malformed target key: ${JSON.stringify(key).slice(0, 120)}`)
518
+ return degraded('invalid_epoch_ledger')
519
+ }
520
+ if (!Number.isInteger(value) || (value as number) < 1) {
521
+ warn(`epoch ledger holds a non-epoch value for a target`)
522
+ return degraded('invalid_epoch_ledger')
523
+ }
524
+ floors.set(key, value as number)
525
+ }
526
+
527
+ const records = new Map<string, BindingRecord>()
528
+ const targets = new Map<string, string>()
529
+ // Optional by design: a store written before the ledger existed hydrates with
530
+ // no turns and simply has no idempotency history yet, which is correct rather
531
+ // than degraded. Every row is validated; a malformed one is DROPPED rather
532
+ // than trusted, because a bad `result` would be replayed to a client verbatim.
533
+ const turns = new Map<string, TurnRecord[]>()
534
+ const rawTurns = (root as Record<string, unknown>).turns
535
+ if (rawTurns && typeof rawTurns === 'object' && !Array.isArray(rawTurns)) {
536
+ for (const [bindingId, rows] of Object.entries(rawTurns as Record<string, unknown>)) {
537
+ if (!Array.isArray(rows)) continue
538
+ const kept: TurnRecord[] = []
539
+ for (const row of rows) {
540
+ if (!row || typeof row !== 'object' || Array.isArray(row)) continue
541
+ const r = row as Record<string, unknown>
542
+ if (typeof r.turnId !== 'string' || !TURN_ID_RE.test(r.turnId)) continue
543
+ if (typeof r.at !== 'number' || !Number.isFinite(r.at)) continue
544
+ if (r.result === undefined) continue
545
+ kept.push({ turnId: r.turnId, result: r.result, at: r.at })
546
+ }
547
+ if (kept.length > 0) turns.set(bindingId, kept.slice(-MAX_TURNS_PER_BINDING))
548
+ }
549
+ }
550
+ let dropped = 0
551
+
552
+ for (const entry of rawRecords as unknown[]) {
553
+ if (!isPlainObject(entry)) { dropped += 1; continue }
554
+ const binding = validateBinding(entry.binding)
555
+ if (!binding) { dropped += 1; continue }
556
+ if (records.has(binding.bindingId)) { dropped += 1; continue }
557
+
558
+ const createdAt = Number.isFinite(entry.createdAt) ? (entry.createdAt as number) : now
559
+ const updatedAt = Number.isFinite(entry.updatedAt) ? (entry.updatedAt as number) : createdAt
560
+ const terminalAt = Number.isFinite(entry.terminalAt)
561
+ ? (entry.terminalAt as number)
562
+ : (isTerminal(binding) ? updatedAt : null)
563
+
564
+ // A pin whose age we cannot read is aged CONSERVATIVELY (as young as the
565
+ // record's last write). Under-estimating a pin's age delays reaping it;
566
+ // over-estimating would drop the lease out from under a live job.
567
+ const pinnedAt: Record<string, number> = {}
568
+ const storedPins = isPlainObject(entry.pinnedAt) ? entry.pinnedAt : {}
569
+ for (const job of binding.pinnedJobs) {
570
+ const at = storedPins[job]
571
+ pinnedAt[job] = Number.isFinite(at) ? (at as number) : updatedAt
572
+ }
573
+
574
+ records.set(binding.bindingId, {
575
+ binding,
576
+ createdAt,
577
+ updatedAt,
578
+ terminalAt,
579
+ lastUnpinAt: Number.isFinite(entry.lastUnpinAt) ? (entry.lastUnpinAt as number) : null,
580
+ pinnedAt: Object.freeze(pinnedAt),
581
+ })
582
+
583
+ // Fold every surviving epoch upward. A record can only have been written
584
+ // alongside its ledger entry, so this should be a no-op — but if the two ever
585
+ // disagree, the HIGHER value is the only safe one.
586
+ const floor = floors.get(binding.targetKey) ?? 0
587
+ if (binding.epoch > floor) floors.set(binding.targetKey, binding.epoch)
588
+ }
589
+
590
+ for (const record of records.values()) {
591
+ if (!blocksTarget(record.binding, now)) continue
592
+ const held = targets.get(record.binding.targetKey)
593
+ if (held !== undefined) {
594
+ warn(`two live bindings claim one target: ${held} and ${record.binding.bindingId}`)
595
+ return degraded('duplicate_target')
596
+ }
597
+ targets.set(record.binding.targetKey, record.binding.bindingId)
598
+ }
599
+
600
+ if (dropped > 0) {
601
+ warn(
602
+ `${dropped} binding record(s) failed validation and were dropped. Their leases are gone ` +
603
+ '(every prompt naming one now rejects as unknown_binding), but their epoch floors are ' +
604
+ 'intact in the ledger, so no replay window opened.',
605
+ )
606
+ }
607
+
608
+ return new AgentSessionBindingRegistry(
609
+ path,
610
+ {
611
+ status: 'loaded',
612
+ degradedReason: null,
613
+ path,
614
+ loadedBindings: records.size,
615
+ droppedRecords: dropped,
616
+ epochTargets: floors.size,
617
+ },
618
+ { records, targets, floors, turns },
619
+ options,
620
+ )
621
+ }
622
+
623
+ // ---------------------------------------------------------------- reads
624
+
625
+ available(): boolean {
626
+ return this.hydration.status !== 'degraded'
627
+ }
628
+
629
+ /** Why nothing works, or null when the registry is usable. */
630
+ unavailableReason(): DegradedReason | null {
631
+ return this.hydration.degradedReason
632
+ }
633
+
634
+ get(bindingId: unknown): NativeBinding | null {
635
+ if (!this.available()) return null
636
+ if (typeof bindingId !== 'string') return null
637
+ return this.records.get(bindingId)?.binding ?? null
638
+ }
639
+
640
+ record(bindingId: unknown): BindingRecord | null {
641
+ if (!this.available()) return null
642
+ if (typeof bindingId !== 'string') return null
643
+ return this.records.get(bindingId) ?? null
644
+ }
645
+
646
+ /**
647
+ * The single binding currently holding a target, or null.
648
+ *
649
+ * "Holding" means it would block a new attach: pinned in any state, or
650
+ * non-terminal and unexpired. An expired or fully detached binding is still
651
+ * retrievable by id — so its rejection reason stays specific — but it no longer
652
+ * owns the target.
653
+ */
654
+ getByTarget(targetKeyValue: unknown, now: number): NativeBinding | null {
655
+ if (!this.available()) return null
656
+ if (typeof targetKeyValue !== 'string') return null
657
+ const id = this.targets.get(targetKeyValue)
658
+ if (id === undefined) return null
659
+ const record = this.records.get(id)
660
+ if (!record) return null
661
+ return blocksTarget(record.binding, now) ? record.binding : null
662
+ }
663
+
664
+ getByThread(provider: unknown, nativeThreadId: unknown, now: number): NativeBinding | null {
665
+ if (!isBindableProvider(provider) || !isValidNativeThreadId(nativeThreadId)) return null
666
+ return this.getByTarget(makeTargetKey(provider, nativeThreadId), now)
667
+ }
668
+
669
+ list(): NativeBinding[] {
670
+ if (!this.available()) return []
671
+ return [...this.records.values()].map(r => r.binding)
672
+ }
673
+
674
+ /**
675
+ * Highest epoch ever issued for this target, from durable state.
676
+ *
677
+ * Null when the registry is degraded — the honest answer, and distinct from 0
678
+ * ("never issued"). A caller must never read 0 out of a broken store.
679
+ */
680
+ epochFloor(targetKeyValue: unknown): number | null {
681
+ if (!this.available()) return null
682
+ if (typeof targetKeyValue !== 'string') return null
683
+ return this.floors.get(targetKeyValue) ?? 0
684
+ }
685
+
686
+ /** Delegates to the pure gate, resolving the binding from durable state. */
687
+ checkQueuedPrompt(claim: QueuedPromptClaim, now: number): RegistryCheck {
688
+ if (!this.available()) return noCheck('store_unavailable')
689
+ if (!isPlainObject(claim) || typeof claim.bindingId !== 'string') return noCheck('unknown_binding')
690
+ return checkQueuedPromptPure(claim, this.records.get(claim.bindingId)?.binding ?? null, now)
691
+ }
692
+
693
+ verifyBoundTo(marker: unknown, bindingId: unknown, now: number): RegistryCheck {
694
+ if (!this.available()) return noCheck('store_unavailable')
695
+ return verifyBoundToPure(marker, this.get(bindingId), now)
696
+ }
697
+
698
+ /** The marker for a binding this registry actually holds. Null otherwise. */
699
+ markerFor(bindingId: unknown): string | null {
700
+ const binding = this.get(bindingId)
701
+ return binding ? boundToMarker(binding) : null
702
+ }
703
+
704
+ // ------------------------------------------------------------- mutations
705
+
706
+ /**
707
+ * Attach.
708
+ *
709
+ * `priorEpoch` is NOT an input. It is read from the durable ledger, which is the
710
+ * entire reason this method exists rather than callers using `createBinding`
711
+ * directly. A caller that supplies one is refused outright rather than quietly
712
+ * ignored, because supplying it is the bug.
713
+ */
714
+ create(input: CreateBindingRequest): RegistryResult {
715
+ return this.runMutation(() => {
716
+ if (!isPlainObject(input)) return reject('invalid_binding_id')
717
+ if (Object.prototype.hasOwnProperty.call(input, 'priorEpoch')) {
718
+ this.warn('create() rejected: the epoch comes from durable state, never from the caller')
719
+ return reject('caller_supplied_epoch')
720
+ }
721
+
722
+ // Validated BEFORE the target key is computed. The key becomes a mutex key and
723
+ // a ledger key; `SAFE_ID_RE` elsewhere in this repo permits ':' and '/', so an
724
+ // unvalidated id could mint a whole namespace of its own.
725
+ if (!isBindableProvider(input.provider)) return reject('invalid_provider')
726
+ if (!isValidNativeThreadId(input.nativeThreadId)) return reject('invalid_thread_id')
727
+ if (typeof input.bindingId !== 'string' || !BINDING_ID_RE.test(input.bindingId)) {
728
+ return reject('invalid_binding_id')
729
+ }
730
+ if (this.records.has(input.bindingId)) return reject('binding_id_in_use')
731
+ if (!Number.isFinite(input.now)) return reject('invalid_ttl')
732
+
733
+ const now = input.now
734
+ const key = makeTargetKey(input.provider, input.nativeThreadId)
735
+
736
+ const holderId = this.targets.get(key)
737
+ const holder = holderId === undefined ? undefined : this.records.get(holderId)
738
+ if (holder && blocksTarget(holder.binding, now)) return reject('target_busy')
739
+
740
+ const floor = this.floors.get(key) ?? 0
741
+ if (!this.floors.has(key) && this.floors.size >= this.maxEpochTargets) {
742
+ this.warn(`epoch ledger is full (${this.floors.size}); refusing a new target rather than evicting a floor`)
743
+ return reject('epoch_ledger_full')
744
+ }
745
+
746
+ const created = createBinding({
747
+ bindingId: input.bindingId,
748
+ cosSessionId: input.cosSessionId,
749
+ provider: input.provider,
750
+ nativeThreadId: input.nativeThreadId,
751
+ workspaceFingerprint: input.workspaceFingerprint,
752
+ sourceFingerprint: input.sourceFingerprint,
753
+ nativeHeadAtAttach: input.nativeHeadAtAttach ?? null,
754
+ priorEpoch: floor,
755
+ ttlMs: input.ttlMs,
756
+ now,
757
+ })
758
+ if (!created.binding) return reject(created.reason)
759
+
760
+ const binding = freezeBinding(created.binding)
761
+ const records = new Map(this.records)
762
+ const targets = new Map(this.targets)
763
+ const floors = new Map(this.floors)
764
+
765
+ // The stale holder keeps its record (so a queued prompt naming it still gets
766
+ // `binding_expired` / `binding_detached` rather than `unknown_binding`) but
767
+ // gives up the target.
768
+ if (holderId !== undefined) targets.delete(key)
769
+
770
+ if (records.size >= this.maxRetainedBindings) {
771
+ const freed = evictOldestDisposable(records, targets, now, records.size - this.maxRetainedBindings + 1)
772
+ if (freed <= 0 || records.size >= this.maxRetainedBindings) {
773
+ this.warn(`registry is full at ${records.size} retained bindings and none are disposable`)
774
+ return reject('registry_full')
775
+ }
776
+ }
777
+
778
+ records.set(binding.bindingId, {
779
+ binding,
780
+ createdAt: now,
781
+ updatedAt: now,
782
+ terminalAt: null,
783
+ lastUnpinAt: null,
784
+ pinnedAt: Object.freeze({}),
785
+ })
786
+ targets.set(key, binding.bindingId)
787
+ // Monotonic by construction: `epoch` is floor + 1.
788
+ floors.set(key, Math.max(floor, binding.epoch))
789
+
790
+ const failure = this.persist({ records, targets, floors, turns: new Map(this.turns) })
791
+ return failure ? reject(failure) : { binding, reason: null }
792
+ })
793
+ }
794
+
795
+ activate(bindingId: unknown, now: number): RegistryResult {
796
+ return this.runMutation(() => {
797
+ const found = this.requireRecord(bindingId)
798
+ if ('reason' in found) return reject(found.reason)
799
+ const { record } = found
800
+ if (!Number.isFinite(now)) return reject('invalid_ttl')
801
+ // Activating a lease that is already dead would publish a usable-looking
802
+ // binding whose every subsequent check fails. Refuse at the transition.
803
+ if (isExpired(record.binding, now)) return reject('binding_expired')
804
+
805
+ const next = activateBinding(record.binding)
806
+ if (!next.binding) return reject(next.reason ?? 'terminal_state')
807
+ return this.commitBinding(record, next.binding, now)
808
+ })
809
+ }
810
+
811
+ beginDetach(bindingId: unknown, now: number): RegistryResult {
812
+ return this.runMutation(() => {
813
+ const found = this.requireRecord(bindingId)
814
+ if ('reason' in found) return reject(found.reason)
815
+ return this.commitBinding(found.record, beginDetachBinding(found.record.binding), now)
816
+ })
817
+ }
818
+
819
+ detach(bindingId: unknown, now: number): RegistryResult {
820
+ return this.runMutation(() => {
821
+ const found = this.requireRecord(bindingId)
822
+ if ('reason' in found) return reject(found.reason)
823
+ const next = detachBinding(found.record.binding)
824
+ if (!next.binding) return reject(next.reason)
825
+ return this.commitBinding(found.record, next.binding, now)
826
+ })
827
+ }
828
+
829
+ forceDetach(bindingId: unknown, now: number): RegistryResult {
830
+ return this.runMutation(() => {
831
+ const found = this.requireRecord(bindingId)
832
+ if ('reason' in found) return reject(found.reason)
833
+ return this.commitBinding(found.record, forceDetachBinding(found.record.binding), now)
834
+ })
835
+ }
836
+
837
+ renew(bindingId: unknown, ttlMs: number, now: number): RegistryResult {
838
+ return this.runMutation(() => {
839
+ const found = this.requireRecord(bindingId)
840
+ if ('reason' in found) return reject(found.reason)
841
+ return this.commitBinding(found.record, renewBinding(found.record.binding, ttlMs, now), now)
842
+ })
843
+ }
844
+
845
+ /**
846
+ * Pin a job to this binding so the TTL cannot expire it.
847
+ *
848
+ * CALLER CONTRACT: a rejection here is FATAL to the turn. An unpinned binding can
849
+ * expire or be detached while the job runs, so a caller that proceeds anyway has
850
+ * defeated the lease. Do not treat a failed pin as advisory.
851
+ */
852
+ pin(bindingId: unknown, jobId: unknown, now: number): RegistryResult {
853
+ return this.runMutation(() => {
854
+ const found = this.requireRecord(bindingId)
855
+ if ('reason' in found) return reject(found.reason)
856
+ const { record } = found
857
+ if (typeof jobId !== 'string' || !PIN_JOB_ID_RE.test(jobId)) return reject('invalid_job_id')
858
+ if (!Number.isFinite(now)) return reject('invalid_ttl')
859
+ if (isTerminal(record.binding)) return reject('terminal_state')
860
+ // Pinning an expired binding would resurrect it into permanent non-expiry.
861
+ if (isExpired(record.binding, now)) return reject('binding_expired')
862
+
863
+ if (record.binding.pinnedJobs.includes(jobId)) {
864
+ return { binding: record.binding, reason: null } // idempotent
865
+ }
866
+ if (record.binding.pinnedJobs.length >= this.maxPinsPerBinding) {
867
+ this.warn(`binding ${record.binding.bindingId} already holds ${record.binding.pinnedJobs.length} pins`)
868
+ return reject('too_many_pins')
869
+ }
870
+
871
+ const next = pinBinding(record.binding, jobId)
872
+ if (!next.pinnedJobs.includes(jobId)) return reject('terminal_state')
873
+ return this.commitBinding(record, next, now, { pinnedAt: { ...record.pinnedAt, [jobId]: now } })
874
+ })
875
+ }
876
+
877
+ unpin(bindingId: unknown, jobId: unknown, now: number): RegistryResult {
878
+ return this.runMutation(() => {
879
+ const found = this.requireRecord(bindingId)
880
+ if ('reason' in found) return reject(found.reason)
881
+ const { record } = found
882
+ if (typeof jobId !== 'string' || !PIN_JOB_ID_RE.test(jobId)) return reject('invalid_job_id')
883
+
884
+ const held = record.binding.pinnedJobs.includes(jobId)
885
+ const next = unpinBinding(record.binding, jobId)
886
+ const pinnedAt = { ...record.pinnedAt }
887
+ delete pinnedAt[jobId]
888
+ return this.commitBinding(record, next, now, {
889
+ pinnedAt,
890
+ // Only an actual release starts the inert clock. Unpinning a job that was
891
+ // never pinned is a no-op and must not extend anything.
892
+ lastUnpinAt: held && Number.isFinite(now) ? now : record.lastUnpinAt,
893
+ })
894
+ })
895
+ }
896
+
897
+ /**
898
+ * Drop leaked pins, then remove bindings that are dead past their retention.
899
+ *
900
+ * A pinned binding NEVER expires and is never reaped — accepted or running work
901
+ * outranks the TTL. The leak that follows from that is the known gap the value
902
+ * type names: a job that dies without unpinning makes its binding immortal, and
903
+ * an immortal `active` binding is a TTL that stopped meaning anything.
904
+ *
905
+ * The correction is deliberately minimal: drop only the pins that are provably
906
+ * older than any possible live job and let the TTL take authority again. It does
907
+ * NOT force-detach the binding, because a sibling pin may still be live, and
908
+ * killing a lease under a running job is a worse failure than a late reap.
909
+ */
910
+ /**
911
+ * Has this exact client turn already reached a terminal state on this binding?
912
+ *
913
+ * A hit means the prompt was ALREADY handed to the provider, so the caller must
914
+ * replay this result instead of delivering again. Two byte-identical POSTs put
915
+ * two copies of the turn into a real transcript before this existed.
916
+ */
917
+ findTurn(bindingId: unknown, turnId: unknown): TurnRecord | null {
918
+ if (typeof bindingId !== 'string' || typeof turnId !== 'string') return null
919
+ if (!TURN_ID_RE.test(turnId)) return null
920
+ const rows = this.turns.get(bindingId)
921
+ if (!rows) return null
922
+ return rows.find(row => row.turnId === turnId) ?? null
923
+ }
924
+
925
+ /**
926
+ * Remember a completed turn so a repeat replays rather than re-delivers.
927
+ *
928
+ * Records the FIRST result for a turn id and never overwrites it: a repeat must
929
+ * see what the original turn actually did, not a later attempt's answer.
930
+ */
931
+ recordTurn(bindingId: unknown, turnId: unknown, result: unknown, now: number): RegistryResult {
932
+ if (typeof bindingId !== 'string' || !this.records.has(bindingId)) return reject('unknown_binding')
933
+ if (typeof turnId !== 'string' || !TURN_ID_RE.test(turnId)) return reject('invalid_job_id')
934
+ if (result === undefined || !Number.isFinite(now)) return reject('invalid_job_id')
935
+
936
+ return this.runMutation(() => {
937
+ const binding = this.records.get(bindingId)!.binding
938
+ const existing = this.turns.get(bindingId) ?? []
939
+ // FIRST result wins, never overwritten: a repeat must see what the original
940
+ // turn actually did, not a later attempt's answer.
941
+ if (existing.some(row => row.turnId === turnId)) return { binding, reason: null }
942
+ const turns = new Map(this.turns)
943
+ turns.set(bindingId, [...existing, { turnId, result, at: now }].slice(-MAX_TURNS_PER_BINDING))
944
+ const failure = this.persist({
945
+ records: new Map(this.records),
946
+ targets: new Map(this.targets),
947
+ floors: new Map(this.floors),
948
+ turns,
949
+ })
950
+ return failure ? reject(failure) : { binding, reason: null }
951
+ })
952
+ }
953
+
954
+ reap(now: number): ReapReport {
955
+ if (!this.available()) return { removed: [], pinsDropped: 0, retained: 0 }
956
+ if (this.mutating) return { removed: [], pinsDropped: 0, retained: this.records.size }
957
+ if (!Number.isFinite(now)) return { removed: [], pinsDropped: 0, retained: this.records.size }
958
+
959
+ this.mutating = true
960
+ try {
961
+ const records = new Map<string, BindingRecord>()
962
+ const removed: string[] = []
963
+ let pinsDropped = 0
964
+ let changed = false
965
+
966
+ for (const [id, record] of this.records) {
967
+ let current = record
968
+ const stale = record.binding.pinnedJobs.filter(job => {
969
+ const at = record.pinnedAt[job]
970
+ return typeof at === 'number' && Number.isFinite(at) && now - at >= this.stalePinMs
971
+ })
972
+ if (stale.length > 0) {
973
+ let binding = record.binding
974
+ const pinnedAt = { ...record.pinnedAt }
975
+ for (const job of stale) {
976
+ binding = unpinBinding(binding, job)
977
+ delete pinnedAt[job]
978
+ }
979
+ pinsDropped += stale.length
980
+ changed = true
981
+ current = {
982
+ ...record,
983
+ binding: freezeBinding(binding),
984
+ updatedAt: now,
985
+ pinnedAt: Object.freeze(pinnedAt),
986
+ lastUnpinAt: now,
987
+ terminalAt: record.terminalAt ?? (isTerminal(binding) ? now : null),
988
+ }
989
+ this.warn(
990
+ `dropped ${stale.length} leaked pin(s) from binding ${id} — held longer than ` +
991
+ `${this.stalePinMs}ms, far past any live job. The TTL governs it again.`,
992
+ )
993
+ }
994
+
995
+ if (this.isDisposable(current, now)) {
996
+ removed.push(id)
997
+ changed = true
998
+ continue
999
+ }
1000
+ records.set(id, current)
1001
+ }
1002
+
1003
+ if (!changed) return { removed: [], pinsDropped: 0, retained: this.records.size }
1004
+
1005
+ const targets = new Map<string, string>()
1006
+ for (const record of records.values()) {
1007
+ if (blocksTarget(record.binding, now)) targets.set(record.binding.targetKey, record.binding.bindingId)
1008
+ }
1009
+
1010
+ // Floors are carried forward untouched. Reaping a binding must never lower
1011
+ // the epoch its target has already reached — that is the replay window.
1012
+ // Turns are carried forward with their bindings; a reaped binding's turns
1013
+ // are dropped with it, since a replay of a turn on a removed binding could
1014
+ // never be answered anyway.
1015
+ const keptTurns = new Map([...this.turns].filter(([id]) => records.has(id)))
1016
+ const failure = this.persist({ records, targets, floors: new Map(this.floors), turns: keptTurns })
1017
+ if (failure) return { removed: [], pinsDropped: 0, retained: this.records.size }
1018
+ return { removed, pinsDropped, retained: records.size }
1019
+ } finally {
1020
+ this.mutating = false
1021
+ }
1022
+ }
1023
+
1024
+ // ------------------------------------------------------------- internals
1025
+
1026
+ /**
1027
+ * When did this binding stop claiming its target?
1028
+ *
1029
+ * Null while it still blocks. Otherwise the LATEST of the reasons it could have
1030
+ * become inert — terminal, expired, or released — because a binding pinned across
1031
+ * its own expiry only became inert at the release, and retention has to run from
1032
+ * there for the rejection reason to still be useful to the client.
1033
+ */
1034
+ private inertSince(record: BindingRecord, now: number): number | null {
1035
+ if (blocksTarget(record.binding, now)) return null
1036
+ let at = Number.NEGATIVE_INFINITY
1037
+ if (isTerminal(record.binding)) at = Math.max(at, record.terminalAt ?? record.updatedAt)
1038
+ if (isExpired(record.binding, now)) at = Math.max(at, record.binding.expiresAt)
1039
+ if (record.lastUnpinAt !== null) at = Math.max(at, record.lastUnpinAt)
1040
+ return Number.isFinite(at) ? at : record.updatedAt
1041
+ }
1042
+
1043
+ private isDisposable(record: BindingRecord, now: number): boolean {
1044
+ const since = this.inertSince(record, now)
1045
+ return since !== null && now - since >= this.terminalRetentionMs
1046
+ }
1047
+
1048
+ private requireRecord(bindingId: unknown): { record: BindingRecord } | { reason: RegistryRejection } {
1049
+ if (typeof bindingId !== 'string') return { reason: 'unknown_binding' }
1050
+ const record = this.records.get(bindingId)
1051
+ return record ? { record } : { reason: 'unknown_binding' }
1052
+ }
1053
+
1054
+ private commitBinding(
1055
+ previous: BindingRecord,
1056
+ nextBinding: NativeBinding,
1057
+ now: number,
1058
+ patch: { pinnedAt?: Record<string, number>; lastUnpinAt?: number | null } = {},
1059
+ ): RegistryResult {
1060
+ const binding = freezeBinding(nextBinding)
1061
+ const pins = patch.pinnedAt ?? { ...previous.pinnedAt }
1062
+ // A state change that clears pins (forceDetach) is also a release.
1063
+ const cleared = previous.binding.pinnedJobs.length > 0 && binding.pinnedJobs.length === 0
1064
+ const lastUnpinAt = patch.lastUnpinAt !== undefined
1065
+ ? patch.lastUnpinAt
1066
+ : (cleared && Number.isFinite(now) ? now : previous.lastUnpinAt)
1067
+
1068
+ const unchanged =
1069
+ binding.state === previous.binding.state &&
1070
+ binding.expiresAt === previous.binding.expiresAt &&
1071
+ binding.pinnedJobs.length === previous.binding.pinnedJobs.length &&
1072
+ binding.pinnedJobs.every((job, i) => job === previous.binding.pinnedJobs[i]) &&
1073
+ Object.keys(pins).length === Object.keys(previous.pinnedAt).length &&
1074
+ Object.keys(pins).every(job => pins[job] === previous.pinnedAt[job])
1075
+ if (unchanged) return { binding: previous.binding, reason: null }
1076
+
1077
+ const records = new Map(this.records)
1078
+ records.set(binding.bindingId, {
1079
+ ...previous,
1080
+ binding,
1081
+ updatedAt: Number.isFinite(now) ? now : previous.updatedAt,
1082
+ terminalAt: previous.terminalAt ?? (isTerminal(binding) && Number.isFinite(now) ? now : previous.terminalAt),
1083
+ lastUnpinAt,
1084
+ pinnedAt: Object.freeze(pins),
1085
+ })
1086
+
1087
+ const targets = new Map(this.targets)
1088
+ if (blocksTarget(binding, now)) targets.set(binding.targetKey, binding.bindingId)
1089
+ else if (targets.get(binding.targetKey) === binding.bindingId) targets.delete(binding.targetKey)
1090
+
1091
+ const failure = this.persist({ records, targets, floors: new Map(this.floors), turns: new Map(this.turns) })
1092
+ return failure ? reject(failure) : { binding, reason: null }
1093
+ }
1094
+
1095
+ /**
1096
+ * PERSIST THEN COMMIT.
1097
+ *
1098
+ * In-memory state is swapped only after the atomic write returns. A failed write
1099
+ * therefore leaves memory and disk agreeing on the OLD state instead of leaving a
1100
+ * lease that exists in this process and nowhere else — which after a restart would
1101
+ * be a silently vanished lease, or worse, a lost epoch bump.
1102
+ */
1103
+ private persist(next: LoadedState): RegistryRejection | null {
1104
+ // MERGE the on-disk epoch floor upward before writing.
1105
+ //
1106
+ // The floor is the anti-replay backstop and this file's header states it never
1107
+ // decreases. In-process that holds. It does NOT hold if anything else ever
1108
+ // writes this store: a second reader hydrates a snapshot, issues its own
1109
+ // epochs, and its next commit rewrites the whole file from a stale view —
1110
+ // measured in review, where a concurrent registry reissued epoch 1 and left the
1111
+ // on-disk floor BELOW an epoch already handed out.
1112
+ //
1113
+ // A second SERVER cannot happen: the instance lock is per-uid and global
1114
+ // (/tmp/cos-glasses-server-<uid>.lock), so bootstrap refuses one. But tooling
1115
+ // opening the store directly can, and did during review. This makes the floor
1116
+ // monotonic against any writer without introducing a lock, because max() of two
1117
+ // views is never lower than either.
1118
+ try {
1119
+ const onDisk = readFileSync(this.path, 'utf-8')
1120
+ const parsed: unknown = JSON.parse(onDisk)
1121
+ const ledger = (parsed as Record<string, unknown> | null)?.epochHighWater
1122
+ if (ledger && typeof ledger === 'object' && !Array.isArray(ledger)) {
1123
+ for (const [key, value] of Object.entries(ledger as Record<string, unknown>)) {
1124
+ if (typeof value !== 'number' || !Number.isInteger(value) || value < 1) continue
1125
+ const held = next.floors.get(key) ?? 0
1126
+ if (value > held) next.floors.set(key, value)
1127
+ }
1128
+ }
1129
+ } catch {
1130
+ // No store yet, or unreadable. Writing our own view is correct for the first
1131
+ // case, and for the second the alternative is refusing to persist at all,
1132
+ // which would strand a live binding over a stale file we cannot read anyway.
1133
+ }
1134
+
1135
+ let json: string
1136
+ try {
1137
+ json = JSON.stringify({
1138
+ version: BINDING_STORE_VERSION,
1139
+ epochHighWater: Object.fromEntries([...next.floors.entries()].sort((a, b) => (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0))),
1140
+ // Additive at version 1: an older build ignores this key, a newer build
1141
+ // reads its absence as "no history yet". Neither direction corrupts.
1142
+ turns: Object.fromEntries([...next.turns.entries()].filter(([, rows]) => rows.length > 0)),
1143
+ records: [...next.records.values()].map(record => ({
1144
+ binding: record.binding,
1145
+ createdAt: record.createdAt,
1146
+ updatedAt: record.updatedAt,
1147
+ terminalAt: record.terminalAt,
1148
+ lastUnpinAt: record.lastUnpinAt,
1149
+ pinnedAt: record.pinnedAt,
1150
+ })),
1151
+ })
1152
+ } catch (err) {
1153
+ this.warn('refusing to commit: state did not serialize', err)
1154
+ return 'persist_failed'
1155
+ }
1156
+
1157
+ try {
1158
+ mkdirSync(dirname(this.path), { recursive: true, mode: 0o700 })
1159
+ durableAtomicWriteFileSync(this.path, json, { mode: 0o600 })
1160
+ } catch (err) {
1161
+ this.warn(`refusing to commit: durable write failed for ${this.path}`, err)
1162
+ return 'persist_failed'
1163
+ }
1164
+
1165
+ this.records = next.records
1166
+ this.targets = next.targets
1167
+ this.floors = next.floors
1168
+ this.turns = next.turns
1169
+ return null
1170
+ }
1171
+
1172
+ /**
1173
+ * The mutation latch.
1174
+ *
1175
+ * Bodies are synchronous, so two callers cannot interleave inside this process.
1176
+ * The one place user code runs mid-mutation is `onWarn`; if a handler re-enters,
1177
+ * it is refused rather than allowed to mutate a half-built state.
1178
+ */
1179
+ private runMutation(body: () => RegistryResult): RegistryResult {
1180
+ if (!this.available()) return reject('store_unavailable')
1181
+ if (this.mutating) return reject('reentrant_mutation')
1182
+ this.mutating = true
1183
+ try {
1184
+ return body()
1185
+ } finally {
1186
+ this.mutating = false
1187
+ }
1188
+ }
1189
+ }
1190
+
1191
+ /**
1192
+ * Would this binding block a new attach to its target?
1193
+ *
1194
+ * Pinned in ANY state blocks, including `detaching` — a draining binding still has
1195
+ * a live job writing to that native thread, and letting a second binding attach
1196
+ * there is the two-writer hazard the whole feature exists to avoid.
1197
+ */
1198
+ function blocksTarget(binding: NativeBinding, now: number): boolean {
1199
+ if (isPinned(binding)) return true
1200
+ if (isTerminal(binding)) return false
1201
+ return !isExpired(binding, now)
1202
+ }
1203
+
1204
+ /** Evict retained-but-dead records, oldest first. Never touches a blocking one. */
1205
+ function evictOldestDisposable(
1206
+ records: Map<string, BindingRecord>,
1207
+ targets: Map<string, string>,
1208
+ now: number,
1209
+ wanted: number,
1210
+ ): number {
1211
+ const candidates = [...records.values()]
1212
+ .filter(record => !blocksTarget(record.binding, now))
1213
+ .sort((a, b) => a.updatedAt - b.updatedAt)
1214
+
1215
+ let freed = 0
1216
+ for (const record of candidates) {
1217
+ if (freed >= wanted) break
1218
+ records.delete(record.binding.bindingId)
1219
+ if (targets.get(record.binding.targetKey) === record.binding.bindingId) {
1220
+ targets.delete(record.binding.targetKey)
1221
+ }
1222
+ freed += 1
1223
+ }
1224
+ return freed
1225
+ }