@gotcos/glasses-server 6.77.0 → 6.78.1
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/.env.example +40 -0
- package/CHANGELOG.md +24 -0
- package/README.md +12 -1
- package/managed-runtime-contract.json +9 -1
- package/package.json +10 -1
- package/server/index.ts +96 -10
- package/server/lib/agent-session-binding-registry.ts +623 -9
- package/server/lib/boot-resume.ts +552 -0
- package/server/lib/claude-bridge.ts +152 -3
- package/server/lib/claude-cli-support.ts +39 -0
- package/server/lib/claude-steer.ts +259 -0
- package/server/lib/env-switch.ts +17 -0
- package/server/lib/managed-restart.ts +8 -0
- package/server/lib/native-head.ts +65 -6
- package/server/lib/provider-actions.ts +18 -0
- package/server/lib/query-job-coordinator.ts +300 -0
- package/server/lib/query-job-feature.ts +39 -0
- package/server/lib/query-job-resume.ts +269 -0
- package/server/lib/query-job-runtime.ts +23 -3
- package/server/lib/query-job-store.ts +516 -7
- package/server/lib/query-job-types.ts +210 -1
- package/server/lib/queue-turn-evidence.ts +213 -0
- package/server/lib/thread-turn-queue-deliver.ts +6 -1
- package/server/lib/thread-turn-queue-store.ts +13 -4
- package/server/lib/thread-turn-queue.ts +151 -1
- package/server/routes/agent-session-bindings.ts +169 -11
- package/server/routes/health.ts +26 -1
- package/server/routes/maintenance.ts +34 -0
- package/server/routes/meeting.ts +27 -9
- package/server/routes/query-jobs.ts +107 -13
- package/server/routes/recovery.ts +4 -0
- package/server/routes/thread-turn-queue.ts +371 -10
- package/server/scripts/live-cues-v2-notes-mutation-gate.mjs +0 -170
- package/server/scripts/transcript-cleanup-mutation-gate.mjs +0 -219
|
@@ -74,6 +74,9 @@ import { dirname, resolve } from 'node:path'
|
|
|
74
74
|
import { durableAtomicWriteFileSync } from './atomic-fs.js'
|
|
75
75
|
import { dataPath } from './data-dir.js'
|
|
76
76
|
import { isValidNativeThreadId } from './native-thread-id.js'
|
|
77
|
+
import { serverMetrics } from './server-metrics.js'
|
|
78
|
+
import { PROC_START_TOLERANCE_MS } from './thread-occupancy.js'
|
|
79
|
+
import { pinBootReapEnabled, queuedTurnReasonCopy } from './thread-turn-queue.js'
|
|
77
80
|
import {
|
|
78
81
|
BINDING_ID_RE,
|
|
79
82
|
activate as activateBinding,
|
|
@@ -244,6 +247,106 @@ export interface BindingRecord {
|
|
|
244
247
|
* changing a type this module does not own.
|
|
245
248
|
*/
|
|
246
249
|
pinnedAt: Readonly<Record<string, number>>
|
|
250
|
+
/**
|
|
251
|
+
* 6.78.0 (COS_PIN_BOOT_REAP): which client turn each pin carries, and the boot that took
|
|
252
|
+
* it. Absent on every pin a 6.77 build wrote, and on every pin taken with the flag off:
|
|
253
|
+
* such a pin can still be released after a crash, but with no turn to name it leaves no
|
|
254
|
+
* ledger record (the upgrade-window gap the 6.78.0 notes state).
|
|
255
|
+
*/
|
|
256
|
+
pinMeta?: Readonly<Record<string, PinMeta>>
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/** 6.78.0: what a pin says about the turn it holds the lease for. */
|
|
260
|
+
export interface PinMeta {
|
|
261
|
+
clientTurnId: string
|
|
262
|
+
bootId: string
|
|
263
|
+
/**
|
|
264
|
+
* 6.78.0 (P7 S6): the resume child this pin's turn spawned, with its MEASURED start time
|
|
265
|
+
* (`ps -o lstart`), recorded at spawn. A dead boot's pin that names one is released only
|
|
266
|
+
* once that child is gone (no such pid, or a pid with a different start time: reused).
|
|
267
|
+
* Absent when the turn went live, never spawned, or the pin predates this build.
|
|
268
|
+
*/
|
|
269
|
+
child?: { pid: number; startMs: number }
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* 6.78.0 (P7 S6): how long a pin a dead boot left holds its thread, counted from the later of
|
|
274
|
+
* the pin and the restart. The attached adapter accepts turns of up to
|
|
275
|
+
* `MAX_ATTACHED_TIMEOUT_MS` (30 min, attached-provider-adapter.ts), so the dead boot's child
|
|
276
|
+
* may legitimately run that long; one minute more covers its SIGTERM-to-SIGKILL grace. A test
|
|
277
|
+
* pins the parity (the registry does not import the adapter: it would pull the provider
|
|
278
|
+
* spawn machinery into every module that reads bindings). Wall clock, so a Mac that slept
|
|
279
|
+
* through it has had the time pass; a pin that names its child also waits for that child.
|
|
280
|
+
*/
|
|
281
|
+
export const PIN_ORPHAN_FENCE_MS = 31 * 60_000
|
|
282
|
+
|
|
283
|
+
/** 6.78.0 (P7 S6): is a recorded resume child still the same running process? */
|
|
284
|
+
export type PinChildProbe = (child: { pid: number; startMs: number }) => 'running' | 'gone'
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* The default probe. `kill(pid, 0)` failing with ESRCH is the only proof the pid is gone;
|
|
288
|
+
* EPERM or any other error means a process holds it (fail closed). A live pid whose measured
|
|
289
|
+
* start time differs from the recorded one by more than the ownership tolerance is a reused
|
|
290
|
+
* pid: gone. A start time that cannot be measured keeps the hold (fail closed).
|
|
291
|
+
*/
|
|
292
|
+
export function makePinChildProbe(processStartMs?: (pid: number) => number | null): PinChildProbe {
|
|
293
|
+
return child => {
|
|
294
|
+
try {
|
|
295
|
+
process.kill(child.pid, 0)
|
|
296
|
+
} catch (error) {
|
|
297
|
+
if ((error as NodeJS.ErrnoException)?.code === 'ESRCH') return 'gone'
|
|
298
|
+
return 'running'
|
|
299
|
+
}
|
|
300
|
+
if (!processStartMs) return 'running'
|
|
301
|
+
let start: number | null = null
|
|
302
|
+
try { start = processStartMs(child.pid) } catch { start = null }
|
|
303
|
+
if (typeof start !== 'number' || !Number.isFinite(start)) return 'running'
|
|
304
|
+
return Math.abs(start - child.startMs) > PROC_START_TOLERANCE_MS ? 'gone' : 'running'
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/** Refusal copy while a dead boot's pin waits on a child that is still running (62 columns or fewer). */
|
|
309
|
+
export const ORPHAN_CHILD_RUNNING_COPY = 'An earlier run is still running on this Mac'
|
|
310
|
+
|
|
311
|
+
/** How a recorded delivery attempt ended. `pending` is "began and never reported back". */
|
|
312
|
+
export type TurnAttemptExit = 'pending' | 'sent' | 'not_sent' | 'unknown'
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* One delivery attempt for a client turn on a target (6.78.0, the attempt journal).
|
|
316
|
+
*
|
|
317
|
+
* TARGET-SCOPED AND SEPARATE FROM THE TURN LEDGER. The ledger lives with a binding and is
|
|
318
|
+
* dropped when the binding is reaped (24 h after it went inert), while a phone retries a key
|
|
319
|
+
* for up to a day and a stranded queue row outlives both. So the journal is keyed by target,
|
|
320
|
+
* survives reaps, and is written in the SAME commit as the pin, before any frame or spawn:
|
|
321
|
+
* "no attempt on file" then proves the turn never reached a delivery hop.
|
|
322
|
+
*/
|
|
323
|
+
export interface TurnAttempt {
|
|
324
|
+
bootId: string
|
|
325
|
+
at: number
|
|
326
|
+
mode: 'turn' | 'steer'
|
|
327
|
+
exit: TurnAttemptExit
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/** What the journal can say about one key. `evictedThrough`: no attempt at or before this instant can be read back. */
|
|
331
|
+
export interface TurnAttemptLookup {
|
|
332
|
+
attempt: TurnAttempt | null
|
|
333
|
+
evictedThrough: number | null
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/** Attempts are kept at least this long: twice the phone's 24 h retry horizon. */
|
|
337
|
+
export const ATTEMPT_RETENTION_MS = 48 * 60 * 60 * 1000
|
|
338
|
+
/** Per target. A target past this sheds its oldest attempt (and records that it did). */
|
|
339
|
+
export const MAX_ATTEMPTS_PER_TARGET = 256
|
|
340
|
+
/** Targets with a journal. Past this the oldest target's attempts are shed. */
|
|
341
|
+
export const MAX_ATTEMPT_TARGETS = 2048
|
|
342
|
+
const ATTEMPT_EXITS: ReadonlySet<string> = new Set(['pending', 'sent', 'not_sent', 'unknown'])
|
|
343
|
+
|
|
344
|
+
/** What a boot-orphan release did. */
|
|
345
|
+
export interface OrphanPinReport {
|
|
346
|
+
/** Pins a dead boot left, released because their orphan child must be gone. */
|
|
347
|
+
released: number
|
|
348
|
+
/** Of those, the ones that named a client turn and got an `ambiguous` ledger record. */
|
|
349
|
+
recorded: number
|
|
247
350
|
}
|
|
248
351
|
|
|
249
352
|
export interface ReapReport {
|
|
@@ -274,12 +377,31 @@ export interface BindingRegistryOptions {
|
|
|
274
377
|
maxRetainedBindings?: number
|
|
275
378
|
maxEpochTargets?: number
|
|
276
379
|
maxPinsPerBinding?: number
|
|
380
|
+
/** 6.78.0: this process's boot, stamped on pins and attempts. Defaults to `serverMetrics.bootId`. */
|
|
381
|
+
bootId?: string
|
|
382
|
+
/** 6.78.0: `COS_PIN_BOOT_REAP`, read per call. Injectable for tests. */
|
|
383
|
+
pinBootReap?: () => boolean
|
|
384
|
+
/**
|
|
385
|
+
* 6.78.0 (P7 S6): how a dead boot's pin learns whether its recorded child still runs.
|
|
386
|
+
* Defaults to `makePinChildProbe()` (kill 0 only); index.ts passes the measured start-time
|
|
387
|
+
* probe so a reused pid reads as gone.
|
|
388
|
+
*/
|
|
389
|
+
pinChildProbe?: PinChildProbe
|
|
277
390
|
}
|
|
278
391
|
|
|
279
392
|
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
|
280
393
|
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
281
394
|
}
|
|
282
395
|
|
|
396
|
+
/** A recorded resume child, or null when the value is not one. */
|
|
397
|
+
function parsePinChild(value: unknown): { pid: number; startMs: number } | null {
|
|
398
|
+
if (!isPlainObject(value)) return null
|
|
399
|
+
const { pid, startMs } = value
|
|
400
|
+
if (typeof pid !== 'number' || !Number.isSafeInteger(pid) || pid <= 1) return null
|
|
401
|
+
if (typeof startMs !== 'number' || !Number.isFinite(startMs) || startMs <= 0) return null
|
|
402
|
+
return { pid, startMs }
|
|
403
|
+
}
|
|
404
|
+
|
|
283
405
|
function positiveIntOption(value: number | undefined, fallback: number): number {
|
|
284
406
|
return Number.isInteger(value) && (value as number) > 0 ? (value as number) : fallback
|
|
285
407
|
}
|
|
@@ -394,6 +516,57 @@ interface LoadedState {
|
|
|
394
516
|
floors: Map<string, number>
|
|
395
517
|
/** bindingId -> completed turns, oldest first. See TURN LEDGER below. */
|
|
396
518
|
turns: Map<string, TurnRecord[]>
|
|
519
|
+
/**
|
|
520
|
+
* 6.78.0: targetKey -> clientTurnId -> attempt. Optional so every pre-6.78 commit path
|
|
521
|
+
* carries the current journal forward untouched; only the journal's own writers set it.
|
|
522
|
+
*/
|
|
523
|
+
attempts?: Map<string, Map<string, TurnAttempt>>
|
|
524
|
+
/** 6.78.0: the newest `at` the journal ever shed (age or count). Null: nothing shed yet. */
|
|
525
|
+
attemptsEvictedThrough?: number | null
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
/** Validate one persisted attempt. Anything unexpected is dropped, never trusted. */
|
|
529
|
+
function validateAttempt(value: unknown): TurnAttempt | null {
|
|
530
|
+
if (!isPlainObject(value)) return null
|
|
531
|
+
const { bootId, at, mode, exit } = value
|
|
532
|
+
if (typeof bootId !== 'string' || bootId.length === 0 || bootId.length > 128) return null
|
|
533
|
+
if (typeof at !== 'number' || !Number.isFinite(at)) return null
|
|
534
|
+
if (mode !== 'turn' && mode !== 'steer') return null
|
|
535
|
+
if (typeof exit !== 'string' || !ATTEMPT_EXITS.has(exit)) return null
|
|
536
|
+
return { bootId, at, mode, exit: exit as TurnAttemptExit }
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
/**
|
|
540
|
+
* Bound the journal: age first (ATTEMPT_RETENTION_MS), then per-target count, then target
|
|
541
|
+
* count. Every attempt shed raises `evictedThrough`, so a lookup can tell "never attempted"
|
|
542
|
+
* from "attempted and forgotten", and the second reads as unknown, never as unsent.
|
|
543
|
+
*/
|
|
544
|
+
function boundAttempts(
|
|
545
|
+
attempts: Map<string, Map<string, TurnAttempt>>,
|
|
546
|
+
evictedThrough: number | null,
|
|
547
|
+
now: number,
|
|
548
|
+
): number | null {
|
|
549
|
+
let through = evictedThrough
|
|
550
|
+
const shed = (at: number): void => { through = through === null ? at : Math.max(through, at) }
|
|
551
|
+
for (const [key, rows] of attempts) {
|
|
552
|
+
for (const [id, row] of rows) {
|
|
553
|
+
if (Number.isFinite(now) && now - row.at > ATTEMPT_RETENTION_MS) { rows.delete(id); shed(row.at) }
|
|
554
|
+
}
|
|
555
|
+
if (rows.size > MAX_ATTEMPTS_PER_TARGET) {
|
|
556
|
+
const oldest = [...rows.entries()].sort((a, b) => a[1].at - b[1].at)
|
|
557
|
+
for (const [id, row] of oldest.slice(0, rows.size - MAX_ATTEMPTS_PER_TARGET)) { rows.delete(id); shed(row.at) }
|
|
558
|
+
}
|
|
559
|
+
if (rows.size === 0) attempts.delete(key)
|
|
560
|
+
}
|
|
561
|
+
if (attempts.size > MAX_ATTEMPT_TARGETS) {
|
|
562
|
+
const newestOf = (rows: Map<string, TurnAttempt>) => Math.max(...[...rows.values()].map(r => r.at))
|
|
563
|
+
const byAge = [...attempts.entries()].sort((a, b) => newestOf(a[1]) - newestOf(b[1]))
|
|
564
|
+
for (const [key, rows] of byAge.slice(0, attempts.size - MAX_ATTEMPT_TARGETS)) {
|
|
565
|
+
for (const row of rows.values()) shed(row.at)
|
|
566
|
+
attempts.delete(key)
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
return through
|
|
397
570
|
}
|
|
398
571
|
|
|
399
572
|
/**
|
|
@@ -421,6 +594,42 @@ export interface TurnRecord {
|
|
|
421
594
|
at: number
|
|
422
595
|
}
|
|
423
596
|
|
|
597
|
+
/** 6.78.0: what `pin` may carry beyond the job id. */
|
|
598
|
+
export interface PinExtras {
|
|
599
|
+
/** Name the client turn this pin holds the lease for (COS_PIN_BOOT_REAP). */
|
|
600
|
+
pinMeta?: { clientTurnId: string }
|
|
601
|
+
/** Open an attempt for this client turn in the same commit (COS_QUEUE_RECONCILE). */
|
|
602
|
+
attempt?: { clientTurnId: string; mode: 'turn' | 'steer' }
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
/** 6.78.0: what `unpin` may carry: how the turn's delivery ended. */
|
|
606
|
+
export interface UnpinExtras {
|
|
607
|
+
attemptExit?: { clientTurnId: string; exit: TurnAttemptExit }
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
function cloneAttempts(source: Map<string, Map<string, TurnAttempt>>): Map<string, Map<string, TurnAttempt>> {
|
|
611
|
+
return new Map([...source].map(([key, rows]) => [key, new Map(rows)]))
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
/**
|
|
615
|
+
* The ledger record a released dead-boot pin leaves for its client turn: the same shape as
|
|
616
|
+
* the turns route's own ambiguous answer, so a retry of the key replays it verbatim and
|
|
617
|
+
* every client already reads it as "do not send again".
|
|
618
|
+
*/
|
|
619
|
+
export function orphanReleaseRecord(turnId: string): Record<string, unknown> {
|
|
620
|
+
return {
|
|
621
|
+
turnId,
|
|
622
|
+
outcome: 'ambiguous',
|
|
623
|
+
deliveryState: 'unknown',
|
|
624
|
+
retryable: false,
|
|
625
|
+
changed: false,
|
|
626
|
+
revision: null,
|
|
627
|
+
reason: 'delivery_unknown_after_restart',
|
|
628
|
+
reasonCopy: queuedTurnReasonCopy('delivery_unknown_after_restart'),
|
|
629
|
+
status: 409,
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
|
|
424
633
|
export class AgentSessionBindingRegistry {
|
|
425
634
|
readonly path: string
|
|
426
635
|
readonly hydration: HydrationReport
|
|
@@ -429,6 +638,25 @@ export class AgentSessionBindingRegistry {
|
|
|
429
638
|
private targets: Map<string, string>
|
|
430
639
|
private floors: Map<string, number>
|
|
431
640
|
private turns: Map<string, TurnRecord[]>
|
|
641
|
+
/** 6.78.0: the attempt journal (targetKey -> clientTurnId -> attempt). */
|
|
642
|
+
private attempts: Map<string, Map<string, TurnAttempt>>
|
|
643
|
+
private attemptsEvictedThrough: number | null
|
|
644
|
+
/**
|
|
645
|
+
* 6.78.0: every pin that was ALREADY on disk when this registry opened, as
|
|
646
|
+
* `bindingId\u0000jobId`. One server owns this store (the instance lock), so a pin loaded at
|
|
647
|
+
* hydration was taken by a boot that is gone, whatever its metadata says, including a 6.77
|
|
648
|
+
* pin that carries none. A pin this process takes is never in the set.
|
|
649
|
+
*/
|
|
650
|
+
private readonly inheritedPins: Set<string>
|
|
651
|
+
private readonly bootId: string
|
|
652
|
+
/**
|
|
653
|
+
* 6.78.0: when this registry opened the store, an upper bound on when the boot that left
|
|
654
|
+
* an inherited pin died. A dead boot's pin is released no earlier than PIN_ORPHAN_FENCE_MS
|
|
655
|
+
* (31 minutes) after BOTH its pin and this instant (`orphanReleaseAt`).
|
|
656
|
+
*/
|
|
657
|
+
private readonly openedAt: number
|
|
658
|
+
private readonly pinBootReap: () => boolean
|
|
659
|
+
private readonly pinChildProbe: PinChildProbe
|
|
432
660
|
|
|
433
661
|
private readonly warn: (message: string, detail?: unknown) => void
|
|
434
662
|
private readonly terminalRetentionMs: number
|
|
@@ -445,6 +673,16 @@ export class AgentSessionBindingRegistry {
|
|
|
445
673
|
this.targets = state.targets
|
|
446
674
|
this.floors = state.floors
|
|
447
675
|
this.turns = state.turns
|
|
676
|
+
this.attempts = state.attempts ?? new Map()
|
|
677
|
+
this.attemptsEvictedThrough = state.attemptsEvictedThrough ?? null
|
|
678
|
+
this.inheritedPins = new Set()
|
|
679
|
+
for (const record of state.records.values()) {
|
|
680
|
+
for (const job of record.binding.pinnedJobs) this.inheritedPins.add(`${record.binding.bindingId}\u0000${job}`)
|
|
681
|
+
}
|
|
682
|
+
this.bootId = typeof options.bootId === 'string' && options.bootId.length > 0 ? options.bootId : serverMetrics.bootId
|
|
683
|
+
this.openedAt = Number.isFinite(options.now) ? (options.now as number) : Date.now()
|
|
684
|
+
this.pinBootReap = typeof options.pinBootReap === 'function' ? options.pinBootReap : () => pinBootReapEnabled()
|
|
685
|
+
this.pinChildProbe = typeof options.pinChildProbe === 'function' ? options.pinChildProbe : makePinChildProbe()
|
|
448
686
|
this.warn = options.onWarn ?? ((message, detail) => console.warn(`[binding-registry] ${message}`, detail ?? ''))
|
|
449
687
|
this.terminalRetentionMs = nonNegativeMsOption(options.terminalRetentionMs, DEFAULT_TERMINAL_RETENTION_MS)
|
|
450
688
|
this.stalePinMs = nonNegativeMsOption(options.stalePinMs, DEFAULT_STALE_PIN_MS)
|
|
@@ -591,6 +829,19 @@ export class AgentSessionBindingRegistry {
|
|
|
591
829
|
pinnedAt[job] = Number.isFinite(at) ? (at as number) : updatedAt
|
|
592
830
|
}
|
|
593
831
|
|
|
832
|
+
// 6.78.0: pin metadata, per pin still held. A malformed entry is dropped: the pin
|
|
833
|
+
// still releases on time, it just cannot name a turn.
|
|
834
|
+
const pinMeta: Record<string, PinMeta> = {}
|
|
835
|
+
const storedMeta = isPlainObject(entry.pinMeta) ? entry.pinMeta : {}
|
|
836
|
+
for (const job of binding.pinnedJobs) {
|
|
837
|
+
const meta = storedMeta[job]
|
|
838
|
+
if (isPlainObject(meta) && typeof meta.clientTurnId === 'string' && TURN_ID_RE.test(meta.clientTurnId)
|
|
839
|
+
&& typeof meta.bootId === 'string' && meta.bootId.length > 0 && meta.bootId.length <= 128) {
|
|
840
|
+
const child = parsePinChild(meta.child)
|
|
841
|
+
pinMeta[job] = { clientTurnId: meta.clientTurnId, bootId: meta.bootId, ...(child ? { child } : {}) }
|
|
842
|
+
}
|
|
843
|
+
}
|
|
844
|
+
|
|
594
845
|
records.set(binding.bindingId, {
|
|
595
846
|
binding,
|
|
596
847
|
createdAt,
|
|
@@ -598,6 +849,7 @@ export class AgentSessionBindingRegistry {
|
|
|
598
849
|
terminalAt,
|
|
599
850
|
lastUnpinAt: Number.isFinite(entry.lastUnpinAt) ? (entry.lastUnpinAt as number) : null,
|
|
600
851
|
pinnedAt: Object.freeze(pinnedAt),
|
|
852
|
+
...(Object.keys(pinMeta).length > 0 ? { pinMeta: Object.freeze(pinMeta) } : {}),
|
|
601
853
|
})
|
|
602
854
|
|
|
603
855
|
// Fold every surviving epoch upward. A record can only have been written
|
|
@@ -617,6 +869,30 @@ export class AgentSessionBindingRegistry {
|
|
|
617
869
|
targets.set(record.binding.targetKey, record.binding.bindingId)
|
|
618
870
|
}
|
|
619
871
|
|
|
872
|
+
// 6.78.0: the attempt journal. Additive at version 1, like `turns`: absent reads as an
|
|
873
|
+
// empty journal, a malformed row is dropped. A dropped row is NOT "never attempted": the
|
|
874
|
+
// shed high-water rises to the newest instant the store could have held it (`now`), so
|
|
875
|
+
// every lookup at or before it reads unknown rather than unsent.
|
|
876
|
+
const attempts = new Map<string, Map<string, TurnAttempt>>()
|
|
877
|
+
let attemptsEvictedThrough: number | null = Number.isFinite(root.attemptsEvictedThrough) ? (root.attemptsEvictedThrough as number) : null
|
|
878
|
+
const rawAttempts = root.attempts
|
|
879
|
+
if (rawAttempts !== undefined) {
|
|
880
|
+
if (!isPlainObject(rawAttempts)) {
|
|
881
|
+
attemptsEvictedThrough = now
|
|
882
|
+
} else {
|
|
883
|
+
for (const [key, rows] of Object.entries(rawAttempts)) {
|
|
884
|
+
if (parseTargetKey(key) === null || !isPlainObject(rows)) { attemptsEvictedThrough = now; continue }
|
|
885
|
+
const kept = new Map<string, TurnAttempt>()
|
|
886
|
+
for (const [id, row] of Object.entries(rows)) {
|
|
887
|
+
const attempt = TURN_ID_RE.test(id) ? validateAttempt(row) : null
|
|
888
|
+
if (attempt) kept.set(id, attempt)
|
|
889
|
+
else attemptsEvictedThrough = now
|
|
890
|
+
}
|
|
891
|
+
if (kept.size > 0) attempts.set(key, kept)
|
|
892
|
+
}
|
|
893
|
+
}
|
|
894
|
+
}
|
|
895
|
+
|
|
620
896
|
if (dropped > 0) {
|
|
621
897
|
warn(
|
|
622
898
|
`${dropped} binding record(s) failed validation and were dropped. Their leases are gone ` +
|
|
@@ -635,7 +911,7 @@ export class AgentSessionBindingRegistry {
|
|
|
635
911
|
droppedRecords: dropped,
|
|
636
912
|
epochTargets: floors.size,
|
|
637
913
|
},
|
|
638
|
-
{ records, targets, floors, turns },
|
|
914
|
+
{ records, targets, floors, turns, attempts, attemptsEvictedThrough },
|
|
639
915
|
options,
|
|
640
916
|
)
|
|
641
917
|
}
|
|
@@ -752,6 +1028,9 @@ export class AgentSessionBindingRegistry {
|
|
|
752
1028
|
|
|
753
1029
|
const now = input.now
|
|
754
1030
|
const key = makeTargetKey(input.provider, input.nativeThreadId)
|
|
1031
|
+
// 6.78.0: a dead boot's pin on THIS target is released here, lazily, once its orphan
|
|
1032
|
+
// window has passed (`reapOrphanedPins`). Its own commit, before this attach's.
|
|
1033
|
+
if (this.pinBootReap()) this.releaseOrphansUnlocked(now, key)
|
|
755
1034
|
|
|
756
1035
|
const holderId = this.targets.get(key)
|
|
757
1036
|
const holder = holderId === undefined ? undefined : this.records.get(holderId)
|
|
@@ -880,7 +1159,7 @@ export class AgentSessionBindingRegistry {
|
|
|
880
1159
|
* expire or be detached while the job runs, so a caller that proceeds anyway has
|
|
881
1160
|
* defeated the lease. Do not treat a failed pin as advisory.
|
|
882
1161
|
*/
|
|
883
|
-
pin(bindingId: unknown, jobId: unknown, now: number): RegistryResult {
|
|
1162
|
+
pin(bindingId: unknown, jobId: unknown, now: number, extras: PinExtras = {}): RegistryResult {
|
|
884
1163
|
return this.runMutation(() => {
|
|
885
1164
|
const found = this.requireRecord(bindingId)
|
|
886
1165
|
if ('reason' in found) return reject(found.reason)
|
|
@@ -901,11 +1180,23 @@ export class AgentSessionBindingRegistry {
|
|
|
901
1180
|
|
|
902
1181
|
const next = pinBinding(record.binding, jobId)
|
|
903
1182
|
if (!next.pinnedJobs.includes(jobId)) return reject('terminal_state')
|
|
904
|
-
|
|
1183
|
+
// 6.78.0: the pin metadata and the attempt land in THIS commit. Nothing is sent before
|
|
1184
|
+
// a pin, so an attempt written with it is on disk before any frame or spawn can be.
|
|
1185
|
+
const meta = extras.pinMeta && TURN_ID_RE.test(extras.pinMeta.clientTurnId)
|
|
1186
|
+
? { ...(record.pinMeta ?? {}), [jobId]: { clientTurnId: extras.pinMeta.clientTurnId, bootId: this.bootId } }
|
|
1187
|
+
: undefined
|
|
1188
|
+
const journal = extras.attempt && TURN_ID_RE.test(extras.attempt.clientTurnId)
|
|
1189
|
+
? this.attemptsWith(record.binding.targetKey, extras.attempt.clientTurnId, { bootId: this.bootId, at: now, mode: extras.attempt.mode, exit: 'pending' }, now)
|
|
1190
|
+
: null
|
|
1191
|
+
return this.commitBinding(record, next, now, {
|
|
1192
|
+
pinnedAt: { ...record.pinnedAt, [jobId]: now },
|
|
1193
|
+
...(meta ? { pinMeta: meta } : {}),
|
|
1194
|
+
...(journal ? { attempts: journal.attempts, attemptsEvictedThrough: journal.evictedThrough } : {}),
|
|
1195
|
+
})
|
|
905
1196
|
})
|
|
906
1197
|
}
|
|
907
1198
|
|
|
908
|
-
unpin(bindingId: unknown, jobId: unknown, now: number): RegistryResult {
|
|
1199
|
+
unpin(bindingId: unknown, jobId: unknown, now: number, extras: UnpinExtras = {}): RegistryResult {
|
|
909
1200
|
return this.runMutation(() => {
|
|
910
1201
|
const found = this.requireRecord(bindingId)
|
|
911
1202
|
if ('reason' in found) return reject(found.reason)
|
|
@@ -916,15 +1207,300 @@ export class AgentSessionBindingRegistry {
|
|
|
916
1207
|
const next = unpinBinding(record.binding, jobId)
|
|
917
1208
|
const pinnedAt = { ...record.pinnedAt }
|
|
918
1209
|
delete pinnedAt[jobId]
|
|
1210
|
+
// 6.78.0: the turn's exit, in the same commit as the release.
|
|
1211
|
+
const journal = extras.attemptExit && TURN_ID_RE.test(extras.attemptExit.clientTurnId)
|
|
1212
|
+
? this.attemptExitWith(record.binding.targetKey, extras.attemptExit.clientTurnId, extras.attemptExit.exit, now)
|
|
1213
|
+
: null
|
|
919
1214
|
return this.commitBinding(record, next, now, {
|
|
920
1215
|
pinnedAt,
|
|
921
1216
|
// Only an actual release starts the inert clock. Unpinning a job that was
|
|
922
1217
|
// never pinned is a no-op and must not extend anything.
|
|
923
1218
|
lastUnpinAt: held && Number.isFinite(now) ? now : record.lastUnpinAt,
|
|
1219
|
+
...(journal ? { attempts: journal.attempts, attemptsEvictedThrough: journal.evictedThrough } : {}),
|
|
924
1220
|
})
|
|
925
1221
|
})
|
|
926
1222
|
}
|
|
927
1223
|
|
|
1224
|
+
// ------------------------------------------------------- 6.78.0 attempt journal
|
|
1225
|
+
|
|
1226
|
+
/**
|
|
1227
|
+
* Record that a delivery is about to begin, durably, BEFORE the frame goes out. The steer
|
|
1228
|
+
* path's first write (it takes no pin). False when the store could not record it, in
|
|
1229
|
+
* which case the caller must not send.
|
|
1230
|
+
*/
|
|
1231
|
+
beginAttempt(provider: unknown, nativeThreadId: unknown, clientTurnId: unknown, mode: 'turn' | 'steer', now: number): boolean {
|
|
1232
|
+
if (!isBindableProvider(provider) || !isValidNativeThreadId(nativeThreadId)) return false
|
|
1233
|
+
if (typeof clientTurnId !== 'string' || !TURN_ID_RE.test(clientTurnId) || !Number.isFinite(now)) return false
|
|
1234
|
+
const result = this.runJournalMutation(() => {
|
|
1235
|
+
const journal = this.attemptsWith(makeTargetKey(provider, nativeThreadId), clientTurnId, { bootId: this.bootId, at: now, mode, exit: 'pending' }, now)
|
|
1236
|
+
return this.persistJournal(journal)
|
|
1237
|
+
})
|
|
1238
|
+
return result
|
|
1239
|
+
}
|
|
1240
|
+
|
|
1241
|
+
/** Record how an attempt ended. False when it could not be written. */
|
|
1242
|
+
settleAttempt(provider: unknown, nativeThreadId: unknown, clientTurnId: unknown, exit: TurnAttemptExit, now: number): boolean {
|
|
1243
|
+
if (!isBindableProvider(provider) || !isValidNativeThreadId(nativeThreadId)) return false
|
|
1244
|
+
if (typeof clientTurnId !== 'string' || !TURN_ID_RE.test(clientTurnId)) return false
|
|
1245
|
+
return this.runJournalMutation(() => {
|
|
1246
|
+
const journal = this.attemptExitWith(makeTargetKey(provider, nativeThreadId), clientTurnId, exit, now)
|
|
1247
|
+
return journal ? this.persistJournal(journal) : true
|
|
1248
|
+
})
|
|
1249
|
+
}
|
|
1250
|
+
|
|
1251
|
+
/**
|
|
1252
|
+
* What the journal knows about one key, or null when the store cannot be read (degraded).
|
|
1253
|
+
* Null is never "no attempt": a caller deciding whether to resend must treat it as unknown.
|
|
1254
|
+
*/
|
|
1255
|
+
attemptFor(provider: unknown, nativeThreadId: unknown, clientTurnId: unknown): TurnAttemptLookup | null {
|
|
1256
|
+
if (!this.available()) return null
|
|
1257
|
+
if (!isBindableProvider(provider) || !isValidNativeThreadId(nativeThreadId)) return null
|
|
1258
|
+
if (typeof clientTurnId !== 'string') return null
|
|
1259
|
+
const row = this.attempts.get(makeTargetKey(provider, nativeThreadId))?.get(clientTurnId) ?? null
|
|
1260
|
+
return { attempt: row ? { ...row } : null, evictedThrough: this.attemptsEvictedThrough }
|
|
1261
|
+
}
|
|
1262
|
+
|
|
1263
|
+
/**
|
|
1264
|
+
* The terminal ledger record for a client turn on a TARGET (every binding that ever held
|
|
1265
|
+
* it), or null. The turns route reads the same thing through `findTurn`; the restart
|
|
1266
|
+
* reconcile has no binding id to start from, only the thread.
|
|
1267
|
+
*/
|
|
1268
|
+
findTurnForTarget(provider: unknown, nativeThreadId: unknown, turnId: unknown): TurnRecord | null {
|
|
1269
|
+
if (!this.available()) return null
|
|
1270
|
+
if (!isBindableProvider(provider) || !isValidNativeThreadId(nativeThreadId)) return null
|
|
1271
|
+
if (typeof turnId !== 'string' || !TURN_ID_RE.test(turnId)) return null
|
|
1272
|
+
const key = makeTargetKey(provider, nativeThreadId)
|
|
1273
|
+
for (const [id, record] of this.records) {
|
|
1274
|
+
if (record.binding.targetKey !== key) continue
|
|
1275
|
+
const hit = this.turns.get(id)?.find(row => row.turnId === turnId)
|
|
1276
|
+
if (hit) return hit
|
|
1277
|
+
}
|
|
1278
|
+
return null
|
|
1279
|
+
}
|
|
1280
|
+
|
|
1281
|
+
// ------------------------------------------------------- 6.78.0 dead-boot pins
|
|
1282
|
+
|
|
1283
|
+
/**
|
|
1284
|
+
* Is this target held ONLY by pins a dead boot left, none of them old enough to release?
|
|
1285
|
+
* Returns when the last of them frees, for the refusal copy; null otherwise (no holder, a
|
|
1286
|
+
* live pin of this boot, or the flag off).
|
|
1287
|
+
*/
|
|
1288
|
+
orphanPinHold(provider: unknown, nativeThreadId: unknown, now: number): { releasesAt: number; childRunning?: true } | null {
|
|
1289
|
+
if (!this.available() || !this.pinBootReap()) return null
|
|
1290
|
+
if (!isBindableProvider(provider) || !isValidNativeThreadId(nativeThreadId) || !Number.isFinite(now)) return null
|
|
1291
|
+
const id = this.targets.get(makeTargetKey(provider, nativeThreadId))
|
|
1292
|
+
const record = id === undefined ? undefined : this.records.get(id)
|
|
1293
|
+
if (!record || record.binding.pinnedJobs.length === 0) return null
|
|
1294
|
+
let releasesAt = Number.NEGATIVE_INFINITY
|
|
1295
|
+
let childRunning = false
|
|
1296
|
+
for (const job of record.binding.pinnedJobs) {
|
|
1297
|
+
if (!this.inheritedPins.has(`${record.binding.bindingId}\u0000${job}`)) return null
|
|
1298
|
+
const at = this.orphanReleaseAt(record, job)
|
|
1299
|
+
releasesAt = Math.max(releasesAt, at)
|
|
1300
|
+
if (now >= at && this.orphanChildRunning(record, job)) childRunning = true
|
|
1301
|
+
}
|
|
1302
|
+
// Past the window with the recorded child still alive: no time to promise (P7 S6).
|
|
1303
|
+
if (childRunning) return { releasesAt: Number.NaN, childRunning: true }
|
|
1304
|
+
return releasesAt > now ? { releasesAt } : null
|
|
1305
|
+
}
|
|
1306
|
+
|
|
1307
|
+
/**
|
|
1308
|
+
* Release every pin a dead boot left whose orphan child must be gone by now.
|
|
1309
|
+
*
|
|
1310
|
+
* WHY 31 MINUTES AND NOT AT BOOT. A crash leaves the turn's resume child running: it is
|
|
1311
|
+
* not this process's child any more, and nothing kills it (the adapter's deadline was a
|
|
1312
|
+
* timer in the process that died). After the restart `cosSpawnLedger` is empty, so the
|
|
1313
|
+
* occupancy gate sees that child as a FOREIGN holder, and while it idles in a long tool the
|
|
1314
|
+
* idle-holder relaxation lets an attach through: releasing the pin at boot would let COS
|
|
1315
|
+
* start a second writer on the thread. `PIN_ORPHAN_FENCE_MS` is the longest turn the
|
|
1316
|
+
* adapter accepts (MAX_ATTACHED_TIMEOUT_MS, 30 min) plus one minute, counted from the later
|
|
1317
|
+
* of the pin and this boot's open (`orphanReleaseAt`). When the pin also names its child
|
|
1318
|
+
* (P7 S6: pid and measured start time, recorded at spawn), it is held past the window for as
|
|
1319
|
+
* long as that process still runs. A pin with no child recorded (a 6.77 pin, a live
|
|
1320
|
+
* delivery, a spawn whose record failed) has only the window, a bound and not a proof.
|
|
1321
|
+
* Until release the pin holds, and the refusal says so.
|
|
1322
|
+
*
|
|
1323
|
+
* A pin whose metadata names a client turn gets an `ambiguous` ledger record, so a retry
|
|
1324
|
+
* of that key replays "may not have been sent" instead of sending it again, and its
|
|
1325
|
+
* attempt (if still `pending`) becomes `unknown`. A 6.77 pin names nothing: it is released
|
|
1326
|
+
* with no record. Off (`COS_PIN_BOOT_REAP=0`): nothing is released before the 24 h reap.
|
|
1327
|
+
*/
|
|
1328
|
+
reapOrphanedPins(now: number, onlyTarget?: string): OrphanPinReport {
|
|
1329
|
+
const none: OrphanPinReport = { released: 0, recorded: 0 }
|
|
1330
|
+
if (!this.available() || !Number.isFinite(now) || !this.pinBootReap()) return none
|
|
1331
|
+
if (this.mutating) return none
|
|
1332
|
+
this.mutating = true
|
|
1333
|
+
try {
|
|
1334
|
+
return this.releaseOrphansUnlocked(now, onlyTarget)
|
|
1335
|
+
} finally {
|
|
1336
|
+
this.mutating = false
|
|
1337
|
+
}
|
|
1338
|
+
}
|
|
1339
|
+
|
|
1340
|
+
/**
|
|
1341
|
+
* When an inherited pin may be released: PIN_ORPHAN_FENCE_MS after the LATER of its pin and
|
|
1342
|
+
* the moment this boot opened the store. The pin time alone is not enough: a pin taken 30
|
|
1343
|
+
* minutes before a crash would free one minute after the restart, while the turn's child,
|
|
1344
|
+
* which no timer of the dead process can stop any more, may still be writing. Measured
|
|
1345
|
+
* from this boot's open, the thread is never offered to a second writer within 31 minutes
|
|
1346
|
+
* of the crash. (A pin that names its child also waits for it: `orphanChildRunning`.)
|
|
1347
|
+
*/
|
|
1348
|
+
private orphanReleaseAt(record: BindingRecord, job: string): number {
|
|
1349
|
+
const at = record.pinnedAt[job]
|
|
1350
|
+
const pinned = Number.isFinite(at) ? at : record.updatedAt
|
|
1351
|
+
return Math.max(pinned, this.openedAt) + PIN_ORPHAN_FENCE_MS
|
|
1352
|
+
}
|
|
1353
|
+
|
|
1354
|
+
/** P7 S6: the pin names a resume child that is still the same running process. */
|
|
1355
|
+
private orphanChildRunning(record: BindingRecord, job: string): boolean {
|
|
1356
|
+
const child = record.pinMeta?.[job]?.child
|
|
1357
|
+
if (!child) return false
|
|
1358
|
+
try {
|
|
1359
|
+
return this.pinChildProbe(child) !== 'gone'
|
|
1360
|
+
} catch {
|
|
1361
|
+
return true
|
|
1362
|
+
}
|
|
1363
|
+
}
|
|
1364
|
+
|
|
1365
|
+
/**
|
|
1366
|
+
* 6.78.0 (P7 S6): record the resume child a pinned turn spawned, so that after a crash the
|
|
1367
|
+
* pin waits for THAT process, not only for the window. Called from the turns route's
|
|
1368
|
+
* `onSpawn`, after the ownership ledger accepted the child and before any prompt byte is
|
|
1369
|
+
* written. Only a pin of this boot that carries metadata (COS_PIN_BOOT_REAP on). Anything
|
|
1370
|
+
* else, or a failed write, changes nothing: the window alone still bounds the pin.
|
|
1371
|
+
*/
|
|
1372
|
+
notePinChild(bindingId: unknown, jobId: unknown, child: { pid: number; startMs: number }): RegistryResult {
|
|
1373
|
+
return this.runMutation(() => {
|
|
1374
|
+
const found = this.requireRecord(bindingId)
|
|
1375
|
+
if ('reason' in found) return reject(found.reason)
|
|
1376
|
+
const { record } = found
|
|
1377
|
+
if (typeof jobId !== 'string' || !PIN_JOB_ID_RE.test(jobId)) return reject('invalid_job_id')
|
|
1378
|
+
const parsed = parsePinChild(child)
|
|
1379
|
+
const meta = record.pinMeta?.[jobId]
|
|
1380
|
+
if (!parsed || !meta || !record.binding.pinnedJobs.includes(jobId) || this.inheritedPins.has(`${record.binding.bindingId}\u0000${jobId}`)) {
|
|
1381
|
+
return { binding: record.binding, reason: null }
|
|
1382
|
+
}
|
|
1383
|
+
const records = new Map(this.records)
|
|
1384
|
+
// `updatedAt` is untouched: recording a pid is not lease activity.
|
|
1385
|
+
records.set(record.binding.bindingId, { ...record, pinMeta: Object.freeze({ ...record.pinMeta, [jobId]: { ...meta, child: parsed } }) })
|
|
1386
|
+
const failure = this.persist({ records, targets: new Map(this.targets), floors: new Map(this.floors), turns: new Map(this.turns) })
|
|
1387
|
+
return failure ? reject(failure) : { binding: record.binding, reason: null }
|
|
1388
|
+
})
|
|
1389
|
+
}
|
|
1390
|
+
|
|
1391
|
+
private releaseOrphansUnlocked(now: number, onlyTarget?: string): OrphanPinReport {
|
|
1392
|
+
const records = new Map(this.records)
|
|
1393
|
+
const turns = new Map(this.turns)
|
|
1394
|
+
let attempts: Map<string, Map<string, TurnAttempt>> | null = null
|
|
1395
|
+
let released = 0, recorded = 0
|
|
1396
|
+
for (const [id, record] of this.records) {
|
|
1397
|
+
if (onlyTarget !== undefined && record.binding.targetKey !== onlyTarget) continue
|
|
1398
|
+
const due = record.binding.pinnedJobs.filter(job => {
|
|
1399
|
+
if (!this.inheritedPins.has(`${id}\u0000${job}`)) return false
|
|
1400
|
+
if (now < this.orphanReleaseAt(record, job)) return false
|
|
1401
|
+
// P7 S6: a pin that names its resume child waits for that child to be gone.
|
|
1402
|
+
return !this.orphanChildRunning(record, job)
|
|
1403
|
+
})
|
|
1404
|
+
if (due.length === 0) continue
|
|
1405
|
+
let binding = record.binding
|
|
1406
|
+
const pinnedAt = { ...record.pinnedAt }
|
|
1407
|
+
const pinMeta: Record<string, PinMeta> = { ...(record.pinMeta ?? {}) }
|
|
1408
|
+
for (const job of due) {
|
|
1409
|
+
binding = unpinBinding(binding, job)
|
|
1410
|
+
delete pinnedAt[job]
|
|
1411
|
+
const meta = pinMeta[job]
|
|
1412
|
+
delete pinMeta[job]
|
|
1413
|
+
released += 1
|
|
1414
|
+
if (!meta) continue
|
|
1415
|
+
const ledger = turns.get(id) ?? []
|
|
1416
|
+
const known = this.findTurn(id, meta.clientTurnId) !== null || ledger.some(row => row.turnId === meta.clientTurnId)
|
|
1417
|
+
if (!known) {
|
|
1418
|
+
turns.set(id, [...ledger, { turnId: meta.clientTurnId, result: orphanReleaseRecord(job), at: now }].slice(-MAX_TURNS_PER_BINDING))
|
|
1419
|
+
recorded += 1
|
|
1420
|
+
}
|
|
1421
|
+
const current = (attempts ?? this.attempts).get(record.binding.targetKey)?.get(meta.clientTurnId)
|
|
1422
|
+
if (current && current.exit === 'pending') {
|
|
1423
|
+
attempts = attempts ?? cloneAttempts(this.attempts)
|
|
1424
|
+
attempts.get(record.binding.targetKey)!.set(meta.clientTurnId, { ...current, exit: 'unknown' })
|
|
1425
|
+
}
|
|
1426
|
+
}
|
|
1427
|
+
const { pinMeta: _old, ...rest } = record
|
|
1428
|
+
records.set(id, {
|
|
1429
|
+
...rest,
|
|
1430
|
+
binding: freezeBinding(binding),
|
|
1431
|
+
// NOT `now`. `updatedAt` is the idle-lease clock (`IDLE_LEASE_YIELD_MS`): a release
|
|
1432
|
+
// is not activity, and restarting that clock here would hold the thread another 90 s
|
|
1433
|
+
// for a client that died with the boot.
|
|
1434
|
+
updatedAt: record.updatedAt,
|
|
1435
|
+
pinnedAt: Object.freeze(pinnedAt),
|
|
1436
|
+
lastUnpinAt: now,
|
|
1437
|
+
terminalAt: record.terminalAt ?? (isTerminal(binding) ? now : null),
|
|
1438
|
+
...(Object.keys(pinMeta).length > 0 ? { pinMeta: Object.freeze(pinMeta) } : {}),
|
|
1439
|
+
})
|
|
1440
|
+
this.warn(`released ${due.length} pin(s) a dead boot left on binding ${id}: past the ${PIN_ORPHAN_FENCE_MS} ms orphan window`)
|
|
1441
|
+
}
|
|
1442
|
+
if (released === 0) return { released: 0, recorded: 0 }
|
|
1443
|
+
// Rebuilt from the records exactly as `reap` and hydration do: a holder is a binding that blocks.
|
|
1444
|
+
const targets = new Map<string, string>()
|
|
1445
|
+
for (const record of records.values()) {
|
|
1446
|
+
if (blocksTarget(record.binding, now)) targets.set(record.binding.targetKey, record.binding.bindingId)
|
|
1447
|
+
}
|
|
1448
|
+
const failure = this.persist({ records, targets, floors: new Map(this.floors), turns, ...(attempts ? { attempts } : {}) })
|
|
1449
|
+
if (failure) return { released: 0, recorded: 0 }
|
|
1450
|
+
return { released, recorded }
|
|
1451
|
+
}
|
|
1452
|
+
|
|
1453
|
+
private attemptsWith(
|
|
1454
|
+
targetKeyValue: string,
|
|
1455
|
+
clientTurnId: string,
|
|
1456
|
+
attempt: TurnAttempt,
|
|
1457
|
+
now: number,
|
|
1458
|
+
): { attempts: Map<string, Map<string, TurnAttempt>>; evictedThrough: number | null } {
|
|
1459
|
+
const attempts = cloneAttempts(this.attempts)
|
|
1460
|
+
const rows = attempts.get(targetKeyValue) ?? new Map<string, TurnAttempt>()
|
|
1461
|
+
rows.delete(clientTurnId)
|
|
1462
|
+
rows.set(clientTurnId, attempt)
|
|
1463
|
+
attempts.set(targetKeyValue, rows)
|
|
1464
|
+
const evictedThrough = boundAttempts(attempts, this.attemptsEvictedThrough, now)
|
|
1465
|
+
return { attempts, evictedThrough }
|
|
1466
|
+
}
|
|
1467
|
+
|
|
1468
|
+
private attemptExitWith(
|
|
1469
|
+
targetKeyValue: string,
|
|
1470
|
+
clientTurnId: string,
|
|
1471
|
+
exit: TurnAttemptExit,
|
|
1472
|
+
now: number,
|
|
1473
|
+
): { attempts: Map<string, Map<string, TurnAttempt>>; evictedThrough: number | null } | null {
|
|
1474
|
+
const current = this.attempts.get(targetKeyValue)?.get(clientTurnId)
|
|
1475
|
+
// Nothing began under this key in the journal: there is no attempt to settle. Writing
|
|
1476
|
+
// one here would invent a `bootId` and an `at` that never happened.
|
|
1477
|
+
if (!current) return null
|
|
1478
|
+
const attempts = cloneAttempts(this.attempts)
|
|
1479
|
+
attempts.get(targetKeyValue)!.set(clientTurnId, { ...current, exit })
|
|
1480
|
+
return { attempts, evictedThrough: boundAttempts(attempts, this.attemptsEvictedThrough, now) }
|
|
1481
|
+
}
|
|
1482
|
+
|
|
1483
|
+
private persistJournal(journal: { attempts: Map<string, Map<string, TurnAttempt>>; evictedThrough: number | null }): boolean {
|
|
1484
|
+
return this.persist({
|
|
1485
|
+
records: new Map(this.records),
|
|
1486
|
+
targets: new Map(this.targets),
|
|
1487
|
+
floors: new Map(this.floors),
|
|
1488
|
+
turns: new Map(this.turns),
|
|
1489
|
+
attempts: journal.attempts,
|
|
1490
|
+
attemptsEvictedThrough: journal.evictedThrough,
|
|
1491
|
+
}) === null
|
|
1492
|
+
}
|
|
1493
|
+
|
|
1494
|
+
private runJournalMutation(body: () => boolean): boolean {
|
|
1495
|
+
if (!this.available() || this.mutating) return false
|
|
1496
|
+
this.mutating = true
|
|
1497
|
+
try {
|
|
1498
|
+
return body()
|
|
1499
|
+
} finally {
|
|
1500
|
+
this.mutating = false
|
|
1501
|
+
}
|
|
1502
|
+
}
|
|
1503
|
+
|
|
928
1504
|
/**
|
|
929
1505
|
* Drop leaked pins, then remove bindings that are dead past their retention.
|
|
930
1506
|
*
|
|
@@ -979,6 +1555,8 @@ export class AgentSessionBindingRegistry {
|
|
|
979
1555
|
attachBlocked(provider: unknown, nativeThreadId: unknown, now: number): boolean {
|
|
980
1556
|
if (!this.available()) return false
|
|
981
1557
|
if (!isBindableProvider(provider) || !isValidNativeThreadId(nativeThreadId)) return false
|
|
1558
|
+
// 6.78.0: the occupancy check is the other place a dead boot's pin is looked at lazily.
|
|
1559
|
+
if (this.pinBootReap()) this.reapOrphanedPins(now, makeTargetKey(provider, nativeThreadId))
|
|
982
1560
|
const id = this.targets.get(makeTargetKey(provider, nativeThreadId))
|
|
983
1561
|
if (id === undefined) return false
|
|
984
1562
|
const record = this.records.get(id)
|
|
@@ -1041,13 +1619,18 @@ export class AgentSessionBindingRegistry {
|
|
|
1041
1619
|
}
|
|
1042
1620
|
pinsDropped += stale.length
|
|
1043
1621
|
changed = true
|
|
1622
|
+
// 6.78.0: metadata follows its pin out.
|
|
1623
|
+
const { pinMeta: oldMeta, ...rest } = record
|
|
1624
|
+
const keptMeta: Record<string, PinMeta> = {}
|
|
1625
|
+
for (const job of binding.pinnedJobs) if (oldMeta?.[job]) keptMeta[job] = oldMeta[job]
|
|
1044
1626
|
current = {
|
|
1045
|
-
...
|
|
1627
|
+
...rest,
|
|
1046
1628
|
binding: freezeBinding(binding),
|
|
1047
1629
|
updatedAt: now,
|
|
1048
1630
|
pinnedAt: Object.freeze(pinnedAt),
|
|
1049
1631
|
lastUnpinAt: now,
|
|
1050
1632
|
terminalAt: record.terminalAt ?? (isTerminal(binding) ? now : null),
|
|
1633
|
+
...(Object.keys(keptMeta).length > 0 ? { pinMeta: Object.freeze(keptMeta) } : {}),
|
|
1051
1634
|
}
|
|
1052
1635
|
this.warn(
|
|
1053
1636
|
`dropped ${stale.length} leaked pin(s) from binding ${id} — held longer than ` +
|
|
@@ -1118,7 +1701,15 @@ export class AgentSessionBindingRegistry {
|
|
|
1118
1701
|
previous: BindingRecord,
|
|
1119
1702
|
nextBinding: NativeBinding,
|
|
1120
1703
|
now: number,
|
|
1121
|
-
patch: {
|
|
1704
|
+
patch: {
|
|
1705
|
+
pinnedAt?: Record<string, number>
|
|
1706
|
+
lastUnpinAt?: number | null
|
|
1707
|
+
/** 6.78.0: the record's pin metadata after this commit. Undefined: carried forward. */
|
|
1708
|
+
pinMeta?: Record<string, PinMeta>
|
|
1709
|
+
/** 6.78.0: the attempt journal after this commit, written in the SAME atomic write. */
|
|
1710
|
+
attempts?: Map<string, Map<string, TurnAttempt>>
|
|
1711
|
+
attemptsEvictedThrough?: number | null
|
|
1712
|
+
} = {},
|
|
1122
1713
|
): RegistryResult {
|
|
1123
1714
|
const binding = freezeBinding(nextBinding)
|
|
1124
1715
|
const pins = patch.pinnedAt ?? { ...previous.pinnedAt }
|
|
@@ -1135,23 +1726,35 @@ export class AgentSessionBindingRegistry {
|
|
|
1135
1726
|
binding.pinnedJobs.every((job, i) => job === previous.binding.pinnedJobs[i]) &&
|
|
1136
1727
|
Object.keys(pins).length === Object.keys(previous.pinnedAt).length &&
|
|
1137
1728
|
Object.keys(pins).every(job => pins[job] === previous.pinnedAt[job])
|
|
1138
|
-
|
|
1729
|
+
// 6.78.0: an attempt-only change (an unpin of a pin that is not held, carrying the
|
|
1730
|
+
// turn's exit) is still a change to commit.
|
|
1731
|
+
if (unchanged && patch.attempts === undefined) return { binding: previous.binding, reason: null }
|
|
1732
|
+
|
|
1733
|
+
// Pin metadata follows the pins: a job no longer pinned keeps no metadata.
|
|
1734
|
+
const metaSource = patch.pinMeta ?? previous.pinMeta ?? {}
|
|
1735
|
+
const pinMeta: Record<string, PinMeta> = {}
|
|
1736
|
+
for (const job of binding.pinnedJobs) if (metaSource[job]) pinMeta[job] = metaSource[job]
|
|
1139
1737
|
|
|
1140
1738
|
const records = new Map(this.records)
|
|
1739
|
+
const { pinMeta: _previousMeta, ...rest } = previous
|
|
1141
1740
|
records.set(binding.bindingId, {
|
|
1142
|
-
...
|
|
1741
|
+
...rest,
|
|
1143
1742
|
binding,
|
|
1144
1743
|
updatedAt: Number.isFinite(now) ? now : previous.updatedAt,
|
|
1145
1744
|
terminalAt: previous.terminalAt ?? (isTerminal(binding) && Number.isFinite(now) ? now : previous.terminalAt),
|
|
1146
1745
|
lastUnpinAt,
|
|
1147
1746
|
pinnedAt: Object.freeze(pins),
|
|
1747
|
+
...(Object.keys(pinMeta).length > 0 ? { pinMeta: Object.freeze(pinMeta) } : {}),
|
|
1148
1748
|
})
|
|
1149
1749
|
|
|
1150
1750
|
const targets = new Map(this.targets)
|
|
1151
1751
|
if (blocksTarget(binding, now)) targets.set(binding.targetKey, binding.bindingId)
|
|
1152
1752
|
else if (targets.get(binding.targetKey) === binding.bindingId) targets.delete(binding.targetKey)
|
|
1153
1753
|
|
|
1154
|
-
const failure = this.persist({
|
|
1754
|
+
const failure = this.persist({
|
|
1755
|
+
records, targets, floors: new Map(this.floors), turns: new Map(this.turns),
|
|
1756
|
+
...(patch.attempts !== undefined ? { attempts: patch.attempts, attemptsEvictedThrough: patch.attemptsEvictedThrough ?? this.attemptsEvictedThrough } : {}),
|
|
1757
|
+
})
|
|
1155
1758
|
return failure ? reject(failure) : { binding, reason: null }
|
|
1156
1759
|
}
|
|
1157
1760
|
|
|
@@ -1195,6 +1798,8 @@ export class AgentSessionBindingRegistry {
|
|
|
1195
1798
|
// which would strand a live binding over a stale file we cannot read anyway.
|
|
1196
1799
|
}
|
|
1197
1800
|
|
|
1801
|
+
const attempts = next.attempts ?? this.attempts
|
|
1802
|
+
const attemptsEvictedThrough = next.attemptsEvictedThrough !== undefined ? next.attemptsEvictedThrough : this.attemptsEvictedThrough
|
|
1198
1803
|
let json: string
|
|
1199
1804
|
try {
|
|
1200
1805
|
json = JSON.stringify({
|
|
@@ -1210,7 +1815,14 @@ export class AgentSessionBindingRegistry {
|
|
|
1210
1815
|
terminalAt: record.terminalAt,
|
|
1211
1816
|
lastUnpinAt: record.lastUnpinAt,
|
|
1212
1817
|
pinnedAt: record.pinnedAt,
|
|
1818
|
+
// 6.78.0: only when a pin carries some, so a store that never used it stays the 6.77 bytes.
|
|
1819
|
+
...(record.pinMeta && Object.keys(record.pinMeta).length > 0 ? { pinMeta: record.pinMeta } : {}),
|
|
1213
1820
|
})),
|
|
1821
|
+
// 6.78.0: the attempt journal, again only once it holds something.
|
|
1822
|
+
...(attempts.size > 0
|
|
1823
|
+
? { attempts: Object.fromEntries([...attempts.entries()].map(([key, rows]) => [key, Object.fromEntries(rows)])) }
|
|
1824
|
+
: {}),
|
|
1825
|
+
...(attemptsEvictedThrough !== null ? { attemptsEvictedThrough } : {}),
|
|
1214
1826
|
})
|
|
1215
1827
|
} catch (err) {
|
|
1216
1828
|
this.warn('refusing to commit: state did not serialize', err)
|
|
@@ -1229,6 +1841,8 @@ export class AgentSessionBindingRegistry {
|
|
|
1229
1841
|
this.targets = next.targets
|
|
1230
1842
|
this.floors = next.floors
|
|
1231
1843
|
this.turns = next.turns
|
|
1844
|
+
this.attempts = attempts
|
|
1845
|
+
this.attemptsEvictedThrough = attemptsEvictedThrough
|
|
1232
1846
|
return null
|
|
1233
1847
|
}
|
|
1234
1848
|
|