@gotcos/glasses-server 6.18.8 → 6.19.0

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/.env.example CHANGED
@@ -139,6 +139,17 @@ BIND_HOST=0.0.0.0
139
139
  # keeps working if the default ever flips to Metal-on.
140
140
  # COS_BATCH_HQ_FORCE_CPU=1
141
141
 
142
+ # ── UNSAVED-CAPTURE QUARANTINE (6.19.0) ─────────────────────────────────
143
+ # Meeting audio whose save never landed is QUARANTINED, never deleted. It
144
+ # surfaces on /api/health (unsaved_captures) and, with COS Control 0.3.1+,
145
+ # as an "Unsaved captures" row in the status card. One authenticated call
146
+ # recovers a capture into a durable meeting scribe:
147
+ # curl -X POST -H "x-cos-token: $COS_API_TOKEN" \
148
+ # http://127.0.0.1:3141/api/meeting/orphans/<sessionId>/recover
149
+ # Quarantined audio expires on this retention clock (hours, clamped 1-720).
150
+ # 72 covers a long weekend away from the Mac.
151
+ # COS_UNSAVED_AUDIO_RETENTION_HOURS=72
152
+
142
153
  # ── LIVE CUES (optional — requires the FULL COS PIPELINE above) ──────────
143
154
  # Live meeting coaching cues on the lens: transcript window -> Composer
144
155
  # planner -> Qdrant -> LightRAG -> Composer insight -> coaching_nudge.
package/CHANGELOG.md CHANGED
@@ -1,3 +1,44 @@
1
+ ## 6.19.0
2
+
3
+ Meeting audio is evidence. This release stops the server from ever deleting an
4
+ unsaved capture, and makes batch status stop lying about finished work.
5
+
6
+ - **Unsaved-capture quarantine (the 2026-08-01 data-loss fix).** The
7
+ session-audio purge (boot sweep + 60s interval + non-saved session close)
8
+ DELETED any directory not tracked in memory once the 4h idle retention
9
+ passed — an offline meeting whose deferred save never landed lost its
10
+ full-fidelity audio within a minute. Two real meetings were destroyed this
11
+ way on 2026-08-01; only speaker-enrollment fragments survived. Audio-bearing
12
+ directories are now MOVED to `data/unsaved-audio/` with a manifest, never
13
+ deleted in place. Empty directories are still cleaned. A failed quarantine
14
+ move leaves the source untouched. Quarantine expires on
15
+ `COS_UNSAVED_AUDIO_RETENTION_HOURS` (default 72, clamped 1–720) — the only
16
+ place quarantined audio is ever deleted.
17
+ - **Unsaved captures are visible.** `/api/health` gains `unsaved_captures`
18
+ (count + compact items, same exposure level as `meeting_sync`). The
19
+ authenticated `GET /api/meeting/orphans` returns full detail.
20
+ - **Miles-triggered recovery, surface-only by decision (2026-08-02).**
21
+ `POST /api/meeting/orphans/:sessionId/recover` batch-transcribes the
22
+ quarantined WAVs (same segment/enhance/Metal-preempt contract as HQ polish,
23
+ under a new `orphan_recovery` maintenance lease), writes a durable scribe,
24
+ and hands off to operations when the COS pipeline is configured. Idempotent
25
+ via the save-receipt short-circuit; the server never drives recovery on its
26
+ own, and audio stays in quarantine until the retention clock — a failed
27
+ recovery is retryable.
28
+ - **Rejected HQ batches release their status.** A terminal batch outcome
29
+ (rejected quality, pipeline failure, accepted-but-unpersisted) now writes
30
+ `_batch_terminal.json` next to the retained WAVs. `meeting_sync` reports
31
+ those as `retained` — never as active work — so a rejected batch no longer
32
+ shows "HQ polish · N chunks" with `blocksRestart: true` for the 12h WAV
33
+ retention after the work already finished (observed on
34
+ meeting_1785695339502_mvqm0p, reason `repetitive-output`). A retry clears
35
+ the terminal record; live progress always wins. `meeting_sync.retained` is
36
+ additive — older consumers ignore it.
37
+ - Deferred by design: the realtime-model fallback port (W3) ships in its own
38
+ release. The app-side module has diverged ~1,100 lines from this repo's
39
+ whisper path; transplanting it alongside the data-loss fix would couple the
40
+ release's safest change to its riskiest. No default flips either way.
41
+
1
42
  ## 6.18.8
2
43
 
3
44
  - **Prompt draft peeks for live ASR.** `POST /api/prompt-drafts/:draftId/peek`
package/README.md CHANGED
@@ -123,6 +123,12 @@ range is the exact Tailscale/CGNAT allocation (`100.64.0.0/10`), not all of
123
123
  locally through a network interruption. Reconnecting reconciles the exact
124
124
  chunks already stored by the Mac, uploads only missing audio, and finalizes
125
125
  through an idempotent save receipt without duplicating the meeting.
126
+ - Since 6.19.0, meeting audio whose save never lands is quarantined for 72 hours
127
+ (`COS_UNSAVED_AUDIO_RETENTION_HOURS`) instead of being cleaned up, surfaces on
128
+ `/api/health` as `unsaved_captures`, and can be recovered into a durable
129
+ meeting scribe with one authenticated call
130
+ (`POST /api/meeting/orphans/:sessionId/recover`; list via
131
+ `GET /api/meeting/orphans`).
126
132
  - Local whisper.cpp transcription (free and local-only by default). OpenAI
127
133
  Whisper fallback is optional and requires both the exact
128
134
  `COS_OPENAI_WHISPER_FALLBACK=1` opt-in and a configured key; a key alone never
@@ -21,6 +21,7 @@
21
21
  "COS_LIVE_CUES_AUTO",
22
22
  "COS_BATCH_HQ_METAL",
23
23
  "COS_BATCH_HQ_FORCE_CPU",
24
+ "COS_UNSAVED_AUDIO_RETENTION_HOURS",
24
25
  "COS_CLAUDE_TRUST_MODE",
25
26
  "COS_CODEX_SANDBOX",
26
27
  "COS_SCRIPTS_DIR"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gotcos/glasses-server",
3
- "version": "6.18.8",
3
+ "version": "6.19.0",
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": {
@@ -54,6 +54,14 @@ export type MaintenanceWorkKind =
54
54
  // budget must stay under COS Control's 90s drain timeout (main.swift:1853)
55
55
  // or every drain that catches a cue in flight hard-fails to Repair.
56
56
  | 'live_cue_pipeline'
57
+ // Miles-triggered recovery of a quarantined unsaved capture (6.19.0):
58
+ // batch-transcribes retained WAVs into a durable scribe. Held for the whole
59
+ // background run so an Update Server drain waits for it like any batch.
60
+ // Long runs are made visible: the active-recovery registry renders a
61
+ // meeting_sync row with blocksRestart so COS Control warns BEFORE
62
+ // committing a drain into a decode that outlives its 90s timeout
63
+ // (main.swift:1963 waitForRestartProof).
64
+ | 'orphan_recovery'
57
65
 
58
66
  export type MaintenanceWorkPhase = 'queued' | 'active'
59
67
  export type MaintenanceOperationScope = 'same_boot' | 'cross_boot'
@@ -5,9 +5,11 @@
5
5
  import { existsSync, readdirSync, readFileSync, statSync, writeFileSync, unlinkSync } from 'node:fs'
6
6
  import { basename, join } from 'node:path'
7
7
  import { dataPath } from './data-dir.js'
8
+ import { listActiveRecoveries } from './unsaved-audio-quarantine.js'
8
9
 
9
10
  export const BATCH_PROGRESS_FILENAME = '_batch_progress.json'
10
11
  export const BATCH_PENDING_MARKER = '_batch_pending.marker'
12
+ export const BATCH_TERMINAL_FILENAME = '_batch_terminal.json'
11
13
 
