@gotcos/glasses-server 6.24.1 → 6.24.3

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,64 @@
1
+ ## 6.24.3
2
+
3
+ Auto-recovery of quarantined audio has never run in production. Miles saw the symptom
4
+ for three turns: "1 recoverable" that opening the phone app could not clear.
5
+
6
+ - **My call sat inside a bare `catch {}`.** `autoRecoverOneQuarantinedCapture()` was one
7
+ line after `purgeExpiredQuarantine()` inside the orphan-audio sweep's
8
+ `try { ... } catch {}`, so any throw in that sweep meant auto-recovery silently never
9
+ executed — on 6.23.1, 6.24.0, 6.24.1 and 6.24.2. Zero `[quarantine]` lines in a 48 MB
10
+ log across every one of those releases. It now has its own try, because recovering
11
+ quarantined audio has nothing to do with sweeping orphaned session-audio dirs and must
12
+ not depend on that succeeding.
13
+ - **That bare catch is why it took three turns to find.** Three minutes of watching a
14
+ live server produced no recovery, no log, and nothing to reason about, because the
15
+ error was discarded. Both catches now report. I chased three wrong causes first — a
16
+ closed admissions gate (`admissionsOpen` was `true`), a stale npm cache (real, but a
17
+ different bug), and a broken picker (it selects the item correctly against live data).
18
+ - **A one-chunk capture is no longer advertised as recoverable.**
19
+ `meeting_1786393815060_tp693w` held ONE 5.6-second chunk that transcribed to silence.
20
+ Recovering it would have produced an empty meeting titled "Recovered capture (audio
21
+ only)"; advertising it produced a badge with instructions that cannot work, since a
22
+ server-side quarantine has no deferred phone save to land. `MIN_RECOVERABLE_CHUNKS`
23
+ is 2, and `isWorthRecovering` is the SINGLE definition used by the picker AND by both
24
+ warning counts — two definitions would let the badge claim something the sweeper has
25
+ already decided to skip.
26
+ - No audio is deleted by any of this. Quarantine retention still owns expiry.
27
+
28
+ Coverage: 8 mutations, all caught. The placement mutations were first measured against a
29
+ RED baseline and re-run once green, because a mutation against a failing tree proves
30
+ nothing. Three of those red iterations were my own test windowing, never the fix: a
31
+ file-wide ban that hit a second legitimate bare catch, a fixed-width slice that ran past
32
+ the fix, and an `indexOf` that matched the function definition instead of the call site.
33
+
34
+ Full suite 1505 serially, tsc clean, gate after the bump.
35
+
36
+ ## 6.24.2
37
+
38
+ The empty-recording restart lock, split out of 6.24.1.
39
+
40
+ **Correction to 6.24.1's own entry.** These bullets were written under 6.24.1 while it
41
+ was still unpublished, but it had already gone to the registry by then, so that heading
42
+ described a published artifact that does not contain this code. Verified by downloading
43
+ the published tarball: 6.24.1 carries the `project` field and has no
44
+ `EMPTY_SESSION_STALE_MS`. Moved here rather than left standing.
45
+
46
+ - **A 6-second aborted recording no longer locks the restart for 30 minutes.**
47
+ `getTranscriptionSessionLiveness` gated purely on elapsed time. The 30-minute grace
48
+ exists to protect a real recording that has gone briefly quiet — a backgrounded phone
49
+ buffering to IndexedDB — but a session with NO canonical text has nothing to protect.
50
+ Observed 2026-08-10: `meeting_1786393815060_tp693w` started, received one 5.6s chunk
51
+ that transcribed to empty, stopped 6.2 seconds later, and then blocked Update Server.
52
+ A session idle past `EMPTY_SESSION_STALE_MS` (2 minutes) with zero canonical chunks
53
+ now stops blocking a restart.
54
+ - **Why that cannot reap a live recording.** `lastActivityAt` is bumped on chunk
55
+ ARRIVAL, before any text filtering, so a live recording in a silent room keeps
56
+ arriving every ~10s and never goes idle at all; its silent chunks land in
57
+ `emptyCompletions`, not `chunks`. Gating on emptiness ALONE would kill exactly that
58
+ session, which is why the rule requires emptiness AND idleness. Counted on canonical
59
+ text rather than array length, because the array is sparse and a silent chunk carries
60
+ no text — the precise shape an aborted recording leaves behind.
61
+
1
62
  ## 6.24.1
2
63
 
