@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.
|
|
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:
|
|
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
|
-
//
|
|
87
|
-
|
|
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
|
-
|
|
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<{
|
|
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'
|