@gotcos/glasses-server 6.50.1 → 6.50.2

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,3 +1,10 @@
1
+ ## 6.50.2
2
+
3
+ - **The Turbo meeting preview no longer switches itself off for good.** Miles, 2026-09-18 08:43: the `~` provisional line on the lens was gone, Mac-reachable, Turbo configured. The preview route had answered "no text" to 5,719 requests since 2026-09-17 16:00 (in under a millisecond) while the Turbo sidecar itself transcribed the same audio in 0.27 s. One failed request (`The operation was aborted due to timeout`, then `fetch failed`, with the Mac under build load) set `previewAvailable = false`, and the only code that sets it back is the sidecar's own start, which runs once per server boot. Now a failure against a sidecar that is still running is a 10 s cooldown (`PREVIEW_FAILURE_COOLDOWN_MS`) and the next request after it tries again; five failures in a row (`PREVIEW_RECYCLE_AFTER_FAILURES`) recycle the sidecar; a sidecar that exited is restarted from the request path, at most once per 60 s window, doubling to 15 min while restarts keep failing and resetting on any answer. A missing model, a missing binary or a foreign listener on port 8177 is never restarted (a restart cannot fix it). After the server's own shutdown stop, nothing revives the sidecar (a request arriving during shutdown could otherwise have spawned one that outlived the process). Prompt previews were never affected (they fall back to the canonical worker); canonical meeting ASR never was. The preview log lines now carry an ISO timestamp: the lines that recorded this failure had none.
4
+ - Tests (`whisper-preview.test.ts`, executed against spawn and fetch fakes and a fake clock): a timeout is a cooldown and the next request after it gets text; a success resets the count so scattered misses never recycle; five in a row recycle and the new sidecar answers; an exited sidecar restarts from the request path and the backoff doubles across failed restarts; a foreign listener is never re-probed. Eight mutants (latch restored, success never resets, no revive, backoff never doubles, cooldown ignored, foreign port restarted, revive after shutdown, start after shutdown) each turn the suite red; three of them survived the first draft of the tests and the tests were tightened (a recycle's kill is synchronous, its respawn is not, so negatives now wait for the loop to settle).
5
+ - **Coming back to a Desktop tab no longer blocks a Continue for 30 seconds.** Miles, 2026-09-17 07:00: five sends from the glasses refused "Your Mac is writing to this thread right now" until he clicked away from the tab. Claude Desktop had written its away summary (the note it drops when you return to a tab you left) to the transcript 11 s earlier, and the sub-agent that wrote it fired `SubagentStop`. The attach gate reads a transcript written inside 30 s as working unless the hooks vouch that the turn ended, and the vouch required the newest hook event to be the `Stop` itself, so the desk's own bookkeeping after it made the gate strict. The vouch now reads "no turn since the Stop": a `SubagentStop` with no sub-agent open, or a notification, keeps it; anything that opens a turn (a prompt, a tool, a sub-agent starting) still refuses, and a newer Stop still moves the clock.
6
+ - Tests: the two bookkeeping events keep the vouch and each other guard still holds behind them; a tool, a prompt, a sub-agent start and a permission request stay strict. Gate: `turns-b6-desk-bookkeeping-is-strict-again` and `turns-b6-any-event-vouches` added; the four B6 cases killed.
7
+
1
8
  ## 6.50.1
2
9
 
3
10
  What the QA pass on 6.50.0 found in the history it serves (6.50.0 was published before the pass finished; nothing it changed regressed, and a client that does not ask for `?turns` sees no difference).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gotcos/glasses-server",
3
- "version": "6.50.1",
3
+ "version": "6.50.2",
4
4
  "description": "COS Glasses \u2014 self-hosted AI heads-up-display server for Even G2 smart glasses, powered by Claude Code, Codex, Cursor Agent CLI, or local Ollama",
5
5
  "type": "module",
6
6
  "bin": {
package/server/index.ts CHANGED
@@ -858,7 +858,7 @@ async function gracefulShutdown(): Promise<void> {
858
858
  try { logActiveSessionsOnShutdown() } catch { /* best-effort flush */ }
859
859
  stopCodexModelCatalogRefresh()
860
860
  stopWhisperServer()
861
- await stopWhisperPreviewServer()
861
+ await stopWhisperPreviewServer({ final: true })
862
862
  stopLocalTtsServer()
863
863
  clearTimeout(forceExit)
864
864
  process.exit(0)
@@ -792,15 +792,28 @@ export function buildOccupancyProbes(
792
792
  *
793
793
  * `signalFor` is the signal store's reader, taking a full session id or the registry's
794
794
  * 8-character form; `registryIdleAfterStop` reads the registry record for the session.
795
- * The probe answers a Stop time only when BOTH vouch: the session's newest hook event is
796
- * that Stop, the turn is closed, no sub-agent is open, the session has not ended, and
797
- * the registry says idle at or after the Stop (the hooks have returned). Codex never
795
+ * The probe answers a Stop time only when BOTH vouch: nothing since the Stop has opened
796
+ * a turn (the desk's own bookkeeping after it does not count: the sub-agent that writes
797
+ * Claude Desktop's away summary, an idle notification), the turn is closed, no sub-agent
798
+ * is open, the session has not ended, and the registry says idle at or after the Stop
799
+ * (the hooks have returned). Codex never
798
800
  * asks (its ids are not Claude sessions, and a Codex thread id sharing a Claude prefix
799
801
  * must not read a Claude signal); `readHolderActivity` guards the provider too.
800
802
  *
801
803
  * Wired at the composition root only when `COS_SESSION_HOOKS` is on. Absent, the
802
804
  * gate is byte-for-byte 6.48.0.
803
805
  */
806
+ /**
807
+ * Hook events that can follow a Stop without a turn having started (6.50.2). Miles,
808
+ * 2026-09-17 07:00: five sends refused `native_thread_working` because Claude Desktop
809
+ * wrote its `away_summary` system row to the transcript 11 s earlier and the sub-agent
810
+ * that wrote it fired SubagentStop, so the newest event was not 'Stop' and the 30 s
811
+ * transcript window held. Every event that DOES mean a turn sets `turnOpen`
812
+ * (UserPromptSubmit, a tool) or `subagentsOpen` (SubagentStart), which the guards
813
+ * behind this set still read; a newer Stop replaces `stopAt`.
814
+ */
815
+ const AFTER_THE_TURN: ReadonlySet<string> = new Set(['Stop', 'SubagentStop', 'Notification'])
816
+
804
817
  export function withHookTurnClock(
805
818
  probes: OccupancyProbes,
806
819
  signalFor: (sessionId: string) => HookTurnSignal | undefined,
@@ -812,7 +825,7 @@ export function withHookTurnClock(
812
825
  if (provider !== 'claude') return null
813
826
  if (!NATIVE_THREAD_ID_RE.test(threadId)) return null
814
827
  const signal = signalFor(threadId)
815
- if (!signal || signal.lastEvent !== 'Stop' || signal.turnOpen || signal.subagentsOpen > 0 || signal.ended !== null) return null
828
+ if (!signal || !AFTER_THE_TURN.has(signal.lastEvent) || signal.turnOpen || signal.subagentsOpen > 0 || signal.ended !== null) return null
816
829
  if (typeof signal.stopAt !== 'number') return null
817
830
  // The engine's own end of turn: the registry flipped idle AFTER this Stop, which
818
831
  // happens only once every Stop hook has returned. Anything else is strict.
@@ -69,6 +69,82 @@ let previewFailure: WhisperPreviewReason = null
69
69
  let previewWorkerModel: WhisperPreviewSidecarModel | null = null
70
70
  let warnedInvalidChoice = false
71
71
 
72
+ /**
73
+ * 6.50.2: a request that fails against a sidecar that is STILL RUNNING is a miss, not a
74
+ * death. Before this, one timeout (the Mac under load) or one refused fetch set
75
+ * `previewAvailable = false`, and its only writer back to true is the sidecar's own start,
76
+ * which runs once per server boot, so the meeting preview lane stayed silent for the rest
77
+ * of the process: 2026-09-17 16:00 to 2026-09-18 08:48 (34 h), 5,719 requests answered 204
78
+ * in under a millisecond while Turbo itself answered the same audio in 0.27 s.
79
+ *
80
+ * Now: a failure against a live sidecar starts a short cooldown and the next request after
81
+ * it tries again. PREVIEW_RECYCLE_AFTER_FAILURES in a row recycles the sidecar (stop, then
82
+ * start). A sidecar that is gone is restarted from the request path, backing off from
83
+ * PREVIEW_RESTART_BASE_MS to PREVIEW_RESTART_MAX_MS so a model that cannot load is not
84
+ * reloaded every minute. Any successful answer resets both counters.
85
+ */
86
+ export const PREVIEW_FAILURE_COOLDOWN_MS = 10_000
87
+ export const PREVIEW_RECYCLE_AFTER_FAILURES = 5
88
+ export const PREVIEW_RESTART_BASE_MS = 60_000
89
+ export const PREVIEW_RESTART_MAX_MS = 15 * 60_000
90
+ let previewCooldownUntil = 0
91
+ let previewConsecutiveFailures = 0
92
+ let previewLastRestartAt = 0
93
+ let previewRestartBackoffMs = PREVIEW_RESTART_BASE_MS
94
+ /** Set by the server's shutdown: no request may revive the sidecar after it (QA, 6.50.2). */
95
+ let previewShutdown = false
96
+
97
+ function previewSidecarAlive(): boolean {
98
+ const child = previewProcess
99
+ return !!child && child.exitCode === null && child.signalCode === null
100
+ }
101
+
102
+ function notePreviewRequestSuccess(): void {
103
+ previewConsecutiveFailures = 0
104
+ previewCooldownUntil = 0
105
+ previewRestartBackoffMs = PREVIEW_RESTART_BASE_MS
106
+ }
107
+
108
+ /** A request to the sidecar failed (not a preemption). Cooldown, recycle, or mark it gone. */
109
+ function notePreviewRequestFailure(error: unknown, label: string): void {
110
+ previewConsecutiveFailures++
111
+ const message = error instanceof Error ? error.message : String(error)
112
+ const at = new Date().toISOString()
113
+ if (previewSidecarAlive() && previewConsecutiveFailures < PREVIEW_RECYCLE_AFTER_FAILURES) {
114
+ previewCooldownUntil = Date.now() + PREVIEW_FAILURE_COOLDOWN_MS
115
+ console.warn(`[whisper-preview] ${at} ${label} failed (${previewConsecutiveFailures} in a row); next try in ${PREVIEW_FAILURE_COOLDOWN_MS / 1000} s: ${message}`)
116
+ return
117
+ }
118
+ const alive = previewSidecarAlive()
119
+ previewAvailable = false
120
+ previewWorkerModel = null
121
+ previewFailure = 'preview_sidecar_unavailable'
122
+ console.warn(`[whisper-preview] ${at} ${label} failed (${previewConsecutiveFailures} in a row); ${alive ? 'recycling the sidecar' : 'the sidecar is gone'}: ${message}`)
123
+ maybeRevivePreviewSidecar()
124
+ }
125
+
126
+ /**
127
+ * Bring a failed sidecar back, from the request path. Only for a sidecar that ran and then
128
+ * failed or exited: a missing model, a missing binary or a foreign listener on the port
129
+ * cannot be fixed by a restart and stay as they are.
130
+ */
131
+ function maybeRevivePreviewSidecar(): void {
132
+ if (previewShutdown || previewAvailable || previewStarting) return
133
+ if (previewFailure !== 'preview_sidecar_unavailable' && previewFailure !== 'preview_start_failed') return
134
+ const now = Date.now()
135
+ if (previewLastRestartAt && now - previewLastRestartAt < previewRestartBackoffMs) return
136
+ if (previewLastRestartAt) previewRestartBackoffMs = Math.min(previewRestartBackoffMs * 2, PREVIEW_RESTART_MAX_MS)
137
+ previewLastRestartAt = now
138
+ previewConsecutiveFailures = 0
139
+ console.log(`[whisper-preview] ${new Date(now).toISOString()} restarting the preview sidecar (${previewFailure}); next restart no sooner than ${Math.round(previewRestartBackoffMs / 1000)} s`)
140
+ void (async () => {
141
+ if (previewProcess) await stopWhisperPreviewServer()
142
+ await startWhisperPreviewServer()
143
+ })().catch(error => {
144
+ console.warn(`[whisper-preview] restart failed: ${error instanceof Error ? error.message : error}`)
145
+ })
146
+ }
147
+
72
148
  interface ProcessEntry {
73
149
  pid: number
74
150
  ppid: number
@@ -260,6 +336,9 @@ async function endpointReady(path: '/health' | '/inference', init?: RequestInit,
260
336
  * uses Turbo while its canonical live worker remains Large-v3. Failure is
261
337
  * cosmetic: every recovery/finalization path stays untouched. */
262
338
  export async function startWhisperPreviewServer(): Promise<void> {
339
+ // A revive already past its stop when shutdown landed must not spawn an orphan that
340
+ // outlives the server.
341
+ if (previewShutdown) return
263
342
  const requested = requestedPreviewModel()
264
343
  const selected = selectedPreviewModel(requested)
265
344
  if (!needsPreviewSidecar(selected) || previewProcess || previewAvailable || previewStarting) return
@@ -359,7 +438,8 @@ function waitForPreviewClose(child: ChildProcess, timeoutMs: number): Promise<bo
359
438
  })
360
439
  }
361
440
 
362
- export async function stopWhisperPreviewServer(): Promise<void> {
441
+ export async function stopWhisperPreviewServer(options: { final?: boolean } = {}): Promise<void> {
442
+ if (options.final) previewShutdown = true
363
443
  const child = previewProcess
364
444
  previewProcess = null
365
445
  previewAvailable = false
@@ -395,27 +475,23 @@ export async function transcribeWhisperPreview(audioBuffer: Buffer): Promise<{
395
475
  model: 'small.en' | WhisperCommitModel
396
476
  backend: 'whisper-preview-server' | 'whisper-server'
397
477
  }> {
398
- if (previewAvailable && previewWorkerModel) {
478
+ if (!previewAvailable) maybeRevivePreviewSidecar()
479
+ if (previewAvailable && previewWorkerModel && Date.now() >= previewCooldownUntil) {
399
480
  const workerModel = previewWorkerModel
400
481
  const previewLease = tryAcquireMetalPreview()
401
482
  if (!previewLease) {
402
483
  return { text: '', model: workerModel, backend: 'whisper-preview-server' }
403
484
  }
404
485
  try {
405
- return {
406
- text: await transcribeViaPreviewServer(audioBuffer, previewLease.signal),
407
- model: workerModel,
408
- backend: 'whisper-preview-server',
409
- }
486
+ const text = await transcribeViaPreviewServer(audioBuffer, previewLease.signal)
487
+ notePreviewRequestSuccess()
488
+ return { text, model: workerModel, backend: 'whisper-preview-server' }
410
489
  } catch (error) {
411
490
  if (previewLease.signal.aborted) {
412
491
  return { text: '', model: workerModel, backend: 'whisper-preview-server' }
413
492
  }
414
- const failedModel = previewWorkerModel
415
- previewAvailable = false
416
- previewWorkerModel = null
417
- previewFailure = 'preview_sidecar_unavailable'
418
- console.warn(`[whisper-preview] ${failedModel} preview failed; falling back to canonical worker: ${error instanceof Error ? error.message : error}`)
493
+ // Falls through to the canonical worker below for THIS request either way.
494
+ notePreviewRequestFailure(error, `${workerModel} preview`)
419
495
  } finally {
420
496
  previewLease.release()
421
497
  }
@@ -448,21 +524,20 @@ export async function transcribeWhisperMeetingPreview(audioBuffer: Buffer): Prom
448
524
  model: 'large-v3-turbo'
449
525
  backend: 'whisper-preview-server'
450
526
  } | null> {
527
+ if (!previewAvailable) maybeRevivePreviewSidecar()
451
528
  if (!previewAvailable || previewWorkerModel !== 'large-v3-turbo') return null
529
+ // A cooldown after a failed request is a quiet miss, never a latch (see above).
530
+ if (Date.now() < previewCooldownUntil) return null
452
531
  const previewLease = tryAcquireMetalPreview()
453
532
  if (!previewLease) return null
454
533
  try {
455
- return {
456
- text: await transcribeViaPreviewServer(audioBuffer, previewLease.signal),
457
- model: 'large-v3-turbo',
458
- backend: 'whisper-preview-server',
459
- }
534
+ const text = await transcribeViaPreviewServer(audioBuffer, previewLease.signal)
535
+ notePreviewRequestSuccess()
536
+ return { text, model: 'large-v3-turbo', backend: 'whisper-preview-server' }
460
537
  } catch (error) {
461
538
  if (previewLease.signal.aborted) return null
462
- previewAvailable = false
463
- previewWorkerModel = null
464
- previewFailure = 'preview_sidecar_unavailable'
465
- console.warn(`[whisper-preview] meeting Turbo preview failed; canonical meeting ASR was not affected: ${error instanceof Error ? error.message : error}`)
539
+ // Canonical meeting ASR is not affected by any of this: the preview is cosmetic.
540
+ notePreviewRequestFailure(error, 'meeting Turbo preview')
466
541
  return null
467
542
  } finally {
468
543
  previewLease.release()