12
14
  export type MeetingBatchPhase =
13
15
  | 'queued'
@@ -44,6 +46,75 @@ export interface MeetingSyncSnapshot {
44
46
  label: string
45
47
  blocksRestart: boolean
46
48
  meetings: MeetingSyncMeeting[]
49
+ /** Batches that reached a terminal outcome but whose WAVs are deliberately
50
+ * retained for retry (rejected quality, failed persist). Additive field —
51
+ * older consumers ignore it. Never counts toward active/blocksRestart. */
52
+ retained: MeetingSyncRetainedMeeting[]
53
+ }
54
+
55
+ export type MeetingBatchOutcome = 'accepted' | 'rejected' | 'failed'
56
+
57
+ export interface MeetingBatchTerminal {
58
+ schemaVersion: 1
59
+ meetingId: string
60
+ outcome: MeetingBatchOutcome
61
+ reason?: string
62
+ at: string
63
+ }
64
+
65
+ export interface MeetingSyncRetainedMeeting {
66
+ meetingId: string
67
+ outcome: MeetingBatchOutcome
68
+ reason: string | null
69
+ chunkFiles: number
70
+ at: string
71
+ label: string
72
+ }
73
+
74
+ /** Record the batch's terminal outcome next to its retained WAVs. Before this
75
+ * file existed (≤6.18.8), a rejected batch's dir kept rendering as active
76
+ * "HQ polish · N chunks" with blocksRestart:true for the full 12h retention —
77
+ * the status conflated "work running" with "evidence retained". */
78
+ export function writeMeetingBatchTerminal(
79
+ audioDir: string,
80
+ input: { outcome: MeetingBatchOutcome; reason?: string; meetingId?: string },
81
+ ): void {
82
+ const payload: MeetingBatchTerminal = {
83
+ schemaVersion: 1,
84
+ meetingId: input.meetingId ?? basename(audioDir),
85
+ outcome: input.outcome,
86
+ ...(input.reason ? { reason: input.reason } : {}),
87
+ at: new Date().toISOString(),
88
+ }
89
+ try {
90
+ writeFileSync(join(audioDir, BATCH_TERMINAL_FILENAME), `${JSON.stringify(payload)}\n`, {
91
+ encoding: 'utf8',
92
+ mode: 0o600,
93
+ })
94
+ } catch {
95
+ // Status only — never fail the pipeline for a status write.
96
+ }
97
+ }
98
+
99
+ /** A retry invalidates the previous terminal state. */
100
+ export function clearMeetingBatchTerminal(audioDir: string): void {
101
+ const path = join(audioDir, BATCH_TERMINAL_FILENAME)
102
+ try {
103
+ if (existsSync(path)) unlinkSync(path)
104
+ } catch { /* ignore */ }
105
+ }
106
+
107
+ function readTerminalFile(dir: string): MeetingBatchTerminal | null {
108
+ const path = join(dir, BATCH_TERMINAL_FILENAME)
109
+ if (!existsSync(path)) return null
110
+ try {
111
+ const raw = JSON.parse(readFileSync(path, 'utf8')) as MeetingBatchTerminal
112
+ if (raw?.schemaVersion !== 1) return null
113
+ if (raw.outcome !== 'accepted' && raw.outcome !== 'rejected' && raw.outcome !== 'failed') return null
114
+ return raw
115
+ } catch {
116
+ return null
117
+ }
47
118
  }
48
119
 
49
120
  function pendingBatchRoot(): string {
@@ -111,6 +182,11 @@ export function clearMeetingBatchProgress(audioDir: string): void {
111
182
  } catch { /* ignore */ }
112
183
  }
113
184
 
185
+ /** Public read for surfaces outside this module (orphan recovery progress). */
186
+ export function readMeetingBatchProgress(dir: string): MeetingBatchProgress | null {
187
+ return readProgressFile(dir)
188
+ }
189
+
114
190
  function readProgressFile(dir: string): MeetingBatchProgress | null {
115
191
  const path = join(dir, BATCH_PROGRESS_FILENAME)
116
192
  if (!existsSync(path)) return null
@@ -140,8 +216,9 @@ export function getMeetingSyncSnapshot(
140
216
  root: string = pendingBatchRoot(),
141
217
  ): MeetingSyncSnapshot {
142
218
  const meetings: MeetingSyncMeeting[] = []
219
+ const retained: MeetingSyncRetainedMeeting[] = []
143
220
  if (!existsSync(root)) {
144
- return { active: false, percent: null, label: 'Idle', blocksRestart: false, meetings }
221
+ return { active: false, percent: null, label: 'Idle', blocksRestart: false, meetings, retained }
145
222
  }
146
223
 
147
224
  let dirs: string[] = []
@@ -154,7 +231,7 @@ export function getMeetingSyncSnapshot(
154
231
  }
155
232
  })
156
233
  } catch {
157
- return { active: false, percent: null, label: 'Idle', blocksRestart: false, meetings }
234
+ return { active: false, percent: null, label: 'Idle', blocksRestart: false, meetings, retained }
158
235
  }
159
236
 
160
237
  for (const name of dirs) {
@@ -165,6 +242,47 @@ export function getMeetingSyncSnapshot(
165
242
  chunkFiles = readdirSync(dir).filter(f => f.endsWith('.wav')).length
166
243
  } catch { /* ignore */ }
167
244
 
245
+ // A terminal outcome ends the meeting's ACTIVE life. Its WAVs stay for
246
+ // retry, reported as retained — never as running work. The gate is
247
+ // progress==null ONLY: the pending marker is refreshed every segment and
248
+ // every 60s during the run, so it is always fresh the moment a terminal
249
+ // is written — gating on marker freshness left the phantom alive for the
250
+ // first 15 minutes, exactly the post-meeting Update Server window. A
251
+ // genuine retry clears the terminal first (runMeetingBatchPipeline) and
252
+ // immediately writes queued progress, so progress presence is the true
253
+ // live signal.
254
+ const terminal = readTerminalFile(dir)
255
+ if (terminal && progress == null) {
256
+ const reasonSuffix = terminal.reason ? `: ${terminal.reason}` : ''
257
+ retained.push({
258
+ meetingId: terminal.meetingId || name,
259
+ outcome: terminal.outcome,
260
+ reason: terminal.reason ?? null,
261
+ chunkFiles,
262
+ at: terminal.at,
263
+ label: `Retained (${terminal.outcome}${reasonSuffix}) · ${chunkFiles} chunk${chunkFiles === 1 ? '' : 's'}`,
264
+ })
265
+ continue
266
+ }
267
+
268
+ // Backfill: a dir with WAVs, no progress, no fresh marker, and NO terminal
269
+ // file is a batch that ended before 6.19.0 existed (or whose terminal
270
+ // write failed). Pre-6.19.0 semantics rendered these as phantom active
271
+ // work with blocksRestart for the rest of the 12h retention — and the
272
+ // first boot after an upgrade is exactly when the user watches COS
273
+ // Control. Classify them as retained with an honest unknown outcome.
274
+ if (progress == null && !markerFresh(dir) && chunkFiles > 0) {
275
+ retained.push({
276
+ meetingId: name,
277
+ outcome: 'failed',
278
+ reason: 'pre-terminal batch (ended before 6.19.0 or terminal write lost)',
279
+ chunkFiles,
280
+ at: new Date(0).toISOString(),
281
+ label: `Retained (unknown outcome) · ${chunkFiles} chunk${chunkFiles === 1 ? '' : 's'}`,
282
+ })
283
+ continue
284
+ }
285
+
168
286
  const active = markerFresh(dir) || progress != null
169
287
  if (!active && chunkFiles === 0) continue
170
288
 
@@ -198,8 +316,36 @@ export function getMeetingSyncSnapshot(
198
316
  meetings.push({ ...row, label: labelFor(row) })
199
317
  }
200
318
 
319
+ // Active orphan recoveries decode in the quarantine root, which this scan
320
+ // never visits — surface them as active rows or COS Control shows "Idle"
321
+ // with blocksRestart:false while a 20-90 minute decode holds the
322
+ // maintenance lease, and an Update Server drain walks blind into its 90s
323
+ // timeout and hard-fails to Repair. Same contract as meeting_batch_finalization.
324
+ for (const recovery of listActiveRecoveries()) {
325
+ const progress = readProgressFile(recovery.dirPath)
326
+ const percent = progress && progress.segmentsTotal > 0
327
+ ? clampPercent(progress.segmentsDone, progress.segmentsTotal)
328
+ : null
329
+ const row: Omit<MeetingSyncMeeting, 'label'> = {
330
+ meetingId: recovery.sessionId,
331
+ phase: progress?.phase ?? 'queued',
332
+ percent,
333
+ segmentsDone: progress && progress.segmentsTotal > 0 ? progress.segmentsDone : null,
334
+ segmentsTotal: progress && progress.segmentsTotal > 0 ? progress.segmentsTotal : null,
335
+ chunkFiles: progress?.chunkFiles ?? 0,
336
+ updatedAt: progress?.updatedAt ?? null,
337
+ }
338
+ meetings.push({
339
+ ...row,
340
+ label: `Recovering unsaved capture${percent != null ? ` ${percent}%` : ''} · do not update/restart`,
341
+ })
342
+ }
343
+
201
344
  if (meetings.length === 0) {
202
- return { active: false, percent: null, label: 'Idle', blocksRestart: false, meetings }
345
+ const label = retained.length > 0
346
+ ? `Idle · ${retained.length} retained batch${retained.length === 1 ? '' : 'es'}`
347
+ : 'Idle'
348
+ return { active: false, percent: null, label, blocksRestart: false, meetings, retained }
203
349
  }
204
350
 
205
351
  const withPercent = meetings.filter(m => m.percent != null)
@@ -217,5 +363,6 @@ export function getMeetingSyncSnapshot(
217
363
  label,
218
364
  blocksRestart: true,
219
365
  meetings,
366
+ retained,
220
367
  }
221
368
  }
