@gotcos/glasses-server 6.76.1 → 6.78.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.
Files changed (52) hide show
  1. package/.env.example +40 -0
  2. package/CHANGELOG.md +56 -0
  3. package/README.md +12 -1
  4. package/managed-runtime-contract.json +17 -1
  5. package/package.json +10 -1
  6. package/server/index.ts +98 -10
  7. package/server/lib/agent-session-binding-registry.ts +623 -9
  8. package/server/lib/boot-resume.ts +552 -0
  9. package/server/lib/claude-bridge.ts +152 -3
  10. package/server/lib/claude-cli-support.ts +39 -0
  11. package/server/lib/claude-steer.ts +259 -0
  12. package/server/lib/cos-bridge-notes.ts +73 -0
  13. package/server/lib/decision-provider.ts +9 -0
  14. package/server/lib/env-switch.ts +17 -0
  15. package/server/lib/fuzzy-correct.ts +77 -49
  16. package/server/lib/live-cos-ask.ts +41 -7
  17. package/server/lib/live-cues-v2-notes.ts +152 -0
  18. package/server/lib/live-cues-v2-settings.ts +63 -0
  19. package/server/lib/live-cues-v2.ts +198 -38
  20. package/server/lib/managed-restart.ts +8 -0
  21. package/server/lib/provider-actions.ts +18 -0
  22. package/server/lib/query-job-coordinator.ts +300 -0
  23. package/server/lib/query-job-feature.ts +39 -0
  24. package/server/lib/query-job-resume.ts +269 -0
  25. package/server/lib/query-job-runtime.ts +23 -3
  26. package/server/lib/query-job-store.ts +516 -7
  27. package/server/lib/query-job-types.ts +210 -1
  28. package/server/lib/queue-turn-evidence.ts +213 -0
  29. package/server/lib/thread-turn-queue-deliver.ts +6 -1
  30. package/server/lib/thread-turn-queue-store.ts +13 -4
  31. package/server/lib/thread-turn-queue.ts +151 -1
  32. package/server/lib/transcript-cleanup-context.ts +234 -0
  33. package/server/lib/transcript-cleanup-ledger.ts +304 -0
  34. package/server/lib/transcript-cleanup-model.ts +217 -0
  35. package/server/lib/transcript-cleanup-screen.ts +249 -0
  36. package/server/lib/transcript-cleanup-spans.ts +358 -0
  37. package/server/lib/transcript-cleanup-state.ts +201 -0
  38. package/server/lib/transcript-cleanup-suggestions.ts +126 -0
  39. package/server/lib/transcript-cleanup-text.ts +200 -0
  40. package/server/lib/transcript-cleanup.ts +427 -0
  41. package/server/routes/agent-session-bindings.ts +169 -11
  42. package/server/routes/glossary.ts +38 -19
  43. package/server/routes/health.ts +29 -1
  44. package/server/routes/live-cues.ts +32 -3
  45. package/server/routes/maintenance.ts +34 -0
  46. package/server/routes/meeting.ts +92 -9
  47. package/server/routes/query-jobs.ts +107 -13
  48. package/server/routes/recovery.ts +4 -0
  49. package/server/routes/thread-turn-queue.ts +371 -10
  50. package/server/routes/transcribe.ts +3 -0
  51. package/server/routes/transcript-cleanup.ts +237 -0
  52. package/shared/live-cues-v2.ts +9 -4
@@ -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
- return this.commitBinding(record, next, now, { pinnedAt: { ...record.pinnedAt, [jobId]: now } })
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
- ...record,
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: { pinnedAt?: Record<string, number>; lastUnpinAt?: number | null } = {},
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
- if (unchanged) return { binding: previous.binding, reason: null }
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
- ...previous,
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({ records, targets, floors: new Map(this.floors), turns: new Map(this.turns) })
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