@gotcos/glasses-server 6.18.7 → 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 +11 -0
- package/CHANGELOG.md +52 -0
- package/README.md +6 -0
- package/managed-runtime-contract.json +1 -0
- package/package.json +1 -1
- package/server/lib/archive.ts +3 -0
- package/server/lib/conversation.ts +1 -0
- package/server/lib/maintenance-lifecycle.ts +9 -0
- package/server/lib/meeting-batch-progress.ts +150 -3
- package/server/lib/meeting-batch-transcribe.ts +29 -2
- package/server/lib/query-job-runtime.ts +2 -2
- package/server/lib/transcribe-audio.ts +14 -15
- package/server/lib/unsaved-audio-quarantine.ts +314 -0
- package/server/lib/whisper-local.ts +49 -30
- package/server/routes/health.ts +16 -0
- package/server/routes/meeting.ts +281 -2
- package/server/routes/prompt-drafts.ts +77 -0
- package/server/routes/transcribe-stream.ts +65 -13
|
@@ -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
|
+
}
|
|
@@ -137,6 +137,12 @@ const BATCH_LARGE_V3_ENABLED = process.env.COS_BATCH_LARGE_V3 !== '0'
|
|
|
137
137
|
const VAD_MODEL_PATH = join(process.env.HOME ?? homedir(), '.local/share/whisper-models/ggml-silero-v5.1.2.bin')
|
|
138
138
|
const VAD_ENABLED = process.env.COS_WHISPER_VAD !== '0'
|
|
139
139
|
|
|
140
|
+
/** Batch meeting HQ keeps CLI --vad. Interactive/prompt HQ must not — VAD was
|
|
141
|
+
* measured dropping leading speech on compose ("device just for your awareness"). */
|
|
142
|
+
export function hqCliVadEnabled(priority?: 'interactive' | 'batch'): boolean {
|
|
143
|
+
return priority === 'batch' && VAD_ENABLED && existsSync(VAD_MODEL_PATH)
|
|
144
|
+
}
|
|
145
|
+
|
|
140
146
|
/** Pick the batch model path. Prefer large-v3 when enabled + on disk; fall
|
|
141
147
|
* back to turbo otherwise. Logged once per process so we know which decoder
|
|
142
148
|
* actually ran when reviewing a meeting later. */
|
|
@@ -732,7 +738,6 @@ export async function transcribeHighQuality(
|
|
|
732
738
|
|
|
733
739
|
const modelPath = resolveBatchModel()
|
|
734
740
|
const useLargeV3 = modelPath === BATCH_MODEL_LARGE_V3
|
|
735
|
-
const useVad = VAD_ENABLED && existsSync(VAD_MODEL_PATH)
|
|
736
741
|
|
|
737
742
|
try {
|
|
738
743
|
writeFileSync(tmpWav, audioBuffer)
|
|
@@ -741,6 +746,9 @@ export async function transcribeHighQuality(
|
|
|
741
746
|
// that is explicitly OUT of batch device policy. Only the long post-meeting
|
|
742
747
|
// batch is admission-controlled against live ASR.
|
|
743
748
|
const isBatch = opts.priority === 'batch'
|
|
749
|
+
// A0 (2026-07-30): CLI --vad on interactive/prompt HQ can drop leading speech
|
|
750
|
+
// (measured: "device just for your awareness"). Keep VAD for meeting batch only.
|
|
751
|
+
const useVad = hqCliVadEnabled(opts.priority)
|
|
744
752
|
const decision: { device: 'metal' | 'cpu'; reason: string; metalEnabled: boolean } = isBatch
|
|
745
753
|
? (opts.forceCpu
|
|
746
754
|
? { device: 'cpu', reason: 'preempt_retry', metalEnabled: batchHqMetalEnabled() }
|
|
@@ -774,9 +782,8 @@ export async function transcribeHighQuality(
|
|
|
774
782
|
args.push('-ojf', '-of', outBase)
|
|
775
783
|
}
|
|
776
784
|
if (useVad) {
|
|
777
|
-
//
|
|
778
|
-
//
|
|
779
|
-
// failure mode even with large-v3's more permissive decoder.
|
|
785
|
+
// Batch-only: strips silence windows before decode. Interactive/prompt HQ
|
|
786
|
+
// omits this — VAD was measured dropping real leading speech on compose.
|
|
780
787
|
args.push('--vad', '--vad-model', VAD_MODEL_PATH)
|
|
781
788
|
}
|
|
782
789
|
const proc = spawn(WHISPER_CLI, args, {
|
|
@@ -1076,8 +1083,14 @@ export function resetDecoderCaches(): void {
|
|
|
1076
1083
|
* auto-restart the server process in the background and throw immediately so the
|
|
1077
1084
|
* caller can use cloud while the server recovers (~20s model load).
|
|
1078
1085
|
*/
|
|
1079
|
-
export async function transcribeLocal(
|
|
1086
|
+
export async function transcribeLocal(
|
|
1087
|
+
audioBuffer: Buffer,
|
|
1088
|
+
context?: string,
|
|
1089
|
+
isQuiet?: boolean,
|
|
1090
|
+
opts?: { affectsCircuit?: boolean },
|
|
1091
|
+
): Promise<{ text: string; backend: 'server' | 'cli'; words?: WhisperWord[] }> {
|
|
1080
1092
|
const start = Date.now()
|
|
1093
|
+
const affectsCircuit = opts?.affectsCircuit !== false
|
|
1081
1094
|
|
|
1082
1095
|
if (!serverAvailable) {
|
|
1083
1096
|
await reconcileWhisperServerHealth()
|
|
@@ -1094,34 +1107,38 @@ export async function transcribeLocal(audioBuffer: Buffer, context?: string, isQ
|
|
|
1094
1107
|
const text = applyCorrections(result.text)
|
|
1095
1108
|
const words = result.words?.map(w => ({ ...w, word: applyCorrections(w.word) }))
|
|
1096
1109
|
const elapsed = Date.now() - start
|
|
1097
|
-
// Reset circuit breaker on success
|
|
1098
|
-
if (serverConsecutiveFailures > 0) {
|
|
1110
|
+
// Reset circuit breaker on success (skip for peek / non-circuit callers)
|
|
1111
|
+
if (affectsCircuit && serverConsecutiveFailures > 0) {
|
|
1099
1112
|
console.log(`[whisper-local] Server recovered after ${serverConsecutiveFailures} consecutive failure(s)`)
|
|
1100
1113
|
serverConsecutiveFailures = 0
|
|
1101
1114
|
}
|
|
1102
1115
|
console.log(`[whisper-local] Server transcribed in ${elapsed}ms (${words?.length ?? 0} words): "${text.slice(0, 80)}${text.length > 80 ? '...' : ''}"`)
|
|
1103
1116
|
return { text, backend: 'server', words }
|
|
1104
1117
|
} catch (err: any) {
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1118
|
+
if (affectsCircuit) {
|
|
1119
|
+
serverConsecutiveFailures++
|
|
1120
|
+
const isTimeout = err.message.includes('timeout') || err.message.includes('aborted')
|
|
1121
|
+
const isDead = err.message.includes('ECONNREFUSED') || err.message.includes('fetch failed')
|
|
1122
|
+
|
|
1123
|
+
if (isDead) {
|
|
1124
|
+
serverAvailable = false
|
|
1125
|
+
console.error(`[whisper-local] Server DEAD (ECONNREFUSED) — marked unavailable. Consecutive failures: ${serverConsecutiveFailures}`)
|
|
1126
|
+
} else if (isTimeout) {
|
|
1127
|
+
// Server process exists but is hung — mark unavailable so we stop trying
|
|
1128
|
+
serverAvailable = false
|
|
1129
|
+
console.error(`[whisper-local] Server HUNG (timeout) — marked unavailable. Consecutive failures: ${serverConsecutiveFailures}`)
|
|
1130
|
+
}
|
|
1117
1131
|
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1132
|
+
// Circuit breaker: auto-restart after threshold
|
|
1133
|
+
if (serverConsecutiveFailures >= SERVER_FAILURE_THRESHOLD && !serverRestarting) {
|
|
1134
|
+
console.error(`[whisper-local] ⚠ CIRCUIT BREAKER OPEN — ${serverConsecutiveFailures} consecutive failures. Auto-restarting server...`)
|
|
1135
|
+
// Non-blocking restart in background
|
|
1136
|
+
void restartWhisperServer()
|
|
1137
|
+
} else if (serverConsecutiveFailures < SERVER_FAILURE_THRESHOLD) {
|
|
1138
|
+
console.warn(`[whisper-local] Server failed (${serverConsecutiveFailures}/${SERVER_FAILURE_THRESHOLD} before restart): ${err.message}`)
|
|
1139
|
+
}
|
|
1140
|
+
} else {
|
|
1141
|
+
console.warn(`[whisper-local] Peek/non-circuit server failure (breaker untouched): ${err.message}`)
|
|
1125
1142
|
}
|
|
1126
1143
|
|
|
1127
1144
|
// Throw to let caller fall to OpenAI cloud (1-3s) — much faster than CLI cold-start (11s)
|
|
@@ -1132,10 +1149,12 @@ export async function transcribeLocal(audioBuffer: Buffer, context?: string, isQ
|
|
|
1132
1149
|
// Server not available — still count toward circuit breaker so auto-restart can fire.
|
|
1133
1150
|
// Without this, the counter stalls after the first failure marks serverAvailable=false
|
|
1134
1151
|
// and subsequent calls never increment, so restart never triggers.
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1152
|
+
if (affectsCircuit) {
|
|
1153
|
+
serverConsecutiveFailures++
|
|
1154
|
+
if (serverConsecutiveFailures >= SERVER_FAILURE_THRESHOLD && !serverRestarting) {
|
|
1155
|
+
console.error(`[whisper-local] CIRCUIT BREAKER OPEN — ${serverConsecutiveFailures} consecutive failures (server unavailable). Auto-restarting...`)
|
|
1156
|
+
void restartWhisperServer()
|
|
1157
|
+
}
|
|
1139
1158
|
}
|
|
1140
1159
|
|
|
1141
1160
|
// Throw so the caller applies the configured recovery policy. CLI is
|
package/server/routes/health.ts
CHANGED
|
@@ -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,
|