@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.
- package/CHANGELOG.md +29 -0
- package/package.json +1 -1
- package/server/index.ts +141 -0
- package/server/lib/agent-session-binding-registry.ts +1225 -0
- package/server/lib/agent-session-binding-store.ts +327 -0
- package/server/lib/agent-session-ownership-store.ts +395 -0
- package/server/lib/attached-provider-adapter.ts +1223 -0
- package/server/lib/attached-workspace.ts +197 -0
- package/server/lib/native-head.ts +649 -0
- package/server/lib/native-thread-id.ts +23 -0
- package/server/lib/occupancy-probes.ts +444 -0
- package/server/lib/query-job-runtime.ts +24 -0
- package/server/lib/thread-occupancy.ts +367 -0
- package/server/routes/agent-session-bindings.ts +1550 -0
|
@@ -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
|
+
}
|