@gotcos/glasses-server 6.36.8 → 6.36.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,81 @@
1
1
  ## Unreleased
2
2
 
3
+ ## 6.36.10
4
+ - **A fenced thread had no exit and left no trace.** An ambiguous delivery fences the
5
+ target so a prompt cannot be double-delivered into a real conversation — that is
6
+ correct and stays. Everything around it was wrong: the fence lived in a process-local
7
+ Map, wrote no log line at either site, had no list, and had no release. It was
8
+ discoverable only by being refused, and the only thing that cleared it was a restart.
9
+ - **Now listable and releasable without a restart.** `GET /api/agent-sessions/fences`
10
+ lists them; `POST /api/agent-sessions/fences/release` clears one. Addressed by DIGEST,
11
+ never by raw target key — the key embeds the private native thread id. Fails closed:
12
+ without `confirm: true` it returns 400 with a preview of what would be reopened. That
13
+ confirmation is a deliberate second call, NOT proof a human looked — the API token is
14
+ shared by the phone, the lens and every COS agent session, so nothing is structurally
15
+ prevented from asserting it. The comment says so rather than overclaiming.
16
+ - **Durable storage ships INERT, behind `COS_THREAD_FENCE_DURABLE=1`, default off.**
17
+ Persisting the fence is the right direction, but durability without a reachable
18
+ release is a regression, not a fix: today "Restart Server" in COS Control clears a
19
+ fence, and making it survive restarts with no operator surface in Control would turn
20
+ an 8-second annoyance into a permanently dead thread needing a terminal. The flag
21
+ flips on when COS Control has a Fences card. The routes above already remove the
22
+ restart from the recovery path.
23
+ - **A write can never erase what it could not read.** `TargetGuard` hydrates only the
24
+ rows it understood and saves its map wholesale, so a single unrecognised row — a
25
+ newer schema, a partial write, one bad field — would otherwise be erased by the next
26
+ fence on an unrelated thread, silently reopening every other fenced thread. Writes now
27
+ merge unrecognised rows back through. A corrupt file is quarantined to
28
+ `.corrupt-<ts>` rather than dropped, and uses `durableAtomicWriteFileSync` (fsync of
29
+ bytes, metadata and directory; randomized exclusive temp name) rather than the
30
+ lightweight cache writer.
31
+ - **A release is persisted before it is reported.** Mutating memory first and reporting
32
+ success meant an operator could be told a thread was open, write to it, and find it
33
+ fenced again after the next restart with no record of why. A failed write now returns
34
+ 500 `persist_failed` and the fence holds. `GET /fences` reports `degraded` when the
35
+ last write failed — a memory-only fence set is otherwise indistinguishable from a
36
+ durable one until the process restarts.
37
+ - **Visible.** Breadcrumbs at both fence-set sites (tagged `ambiguous` vs `route_error`)
38
+ and both fence-hit routes (turn, attach). No raw target key in any of them. The
39
+ route_error line reports the hoisted pre-turn head rather than hardcoding
40
+ `unavailable`, which contradicted the record it had just written.
41
+ - **Known, not fixed here:** releasing a fence does not by itself make the thread
42
+ attachable — the turn that fenced it left a binding holding the target for its
43
+ 30-minute TTL, so the next attach refuses `native_target_busy`. There is no detach
44
+ route. `/attachability` still does not consult the fence, so the lens menu renders
45
+ Continue enabled on a fenced thread; the refusal is honest, the menu is not yet.
46
+ - **Coverage.** 11 mutations against the new guards fail the suite, including all four
47
+ that survived the first QA pass (the release handle check, and the provider/reason/
48
+ fencedAt row validators). One documented survivor remains: removing the write-once
49
+ guard on `fence()`, which no route can reach because both fence sites sit inside the
50
+ `tryClaim` section. The code says so at the call site.
51
+
52
+ ## 6.36.9
53
+ - **The queue gate can now see COS's own bindings, which closes a 30-minute lockout.**
54
+ `threadOccupancy` sees FOREIGN holders only — `OccupancyReason` has no
55
+ `native_target_busy`, because that refusal comes from the binding registry. So when a
56
+ live COS binding held a thread, Continue refused `native_target_busy`, the client
57
+ armed the queue, and this gate answered `409 thread_free` ("Pick Continue again"),
58
+ which refused identically. A closed loop for the whole binding TTL, while the refusal
59
+ copy said "detach that one first" — an action with no endpoint.
60
+ - The same gap burnt the delivery ceiling: the drainer saw `attachable`, ATTEMPTED, and
61
+ the attach route refused. Five of those retired a turn in about two minutes. With the
62
+ binding visible, `drainDecision` returns `hold` and spends nothing — one change closes
63
+ both the loop and the burn.
64
+ - **A gate refusal no longer spends an attempt.** Classified by REASON, never by status:
65
+ every refusal that is not `invalid_request` (400) or a capability gap (503) comes back
66
+ 409, so a status rule would have retried `native_target_fenced` — "an earlier turn may
67
+ or may not have been delivered" — up to 1,080 times. Reuses `queueableRefusal`, and
68
+ fails CLOSED on an unrecognised reason.
69
+ - The attempt is still written and fsynced BEFORE the call, then REFUNDED once the
70
+ outcome is known. Deferring the increment would have reopened the crash-safety hole
71
+ the comment there describes, and a test reads `attempts` off disk mid-delivery to
72
+ prove it.
73
+ - **The refusal reason is no longer discarded.** `deliver` read `body.error`; the attach
74
+ route emits `reason` and has never emitted `error`, so every stored reason in the
75
+ field was the literal `attach_409` and the whole feature was reason-blind. Also
76
+ carries the turn route's own `retryable` verdict, which outranks our inference.
77
+
78
+
3
79
  ## 6.36.8