@@ -9,7 +9,9 @@ import { transcribeHighQuality, type WhisperWord } from './whisper-local.js'
9
9
  import { isMetalBatchPreempted } from './whisper-metal-gate.js'
10
10
  import {
11
11
  clearMeetingBatchProgress,
12
+ clearMeetingBatchTerminal,
12
13
  writeMeetingBatchProgress,
14
+ writeMeetingBatchTerminal,
13
15
  } from './meeting-batch-progress.js'
14
16
  import type { IndexedTranscriptChunk } from '../routes/transcribe-stream.js'
15
17
  import {
@@ -165,7 +167,7 @@ function mapWordsToSpeakers(
165
167
  })
166
168
  }
167
169
 
168
- async function transcribeSegments(
170
+ export async function transcribeSegments(
169
171
  audioDir: string,
170
172
  segments: BatchSegment[],
171
173
  entries: IndexedTranscriptChunk[],
@@ -237,6 +239,16 @@ async function transcribeSegments(
237
239
 
238
240
  let batchQueueTail: Promise<void> = Promise.resolve()
239
241
 
242
+ /** Chain arbitrary HQ-decoder work onto the same serialization tail the batch
243
+ * pipeline uses. Orphan recovery MUST go through this: transcribeSegments has
244
+ * no internal queue, so calling it directly would run a second (or third)
245
+ * 16-thread large-v3 decoder in parallel with a live post-meeting batch. */
246
+ export function enqueueSerializedHqWork<T>(work: () => Promise<T>): Promise<T> {
247
+ const job = batchQueueTail.then(work)
248
+ batchQueueTail = job.then(() => undefined, () => undefined)
249
+ return job
250
+ }
251
+
240
252
  /** Serialize 16-thread HQ decoders across meetings on a public user's Mac. */
241
253
  export function runMeetingBatchPipeline(
242
254
  audioDir: string,
@@ -246,6 +258,8 @@ export function runMeetingBatchPipeline(
246
258
  // Lease immediately, including time spent behind another HQ decoder. Without
247
259
  // this, the two-hour cleanup could delete a queued meeting before it starts.
248
260
  refreshPendingLease(audioDir)
261
+ // A retry invalidates any prior terminal outcome — live signals must win.
262
+ clearMeetingBatchTerminal(audioDir)
249
263
  writeMeetingBatchProgress(audioDir, {
250
264
  phase: 'queued',
251
265
  segmentsDone: 0,
@@ -278,7 +292,12 @@ async function runMeetingBatchPipelineNow(
278
292
  return { transcriptionQuality: 'streaming' }
279
293
  }
280
294
  const segments = segmentTranscriptChunks(entries)
281
- if (segments.length === 0) return { transcriptionQuality: 'streaming' }
295
+ if (segments.length === 0) {
296
+ // Terminal too: WAVs exist but nothing is transcribable. Without this,
297
+ // the dir re-creates the exact phantom-active state W2 removes.
298
+ writeMeetingBatchTerminal(audioDir, { outcome: 'failed', reason: 'no_segments' })
299
+ return { transcriptionQuality: 'streaming' }
300
+ }
282
301
 
283
302
  writeMeetingBatchProgress(audioDir, {
284
303
  phase: 'hq_polish',
@@ -302,12 +321,20 @@ async function runMeetingBatchPipelineNow(
302
321
  + `${qualityReport.streamingWordCount} live words, `
303
322
  + `${(qualityReport.duplicateWordRatio * 100).toFixed(1)}% duplicate`,
304
323
  )
324
+ // Terminal: the batch RAN and lost. WAVs stay for retry, but status must
325
+ // stop reporting active work (pre-6.19.0 this looked like 12h of
326
+ // "HQ polish · N chunks" with blocksRestart:true after the work ended).
327
+ writeMeetingBatchTerminal(audioDir, { outcome: 'rejected', reason: qualityReport.reason })
305
328
  return { transcriptionQuality: 'streaming', qualityReport }
306
329
  }
307
330
 
308
331
  return { transcriptionQuality: 'batch', batchTranscript, batchSegments, qualityReport }
309
332
  } catch (error) {
310
333
  console.error(`[meeting-batch] Pipeline failed: ${error instanceof Error ? error.message : String(error)}`)
334
+ writeMeetingBatchTerminal(audioDir, {
335
+ outcome: 'failed',
336
+ reason: error instanceof Error ? error.message.slice(0, 200) : String(error).slice(0, 200),
337
+ })
311
338
  return { transcriptionQuality: 'streaming' }
312
339
  }
313
340
  }
@@ -0,0 +1,314 @@
1
+ // Quarantine for unsaved meeting audio.
2
+ //
3
+ // Until 6.19.0 the session-audio purge (boot sweep + 60s interval) DELETED any
4
+ // session-audio directory that was not in the in-memory sessions map and did
5
+ // not carry a fresh save-preserved marker. An offline meeting whose deferred
6
+ // save never landed therefore lost its full-fidelity audio within a minute of
7
+ // the server no longer tracking the session — two real meetings were destroyed
8
+ // this way on 2026-08-01. The only surviving fragments were in ext-audio,
9
+ // which is a speaker-enrollment store (unrecognized-speaker chunks, hard
10
+ // capped), not meeting audio.
11
+ //
12
+ // The rule now: audio evidence is moved here, never deleted in place. A
13
+ // quarantined capture is surfaced on /api/health (unsaved_captures) and can be
14
+ // driven to a durable scribe via POST /api/meeting/orphans/:sessionId/recover.
15
+ // Quarantine expires on a retention clock (default 72h) — long enough to
16
+ // survive a weekend away from the Mac, bounded enough not to grow forever.
17
+
18
+ import {
19
+ existsSync,
20
+ mkdirSync,
21
+ readdirSync,
22
+ readFileSync,
23
+ renameSync,
24
+ rmSync,
25
+ statSync,
26
+ writeFileSync,
27
+ } from 'node:fs'
28
+ import { basename, join, resolve } from 'node:path'
29
+ import { dataPath } from './data-dir.js'
30
+
31
+ export const QUARANTINE_MANIFEST = '_quarantine.json'
32
+ export const RECOVERED_RECEIPT = '_recovered.json'
33
+ const CHUNK_WAV_RE = /^chunk_\d{4}\.wav$/
34
+
35
+ const DEFAULT_RETENTION_HOURS = 72
36
+ const MIN_RETENTION_HOURS = 1
37
+ const MAX_RETENTION_HOURS = 720
38
+
39
+ export interface QuarantineManifest {
40
+ schemaVersion: 1
41
+ sessionId: string
42
+ quarantinedAt: string
43
+ reason: string
44
+ chunkFiles: number
45
+ bytes: number
46
+ }
47
+
48
+ export interface UnsavedCapture {
49
+ sessionId: string
50
+ dirName: string
51
+ quarantinedAt: string | null
52
+ ageHours: number | null
53
+ chunkFiles: number
54
+ bytes: number
55
+ reason: string | null
56
+ expiresAt: string | null
57
+ recovered: boolean
58
+ }
59
+
60
+ export function unsavedAudioRoot(): string {
61
+ return dataPath('unsaved-audio')
62
+ }
63
+
64
+ export function unsavedAudioRetentionMs(env: NodeJS.ProcessEnv = process.env): number {
65
+ // An EMPTY value means unset, not zero: Number('') === 0, which would clamp
66
+ // to the 1h minimum and silently collapse the 72h safety net — on the one
67
+ // knob whose failure mode is losing the audio this module exists to keep.
68
+ const rawText = (env.COS_UNSAVED_AUDIO_RETENTION_HOURS ?? '').trim()
69
+ if (rawText === '') return DEFAULT_RETENTION_HOURS * 60 * 60 * 1000
70
+ const raw = Number(rawText)
71
+ const hours = Number.isFinite(raw)
72
+ ? Math.min(MAX_RETENTION_HOURS, Math.max(MIN_RETENTION_HOURS, raw))
73
+ : DEFAULT_RETENTION_HOURS
74
+ return hours * 60 * 60 * 1000
75
+ }
76
+
77
+ export function countChunkWavs(dirPath: string): number {
78
+ try {
79
+ return readdirSync(dirPath).filter(name => CHUNK_WAV_RE.test(name)).length
80
+ } catch {
81
+ return 0
82
+ }
83
+ }
84
+
85
+ function dirBytes(dirPath: string): number {
86
+ let total = 0
87
+ try {
88
+ for (const name of readdirSync(dirPath)) {
89
+ try { total += statSync(join(dirPath, name)).size } catch { /* skip */ }
90
+ }
91
+ } catch { /* empty */ }
92
+ return total
93
+ }
94
+
95
+ /** Move one session-audio dir into quarantine instead of deleting it.
96
+ * Returns the quarantine path, or null when the move could not be made
97
+ * (in which case the SOURCE IS LEFT IN PLACE — never deleted on failure). */
98
+ export function quarantineSessionAudio(
99
+ sourceDir: string,
100
+ reason: string,
101
+ root: string = unsavedAudioRoot(),
102
+ ): string | null {
103
+ const sessionId = basename(sourceDir)
104
+ try {
105
+ if (!existsSync(sourceDir)) return null
106
+ mkdirSync(root, { recursive: true, mode: 0o700 })
107
+ let target = resolve(root, sessionId)
108
+ if (existsSync(target)) target = resolve(root, `${sessionId}.${Date.now()}`)
109
+ renameSync(sourceDir, target)
110
+ const manifest: QuarantineManifest = {
111
+ schemaVersion: 1,
112
+ sessionId,
113
+ quarantinedAt: new Date().toISOString(),
114
+ reason,
115
+ chunkFiles: countChunkWavs(target),
116
+ bytes: dirBytes(target),
117
+ }
118
+ try {
119
+ writeFileSync(resolve(target, QUARANTINE_MANIFEST), `${JSON.stringify(manifest)}\n`, {
120
+ encoding: 'utf8',
121
+ mode: 0o600,
122
+ })
123
+ } catch { /* manifest is observability; the audio move already succeeded */ }
124
+ return target
125
+ } catch {
126
+ // Rename failed (cross-device, permissions, race). Leave the source alone —
127
+ // a skipped purge is recoverable, a deleted capture is not.
128
+ return null
129
+ }
130
+ }
131
+
132
+ export type OrphanSweepAction =
133
+ | { dir: string; action: 'kept_live' }
134
+ | { dir: string; action: 'kept_preserved' }
135
+ | { dir: string; action: 'quarantined'; target: string }
136
+ | { dir: string; action: 'quarantine_failed' }
137
+ | { dir: string; action: 'deleted_empty' }
138
+
139
+ /** Shared sweep for the boot path and the 60s interval. A directory that
140
+ * still holds chunk audio is quarantined; only chunk-less directories are
141
+ * deleted. A failed quarantine move keeps the source in place. */
142
+ export function sweepOrphanedSessionAudio(
143
+ sessionAudioRoot: string,
144
+ opts: {
145
+ isLive: (sessionId: string) => boolean
146
+ hasFreshPreservedMarker: (dirPath: string) => boolean
147
+ reason: string
148
+ quarantineRoot?: string
149
+ },
150
+ ): OrphanSweepAction[] {
151
+ const actions: OrphanSweepAction[] = []
152
+ let entries: string[] = []
153
+ try {
154
+ entries = readdirSync(sessionAudioRoot)
155
+ } catch {
156
+ return actions
157
+ }
158
+ for (const dir of entries) {
159
+ const dirPath = resolve(sessionAudioRoot, dir)
160
+ try {
161
+ if (!statSync(dirPath).isDirectory()) continue
162
+ } catch { continue }
163
+ if (opts.isLive(dir)) { actions.push({ dir, action: 'kept_live' }); continue }
164
+ if (opts.hasFreshPreservedMarker(dirPath)) { actions.push({ dir, action: 'kept_preserved' }); continue }
165
+ if (countChunkWavs(dirPath) > 0) {
166
+ const target = quarantineSessionAudio(dirPath, opts.reason, opts.quarantineRoot)
167
+ actions.push(target
168
+ ? { dir, action: 'quarantined', target }
169
+ : { dir, action: 'quarantine_failed' })
170
+ continue
171
+ }
172
+ try {
173
+ rmSync(dirPath, { recursive: true, force: true })
174
+ actions.push({ dir, action: 'deleted_empty' })
175
+ } catch { /* next sweep retries */ }
176
+ }
177
+ return actions
178
+ }
179
+
180
+ function readManifest(dirPath: string): QuarantineManifest | null {
181
+ try {
182
+ const raw = JSON.parse(readFileSync(resolve(dirPath, QUARANTINE_MANIFEST), 'utf8')) as QuarantineManifest
183
+ if (raw?.schemaVersion !== 1 || typeof raw.sessionId !== 'string') return null
184
+ return raw
185
+ } catch {
186
+ return null
187
+ }
188
+ }
189
+
190
+ function quarantinedAtMs(dirPath: string, manifest: QuarantineManifest | null): number | null {
191
+ if (manifest) {
192
+ const parsed = Date.parse(manifest.quarantinedAt)
193
+ if (Number.isFinite(parsed)) return parsed
194
+ }
195
+ try {
196
+ return statSync(dirPath).mtimeMs
197
+ } catch {
198
+ return null
199
+ }
200
+ }
201
+
202
+ /** Everything currently in quarantine, newest first. Recovered captures are
203
+ * flagged (they linger until the retention clock clears them) so health can
204
+ * exclude them from the actionable count. */
205
+ export function listUnsavedCaptures(
206
+ root: string = unsavedAudioRoot(),
207
+ retentionMs: number = unsavedAudioRetentionMs(),
208
+ ): UnsavedCapture[] {
209
+ const captures: UnsavedCapture[] = []
210
+ let entries: string[] = []
211
+ try {
212
+ entries = readdirSync(root)
213
+ } catch {
214
+ return captures
215
+ }
216
+ for (const dir of entries) {
217
+ const dirPath = resolve(root, dir)
218
+ try {
219
+ if (!statSync(dirPath).isDirectory()) continue
220
+ } catch { continue }
221
+ const manifest = readManifest(dirPath)
222
+ const atMs = quarantinedAtMs(dirPath, manifest)
223
+ captures.push({
224
+ sessionId: manifest?.sessionId ?? dir.replace(/\.\d+$/, ''),
225
+ dirName: dir,
226
+ quarantinedAt: atMs != null ? new Date(atMs).toISOString() : null,
227
+ ageHours: atMs != null ? Math.round(((Date.now() - atMs) / 3_600_000) * 10) / 10 : null,
228
+ chunkFiles: manifest?.chunkFiles ?? countChunkWavs(dirPath),
229
+ bytes: manifest?.bytes ?? dirBytes(dirPath),
230
+ reason: manifest?.reason ?? null,
231
+ expiresAt: atMs != null ? new Date(atMs + retentionMs).toISOString() : null,
232
+ recovered: existsSync(resolve(dirPath, RECOVERED_RECEIPT)),
233
+ })
234
+ }
235
+ captures.sort((a, b) => (b.quarantinedAt ?? '').localeCompare(a.quarantinedAt ?? ''))
236
+ return captures
237
+ }
238
+
239
+ /** Retention: quarantined captures past the clock are removed. This is the
240
+ * ONLY place quarantined audio is ever deleted. */
241
+ export function purgeExpiredQuarantine(
242
+ root: string = unsavedAudioRoot(),
243
+ retentionMs: number = unsavedAudioRetentionMs(),
244
+ ): string[] {
245
+ const purged: string[] = []
246
+ for (const capture of listUnsavedCaptures(root, retentionMs)) {
247
+ // Never delete a directory a recovery is actively decoding — a capture
248
+ // sitting exactly at the retention boundary would otherwise lose its
249
+ // audio mid-run, in the module whose thesis is "never delete evidence".
250
+ if (isRecoveryActive(capture.dirName)) continue
251
+ const atMs = capture.quarantinedAt ? Date.parse(capture.quarantinedAt) : NaN
252
+ if (!Number.isFinite(atMs)) continue
253
+ if (Date.now() - atMs > retentionMs) {
254
+ try {
255
+ rmSync(resolve(root, capture.dirName), { recursive: true, force: true })
256
+ purged.push(capture.dirName)
257
+ } catch { /* next sweep retries */ }
258
+ }
259
+ }
260
+ return purged
261
+ }
262
+
263
+ /** Resolve the quarantine dir for a sessionId (exact dir or timestamp-suffixed). */
264
+ export function findQuarantineDir(
265
+ sessionId: string,
266
+ root: string = unsavedAudioRoot(),
267
+ ): string | null {
268
+ const exact = resolve(root, sessionId)
269
+ if (existsSync(exact)) return exact
270
+ try {
271
+ for (const dir of readdirSync(root)) {
272
+ if (dir === sessionId || dir.startsWith(`${sessionId}.`)) {
273
+ const dirPath = resolve(root, dir)
274
+ if (statSync(dirPath).isDirectory()) return dirPath
275
+ }
276
+ }
277
+ } catch { /* fall through */ }
278
+ return null
279
+ }
280
+
281
+ // ── Active recovery registry ─────────────────────────────────────────────
282
+ // In-memory, single-process (matches the sessions map's own scope). Two
283
+ // consumers: purgeExpiredQuarantine skips dirs being recovered (a capture at
284
+ // the retention boundary must not be deleted mid-decode), and meeting_sync
285
+ // renders an active row so COS Control warns BEFORE committing an Update
286
+ // Server drain into a 20-90 minute recovery it cannot see.
287
+
288
+ const activeRecoveries = new Map<string, { sessionId: string; dirPath: string; startedAt: number }>()
289
+
290
+ export function registerActiveRecovery(sessionId: string, dirPath: string): void {
291
+ activeRecoveries.set(basename(dirPath), { sessionId, dirPath, startedAt: Date.now() })
292
+ }
293
+
294
+ export function clearActiveRecovery(dirPath: string): void {
295
+ activeRecoveries.delete(basename(dirPath))
296
+ }
297
+
298
+ export function listActiveRecoveries(): Array<{ sessionId: string; dirPath: string; startedAt: number }> {
299
+ return [...activeRecoveries.values()]
300
+ }
301
+
302
+ export function isRecoveryActive(dirName: string): boolean {
303
+ return activeRecoveries.has(dirName)
304
+ }
305
+
306
+ export function markRecovered(dirPath: string, savedFilename: string): void {
307
+ try {
308
+ writeFileSync(
309
+ resolve(dirPath, RECOVERED_RECEIPT),
310
+ `${JSON.stringify({ schemaVersion: 1, recoveredAt: new Date().toISOString(), savedFilename })}\n`,
311
+ { encoding: 'utf8', mode: 0o600 },
312
+ )
313
+ } catch { /* receipt is best-effort; findBySessionId remains the true guard */ }
314
+ }
@@ -38,6 +38,7 @@ import { maintenanceLifecycle } from '../lib/maintenance-lifecycle.js'
38
38
  import { getLocalTtsHealth, refreshLocalTtsHealth } from '../lib/tts-local.js'
39
39
  import { liveCuesCapability } from '../lib/live-cues-capability.js'
40
40
  import { getMeetingSyncSnapshot } from '../lib/meeting-batch-progress.js'
41
+ import { listUnsavedCaptures } from '../lib/unsaved-audio-quarantine.js'
41
42
 
42
43
  export const healthRouter = Router()
43
44
 
@@ -261,6 +262,20 @@ healthRouter.get('/health', async (_req, res) => {
261
262
  const cursorSnapshot = getCursorModelCatalogSnapshot()
262
263
  const { agentBinary: _cursorAgentBinary, ...cursor_models } = cursorSnapshot
263
264
  const meeting_sync = getMeetingSyncSnapshot()
265
+ // Quarantined unsaved captures (6.19.0). Compact on this unauthenticated
266
+ // surface — same exposure level as meeting_sync's meetingIds. Full detail
267
+ // plus the recover action live on the authenticated /api/meeting/orphans.
268
+ const unsavedList = listUnsavedCaptures()
269
+ const unsaved_captures = {
270
+ count: unsavedList.filter(item => !item.recovered).length,
271
+ items: unsavedList.slice(0, 10).map(item => ({
272
+ sessionId: item.sessionId,
273
+ ageHours: item.ageHours,
274
+ chunkFiles: item.chunkFiles,
275
+ expiresAt: item.expiresAt,
276
+ recovered: item.recovered,
277
+ })),
278
+ }
264
279
  res.json({
265
280
  ...checks,
266
281
  server_version: managedServerVersion(),
@@ -276,6 +291,7 @@ healthRouter.get('/health', async (_req, res) => {
276
291
  codex_models,
277
292
  cursor_models,
278
293
  meeting_sync,
294
+ unsaved_captures,
279
295
  capabilities: {
280
296
  transcription: { ...transcription, hq: transcriptionHq },
281
297
  recovery,
@@ -2,7 +2,8 @@
2
2
  // the standalone public meeting store. The live transcript and chunk metadata
3
3
  // are durable before the session is closed; batch improvement runs afterward.
4
4
 
5
- import { rmSync } from 'node:fs'
5
+ import { readdirSync, rmSync, statSync, unlinkSync } from 'node:fs'
6
+ import { resolve } from 'node:path'
6
7
  import { Router } from 'express'
7
8
  import { emitDisplay } from '../lib/display-bus.js'
8
9
  import { cleanTranscriptLines } from '../lib/hallucination-filter.js'
@@ -17,7 +18,29 @@ import {
17
18
  persistBatchDecisionSidecar,
18
19
  replaceMeetingTranscriptAtomic,
19
20
  } from '../lib/meeting-batch-persistence.js'
20
- import { runMeetingBatchPipeline } from '../lib/meeting-batch-transcribe.js'
21
+ import {
22
+ BATCH_PENDING_MARKER,
23
+ clearMeetingBatchProgress,
24
+ readMeetingBatchProgress,
25
+ writeMeetingBatchTerminal,
26
+ } from '../lib/meeting-batch-progress.js'
27
+ import {
28
+ enqueueSerializedHqWork,
29
+ runMeetingBatchPipeline,
30
+ segmentTranscriptChunks,
31
+ transcribeSegments,
32
+ } from '../lib/meeting-batch-transcribe.js'
33
+ import {
34
+ clearActiveRecovery,
35
+ findQuarantineDir,
36
+ listUnsavedCaptures,
37
+ markRecovered,
38
+ registerActiveRecovery,
39
+ } from '../lib/unsaved-audio-quarantine.js'
40
+ import {
41
+ clearSessionHallucinationState,
42
+ stripInlineHallucinations,
43
+ } from '../lib/hallucination-filter.js'
21
44
  import {
22
45
  selectBatchTranscriptForPersistence,
23
46
  type BatchTranscription,
@@ -376,9 +399,255 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
376
399
  }
377
400
  })
378
401
 
402
+ // ── Unsaved-capture recovery (6.19.0) ─────────────────────────────────
403
+ // Surface-only by decision (Miles, 2026-08-02): the server NEVER drives
404
+ // recovery on its own. It lists what the quarantine holds, and one
405
+ // authenticated POST drives one capture to a durable scribe.
406
+ const recoveringOrphans = new Set<string>()
407
+
408
+ router.get('/meeting/orphans', (_req, res) => {
409
+ res.set('Cache-Control', 'private, no-store')
410
+ const items = listUnsavedCaptures()
411
+ // In-flight recovery progress: transcribeSegments writes its progress file
412
+ // into the quarantine dir, invisible to meeting_sync (different root) —
413
+ // surface it here so a long recovery is not a black box.
414
+ const recoveringProgress: Record<string, { segmentsDone: number; segmentsTotal: number } | null> = {}
415
+ for (const id of recoveringOrphans) {
416
+ const dir = findQuarantineDir(id)
417
+ const progress = dir ? readMeetingBatchProgress(dir) : null
418
+ recoveringProgress[id] = progress
419
+ ? { segmentsDone: progress.segmentsDone, segmentsTotal: progress.segmentsTotal }
420
+ : null
421
+ }
422
+ res.json({
423
+ count: items.filter(item => !item.recovered).length,
424
+ recovering: [...recoveringOrphans],
425
+ recoveringProgress,
426
+ items,
427
+ })
428
+ })
429
+
430
+ router.post('/meeting/orphans/:sessionId/recover', (req, res) => {
431
+ const sessionId = String(req.params.sessionId ?? '')
432
+ if (!/^[A-Za-z0-9:_-]{3,96}$/.test(sessionId)) {
433
+ res.status(400).json({ error: 'Invalid sessionId', reason: 'invalid_session_id' })
434
+ return
435
+ }
436
+ const body = (req.body ?? {}) as Record<string, unknown>
437
+ if (body.title !== undefined && typeof body.title !== 'string') {
438
+ res.status(400).json({ error: 'Invalid title', reason: 'invalid_title' })
439
+ return
440
+ }
441
+ if (body.domain !== undefined && typeof body.domain !== 'string') {
442
+ res.status(400).json({ error: 'Invalid domain', reason: 'invalid_domain' })
443
+ return
444
+ }
445
+ // Idempotent: a capture that already reached a durable save replays its
446
+ // receipt — same contract as POST /meeting/save.
447
+ const alreadySaved = store.findBySessionId(sessionId)
448
+ if (alreadySaved) {
449
+ // Stamp the quarantine receipt too, or a saved-but-quarantined capture
450
+ // (e.g. save succeeded but the pending handoff failed, marker expired,
451
+ // sweep quarantined the leftovers — the meeting IS saved) counts as
452
+ // "unsaved" on health forever, a false alarm that trains the user to
453
+ // ignore the one channel built to report real losses.
454
+ const staleQuarantine = findQuarantineDir(sessionId)
455
+ if (staleQuarantine) markRecovered(staleQuarantine, alreadySaved.filename)
456
+ res.set('Cache-Control', 'private, no-store')
457
+ res.json({ accepted: false, alreadySaved: true, receipt: publicSaveResponse(alreadySaved, true) })
458
+ return
459
+ }
460
+ const quarantineDir = findQuarantineDir(sessionId)
461
+ if (!quarantineDir) {
462
+ res.status(404).json({ error: 'No quarantined audio for this session', reason: 'orphan_not_found' })
463
+ return
464
+ }
465
+ if (recoveringOrphans.has(sessionId)) {
466
+ res.status(409).json({ error: 'Recovery already in progress', reason: 'recovery_in_progress' })
467
+ return
468
+ }
469
+ const capture = synthesizeEntriesFromChunkWavs(quarantineDir)
470
+ const entries = capture.entries
471
+ if (entries.length === 0) {
472
+ res.status(422).json({ error: 'Quarantined directory holds no readable chunk audio', reason: 'no_chunk_audio' })
473
+ return
474
+ }
475
+
476
+ // Acquire BEFORE marking the session as recovering: if a drain gate makes
477
+ // acquire throw, nothing must linger in recoveringOrphans — a leaked entry
478
+ // would 409 every retry for the exact capture this route exists to save.
479
+ let lease: MaintenanceWorkLease
480
+ try {
481
+ lease = acquireMaintenanceWork('orphan_recovery', { phase: 'queued' })
482
+ } catch {
483
+ res.status(503).json({
484
+ error: 'Server is draining for maintenance — retry after the update completes',
485
+ reason: 'maintenance_drain',
486
+ })
487
+ return
488
+ }
489
+ recoveringOrphans.add(sessionId)
490
+ // Registry drives two protections: meeting_sync renders this as an active
491
+ // row (COS Control warns before an Update Server drain walks into a
492
+ // 20-90 min decode), and purgeExpiredQuarantine will not delete the dir
493
+ // mid-run even at the retention boundary.
494
+ registerActiveRecovery(sessionId, quarantineDir)
495
+ res.status(202).set('Cache-Control', 'private, no-store').json({
496
+ accepted: true,
497
+ sessionId,
498
+ chunkFiles: entries.length,
499
+ note: 'Recovery runs in the background behind the HQ decoder queue — expect minutes for a long meeting '
500
+ + '(watch recoveringProgress on GET /api/meeting/orphans). Speakers are labeled Unknown: no live ASR ever '
501
+ + 'ran for this capture. The capture leaves the unsaved count once its scribe is durable.',
502
+ })
503
+
504
+ const task = Promise.resolve().then(async () => {
505
+ lease.setPhase('active')
506
+ const startedAt = Date.now()
507
+ const segments = segmentTranscriptChunks(entries)
508
+ // transcribeSegments carries the batch contract (enhancement, Metal
509
+ // preemption discard + one CPU retry, overlap stripping) but NOT the
510
+ // decoder serialization — that lives in the queue tail. Chain onto it,
511
+ // or two back-to-back recoveries plus a live post-meeting batch would
512
+ // run parallel 16-thread large-v3 decoders on the user's Mac.
513
+ const results = await enqueueSerializedHqWork(
514
+ () => transcribeSegments(quarantineDir, segments, entries),
515
+ )
516
+ // Batch whisper over a dead session has no streaming baseline to
517
+ // compare against (evaluateBatchQuality needs one), but the inline
518
+ // hallucination filter needs none — run the same two-pass the boot
519
+ // session-recovery path uses: pass 1 builds the frequency blocklist
520
+ // across all segments, pass 2 strips with the final blocklist.
521
+ for (const item of results) {
522
+ if (item.text) stripInlineHallucinations(item.text, sessionId)
523
+ }
524
+ for (const item of results) {
525
+ if (item.text) item.text = stripInlineHallucinations(item.text, sessionId)
526
+ }
527
+ clearSessionHallucinationState(sessionId)
528
+ const transcript = cleanFinalTranscript(results.map(item => item.text).join(' '))
529
+ if (!transcript.trim()) {
530
+ throw new Error('recovery produced an empty transcript')
531
+ }
532
+ const recoveredChunks = results.map(item => ({
533
+ text: item.text,
534
+ speaker: 'Unknown',
535
+ elapsed: item.segment.startElapsed,
536
+ similarity: 0,
537
+ words: item.words,
538
+ canonical: true,
539
+ }))
540
+ const saved = store.save({
541
+ sessionId,
542
+ title: typeof body.title === 'string' && body.title.trim() ? body.title : undefined,
543
+ domain: typeof body.domain === 'string' && body.domain.trim() ? body.domain : undefined,
544
+ transcript,
545
+ startTime: capture.startTime,
546
+ durationMs: capture.durationMs,
547
+ chunks: recoveredChunks,
548
+ chunkEntries: recoveredChunks.map((chunk, position) => ({
549
+ chunkIndex: results[position]?.segment.startChunkIdx ?? position,
550
+ chunk,
551
+ })),
552
+ transferIntegrity: null,
553
+ })
554
+ markRecovered(quarantineDir, saved.filename)
555
+ console.log(
556
+ `[meeting/orphans] Recovered ${sessionId} → ${saved.filename} `
557
+ + `(${entries.length} chunks, ${Math.round((Date.now() - startedAt) / 1000)}s)`,
558
+ )
559
+ try {
560
+ emit({
561
+ type: 'recording_stop',
562
+ data: {
563
+ sessionId,
564
+ filename: saved.filename,
565
+ durationMin: saved.durationMin,
566
+ domain: saved.domain,
567
+ },
568
+ })
569
+ } catch { /* display is best-effort */ }
570
+ if (cosOpsPipelineConfigured()) {
571
+ await handoffMeetingToOperations(saved.filepath)
572
+ }
573
+ }).catch(error => {
574
+ // The quarantined audio is untouched on failure — retry stays possible
575
+ // until the retention clock clears it.
576
+ console.error(
577
+ `[meeting/orphans] Recovery failed for ${sessionId}: `
578
+ + `${error instanceof Error ? error.message : String(error)}`,
579
+ )
580
+ }).finally(() => {
581
+ // transcribeSegments wrote progress + a pending marker into the
582
+ // quarantine dir; the batch pipeline's own finally never runs here, so
583
+ // clean them up or the dir carries stale work files until retention.
584
+ try { clearMeetingBatchProgress(quarantineDir) } catch { /* best-effort */ }
585
+ try { unlinkSync(resolve(quarantineDir, BATCH_PENDING_MARKER)) } catch { /* best-effort */ }
586
+ clearActiveRecovery(quarantineDir)
587
+ recoveringOrphans.delete(sessionId)
588
+ lease.release()
589
+ })
590
+ scheduleBackground(task)
591
+ })
592
+
379
593
  return router
380
594
  }
381
595
 
596
+ const CHUNK_WAV_NAME = /^chunk_(\d{4})\.wav$/
597
+ const WAV_HEADER_BYTES = 44
598
+ const PCM_BYTES_PER_MS = 32 // 16 kHz mono 16-bit
599
+
600
+ interface RecoveredCapture {
601
+ entries: IndexedTranscriptChunk[]
602
+ /** Meeting start ≈ the earliest chunk's write time (chunk files keep their
603
+ * original mtimes across renames — the pending-batch cleanup relies on the
604
+ * same property). Falls back to now-minus-duration when mtimes are unusable. */
605
+ startTime: number
606
+ durationMs: number
607
+ }
608
+
609
+ /** Rebuild a chunk timeline for a dead session from its WAV files alone:
610
+ * index from the filename, duration from PCM byte length, elapsed cumulative.
611
+ * Text is empty — recovery exists precisely because no ASR ever ran. */
612
+ function synthesizeEntriesFromChunkWavs(audioDir: string): RecoveredCapture {
613
+ const rows: Array<{ chunkIndex: number; durationMs: number; mtimeMs: number }> = []
614
+ try {
615
+ for (const name of readdirSync(audioDir)) {
616
+ const match = CHUNK_WAV_NAME.exec(name)
617
+ if (!match) continue
618
+ try {
619
+ const stats = statSync(resolve(audioDir, name))
620
+ rows.push({
621
+ chunkIndex: Number(match[1]),
622
+ durationMs: Math.max(0, Math.round((stats.size - WAV_HEADER_BYTES) / PCM_BYTES_PER_MS)),
623
+ mtimeMs: stats.mtimeMs,
624
+ })
625
+ } catch { /* unreadable chunk — skip */ }
626
+ }
627
+ } catch {
628
+ return { entries: [], startTime: Date.now(), durationMs: 0 }
629
+ }
630
+ rows.sort((a, b) => a.chunkIndex - b.chunkIndex)
631
+ let elapsed = 0
632
+ const entries = rows.map(row => {
633
+ const entry: IndexedTranscriptChunk = {
634
+ chunkIndex: row.chunkIndex,
635
+ chunk: { text: '', speaker: 'Unknown', elapsed, similarity: 0 },
636
+ }
637
+ elapsed += row.durationMs
638
+ return entry
639
+ })
640
+ const durationMs = elapsed
641
+ const earliestMtime = rows.reduce(
642
+ (minimum, row) => (Number.isFinite(row.mtimeMs) && row.mtimeMs > 0 ? Math.min(minimum, row.mtimeMs) : minimum),
643
+ Number.POSITIVE_INFINITY,
644
+ )
645
+ const startTime = Number.isFinite(earliestMtime) && earliestMtime !== Number.POSITIVE_INFINITY
646
+ ? Math.round(earliestMtime)
647
+ : Date.now() - durationMs
648
+ return { entries, startTime, durationMs }
649
+ }
650
+
382
651
  async function finalizeBatch(options: {
383
652
  audioDir: string
384
653
  entries: IndexedTranscriptChunk[]
@@ -431,6 +700,16 @@ async function finalizeBatch(options: {
431
700
  rmSync(options.audioDir, { recursive: true, force: true })
432
701
  } else {
433
702
  console.warn('[meeting/save] Pending raw audio retained for bounded cleanup')
703
+ // The batch reached a terminal outcome but its WAVs stay behind. Record it
704
+ // so meeting_sync reports "retained", not perpetual active work. Rejected
705
+ // and pipeline-failed runs already wrote their terminal inside runBatch;
706
+ // this covers the accepted-but-not-fully-persisted case.
707
+ if (result.transcriptionQuality === 'batch') {
708
+ writeMeetingBatchTerminal(options.audioDir, {
709
+ outcome: 'accepted',
710
+ reason: transcriptApplied ? 'metadata_persist_failed' : 'transcript_apply_failed',
711
+ })
712
+ }
434
713
  }
435
714
  }
436
715
 
@@ -35,6 +35,12 @@ import {
35
35
  isVocabEchoOnly,
36
36
  } from '../lib/hallucination-filter.js'
37
37
  import { dataPath } from '../lib/data-dir.js'
38
+ import {
39
+ countChunkWavs,
40
+ purgeExpiredQuarantine,
41
+ quarantineSessionAudio,
42
+ sweepOrphanedSessionAudio,
43
+ } from '../lib/unsaved-audio-quarantine.js'
38
44
  import { durableAtomicWriteFileSync } from '../lib/atomic-fs.js'
39
45
  import {
40
46
  LOCAL_FIRST_MEETING_IDLE_RETENTION_MS,
@@ -612,16 +618,28 @@ function recoverSessions(): void {
612
618
  }
613
619
  } catch { /* skip corrupt files */ }
614
620
  }
615
- // Remove orphaned session-audio dirs with no matching recovered session
621
+ // Orphaned session-audio dirs with no matching recovered session: audio
622
+ // evidence is QUARANTINED, never deleted (6.19.0 — two meetings were
623
+ // destroyed here on 2026-08-01 when their deferred saves never landed).
624
+ // Only chunk-less dirs are removed.
616
625
  try {
617
626
  if (existsSync(SESSION_AUDIO_DIR)) {
618
- for (const dir of readdirSync(SESSION_AUDIO_DIR)) {
619
- if (!recoveredIds.has(dir) && !hasFreshPreservedAudioMarker(resolve(SESSION_AUDIO_DIR, dir))) {
620
- rmSync(resolve(SESSION_AUDIO_DIR, dir), { recursive: true, force: true })
621
- console.log(`[session-recovery] Cleaned orphaned session-audio: ${dir}`)
627
+ const actions = sweepOrphanedSessionAudio(SESSION_AUDIO_DIR, {
628
+ isLive: id => recoveredIds.has(id),
629
+ hasFreshPreservedMarker: hasFreshPreservedAudioMarker,
630
+ reason: 'boot_sweep_unsaved',
631
+ })
632
+ for (const entry of actions) {
633
+ if (entry.action === 'quarantined') {
634
+ console.warn(`[session-recovery] Quarantined unsaved session-audio: ${entry.dir} → ${entry.target}`)
635
+ } else if (entry.action === 'deleted_empty') {
636
+ console.log(`[session-recovery] Cleaned empty session-audio: ${entry.dir}`)
637
+ } else if (entry.action === 'quarantine_failed') {
638
+ console.error(`[session-recovery] Quarantine move failed, source retained: ${entry.dir}`)
622
639
  }
623
640
  }
624
641
  }
642
+ purgeExpiredQuarantine()
625
643
  } catch {}
626
644
  } catch { /* non-critical */ }
627
645
  }
@@ -658,13 +676,24 @@ setInterval(() => {
658
676
  closeTranscriptSession(id, 'expired')
659
677
  }
660
678
  }
661
- // Purge orphaned session-audio dirs (no matching active session)
679
+ // Orphaned session-audio dirs (no matching active session): quarantine any
680
+ // dir still holding chunk audio; delete only chunk-less dirs. Quarantine
681
+ // itself expires on the unsaved-audio retention clock, the ONLY place
682
+ // quarantined audio is ever deleted.
662
683
  try {
663
- for (const dir of readdirSync(SESSION_AUDIO_DIR)) {
664
- if (!sessions.has(dir) && !hasFreshPreservedAudioMarker(resolve(SESSION_AUDIO_DIR, dir))) {
665
- rmSync(resolve(SESSION_AUDIO_DIR, dir), { recursive: true, force: true })
684
+ const actions = sweepOrphanedSessionAudio(SESSION_AUDIO_DIR, {
685
+ isLive: id => sessions.has(id),
686
+ hasFreshPreservedMarker: hasFreshPreservedAudioMarker,
687
+ reason: 'idle_expiry_unsaved',
688
+ })
689
+ for (const entry of actions) {
690
+ if (entry.action === 'quarantined') {
691
+ console.warn(`[cleanup] Quarantined unsaved session-audio: ${entry.dir} → ${entry.target}`)
692
+ } else if (entry.action === 'quarantine_failed') {
693
+ console.error(`[cleanup] Quarantine move failed, source retained: ${entry.dir}`)
666
694
  }
667
695
  }
696
+ purgeExpiredQuarantine()
668
697
  } catch {}
669
698
  // Purge stale pending-batch dirs. HQ large-v3 on long meetings + Control
670
699
  // restart can exceed 2h (2026-07-27: two sessions purged before batch).
@@ -1022,7 +1051,7 @@ function closeTranscriptSession(
1022
1051
  maxChunkIndex,
1023
1052
  reason,
1024
1053
  })
1025
- finishClosingTranscriptSession(sessionId, options)
1054
+ finishClosingTranscriptSession(sessionId, { ...options, closeReason: reason })
1026
1055
  }
1027
1056
 
1028
1057
  /** Delete session after save */
@@ -1030,7 +1059,10 @@ export function deleteSession(sessionId: string, options: { preserveAudio?: bool
1030
1059
  closeTranscriptSession(sessionId, 'saved', options)
1031
1060
  }
1032
1061
 
1033
- function finishClosingTranscriptSession(sessionId: string, options: { preserveAudio?: boolean }): void {
1062
+ function finishClosingTranscriptSession(
1063
+ sessionId: string,
1064
+ options: { preserveAudio?: boolean; closeReason?: ClosedTranscriptSession['reason'] },
1065
+ ): void {
1034
1066
  sessions.delete(sessionId)
1035
1067
  sessionAudioBytes.delete(sessionId)
1036
1068
  sessionAudioWrites.delete(sessionId)
@@ -1049,8 +1081,28 @@ function finishClosingTranscriptSession(sessionId: string, options: { preserveAu
1049
1081
  writeFileSync(marker, String(Date.now()), { encoding: 'utf8', mode: 0o600 })
1050
1082
  chmodSync(marker, 0o600)
1051
1083
  } catch {}
1052
- } else {
1053
- try { rmSync(audioDir, { recursive: true, force: true }) } catch {}
1084
+ } else if (options.closeReason === 'saved') {
1085
+ // Saved sessions moved their audio to pending-batch (or explicitly
1086
+ // preserved it above); a leftover dir here is residue, safe to delete.
1087
+ // UNLESS chunks are still present — hasAudio() returns false on a
1088
+ // transient statSync error, which makes preserveAudio false while the
1089
+ // move never ran. Chunk-bearing evidence goes to quarantine, never rmSync.
1090
+ if (countChunkWavs(audioDir) > 0) {
1091
+ const target = quarantineSessionAudio(audioDir, 'close_saved_residual_chunks')
1092
+ if (target) console.warn(`[transcribe-stream] Saved-close left chunk audio behind; quarantined: ${sessionId} → ${target}`)
1093
+ } else {
1094
+ try { rmSync(audioDir, { recursive: true, force: true }) } catch {}
1095
+ }
1096
+ } else if (existsSync(audioDir)) {
1097
+ // Any non-saved close (expired, error, …) with chunk audio still on disk
1098
+ // is an unsaved capture. Quarantine it — never delete evidence (6.19.0).
1099
+ if (countChunkWavs(audioDir) > 0) {
1100
+ const target = quarantineSessionAudio(audioDir, `close_${options.closeReason ?? 'unknown'}`)
1101
+ if (target) console.warn(`[transcribe-stream] Quarantined unsaved session-audio on close: ${sessionId} → ${target}`)
1102
+ else console.error(`[transcribe-stream] Quarantine move failed on close, source retained: ${sessionId}`)
1103
+ } else {
1104
+ try { rmSync(audioDir, { recursive: true, force: true }) } catch {}
1105
+ }
1054
1106
  }
1055
1107
  }
1056
1108