@gotcos/glasses-server 6.21.23 → 6.21.24

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,30 @@
1
+ ## 6.21.24
2
+
3
+ - A leaked recording session can no longer block every restart. The maintenance
4
+ drain gate counted `sessions.size`, so a session whose phone dropped
5
+ mid-recording without sending a close pinned that count at 1 indefinitely and
6
+ Install, Repair, Restart and Update Server each drained it, timed out after
7
+ 60s, and failed — with no user-visible reason. Hit twice on 2026-08-06
8
+ (6.21.20 and 6.21.22); the second phantom had been silent 54 minutes and the
9
+ only way through was to finalize it as a real meeting, which it was not.
10
+
11
+ The gate now counts only sessions active within the last 30 minutes. A stale
12
+ one is reported in status as `staleTranscriptionSessions` with its session id,
13
+ silent duration and chunk count, so a blocked operator can see the cause —
14
+ and `/api/meeting/orphans` reporting 0 can no longer coexist silently with a
15
+ held lock, since the two read different stores.
16
+
17
+ **Nothing is reaped.** The session, its chunks and its recoverability are
18
+ untouched; only its claim on the restart gate expires. The 30-minute window
19
+ sits far above the reasons a live recording legitimately goes quiet (a
20
+ backgrounded phone buffering to IndexedDB, a network drop) and far below the
21
+ 4h durable-chunk retention.
22
+
23
+ Note for anyone reading the health payload: `oldestWorkStartedAt: null` does
24
+ NOT indicate a leak. `recording_session` reaches the gate through
25
+ `extraActiveByKind`, which never contributes a timestamp, so every recording
26
+ session reports null — healthy or not. Only `lastActivityAt` separates them.
27
+
1
28
  ## 6.21.23
2
29
 
