@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.
|
|
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
|
|
62
|
-
if (item
|
|
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
|
package/server/routes/health.ts
CHANGED
|
@@ -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(
|
|
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,
|
package/server/routes/meeting.ts
CHANGED
|
@@ -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(
|
|
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
|
-
|
|
483
|
-
|
|
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.
|