3
64
  Surfaces `project` on session rows, so the list can group the way Claude Code's own
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gotcos/glasses-server",
3
- "version": "6.24.1",
3
+ "version": "6.24.3",
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": {
@@ -27,6 +27,34 @@
27
27
 
28
28
  import type { UnsavedCapture } from './unsaved-audio-quarantine.js'
29
29
 
30
+ /**
31
+ * Chunks below which a capture is not worth turning into a meeting.
32
+ *
33
+ * A chunk covers roughly 5 to 10 seconds, so one chunk is a recording that started and
34
+ * stopped almost immediately. Observed 2026-08-10: `meeting_1786393815060_tp693w` held
35
+ * ONE 5.6-second chunk that transcribed to silence, and the panel advertised it as
36
+ * "1 recoverable" with instructions to open the phone app — which cannot clear a
37
+ * server-side quarantine, so the badge simply persisted.
38
+ *
39
+ * Recovering it would run a full batch transcription and produce an empty meeting
40
+ * titled "Recovered capture (audio only)". That is noise, not rescue. The audio is NOT
41
+ * deleted here — quarantine retention still owns that decision and expires it on its
42
+ * own clock. This only decides what is worth acting on and worth warning about.
43
+ */
44
+ export const MIN_RECOVERABLE_CHUNKS = 2
45
+
46
+ /**
47
+ * Is this capture substantial enough to act on?
48
+ *
49
+ * One definition, used by the auto-recover picker AND by the counts that drive the
50
+ * "unsaved captures" warning, so the badge cannot claim something is recoverable that
51
+ * the sweeper has already decided to leave alone.
52
+ */
53
+ export function isWorthRecovering(item: { recovered: boolean; chunkFiles: number }): boolean {
54
+ if (item.recovered) return false
55
+ return item.chunkFiles >= MIN_RECOVERABLE_CHUNKS
56
+ }
57
+
30
58
  /** Attempts per capture before the sweep stops trying on its own. */
31
59
  export const MAX_AUTO_RECOVER_ATTEMPTS = 3
32
60
 