3
30
  - `GET /meeting/:sessionId/embeddings` — why each chunk was labelled the way it
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gotcos/glasses-server",
3
- "version": "6.21.23",
3
+ "version": "6.21.24",
4
4
  "description": "COS Glasses — 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": {
@@ -15,7 +15,7 @@ import { getQueryJobRuntimeHealth } from '../lib/query-job-runtime.js'
15
15
  import { serverMetrics } from '../lib/server-metrics.js'
16
16
  import { getServerInstanceId } from '../lib/server-instance-id.js'
17
17
  import { getWhisperHealth } from '../lib/whisper-local.js'
18
- import { getActiveTranscriptionSessionCount } from './transcribe-stream.js'
18
+ import { getActiveTranscriptionSessionCount, getTranscriptionSessionLiveness } from './transcribe-stream.js'
19
19
 
20
20
  export const maintenanceRouter = Router()
21
21
 
@@ -71,15 +71,21 @@ function operationCredentials(req: Request): MaintenanceOperationCredentials {
71
71
  function statusSnapshot(credentials: MaintenanceOperationCredentials = {}) {
72
72
  const jobs = getQueryJobRuntimeHealth()
73
73
  const activeTranscriptionSessions = getActiveTranscriptionSessionCount()
74
+ // Only sessions still plausibly recording may hold the drain gate. A leaked
75
+ // session — phone dropped mid-recording, no close ever sent — otherwise pins
76
+ // this at 1 forever and every Install/Repair/Restart/Update drains, times out
77
+ // and fails with nothing to show the user. The stale ones are reported below
78
+ // rather than counted, and are never deleted here.
79
+ const sessionLiveness = getTranscriptionSessionLiveness()
74
80
  const managed = managedRuntimeCapability()
75
81
  const tracked = maintenanceLifecycle.snapshot(credentials, {
76
- recording_session: activeTranscriptionSessions,
82
+ recording_session: sessionLiveness.live,
77
83
  })
78
84
  const untrackedDurableRuns = Math.max(0, jobs.activeRuns - (tracked.activeByKind.durable_query ?? 0))
79
85
  const lifecycle = untrackedDurableRuns > 0
80
86
  ? maintenanceLifecycle.snapshot(credentials, {
81
87
  durable_query_runtime: untrackedDurableRuns,
82
- recording_session: activeTranscriptionSessions,
88
+ recording_session: sessionLiveness.live,
83
89
  })
84
90
  : tracked
85
91
  return {
@@ -91,6 +97,13 @@ function statusSnapshot(credentials: MaintenanceOperationCredentials = {}) {
91
97
  bootId: serverMetrics.bootId,
92
98
  activeJobs: jobs.activeRuns,
93
99
  activeTranscriptionSessions,
100
+ // Split out so a blocked operator can SEE why, and so "0 orphans" can never
101
+ // again coexist with a held lock: the orphans endpoint reads the quarantine
102
+ // directory, this reads the in-memory session map, and they are different
103
+ // stores. A stale session is surfaced here and blocks nothing.
104
+ liveTranscriptionSessions: sessionLiveness.live,
105
+ staleTranscriptionSessions: sessionLiveness.stale,
106
+ staleTranscriptionSessionDetail: sessionLiveness.staleSessions,
94
107
  shuttingDown: jobs.shuttingDown,
95
108
  durableStoreState: jobs.store.state,
96
109
  lifecycle,
@@ -342,6 +342,76 @@ export function getActiveTranscriptionSessionCount(): number {
342
342
  return sessions.size
343
343
  }
344
344
 
345
+ /**
346
+ * How long a session may go silent before it stops BLOCKING a restart.
347
+ *
348
+ * Not a retention policy and not a reap: the session, its chunks and its
349
+ * recoverability are untouched. This decides one thing — whether it still
350
+ * counts toward the maintenance drain gate.
351
+ *
352
+ * Sized against the reasons a LIVE recording legitimately goes quiet: the phone
353
+ * backgrounded and buffering to IndexedDB, a network drop, a long pause. Those
354
+ * run to minutes. It sits deliberately far below
355
+ * LOCAL_FIRST_MEETING_IDLE_RETENTION_MS (4h), which governs how long chunks stay
356
+ * recoverable and is far too long to hold a restart on.
357
+ */
358
+ export const RECORDING_SESSION_STALE_MS = 30 * 60 * 1000
359
+
360
+ export interface TranscriptionSessionLiveness {
361
+ /** Sessions still plausibly recording. These block a restart. */
362
+ live: number
363
+ /** Sessions silent past the threshold. Surfaced, NOT counted, NOT deleted. */
364
+ stale: number
365
+ /** Which ones, least-silent first, so an operator can act on a name. */
366
+ staleSessions: Array<{ sessionId: string; silentForMs: number; chunks: number }>
367
+ }
368
+
369
+ /**
370
+ * Split active sessions into live and stale.
371
+ *
372
+ * WHY THIS EXISTS. The maintenance gate counts `sessions.size` and blocks every
373
+ * restart while it is non-zero. A session that leaks — the phone drops
374
+ * mid-recording and never sends a close — pins that count at 1 indefinitely, so
375
+ * Install, Repair, Restart and Update Server all drain, time out and fail with
376
+ * no user-visible reason. Observed twice on 2026-08-06 (servers 6.21.20 and
377
+ * 6.21.22); the second phantom had been silent 54 minutes and the only way
378
+ * through was to finalize it as a real meeting, which it was not.
379
+ *
380
+ * THE DISCRIMINATOR IS `lastActivityAt`, NOT `oldestWorkStartedAt`. The obvious
381
+ * reading — "activeTotal >= 1 with oldestWorkStartedAt null means leaked" — is
382
+ * WRONG and would strand healthy recordings. `recording_session` reaches the
383
+ * gate through `extraActiveByKind` (routes/maintenance.ts), and the snapshot's
384
+ * `oldestStartedAtMs` loop walks only tracked `work` entries, so an extra count
385
+ * never contributes a timestamp. EVERY recording session therefore reports
386
+ * `oldestWorkStartedAt: null`, healthy or leaked. Only the session's own
387
+ * `lastActivityAt` tells them apart.
388
+ */
389
+ /**
390
+ * The live session map, for tests only.
391
+ *
392
+ * Exposed because the liveness split is pure logic over `lastActivityAt` but
393
+ * the map is module-private, and a test that cannot seed it can only assert the
394
+ * empty case. An earlier draft of the liveness test guarded on this symbol
395
+ * existing and silently no-opped four of its six cases — passing green while
396
+ * exercising nothing. Never guard a test on its own seam; make the seam real.
397
+ */
398
+ export const __sessionsForTests = sessions
399
+
400
+ export function getTranscriptionSessionLiveness(now = Date.now()): TranscriptionSessionLiveness {
401
+ let live = 0
402
+ const staleSessions: TranscriptionSessionLiveness['staleSessions'] = []
403
+ for (const [sessionId, session] of sessions) {
404
+ const silentForMs = now - session.lastActivityAt
405
+ if (silentForMs >= RECORDING_SESSION_STALE_MS) {
406
+ staleSessions.push({ sessionId, silentForMs, chunks: session.chunks.length })
407
+ } else {
408
+ live++
409
+ }
410
+ }
411
+ staleSessions.sort((a, b) => a.silentForMs - b.silentForMs)
412
+ return { live, stale: staleSessions.length, staleSessions }
413
+ }
414
+
345
415
  // Incremental chunk persistence — survive server restarts
346
416
  const CHUNK_PERSIST_DIR = dataPath('active-sessions')
347
417
  ensurePrivateDirectory(CHUNK_PERSIST_DIR)