@gotcos/glasses-server 6.36.7 → 6.36.9

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,48 @@
1
1
  ## Unreleased
2
2
 
3
+ ## 6.36.9
4
+ - **The queue gate can now see COS's own bindings, which closes a 30-minute lockout.**
5
+ `threadOccupancy` sees FOREIGN holders only — `OccupancyReason` has no
6
+ `native_target_busy`, because that refusal comes from the binding registry. So when a
7
+ live COS binding held a thread, Continue refused `native_target_busy`, the client
8
+ armed the queue, and this gate answered `409 thread_free` ("Pick Continue again"),
9
+ which refused identically. A closed loop for the whole binding TTL, while the refusal
10
+ copy said "detach that one first" — an action with no endpoint.
11
+ - The same gap burnt the delivery ceiling: the drainer saw `attachable`, ATTEMPTED, and
12
+ the attach route refused. Five of those retired a turn in about two minutes. With the
13
+ binding visible, `drainDecision` returns `hold` and spends nothing — one change closes
14
+ both the loop and the burn.
15
+ - **A gate refusal no longer spends an attempt.** Classified by REASON, never by status:
16
+ every refusal that is not `invalid_request` (400) or a capability gap (503) comes back
17
+ 409, so a status rule would have retried `native_target_fenced` — "an earlier turn may
18
+ or may not have been delivered" — up to 1,080 times. Reuses `queueableRefusal`, and
19
+ fails CLOSED on an unrecognised reason.
20
+ - The attempt is still written and fsynced BEFORE the call, then REFUNDED once the
21
+ outcome is known. Deferring the increment would have reopened the crash-safety hole
22
+ the comment there describes, and a test reads `attempts` off disk mid-delivery to
23
+ prove it.
24
+ - **The refusal reason is no longer discarded.** `deliver` read `body.error`; the attach
25
+ route emits `reason` and has never emitted `error`, so every stored reason in the
26
+ field was the literal `attach_409` and the whole feature was reason-blind. Also
27
+ carries the turn route's own `retryable` verdict, which outranks our inference.
28
+
29
+
30
+ ## 6.36.8
31
+ - **The queue now covers the refusal that actually fires.** Device diagnostics, once the
32
+ path was finally instrumented, recorded `native_target_busy` on every Continue that
33
+ reached the server — never `native_thread_working`, which is what 6.36.7 was built
34
+ around. Selecting Continue SUCCEEDS and mints a binding; if the dictation is not
35
+ completed the binding lingers to its TTL, and every Continue inside that window
36
+ refuses. Twelve such bindings had stacked up on one thread over an evening.
37
+ - `native_target_busy` and `native_turn_in_progress` are now queueable. Both are
38
+ transient by construction — a clock clears them — and both are COS's OWN bookkeeping
39
+ rather than a foreign process holding the thread. Delivery still re-runs the full
40
+ gate, so nothing about the safety model changes.
41
+ - `native_target_fenced` is deliberately NOT queueable: "may or may not have been
42
+ delivered" cannot be resolved by waiting, and queueing it risks a duplicate turn in a
43
+ real conversation.
44
+
45
+
3
46
  ## 6.36.7
4
47
  - **A turn spoken at a busy thread is now queued instead of refused.** Miles: "if
5
48
  there's a session that's still running, that would just put it into the queue the same
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gotcos/glasses-server",
3
- "version": "6.36.7",
3
+ "version": "6.36.9",
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
@@ -517,10 +517,33 @@ app.use('/api', claudeSessionsRouter)
517
517
  // buys the guarantee that the queue CANNOT weaken the gate even by accident.
518
518
  if (threadAttachEnabled()) {
519
519
  const queueDeps = {
520
+ // TWO GATES, ONE ANSWER. `threadOccupancy` sees FOREIGN holders -- another app on
521
+ // the Mac writing the transcript. It does not, and cannot, see COS's OWN bindings:
522
+ // `OccupancyReason` has no `native_target_busy` member, because that refusal is
523
+ // produced only by the binding registry inside `bindings.create`.
524
+ //
525
+ // That gap is what trapped Miles on 2026-08-18. Continue refused
526
+ // `native_target_busy` (a live COS binding held the thread), the client armed the
527
+ // queue, he dictated -- and this gate, asking occupancy alone, answered
528
+ // `attachable: true`, so the route replied `409 thread_free` ("Pick Continue again
529
+ // to send it"). Continue refused identically. A closed loop for the 30-minute
530
+ // binding TTL, with the refusal copy telling him to "detach that one first" -- an
531
+ // action with no endpoint.
532
+ //
533
+ // It also burnt the delivery ceiling: the drainer saw `attachable` and ATTEMPTED,
534
+ // the attach route refused, and five of those retired the turn in ~2 minutes. With
535
+ // the binding visible here, `drainDecision` returns 'hold' instead and no attempt
536
+ // is spent -- so this single change closes the loop AND the attempt burn.
520
537
  occupancy: (provider: string, threadId: string) => {
521
538
  try {
522
539
  const v = threadOccupancy(provider, threadId, occupancyProbes, occupancyDirs)
523
- return { attachable: v.attachable === true, reason: v.reason ?? null }
540
+ if (v.attachable !== true) return { attachable: false, reason: v.reason ?? null }
541
+ // Occupancy is happy; ask the registry the question it cannot answer.
542
+ // `getByThread` returns the binding ONLY when it actually blocks the target
543
+ // (`blocksTarget`), so an expired or terminal one correctly reads as free.
544
+ const holder = agentSessionBindingRegistry.getByThread(provider, threadId, Date.now())
545
+ if (holder !== null) return { attachable: false, reason: 'native_target_busy' }
546
+ return { attachable: true, reason: null }
524
547
  } catch {
525
548
  // A throwing probe is not an open door.
526
549
  return { attachable: false, reason: 'probe_failed' }
@@ -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
  }
@@ -120,6 +120,19 @@ export const MAX_DELIVERY_ATTEMPTS = 5
120
120
  */
121
121
  const QUEUEABLE_REFUSALS: ReadonlySet<string> = new Set([
122
122
  'native_thread_working',
123
+ // COS'S OWN LEFTOVER BINDING, and the reason this feature did not work for a day.
124
+ // Proven from device diagnostics 2026-08-17: every Continue that reached the server
125
+ // was refused `native_target_busy` ("already attached to another COS chat"), never
126
+ // `native_thread_working`. Selecting Continue SUCCEEDS and mints a binding; if the
127
+ // dictation is not completed the binding lingers to its TTL, and every Continue in
128
+ // that window refuses. Twelve such bindings had stacked up on one thread.
129
+ //
130
+ // It belongs here because it is transient BY CONSTRUCTION -- the binding expires on
131
+ // a clock -- and because it is COS's own bookkeeping, not a foreign process holding
132
+ // the thread. Queueing waits it out, and delivery re-runs the whole gate as always.
133
+ 'native_target_busy',
134
+ // Same shape: a COS turn is mid-flight on this thread and will finish.
135
+ 'native_turn_in_progress',
123
136
  'live_desktop_process',
124
137
  'thread_busy',
125
138
  'binding_conflict',
@@ -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'