4
80
  - **The queue now covers the refusal that actually fires.** Device diagnostics, once the
5
81
  path was finally instrumented, recorded `native_target_busy` on every Continue that
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gotcos/glasses-server",
3
- "version": "6.36.8",
3
+ "version": "6.36.10",
4
4
  "description": "COS Glasses \u2014 self-hosted AI heads-up-display server for Even G2 smart glasses, powered by Claude Code, Codex, or Cursor Agent CLI",
5
5
  "type": "module",
6
6
  "bin": {
package/server/index.ts CHANGED
@@ -109,6 +109,7 @@ import { createThreadTurnQueueRouter, drainAllThreads } from './routes/thread-tu
109
109
  import { transcriptTurnEnded } from './lib/thread-turn-queue-store.js'
110
110
  import { transcriptPathFor } from './lib/native-head.js'
111
111
  import { deliverQueuedTurnOverLoopback } from './lib/thread-turn-queue-deliver.js'
112
+ import { readFences, writeFences } from './lib/thread-fence-store.js'
112
113
  import type { QueuedThreadTurn } from './lib/thread-turn-queue.js'
113
114
 
114
115
  const PORT = parseInt(process.env.PORT ?? '3141', 10)
@@ -517,10 +518,33 @@ app.use('/api', claudeSessionsRouter)
517
518
  // buys the guarantee that the queue CANNOT weaken the gate even by accident.
518
519
  if (threadAttachEnabled()) {
519
520
  const queueDeps = {
521
+ // TWO GATES, ONE ANSWER. `threadOccupancy` sees FOREIGN holders -- another app on
522
+ // the Mac writing the transcript. It does not, and cannot, see COS's OWN bindings:
523
+ // `OccupancyReason` has no `native_target_busy` member, because that refusal is
524
+ // produced only by the binding registry inside `bindings.create`.
525
+ //
526
+ // That gap is what trapped Miles on 2026-08-18. Continue refused
527
+ // `native_target_busy` (a live COS binding held the thread), the client armed the
528
+ // queue, he dictated -- and this gate, asking occupancy alone, answered
529
+ // `attachable: true`, so the route replied `409 thread_free` ("Pick Continue again
530
+ // to send it"). Continue refused identically. A closed loop for the 30-minute
531
+ // binding TTL, with the refusal copy telling him to "detach that one first" -- an
532
+ // action with no endpoint.
533
+ //
534
+ // It also burnt the delivery ceiling: the drainer saw `attachable` and ATTEMPTED,
535
+ // the attach route refused, and five of those retired the turn in ~2 minutes. With
536
+ // the binding visible here, `drainDecision` returns 'hold' instead and no attempt
537
+ // is spent -- so this single change closes the loop AND the attempt burn.
520
538
  occupancy: (provider: string, threadId: string) => {
521
539
  try {
522
540
  const v = threadOccupancy(provider, threadId, occupancyProbes, occupancyDirs)
523
- return { attachable: v.attachable === true, reason: v.reason ?? null }
541
+ if (v.attachable !== true) return { attachable: false, reason: v.reason ?? null }
542
+ // Occupancy is happy; ask the registry the question it cannot answer.
543
+ // `getByThread` returns the binding ONLY when it actually blocks the target
544
+ // (`blocksTarget`), so an expired or terminal one correctly reads as free.
545
+ const holder = agentSessionBindingRegistry.getByThread(provider, threadId, Date.now())
546
+ if (holder !== null) return { attachable: false, reason: 'native_target_busy' }
547
+ return { attachable: true, reason: null }
524
548
  } catch {
525
549
  // A throwing probe is not an open door.
526
550
  return { attachable: false, reason: 'probe_failed' }
@@ -559,6 +583,23 @@ if (threadAttachEnabled()) {
559
583
  }
560
584
 
561
585
  app.use('/api', createAgentSessionBindingsRouter({
586
+ // Durable fences (6.36.10), OFF BY DEFAULT.
587
+ //
588
+ // The fence is the one piece of state whose loss writes twice into a real
589
+ // conversation, so persisting it is the right direction. But durability
590
+ // without a reachable release is a REGRESSION, not a fix: today "Restart
591
+ // Server" in COS Control clears a fence, and making it survive restarts with
592
+ // no operator surface in Control turns an 8-second annoyance into a
593
+ // permanently dead thread that needs a terminal to clear. Miles, 2026-08-12:
594
+ // "we couldn't do anything without bash, that shouldn't be the case."
595
+ //
596
+ // So the storage ships inert. GET /agent-sessions/fences and
597
+ // POST /agent-sessions/fences/release work either way — which already removes
598
+ // the restart from the recovery path — and this flag flips on once COS Control
599
+ // has a Fences card.
600
+ fencePersistence: process.env.COS_THREAD_FENCE_DURABLE === '1'
601
+ ? { load: readFences, save: writeFences }
602
+ : undefined,
562
603
  probes: occupancyProbes,
563
604
  dirs: occupancyDirs,
564
605
  now: () => Date.now(),
@@ -0,0 +1,102 @@
1
+ // Durable storage for target fences.
2
+ //
3
+ // A fence shuts a native thread that may already hold an undelivered COS turn. It
4
+ // is the one piece of state whose LOSS writes twice into a real human conversation
5
+ // — agent-session-binding-registry.ts records the incident verbatim: "the
6
+ // process-local fence re-opened on restart and delivered a second copy."
7
+ //
8
+ // SEPARATE FROM THE DECISIONS, matching thread-turn-queue-store.ts: TargetGuard
9
+ // holds the rules and takes load/save as injected callbacks, so its behaviour stays
10
+ // testable in memory and only production touches the disk.
11
+ //
12
+ // UNDER THE DATA HOME, never the generation directory, which Update Server replaces
13
+ // wholesale. Same lesson as the stranded voice profiles.
14
+ //
15
+ // NEVER TTL'd AND NEVER EVICTED. `heads` and fork refs both bound their maps, and
16
+ // both evict in the SAFE direction — losing a head asks the user to acknowledge,
17
+ // losing a fork ref makes them find the thread by hand. Losing a fence silently
18
+ // reopens a thread that may hold an undelivered turn, so the only way an entry
19
+ // leaves this file is an explicit operator release.
20
+
21
+ import { durableAtomicWriteFileSync, loadJsonOrQuarantine } from './atomic-fs.js'
22
+ import { dataPath } from './data-dir.js'
23
+
24
+ export interface FenceRecord {
25
+ /** The raw target key. On disk only — it embeds the private native thread id and
26
+ * is never emitted by any route. Callers outside this module use the digest. */
27
+ targetKey: string
28
+ provider: string
29
+ reason: string
30
+ /** The head digest as it stood BEFORE the ambiguous turn. Null ONLY when the
31
+ * failure happened before the head was read — `head` is scoped to the try, so
32
+ * the route-error site reads a hoisted copy rather than nothing. */
33
+ headBefore: string | null
34
+ turnId: string
35
+ bindingId: string | null
36
+ fencedAt: number
37
+ }
38
+
39
+ export function fencePath(): string {
40
+ return dataPath('thread-fences.json')
41
+ }
42
+
43
+ function isFenceRecord(r: unknown): r is FenceRecord {
44
+ return !!r && typeof r === 'object'
45
+ && typeof (r as FenceRecord).targetKey === 'string' && (r as FenceRecord).targetKey.length > 0
46
+ && typeof (r as FenceRecord).provider === 'string'
47
+ && typeof (r as FenceRecord).reason === 'string'
48
+ && typeof (r as FenceRecord).fencedAt === 'number'
49
+ }
50
+
51
+ /** The raw array on disk, or [] when the file is missing or was quarantined. */
52
+ function rawRows(): unknown[] {
53
+ const loaded = loadJsonOrQuarantine<unknown>(fencePath())
54
+ if (loaded.status === 'corrupt') {
55
+ // Quarantined to `<path>.corrupt-<ts>` rather than discarded: the bytes are
56
+ // the only record of which threads were fenced, and this is the one state
57
+ // whose silent loss double-writes a real conversation.
58
+ console.warn(`[thread-fence-store] fence file was corrupt, quarantined as ${loaded.quarantinedAs}`)
59
+ return []
60
+ }
61
+ if (loaded.status !== 'ok') return []
62
+ if (!Array.isArray(loaded.data)) {
63
+ console.warn('[thread-fence-store] fence file is not an array — treating as empty')
64
+ return []
65
+ }
66
+ return loaded.data
67
+ }
68
+
69
+ /**
70
+ * Every stored fence this build can understand.
71
+ *
72
+ * A missing or corrupt file reads as empty. That fails OPEN, deliberately:
73
+ * failing closed would refuse every thread on the machine with no way back,
74
+ * while failing open is exactly the pre-6.36.10 behaviour (the fence was
75
+ * process-local and died on restart), so it cannot be a regression. A corrupt
76
+ * file is quarantined rather than dropped, so the evidence survives.
77
+ */
78
+ export function readFences(): FenceRecord[] {
79
+ return rawRows().filter(isFenceRecord)
80
+ }
81
+
82
+ /**
83
+ * Replace the stored set, PRESERVING rows this build could not validate.
84
+ *
85
+ * THE MERGE IS THE WHOLE POINT. `TargetGuard` holds only the rows `readFences`
86
+ * understood and saves its map wholesale, so without this a single unrecognised
87
+ * row — a newer schema, a partial write, one bad field — would be erased by the
88
+ * next fence on an unrelated thread, silently reopening every other fenced
89
+ * thread. Preserved rows are inert (nothing enforces a fence that is not in the
90
+ * map) but they are never destroyed by a write that did not understand them.
91
+ *
92
+ * Uses the DURABLE writer, not the lightweight one: fsync of bytes, metadata and
93
+ * directory, plus a randomized exclusive temp name so two independent writers
94
+ * cannot share `<path>.tmp`.
95
+ */
96
+ export function writeFences(rows: FenceRecord[]): void {
97
+ const preserved = rawRows().filter(r => !isFenceRecord(r))
98
+ if (preserved.length > 0) {
99
+ console.warn(`[thread-fence-store] preserving ${preserved.length} unrecognised fence row(s) through this write`)
100
+ }
101
+ durableAtomicWriteFileSync(fencePath(), `${JSON.stringify([...rows, ...preserved], null, 2)}\n`)
102
+ }
@@ -75,7 +75,7 @@ export async function deliverQueuedTurnOverLoopback(
75
75
  turn: QueuedThreadTurn,
76
76
  port: number,
77
77
  token: string,
78
- ): Promise<{ ok: boolean; reason?: string }> {
78
+ ): Promise<{ ok: boolean; reason?: string; serverRetryable?: boolean }> {
79
79
  try {
80
80
  const attach = await post(
81
81
  port, token,
@@ -83,8 +83,17 @@ export async function deliverQueuedTurnOverLoopback(
83
83
  { cosSessionId: turn.cosSessionId },
84
84
  )
85
85
  if (attach.status !== 200 && attach.status !== 201) {
86
- // The gate said no at drain time. Not an error -- the queue holds and tries again.
87
- return { ok: false, reason: String(attach.body.error ?? `attach_${attach.status}`) }
86
+ // THE KEY IS `reason`, NOT `error`. `refuseAttach` emits
87
+ // `{ attached: false, reason, reasonCopy }` and has never emitted an `error`
88
+ // key, so reading `body.error` always fell through to the status fallback and
89
+ // every stored reason in the field was the literal string `attach_409`.
90
+ //
91
+ // That made the whole feature reason-blind: 409 is the DEFAULT status for every
92
+ // refusal that is not `invalid_request` (400) or a capability gap (503), so
93
+ // `attach_409` covered transient `native_target_busy` and permanent
94
+ // `native_target_fenced` alike. Any retry policy keyed on the status would have
95
+ // treated "an earlier turn may or may not have been delivered" as retryable.
96
+ return { ok: false, reason: String(attach.body.reason ?? attach.body.error ?? `attach_${attach.status}`) }
88
97
  }
89
98
  const bindingId = typeof attach.body.bindingId === 'string' ? attach.body.bindingId : ''
90
99
  if (!bindingId) return { ok: false, reason: 'attach_no_binding' }
@@ -99,7 +108,14 @@ export async function deliverQueuedTurnOverLoopback(
99
108
  )
100
109
  // 202 is the success shape: admitted, delivered in the background, poll the ledger.
101
110
  if (sent.status === 202 || sent.status === 200) return { ok: true }
102
- return { ok: false, reason: String(sent.body.error ?? `turn_${sent.status}`) }
111
+ // Same defect on the turn leg: `refuseTurn` emits `reason`/`reasonCopy`/`retryable`
112
+ // and no `error`. `retryable` is the server's OWN judgement about this refusal --
113
+ // carried through rather than re-derived, so the two layers cannot disagree.
114
+ return {
115
+ ok: false,
116
+ reason: String(sent.body.reason ?? sent.body.error ?? `turn_${sent.status}`),
117
+ serverRetryable: typeof sent.body.retryable === 'boolean' ? sent.body.retryable : undefined,
118
+ }
103
119
  } catch (error) {
104
120
  return { ok: false, reason: error instanceof Error ? error.message : 'deliver_failed' }
105
121
  }
@@ -94,6 +94,7 @@
94
94
  // does not — a future remount above the parser — the POST routes see a non-object
95
95
  // and answer 400. They never treat an unparsed body as an empty one.
96
96
 
97
+ import type { FenceRecord } from '../lib/thread-fence-store.js'
97
98
  import { Router, type Request, type Response } from 'express'
98
99
  import { createHash, randomUUID } from 'node:crypto'
99
100
  import {
@@ -268,6 +269,12 @@ export type AttachedTurnResult =
268
269
  | { ok: boolean; delivery: 'not_attempted' | 'aborted' | 'ambiguous' | 'delivered' }
269
270
 
270
271
  export interface AgentSessionBindingsDeps {
272
+ /**
273
+ * Durable fence storage. OPTIONAL, and omitting it is what keeps the existing
274
+ * suite in memory: a test that silently began writing the real data home would
275
+ * leak fences between cases and into the running server. Production wires it.
276
+ */
277
+ fencePersistence?: FencePersistence
271
278
  probes: OccupancyProbes
272
279
  dirs: OccupancyDirs
273
280
  /** Epoch ms. Injected so lease expiry is decidable in a test without waiting. */
@@ -829,24 +836,86 @@ export const MAX_TRACKED_HEADS = 512
829
836
  /** A COS session id may contain ':' and '/', which is exactly why it is never projected. */
830
837
  export const COS_SESSION_ID_RE = /^[A-Za-z0-9][A-Za-z0-9._:@/-]{0,127}$/
831
838
 
839
+ /** What a fence records about the turn that set it. */
840
+ export interface FenceEvidence {
841
+ provider: string
842
+ /** The head BEFORE the ambiguous turn. Null ONLY when the failure happened
843
+ * before the head was read. */
844
+ headBefore: string | null
845
+ turnId: string
846
+ bindingId: string | null
847
+ now: number
848
+ }
849
+
850
+ export type ReleaseOutcome =
851
+ | { ok: true; row: FenceRecord }
852
+ | { ok: false; reason: 'unknown_fence' | 'persist_failed' }
853
+
854
+ /** Injected so TargetGuard stays testable in memory; production wires the store. */
855
+ export interface FencePersistence {
856
+ load: () => FenceRecord[]
857
+ save: (rows: FenceRecord[]) => void
858
+ }
859
+
832
860
  /**
833
- * The in-process half of plan 4.5 and 4.6: one COS turn per native target, and a
834
- * target that may already hold an undelivered turn stays shut.
861
+ * One COS turn per native target, and a target that may already hold an
862
+ * undelivered turn stays shut.
835
863
  *
836
- * NOT DURABLE, AND THAT IS A STATED GAP. Plan 4.5 wants the reservation
837
- * persisted in the job journal and rehydrated on boot, and 4.6 item 3 wants the
838
- * fence to have its own lifecycle and an operator release path. Both belong to
839
- * Phase 2, which owns that journal. What lives here is the process-lifetime
840
- * version, which is enough to make the two properties true for a running server
841
- * and fails in the safe direction on restart: a claim is released (no turn is
842
- * running after a restart anyway) and a FENCE is lost, which is the one that
843
- * matters and is why it is called out rather than implied.
864
+ * CLAIMS ARE PROCESS-LOCAL; FENCES ARE DURABLE (6.36.10). The two states fail in
865
+ * opposite directions, which is why only one of them is persisted. Losing a claim
866
+ * on restart is safe no turn is running after a restart anyway. Losing a FENCE
867
+ * reopens a thread that may already hold an undelivered turn, and the binding
868
+ * registry records what that cost: "the process-local fence re-opened on restart
869
+ * and delivered a second copy." So fences are written through to disk and
870
+ * rehydrated in the constructor, and the ONLY way one leaves the map is an
871
+ * explicit operator release (`releaseFence`).
872
+ *
873
+ * Persistence is INJECTED rather than imported. A test that silently began writing
874
+ * the real data home would leak fences between cases and into the running server,
875
+ * so the suite runs with `null` and stays in memory.
844
876
  */
845
877
  class TargetGuard {
846
878
  /** targetKey -> turnId of the single COS turn allowed to be in flight. */
847
879
  private readonly claims = new Map<string, string>()
848
- /** targetKey -> why no further turn may be delivered. */
849
- private readonly fences = new Map<string, WriteRefusal>()
880
+ /** targetKey -> the fence record. DURABLE as of 6.36.10: persistence is injected
881
+ * so tests stay in memory and only production touches the data home. */
882
+ private readonly fences = new Map<string, FenceRecord>()
883
+ private readonly persistence: FencePersistence | null
884
+ private persistDegraded = false
885
+
886
+ constructor(persistence: FencePersistence | null = null) {
887
+ this.persistence = persistence
888
+ if (persistence === null) return
889
+ // Rehydrate BEFORE the router serves. A fence that died on restart is exactly
890
+ // how a second copy of a turn reached a real transcript.
891
+ try {
892
+ for (const row of persistence.load()) this.fences.set(row.targetKey, row)
893
+ } catch (error) {
894
+ console.error(`[agent-session-bindings] fence rehydrate failed: ${error instanceof Error ? error.message : error}`)
895
+ }
896
+ }
897
+
898
+ /** True when the durable write succeeded (or there is nothing to persist to). */
899
+ private persistFences(rows: FenceRecord[]): boolean {
900
+ if (this.persistence === null) return true
901
+ try {
902
+ this.persistence.save(rows)
903
+ this.persistDegraded = false
904
+ return true
905
+ } catch (error) {
906
+ // The in-memory fence still holds for this process, so the thread stays shut
907
+ // NOW; what is lost is survival across a restart. Loud, and surfaced on
908
+ // GET /fences — a silent fallback is indistinguishable from working.
909
+ this.persistDegraded = true
910
+ console.error(`[agent-session-bindings] fence persist FAILED (fences hold in memory only): ${error instanceof Error ? error.message : error}`)
911
+ return false
912
+ }
913
+ }
914
+
915
+ /** Whether the last durable write failed. Reported, never inferred. */
916
+ degraded(): boolean {
917
+ return this.persistDegraded
918
+ }
850
919
  /** bindingId -> the head digest this binding is currently reconciled to. */
851
920
  private readonly heads = new Map<string, string>()
852
921
 
@@ -869,12 +938,72 @@ class TargetGuard {
869
938
  if (this.claims.get(targetKey) === turnId) this.claims.delete(targetKey)
870
939
  }
871
940
 
872
- fence(targetKey: string, reason: WriteRefusal): void {
873
- if (!this.fences.has(targetKey)) this.fences.set(targetKey, reason)
941
+ /**
942
+ * Write-once: the FIRST reason wins, so a later ambiguity cannot overwrite the
943
+ * evidence chain of an unresolved one.
944
+ *
945
+ * DEFENSIVE, AND UNVERIFIED BY EXECUTION. Both fence sites sit inside the
946
+ * `tryClaim` section, which serialises them per target, and a fenced target is
947
+ * refused at the check before it can reach either site again — so no route can
948
+ * currently fence the same key twice, and a mutation removing this guard passes
949
+ * the whole suite. It is kept because the one path that could reach it (the
950
+ * ambiguous site fences, then the response throws into the catch, which fences
951
+ * `claimedKey` again) would otherwise replace a record carrying `bindingId` with
952
+ * one carrying null. Do not read the passing suite as coverage of this line.
953
+ */
954
+ fence(targetKey: string, reason: WriteRefusal, evidence: FenceEvidence): void {
955
+ if (this.fences.has(targetKey)) return
956
+ this.fences.set(targetKey, {
957
+ targetKey,
958
+ provider: evidence.provider,
959
+ reason,
960
+ headBefore: evidence.headBefore,
961
+ turnId: evidence.turnId,
962
+ bindingId: evidence.bindingId,
963
+ fencedAt: evidence.now,
964
+ })
965
+ this.persistFences([...this.fences.values()])
874
966
  }
875
967
 
876
968
  fencedReason(targetKey: string): WriteRefusal | null {
877
- return this.fences.get(targetKey) ?? null
969
+ const row = this.fences.get(targetKey)
970
+ return row === undefined ? null : (row.reason as WriteRefusal)
971
+ }
972
+
973
+ /** Every fence, REDACTED for the wire: the raw targetKey embeds the private
974
+ * native thread id, so callers address a fence by its deterministic digest. */
975
+ listFences(): Array<{ target: string; provider: string; reason: string; headBefore: string | null; turnId: string; fencedAt: number }> {
976
+ return [...this.fences.values()].map(row => ({
977
+ target: opaqueRevision(row.targetKey),
978
+ provider: row.provider,
979
+ reason: row.reason,
980
+ headBefore: row.headBefore,
981
+ turnId: row.turnId,
982
+ fencedAt: row.fencedAt,
983
+ }))
984
+ }
985
+
986
+ /**
987
+ * The operator release. THE ONLY WAY A FENCE LEAVES THIS MAP.
988
+ *
989
+ * Addressed by digest, never by raw target key. Returns false when no fence
990
+ * matches, so a stale handle reports honestly instead of silently succeeding.
991
+ */
992
+ releaseFence(targetDigest: string): ReleaseOutcome {
993
+ for (const [key, row] of this.fences) {
994
+ // THE authority on which fence a handle names. The route also looks the row
995
+ // up for its preview, but the release decision is made here — a duplicate
996
+ // lookup upstream would leave this comparison enforced by nothing.
997
+ if (opaqueRevision(row.targetKey) !== targetDigest) continue
998
+ // PERSIST FIRST. Reporting a release that was not durably recorded is how an
999
+ // operator is told a thread is open, writes to it, and finds it fenced again
1000
+ // after the next restart with no record of why.
1001
+ const remaining = [...this.fences.values()].filter(r => r.targetKey !== key)
1002
+ if (!this.persistFences(remaining)) return { ok: false, reason: 'persist_failed' }
1003
+ this.fences.delete(key)
1004
+ return { ok: true, row }
1005
+ }
1006
+ return { ok: false, reason: 'unknown_fence' }
878
1007
  }
879
1008
 
880
1009
  /**
@@ -1135,7 +1264,7 @@ export function createAgentSessionBindingsRouter(deps: AgentSessionBindingsDeps)
1135
1264
  const canListBindings = bindingDepsUsable(deps)
1136
1265
  const canWriteBindings = bindingWriteDepsUsable(deps)
1137
1266
  const detect = deps?.occupancy ?? threadOccupancy
1138
- const guard = new TargetGuard()
1267
+ const guard = new TargetGuard(deps.fencePersistence ?? null)
1139
1268
  const ownership = deps?.ownership ?? { record: recordCosSpawn, release: releaseCosSpawn }
1140
1269
  // One per router. Injectable so the follow-on (attach accepting a `forkRef`)
1141
1270
  // shares this instance rather than standing up a second, disconnected one.
@@ -1216,6 +1345,54 @@ export function createAgentSessionBindingsRouter(deps: AgentSessionBindingsDeps)
1216
1345
  }
1217
1346
  }
1218
1347
 
1348
+ // ------------------------------------------------------------------ fences
1349
+ //
1350
+ // Mounted BEFORE the parameterised routes so `fences` can never be read as a
1351
+ // provider. Both are operator surfaces: a fenced thread was previously
1352
+ // discoverable only by trying to use it and being refused.
1353
+ //
1354
+ // A fence is addressed by DIGEST. The raw target key embeds the private native
1355
+ // thread id, and the redaction contract at the top of this file is absolute.
1356
+
1357
+ router.get('/agent-sessions/fences', (_req, res) => {
1358
+ // A fence list is a liveness answer; a cached one is worse than none.
1359
+ res.set('Cache-Control', 'private, no-store')
1360
+ // `degraded` is reported, never inferred: a memory-only fence set behaves
1361
+ // identically to a durable one until the process restarts, so a silent
1362
+ // fallback would be indistinguishable from working.
1363
+ res.json({ fences: guard.listFences(), degraded: guard.degraded() })
1364
+ })
1365
+
1366
+ router.post('/agent-sessions/fences/release', (req, res) => {
1367
+ const body = (req.body ?? {}) as { target?: unknown; confirm?: unknown }
1368
+ const target = body.target
1369
+ if (typeof target !== 'string' || target.length === 0 || target.length > 256) {
1370
+ res.status(400).json({ released: false, reason: 'invalid_request' })
1371
+ return
1372
+ }
1373
+ res.set('Cache-Control', 'private, no-store')
1374
+ if (body.confirm !== true) {
1375
+ // FAILS CLOSED, like every other destructive COS call. NOTE WHAT THIS IS
1376
+ // AND IS NOT: it is a deliberate second call, not proof a human looked.
1377
+ // The API token is shared by the phone, the lens and every COS agent
1378
+ // session, so nothing is structurally prevented from asserting `confirm`.
1379
+ // It stops an accidental release, not an automated one.
1380
+ const preview = guard.listFences().find(f => f.target === target) ?? null
1381
+ res.status(400).json({ released: false, reason: 'confirmation_required', preview })
1382
+ return
1383
+ }
1384
+ // The guard decides. It re-matches the handle itself rather than trusting a
1385
+ // lookup performed up here, and it persists BEFORE it mutates.
1386
+ const outcome = guard.releaseFence(target)
1387
+ if (!outcome.ok) {
1388
+ const status = outcome.reason === 'unknown_fence' ? 404 : 500
1389
+ res.status(status).json({ released: false, reason: outcome.reason })
1390
+ return
1391
+ }
1392
+ console.warn(`[agent-session-bindings] fence RELEASED by operator target=${target} provider=${outcome.row.provider} fencedAt=${outcome.row.fencedAt}`)
1393
+ res.json({ released: true, target, provider: outcome.row.provider })
1394
+ })
1395
+
1219
1396
  router.get('/agent-sessions/:provider/:threadId/attachability', (req, res) => {
1220
1397
  // An occupancy verdict is a liveness answer with a lifetime of roughly now.
1221
1398
  // A cached `attachable: true` is indistinguishable from a stale one, which is
@@ -1375,7 +1552,10 @@ export function createAgentSessionBindingsRouter(deps: AgentSessionBindingsDeps)
1375
1552
  // again just because the binding that delivered it is gone. Checked here as
1376
1553
  // well as in the turn route, because a fresh attach is the obvious way around
1377
1554
  // a per-binding fence.
1378
- if (fenced !== null) return refuseAttach(res, fenced)
1555
+ if (fenced !== null) {
1556
+ console.warn(`[agent-session-bindings] fence hit route=attach provider=${providerParam} target=${opaqueRevision(key)}`)
1557
+ return refuseAttach(res, fenced)
1558
+ }
1379
1559
 
1380
1560
  const resolve = deps.resolveTarget
1381
1561
  if (typeof resolve !== 'function') return refuseAttach(res, 'target_unresolvable')
@@ -1623,6 +1803,11 @@ export function createAgentSessionBindingsRouter(deps: AgentSessionBindingsDeps)
1623
1803
  const turnId = mintId()
1624
1804
  /** The target we hold a claim on, released in the finally. */
1625
1805
  let claimedKey: string | null = null
1806
+ // Hoisted so the CATCH site can fence with evidence. `binding` and `head` are
1807
+ // both declared inside the try, so neither is in scope where the route-error
1808
+ // fence is set — without these it would store a fence it can say nothing about.
1809
+ let fenceProvider = ''
1810
+ let preTurnHeadDigest: string | null = null
1626
1811
  /** Children the adapter reported, released in the finally. */
1627
1812
  const recordedPids: number[] = []
1628
1813
  let pinnedBindingId: string | null = null
@@ -1789,6 +1974,7 @@ export function createAgentSessionBindingsRouter(deps: AgentSessionBindingsDeps)
1789
1974
  if (gate?.ok !== true) return refuseTurn(registryRefusal(gate?.reason))
1790
1975
 
1791
1976
  const binding = deps.bindings.get!(bindingId)
1977
+ if (binding) fenceProvider = binding.provider
1792
1978
  // Only `active` runs work. `staging` is the pre-commit state of the journaled
1793
1979
  // Chat handoff and must never execute against a Chat that can still roll back.
1794
1980
  const usable = assertUsable(binding ?? null, now)
@@ -1805,6 +1991,7 @@ export function createAgentSessionBindingsRouter(deps: AgentSessionBindingsDeps)
1805
1991
  const key = binding.targetKey
1806
1992
  const fenced = guard.fencedReason(key)
1807
1993
  if (fenced !== null) {
1994
+ console.warn(`[agent-session-bindings] fence hit route=turn provider=${binding.provider} target=${opaqueRevision(key)} turnId=${turnId}`)
1808
1995
  return refuseTurn(fenced, { retryable: false, deliveryState: 'unknown' })
1809
1996
  }
1810
1997
 
@@ -1823,6 +2010,7 @@ export function createAgentSessionBindingsRouter(deps: AgentSessionBindingsDeps)
1823
2010
 
1824
2011
  const head = await readHead(binding.provider, binding.nativeThreadId)
1825
2012
  if (head === null) return refuseTurn('native_head_unavailable')
2013
+ preTurnHeadDigest = head.digest
1826
2014
 
1827
2015
  // The attach baseline, advanced by each completed turn and by each explicit
1828
2016
  // Continue Anyway. Without the advance the SECOND turn on a binding always
@@ -1928,7 +2116,18 @@ export function createAgentSessionBindingsRouter(deps: AgentSessionBindingsDeps)
1928
2116
  // Fenced under its own reason, not this turn's: `delivery_ambiguous`
1929
2117
  // describes what happened to THIS request, while a later caller needs to
1930
2118
  // be told the thread is shut and why it must be inspected first.
1931
- guard.fence(key, 'native_target_fenced')
2119
+ guard.fence(key, 'native_target_fenced', {
2120
+ provider: binding.provider,
2121
+ headBefore: head.digest,
2122
+ turnId,
2123
+ bindingId,
2124
+ now: Date.now(),
2125
+ })
2126
+ // A fence shuts a thread until a human acts, and until now it wrote NO log
2127
+ // line at either site — so a fenced thread was discoverable only by trying
2128
+ // to use it (Miles, 2026-08-18). Never log `key`: it embeds the private
2129
+ // native thread id, which this router does not emit anywhere.
2130
+ console.warn(`[agent-session-bindings] fence set site=ambiguous provider=${binding.provider} target=${opaqueRevision(key)} turnId=${turnId} bindingId=${bindingId} headBefore=${head.digest}`)
1932
2131
  return reportAmbiguous()
1933
2132
  }
1934
2133
 
@@ -1958,7 +2157,21 @@ export function createAgentSessionBindingsRouter(deps: AgentSessionBindingsDeps)
1958
2157
  if (deliveryAttempted) {
1959
2158
  // A bug in this route that happened AROUND a delivery is indistinguishable
1960
2159
  // from a delivery.
1961
- if (claimedKey !== null) guard.fence(claimedKey, 'native_target_fenced')
2160
+ if (claimedKey !== null) {
2161
+ guard.fence(claimedKey, 'native_target_fenced', {
2162
+ provider: fenceProvider,
2163
+ headBefore: preTurnHeadDigest,
2164
+ turnId,
2165
+ bindingId: null,
2166
+ now: Date.now(),
2167
+ })
2168
+ // `head` is scoped to the try, so `preTurnHeadDigest` is hoisted to the
2169
+ // handler specifically to reach this site. It is null ONLY when the throw
2170
+ // happened before the head was read. An earlier version of this line
2171
+ // hardcoded `unavailable` and so contradicted the record it had just
2172
+ // written — an operator would read "no baseline" off a fence that has one.
2173
+ console.warn(`[agent-session-bindings] fence set site=route_error target=${opaqueRevision(claimedKey)} turnId=${turnId} headBefore=${preTurnHeadDigest ?? 'unavailable'}`)
2174
+ }
1962
2175
  reportAmbiguous()
1963
2176
  } else {
1964
2177
  refuseTurn('turn_failed')
@@ -36,7 +36,12 @@ export interface ThreadTurnQueueDeps {
36
36
  * ambiguous must resolve `ok: false` with a reason: an unknown delivery that is
37
37
  * retried puts the same sentence into a real conversation twice.
38
38
  */
39
- deliver: (turn: QueuedThreadTurn) => Promise<{ ok: boolean; reason?: string }>
39
+ deliver: (turn: QueuedThreadTurn) => Promise<{
40
+ ok: boolean
41
+ reason?: string
42
+ /** The turn route's OWN `retryable` judgement, when it sent one. Authoritative. */
43
+ serverRetryable?: boolean
44
+ }>
40
45
  now: () => number
41
46
  }
42
47
 
@@ -54,6 +59,31 @@ function publicRow(turn: QueuedThreadTurn, position: number): Record<string, unk
54
59
  }
55
60
  }
56
61
 
62
+ /**
63
+ * Is this failed delivery a "not yet" rather than a "no"?
64
+ *
65
+ * CLASSIFIED BY REASON, NEVER BY STATUS. Every attach refusal that is not
66
+ * `invalid_request` (400) or a capability gap (503) comes back 409 -- transient
67
+ * `native_target_busy` and permanent `native_target_fenced` alike -- so a status-based
68
+ * rule would retry a turn whose copy reads "an earlier turn on this thread may or may
69
+ * not have been delivered" up to 1,080 times inside the TTL. That is the one refusal
70
+ * that needs a human.
71
+ *
72
+ * `queueableRefusal` is reused rather than a second list being written: it already
73
+ * encodes "can this condition ever pass", it is tested, and a turn should be retried
74
+ * for exactly the reasons it was allowed to queue for.
75
+ *
76
+ * FAILS CLOSED. An unrecognised reason, an absent reason, or a thrown deliver all
77
+ * return false and spend an attempt. A new refusal added upstream is therefore bounded
78
+ * by default rather than silently retried forever.
79
+ */
80
+ function isRetryableDelivery(outcome: { reason?: string; serverRetryable?: boolean }): boolean {
81
+ // The turn route publishes its own verdict; it outranks our inference either way.
82
+ if (outcome.serverRetryable === false) return false
83
+ if (outcome.serverRetryable === true) return true
84
+ return queueableRefusal(outcome.reason)
85
+ }
86
+
57
87
  /**
58
88
  * One drain pass over one thread.
59
89
  *
@@ -103,7 +133,7 @@ export async function drainThread(
103
133
  writeQueue(provider, threadId, queue)
104
134
  dirty = true
105
135
 
106
- let outcome: { ok: boolean; reason?: string }
136
+ let outcome: { ok: boolean; reason?: string; serverRetryable?: boolean }
107
137
  try {
108
138
  outcome = await deps.deliver(turn)
109
139
  } catch (error) {
@@ -114,6 +144,20 @@ export async function drainThread(
114
144
  turn.status = 'delivered'
115
145
  turn.settledAt = deps.now()
116
146
  delivered += 1
147
+ } else if (isRetryableDelivery(outcome)) {
148
+ // A GATE REFUSAL IS NOT A FAILED DELIVERY, so it must not spend the ceiling --
149
+ // measured cost of getting this wrong: three of Miles's turns retired in about
150
+ // two minutes each, never delivered, while the 6h TTL never got to matter.
151
+ //
152
+ // REFUNDED, not deferred. The increment and its fsync stay BEFORE the call
153
+ // (routes:101-103) because that is what survives a crash mid-delivery -- the
154
+ // ceiling only bounds anything if it outlives the crash it is bounding, and a
155
+ // test reads `attempts` off disk from inside the deliver callback to prove it.
156
+ // So the attempt is spent first and given back here, where the outcome is known.
157
+ turn.attempts = Math.max(0, turn.attempts - 1)
158
+ turn.status = 'waiting'
159
+ turn.reason = outcome.reason
160
+ held += 1
117
161
  } else if (turn.attempts >= MAX_DELIVERY_ATTEMPTS) {
118
162
  turn.status = 'refused'
119
163
  turn.reason = outcome.reason ?? 'delivery_failed'