@@ -58,10 +86,8 @@ export function pickQuarantineToRecover(
58
86
  state: AutoRecoverState,
59
87
  ): UnsavedCapture | null {
60
88
  const eligible = items.filter(item => {
61
- // Already a meeting. Recovering again would duplicate it.
62
- if (item.recovered) return false
63
- // Nothing to transcribe: a chunk-less dir is residue, not evidence.
64
- if (item.chunkFiles <= 0) return false
89
+ // Already a meeting, chunk-less residue, or too small to be a meeting at all.
90
+ if (!isWorthRecovering(item)) return false
65
91
  // Another recovery owns this one.
66
92
  if (state.inFlight.has(item.sessionId)) return false
67
93
  return (state.attempts.get(item.sessionId) ?? 0) < MAX_AUTO_RECOVER_ATTEMPTS
@@ -1,4 +1,5 @@
1
1
  import { Router } from 'express'
2
+ import { isWorthRecovering } from '../lib/quarantine-auto-recover.js'
2
3
  import { statSync } from 'node:fs'
3
4
  import { resolve } from 'node:path'
4
5
  import { COS_SCRIPTS_DIR, COS_MODE } from '../lib/python-bridge.js'
@@ -227,7 +228,7 @@ healthRouter.get('/health', async (_req, res) => {
227
228
  // plus the recover action live on the authenticated /api/meeting/orphans.
228
229
  const unsavedList = listUnsavedCaptures()
229
230
  const unsaved_captures = {
230
- count: unsavedList.filter(item => !item.recovered).length,
231
+ count: unsavedList.filter(isWorthRecovering).length,
231
232
  items: unsavedList.slice(0, 10).map(item => ({
232
233
  sessionId: item.sessionId,
233
234
  ageHours: item.ageHours,
@@ -3,6 +3,7 @@
3
3
  // are durable before the session is closed; batch improvement runs afterward.
4
4
 
5
5
  import { existsSync, readdirSync, readFileSync, rmSync, statSync, unlinkSync } from 'node:fs'
6
+ import { isWorthRecovering } from '../lib/quarantine-auto-recover.js'
6
7
  import { resolve } from 'node:path'
7
8
  import { Router } from 'express'
8
9
  import { emitDisplay } from '../lib/display-bus.js'
@@ -1672,7 +1673,7 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
1672
1673
  // route answered count: 0.
1673
1674
  const stranded = getStrandedCaptures()
1674
1675
  res.json({
1675
- count: items.filter(item => !item.recovered).length,
1676
+ count: items.filter(isWorthRecovering).length,
1676
1677
  strandedCount: stranded.length,
1677
1678
  stranded,
1678
1679
  recovering: [...recoveringOrphans],
@@ -387,6 +387,26 @@ export function getActiveTranscriptionSessionCount(): number {
387
387
  */
388
388
  export const RECORDING_SESSION_STALE_MS = STRANDED_STALE_MS
389
389
 
390
+ /**
391
+ * How long an EMPTY session may hold the restart lock.
392
+ *
393
+ * The 30-minute grace above protects a real recording that has gone briefly quiet — a
394
+ * backgrounded phone buffering to IndexedDB, a network drop, a long pause. A session
395
+ * that has produced NO canonical text has nothing to protect, so it must not lock
396
+ * Install, Repair, Restart and Update Server for half an hour.
397
+ *
398
+ * Observed 2026-08-10: meeting_1786393815060_tp693w started, took ONE 5.6s chunk that
399
+ * transcribed to empty, stopped 6.2s later, then blocked every restart for 30 minutes.
400
+ *
401
+ * WHY THIS CANNOT REAP A LIVE RECORDING. `lastActivityAt` is bumped on chunk ARRIVAL
402
+ * in processStreamChunk, before any text filtering, so a live recording in a silent
403
+ * room keeps arriving every ~10s and never goes idle — its silent chunks land in
404
+ * `emptyCompletions`, not `chunks`. Gating on emptiness ALONE would kill exactly that
405
+ * session; gating on emptiness AND idleness cannot. Two minutes is comfortably more
406
+ * than one chunk interval, so a recording that has just started is safe.
407
+ */
408
+ export const EMPTY_SESSION_STALE_MS = 2 * 60_000
409
+
390
410
  export interface TranscriptionSessionLiveness {
391
411
  /** Sessions still plausibly recording. These block a restart. */
392
412
  live: number
@@ -479,8 +499,12 @@ export function getTranscriptionSessionLiveness(now = Date.now()): Transcription
479
499
  const staleSessions: TranscriptionSessionLiveness['staleSessions'] = []
480
500
  for (const [sessionId, session] of sessions) {
481
501
  const silentForMs = now - session.lastActivityAt
482
- if (silentForMs >= RECORDING_SESSION_STALE_MS) {
483
- staleSessions.push({ sessionId, silentForMs, chunks: session.chunks.length })
502
+ // Canonical text, not array length: the array is sparse and silent chunks carry no
503
+ // text, which is exactly the shape an aborted recording leaves behind.
504
+ const canonicalChunks = session.chunks.filter(chunk => chunk && chunk.text).length
505
+ const emptyAndIdle = canonicalChunks === 0 && silentForMs >= EMPTY_SESSION_STALE_MS
506
+ if (silentForMs >= RECORDING_SESSION_STALE_MS || emptyAndIdle) {
507
+ staleSessions.push({ sessionId, silentForMs, chunks: canonicalChunks })
484
508
  } else {
485
509
  live++
486
510
  }
@@ -991,8 +1015,22 @@ setInterval(() => {
991
1015
  }
992
1016
  }
993
1017
  purgeExpiredQuarantine()
1018
+ } catch (error) {
1019
+ // Was a bare `catch {}`. That is what made the auto-recover failure below
1020
+ // undiagnosable: three minutes of watching a live server produced no recovery, no
1021
+ // log, and nothing to reason about, because any throw in this block vanished.
1022
+ console.error(`[cleanup] Orphan-audio sweep failed: ${error instanceof Error ? error.message : error}`)
1023
+ }
1024
+ // DELIBERATELY ITS OWN TRY. This used to sit inside the block above, one line after
1025
+ // purgeExpiredQuarantine, so a throw anywhere in that sweep meant auto-recovery
1026
+ // silently never ran — which is exactly what happened in production on 6.23.1
1027
+ // through 6.24.2. Recovering quarantined audio has nothing to do with sweeping
1028
+ // orphaned session-audio dirs and must not depend on it succeeding.
1029
+ try {
994
1030
  autoRecoverOneQuarantinedCapture()
995
- } catch {}
1031
+ } catch (error) {
1032
+ console.error(`[quarantine] Auto-recover pass failed: ${error instanceof Error ? error.message : error}`)
1033
+ }
996
1034
  // Purge stale pending-batch dirs. HQ large-v3 on long meetings + Control
997
1035
  // restart can exceed 2h (2026-07-27: two sessions purged before batch).
998
1036
  // Use 12h, and never purge while a meeting_batch_finalization lease is held.