@gotcos/glasses-server 6.71.12 → 6.71.14

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.
@@ -77,8 +77,16 @@ import {
77
77
  listUnsavedCaptures,
78
78
  purgeExpiredQuarantine,
79
79
  quarantineSessionAudio,
80
+ readCaptureDisposition,
80
81
  sweepOrphanedSessionAudio,
81
82
  } from '../lib/unsaved-audio-quarantine.js'
83
+ import {
84
+ STALE_ACTIVE_SESSION_DIR_NAME,
85
+ moveActiveSessionFileAside,
86
+ purgeExpiredStaleActiveSessionFiles,
87
+ sweepStaleActiveSessionFiles,
88
+ type StaleActiveSessionMove,
89
+ } from '../lib/stale-active-session-files.js'
82
90
  import { durableAtomicWriteFileSync } from '../lib/atomic-fs.js'
83
91
  import { saveTrainingAudioSample } from '../lib/training-audio-save.js'
84
92
  import {
@@ -92,6 +100,7 @@ import {
92
100
  acquireMaintenanceWork,
93
101
  maintenanceAdmissionsOpen,
94
102
  maintenanceLifecycle,
103
+ onMaintenanceAdmissionsOpen,
95
104
  type MaintenanceWorkLease,
96
105
  } from '../lib/maintenance-lifecycle.js'
97
106
  import { feedLiveCueTranscript } from '../lib/live-cues-engine.js'
@@ -542,6 +551,8 @@ export function getTranscriptionSessionLiveness(now = Date.now()): Transcription
542
551
  // Incremental chunk persistence — survive server restarts
543
552
  const CHUNK_PERSIST_DIR = dataPath('active-sessions')
544
553
  ensurePrivateDirectory(CHUNK_PERSIST_DIR)
554
+ /** 6.71.14: where an active-session file the server no longer owns is moved, never deleted in place. */
555
+ const STALE_ACTIVE_SESSION_DIR = dataPath(STALE_ACTIVE_SESSION_DIR_NAME)
545
556
 
546
557
  function readClosedSessions(): Record<string, ClosedTranscriptSession> {
547
558
  if (!existsSync(CLOSED_SESSIONS_FILE)) return {}
@@ -691,6 +702,71 @@ function persistSessionBestEffort(sessionId: string): void {
691
702
  try { persistSessionRequired(sessionId) } catch { /* recovery cleanup is non-admission work */ }
692
703
  }
693
704
 
705
+ /**
706
+ * 6.71.14: one pass of the stale active-session sweep, against the real session
707
+ * map. Exported so a test drives the exact wiring the minute interval runs.
708
+ */
709
+ export function sweepStaleActiveSessionFilesNow(now: number): StaleActiveSessionMove[] {
710
+ const moves = sweepStaleActiveSessionFiles({
711
+ dir: CHUNK_PERSIST_DIR,
712
+ targetDir: STALE_ACTIVE_SESSION_DIR,
713
+ isLive: id => sessions.has(id),
714
+ now,
715
+ idleRetentionMs: LOCAL_FIRST_MEETING_IDLE_RETENTION_MS,
716
+ })
717
+ for (const move of moves) {
718
+ if (move.target) {
719
+ staleMoveFailuresLogged.delete(move.sessionId)
720
+ console.warn(
721
+ `[cleanup] Moved stale active-session file aside: ${move.sessionId} `
722
+ + `(${move.reason}, idle ${Math.round((move.idleMs ?? 0) / 60_000)}m, not live) \u2192 ${move.target}`,
723
+ )
724
+ } else {
725
+ logStaleMoveFailure(move.sessionId, `[cleanup] Could not move stale active-session file aside for ${move.sessionId} (${move.reason}); it stays in active-sessions`)
726
+ }
727
+ }
728
+ const purged = purgeExpiredStaleActiveSessionFiles(STALE_ACTIVE_SESSION_DIR, now)
729
+ if (purged > 0) console.log(`[cleanup] Purged ${purged} stale active-session file(s) past retention`)
730
+ return moves
731
+ }
732
+
733
+ /** A failed move repeats every minute; say so once per session id, not every tick. */
734
+ const staleMoveFailuresLogged = new Set<string>()
735
+ function logStaleMoveFailure(sessionId: string, message: string): void {
736
+ if (staleMoveFailuresLogged.has(sessionId)) return
737
+ staleMoveFailuresLogged.add(sessionId)
738
+ if (staleMoveFailuresLogged.size > 500) staleMoveFailuresLogged.delete(staleMoveFailuresLogged.values().next().value as string)
739
+ console.error(message)
740
+ }
741
+
742
+ /**
743
+ * 6.71.14: is this active-session file the leftover of a capture that is already
744
+ * decoded (recovered or receipted no_speech), rather than a session that RESUMED
745
+ * under the same id after that capture was quarantined?
746
+ *
747
+ * A late chunk is accepted (the phone deletes its copy on a 410), so a session can
748
+ * restart under an id whose older quarantine dir is already receipted. Its file is
749
+ * then newer than the quarantine and its own audio sits in session-audio/<id>.
750
+ * Only a file that is no newer than the quarantine AND has no chunk audio of its
751
+ * own is the leftover; anything else is adopted as 6.71.13 would, so a meeting is
752
+ * never split across a restart.
753
+ */
754
+ function isTerminalCaptureFile(sessionId: string, recordedLastActivityAt: unknown, filePath: string): boolean {
755
+ const disposition = readCaptureDisposition(sessionId)
756
+ if (!disposition || (disposition.kind !== 'no_speech' && disposition.kind !== 'recovered')) return false
757
+ const terminalAtIso = disposition.quarantinedAt ?? disposition.receiptedAt
758
+ const terminalAt = terminalAtIso ? Date.parse(terminalAtIso) : NaN
759
+ if (!Number.isFinite(terminalAt)) return false
760
+ let fileActivity = typeof recordedLastActivityAt === 'number' && Number.isFinite(recordedLastActivityAt)
761
+ ? recordedLastActivityAt
762
+ : NaN
763
+ if (!Number.isFinite(fileActivity)) {
764
+ try { fileActivity = statSync(filePath).mtimeMs } catch { return false }
765
+ }
766
+ if (terminalAt < fileActivity) return false
767
+ return countChunkWavs(resolve(SESSION_AUDIO_DIR, sessionId)) === 0
768
+ }
769
+
694
770
  /** Recover sessions from disk on server restart */
695
771
  function recoverSessions(): void {
696
772
  try {
@@ -701,6 +777,27 @@ function recoverSessions(): void {
701
777
  if (!file.endsWith('.json')) continue
702
778
  try {
703
779
  const data = JSON.parse(readFileSync(resolve(CHUNK_PERSIST_DIR, file), 'utf-8'))
780
+ // 6.71.14: recovery can now run after boot (deferred behind a closed
781
+ // maintenance gate). A session already in memory owns its file; the disk
782
+ // copy must never overwrite it.
783
+ if (typeof data.sessionId === 'string' && sessions.has(data.sessionId)) continue
784
+ // A capture whose quarantined audio was already decoded to a terminal
785
+ // outcome (a saved recovery or a no_speech receipt) is done. Adopting its
786
+ // file would bring back a live session for it; tombstoning would delete the
787
+ // file. Move it aside instead. Plain `quarantined` (never decoded) is NOT
788
+ // done: a crash between a chunk's first WAV write and its first
789
+ // active-session persist quarantines the audio, and the phone's retry then
790
+ // starts the session again. That file is adopted exactly as before.
791
+ if (typeof data.sessionId === 'string' && isTerminalCaptureFile(data.sessionId, data.lastActivityAt, resolve(CHUNK_PERSIST_DIR, file))) {
792
+ const terminal = readCaptureDisposition(data.sessionId)!
793
+ const target = moveActiveSessionFileAside(
794
+ resolve(CHUNK_PERSIST_DIR, file), data.sessionId, STALE_ACTIVE_SESSION_DIR, 'terminal_capture', Date.now(),
795
+ { lastActivityAt: typeof data.lastActivityAt === 'number' ? data.lastActivityAt : null },
796
+ )
797
+ if (target) console.warn(`[session-recovery] ${data.sessionId} was already ${terminal.kind}; moved its active-session file aside: ${target}`)
798
+ else logStaleMoveFailure(data.sessionId, `[session-recovery] Could not move the active-session file aside for ${data.sessionId} (already ${terminal.kind}); left in place, not adopted`)
799
+ continue
800
+ }
704
801
  // Reconstruct the (sparse) chunk array. New format keeps original
705
802
  // indices via chunksIndexed; legacy format stored a dense `chunks`.
706
803
  const indexed: Array<{ i: number; c: TranscriptChunk }> | null =
@@ -859,7 +956,9 @@ function recoverSessions(): void {
859
956
  try {
860
957
  if (existsSync(SESSION_AUDIO_DIR)) {
861
958
  const actions = sweepOrphanedSessionAudio(SESSION_AUDIO_DIR, {
862
- isLive: id => recoveredIds.has(id),
959
+ // `|| sessions.has`: deferred recovery (6.71.14) can run while sessions
960
+ // are live in memory; their audio is not orphaned.
961
+ isLive: id => recoveredIds.has(id) || sessions.has(id),
863
962
  hasFreshPreservedMarker: hasFreshPreservedAudioMarker,
864
963
  reason: 'boot_sweep_unsaved',
865
964
  })
@@ -896,9 +995,40 @@ export function isSessionDeleted(sessionId: string): boolean {
896
995
  // A committed cross-boot maintenance operation owns durable state until the
897
996
  // trusted controller adopts the candidate. Boot recovery must not mutate or
898
997
  // promote sessions while that gate is closed.
899
- if (maintenanceAdmissionsOpen()) {
998
+ //
999
+ // 6.71.14: and once that gate opens, recovery MUST still run. Until 6.71.13 this was
1000
+ // a module-scope `if` evaluated once at import: a boot behind a closed gate (every
1001
+ // managed update) skipped it for the life of the process. Persisted sessions were
1002
+ // never adopted or tombstoned, and the closed-session tombstones were never
1003
+ // restored. On 2026-10-12 that left `active-sessions/<id>.json` for a receipted
1004
+ // capture on disk for 17 hours, reported as live by COS Control and `missing` by
1005
+ // the status route.
1006
+ let durableSessionRecoveryRan = false
1007
+ function runDurableSessionRecovery(trigger: 'boot' | 'admissions_open'): void {
1008
+ if (durableSessionRecoveryRan) return
1009
+ durableSessionRecoveryRan = true
900
1010
  recoverClosedSessions()
901
1011
  recoverSessions()
1012
+ if (trigger === 'admissions_open') {
1013
+ console.log(`[session-recovery] Ran deferred durable recovery after the maintenance gate opened (${sessions.size} live session(s))`)
1014
+ }
1015
+ }
1016
+ if (maintenanceAdmissionsOpen()) {
1017
+ runDurableSessionRecovery('boot')
1018
+ } else {
1019
+ // Delivered on every later open; the flag above makes every delivery after the
1020
+ // first a no-op, so disk is never re-read over a live session map.
1021
+ onMaintenanceAdmissionsOpen(() => runDurableSessionRecovery('admissions_open'))
1022
+ }
1023
+
1024
+ /**
1025
+ * 6.71.14: has durable session recovery run in this process yet? Until it has, the
1026
+ * in-memory map does not yet hold the persisted sessions, so "not in memory" is not
1027
+ * evidence of anything. The status route answers 503 `recovery_pending` instead of
1028
+ * `missing` while this is false.
1029
+ */
1030
+ export function durableSessionRecoveryHasRun(): boolean {
1031
+ return durableSessionRecoveryRan
902
1032
  }
903
1033
 
904
1034
  /**
@@ -1011,6 +1141,10 @@ function promoteStrandedCapture(sessionId: string, lastActivityAt: number): void
1011
1141
  // Auto-cleanup sessions idle for the advertised retention horizon.
1012
1142
  setInterval(() => {
1013
1143
  if (!maintenanceAdmissionsOpen()) return
1144
+ // 6.71.14: the sweeps below judge a session dead by its absence from the
1145
+ // in-memory map. Before recovery has adopted the persisted sessions that map is
1146
+ // empty by construction, so sweeping then would quarantine a live capture.
1147
+ if (!durableSessionRecoveryRan) return
1014
1148
  // A capture whose phone went away must still become a meeting. This loop used
1015
1149
  // to close every idle session as 'expired', which quarantined its audio and
1016
1150
  // produced NO meeting — recoverable for 72h, but only by whoever thought to
@@ -1053,6 +1187,15 @@ setInterval(() => {
1053
1187
  // log, and nothing to reason about, because any throw in this block vanished.
1054
1188
  console.error(`[cleanup] Orphan-audio sweep failed: ${error instanceof Error ? error.message : error}`)
1055
1189
  }
1190
+ // 6.71.14: its own try, for the same reason as auto-recovery below. An
1191
+ // active-session file whose session is not live and has been idle past the
1192
+ // retention horizon is moved aside; COS Control reads that directory directly
1193
+ // and would otherwise show it as a live recording indefinitely.
1194
+ try {
1195
+ sweepStaleActiveSessionFilesNow(Date.now())
1196
+ } catch (error) {
1197
+ console.error(`[cleanup] Stale active-session sweep failed: ${error instanceof Error ? error.message : error}`)
1198
+ }
1056
1199
  // DELIBERATELY ITS OWN TRY. This used to sit inside the block above, one line after
1057
1200
  // purgeExpiredQuarantine, so a throw anywhere in that sweep meant auto-recovery
1058
1201
  // silently never ran — which is exactly what happened in production on 6.23.1
@@ -170,6 +170,19 @@ export interface MeetingAskExchange {
170
170
  files: MeetingAskFile[];
171
171
  error?: string;
172
172
  retryOf?: string;
173
+ /** Server 6.71.13 (capabilities.factCheckScope): what a completed Fact check covered. Named-claim checks and follow-ups carry none. */
174
+ factCheckRange?: MeetingAskFactCheckRange;
175
+ }
176
+ /**
177
+ * What one Fact check covered, in meeting-elapsed ms (the clock of throughMs and turn.elapsedMs). The first
178
+ * check covers the whole meeting (full); each later one covers only what is new since the last completed
179
+ * check's throughMs. A check that found nothing new still completes with claims 0, so the next one moves on.
180
+ */
181
+ export interface MeetingAskFactCheckRange {
182
+ fromMs: number;
183
+ throughMs: number;
184
+ full: boolean;
185
+ claims: number;
173
186
  }
174
187
  export interface MeetingAskCapabilities {
175
188
  protocolVersion: 2;
@@ -179,6 +192,8 @@ export interface MeetingAskCapabilities {
179
192
  publicTopics?: true;
180
193
  /** Explicit recent-claim Fact check CTA; transport remains action=ask. */
181
194
  factCheck?: true;
195
+ /** 6.71.13: the server checks the whole meeting first, then only what is new, and says which in factCheckRange. */
196
+ factCheckScope?: 1;
182
197
  /** 6.71.1: POST /api/meeting-ask/files/media turns uploaded media into meeting files. */
183
198
  phoneFiles?: true;
184
199
  /** 6.71.1: config carries cosSearch and PUT config {cosSearch} changes it. */