@gotcos/glasses-server 6.12.5 → 6.12.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,25 @@
1
1
  # Changelog
2
2
 
3
+ ## 6.12.6
4
+
5
+ Pairs with COS Glasses build 222 to harden local-first meeting recovery and
6
+ local Whisper supervision while preserving the existing public API surface.
7
+
8
+ - **Meeting work stays on its admitting Mac.** Capability-aware clients pin
9
+ upload, status, and save requests to one `serverInstanceId`; a mismatch fails
10
+ before audio is consumed. Legacy unpinned clients continue to work.
11
+ - **Receipt is not transcription.** Durable ledgers distinguish raw audio
12
+ receipt, completed ASR, and canonical transcript text. Silent chunks persist
13
+ as terminal empty completions, so replay does not rerun ASR or invent text.
14
+ - **Whisper recovery is single-owner.** Concurrent start/restart requests are
15
+ serialized and coalesced. COS reaps only a process tree proven to own the
16
+ configured model and port 8178, verifies the port is clear, and launches one
17
+ replacement. An unrelated Whisper process is never killed; uncertain
18
+ ownership fails closed.
19
+ - **Backward compatible.** Existing query, prompt recovery, media, display,
20
+ diagnostics, transcription, and legacy meeting contracts retain their prior
21
+ behavior. The new capability flags are additive.
22
+
3
23
  ## 6.12.5
4
24
 
5
25
  Extends the file-permission hardening to the remaining append-only logs that
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gotcos/glasses-server",
3
- "version": "6.12.5",
3
+ "version": "6.12.6",
4
4
  "description": "COS Glasses — self-hosted AI heads-up-display server for Even G2 smart glasses, powered by your local Claude Code or Codex CLI",
5
5
  "type": "module",
6
6
  "bin": {
@@ -6,6 +6,10 @@ export interface LocalFirstMeetingsCapability {
6
6
  serverInstanceId: string
7
7
  idempotentSave: true
8
8
  sessionStatus: true
9
+ /** Capability-admitted clients pin every upload/status/save to one Mac. */
10
+ pinnedServerIdentity: true
11
+ /** Status and upload receipts distinguish raw receive, ASR completion, and canonical text. */
12
+ asrCompletionStatus: true
9
13
  retentionMs: number
10
14
  }
11
15
 
@@ -19,6 +23,8 @@ export function localFirstMeetingsCapability(serverInstanceId: string | null): L
19
23
  serverInstanceId,
20
24
  idempotentSave: true,
21
25
  sessionStatus: true,
26
+ pinnedServerIdentity: true,
27
+ asrCompletionStatus: true,
22
28
  retentionMs: LOCAL_FIRST_MEETING_IDLE_RETENTION_MS,
23
29
  }
24
30
  }
@@ -6,7 +6,8 @@
6
6
  // 2. whisper-cli (spawned per request, model loaded from disk) → ~500-700ms
7
7
  // 3. OpenAI API (cloud, handled by transcribe.ts) → ~1000-3000ms
8
8
 
9
- import { spawn, execSync } from 'node:child_process'
9
+ import { spawn, execFileSync } from 'node:child_process'
10
+ import type { ChildProcess } from 'node:child_process'
10
11
  import { writeFileSync, unlinkSync, existsSync } from 'node:fs'
11
12
  import { join } from 'node:path'
12
13
  import { homedir } from 'node:os'
@@ -142,14 +143,171 @@ const WHISPER_SERVER_URL = `http://127.0.0.1:${WHISPER_SERVER_PORT}`
142
143
  let cliAvailable = false
143
144
  let serverAvailable = false
144
145
  let serverProcess: ReturnType<typeof spawn> | null = null
146
+ const ownedServerChildren = new Set<ChildProcess>()
145
147
 
146
148
  // Circuit breaker: track consecutive server failures to detect hung process
147
149
  let serverConsecutiveFailures = 0
148
150
  const SERVER_FAILURE_THRESHOLD = 3 // After 3 consecutive failures, auto-restart
149
- let serverRestarting = false // Prevents concurrent restart attempts
151
+ let serverRestarting = false // Exposed in the existing health shape
150
152
  let serverStarting = false // Initial model load is not a circuit failure
153
+ let serverStartPromise: Promise<void> | null = null
154
+ let serverRestartPromise: Promise<WhisperRestartResult> | null = null
151
155
  let serverHealthProbe: Promise<boolean> | null = null
152
156
 
157
+ interface ProcessEntry {
158
+ pid: number
159
+ ppid: number
160
+ command: string
161
+ }
162
+
163
+ export interface WhisperRestartResult {
164
+ status: 'recovered' | 'failed'
165
+ error?: string
166
+ }
167
+
168
+ const sleep = (ms: number): Promise<void> => new Promise(resolve => setTimeout(resolve, ms))
169
+
170
+ function listProcesses(): ProcessEntry[] {
171
+ // `command=` includes arguments, which lets us distinguish this COS-owned
172
+ // port/model signature from unrelated whisper-server instances.
173
+ const output = execFileSync('ps', ['-axww', '-o', 'pid=,ppid=,command='], { encoding: 'utf8' })
174
+ return output.split('\n').flatMap(line => {
175
+ const match = line.match(/^\s*(\d+)\s+(\d+)\s+(.+?)\s*$/)
176
+ if (!match) return []
177
+ return [{ pid: Number(match[1]), ppid: Number(match[2]), command: match[3] }]
178
+ })
179
+ }
180
+
181
+ function listeningPids(): number[] {
182
+ try {
183
+ const output = execFileSync(
184
+ 'lsof',
185
+ ['-nP', `-iTCP:${WHISPER_SERVER_PORT}`, '-sTCP:LISTEN', '-t'],
186
+ { encoding: 'utf8' },
187
+ )
188
+ return output.split(/\s+/).map(Number).filter(pid => Number.isInteger(pid) && pid > 0)
189
+ } catch (err: any) {
190
+ // lsof uses exit 1 for "no matches". Anything else means we could not
191
+ // prove the port state, so startup must fail closed.
192
+ if (err?.status === 1) return []
193
+ throw new Error(`unable to inspect whisper-server port ${WHISPER_SERVER_PORT}: ${err?.message ?? err}`)
194
+ }
195
+ }
196
+
197
+ function isCosWhisperServerCommand(command: string): boolean {
198
+ const executable = /(?:^|\s)(?:\S*\/)?whisper-server(?:\s|$)/.test(command)
199
+ const configuredPort = new RegExp(`(?:^|\\s)--port(?:=|\\s+)${WHISPER_SERVER_PORT}(?:\\s|$)`).test(command)
200
+ return executable && configuredPort && command.includes(MODEL_PATH)
201
+ }
202
+
203
+ function collectDescendants(processes: ProcessEntry[], roots: Iterable<number>): Set<number> {
204
+ const descendants = new Set<number>(roots)
205
+ let changed = true
206
+ while (changed) {
207
+ changed = false
208
+ for (const entry of processes) {
209
+ if (!descendants.has(entry.pid) && descendants.has(entry.ppid)) {
210
+ descendants.add(entry.pid)
211
+ changed = true
212
+ }
213
+ }
214
+ }
215
+ return descendants
216
+ }
217
+
218
+ function signalPid(pid: number): void {
219
+ try {
220
+ process.kill(pid, 'SIGKILL')
221
+ } catch (err: any) {
222
+ if (err?.code !== 'ESRCH') throw err
223
+ }
224
+ }
225
+
226
+ async function waitForOwnedChildClose(child: ChildProcess, timeoutMs = 2_000): Promise<boolean> {
227
+ if (child.exitCode !== null || child.signalCode !== null) return true
228
+ return new Promise(resolve => {
229
+ let settled = false
230
+ const finish = (closed: boolean) => {
231
+ if (settled) return
232
+ settled = true
233
+ clearTimeout(timeout)
234
+ child.off('close', onClose)
235
+ resolve(closed)
236
+ }
237
+ const onClose = () => finish(true)
238
+ const timeout = setTimeout(() => finish(false), timeoutMs)
239
+ child.once('close', onClose)
240
+ })
241
+ }
242
+
243
+ /**
244
+ * Kill every whisper-server we own or can identify as stale, plus all of its
245
+ * descendants. Direct children are awaited so Node reaps them before another
246
+ * model process is allowed to bind the port.
247
+ */
248
+ async function killAndReapWhisperProcesses(): Promise<void> {
249
+ serverAvailable = false
250
+
251
+ for (let round = 0; round < 3; round++) {
252
+ const processes = listProcesses()
253
+ const ownedPids = [...ownedServerChildren]
254
+ .map(child => child.pid)
255
+ .filter((pid): pid is number => typeof pid === 'number')
256
+ const staleWhisperPids = processes
257
+ .filter(entry => isCosWhisperServerCommand(entry.command))
258
+ .map(entry => entry.pid)
259
+ const targets = collectDescendants(processes, [...ownedPids, ...staleWhisperPids])
260
+
261
+ if (targets.size === 0 && ownedServerChildren.size === 0) break
262
+
263
+ // Descendants first prevents a model worker from surviving its supervisor.
264
+ const depth = new Map<number, number>()
265
+ const byPid = new Map(processes.map(entry => [entry.pid, entry]))
266
+ const getDepth = (pid: number): number => {
267
+ if (depth.has(pid)) return depth.get(pid)!
268
+ const parent = byPid.get(pid)?.ppid
269
+ const value = parent && targets.has(parent) ? getDepth(parent) + 1 : 0
270
+ depth.set(pid, value)
271
+ return value
272
+ }
273
+ const orderedTargets = [...targets].sort((a, b) => getDepth(b) - getDepth(a))
274
+
275
+ for (const pid of orderedTargets) {
276
+ const ownedChild = [...ownedServerChildren].find(child => child.pid === pid)
277
+ if (ownedChild) {
278
+ try { ownedChild.kill('SIGKILL') } catch { /* already exited */ }
279
+ } else {
280
+ signalPid(pid)
281
+ }
282
+ }
283
+
284
+ const closeResults = await Promise.all([...ownedServerChildren].map(child => waitForOwnedChildClose(child)))
285
+ if (closeResults.some(closed => !closed)) {
286
+ throw new Error('owned whisper-server child did not exit after SIGKILL')
287
+ }
288
+ await sleep(50)
289
+ }
290
+
291
+ const remaining = listProcesses().filter(entry => isCosWhisperServerCommand(entry.command))
292
+ if (remaining.length > 0) {
293
+ throw new Error(`stale whisper-server process(es) remain: ${remaining.map(entry => entry.pid).join(', ')}`)
294
+ }
295
+
296
+ serverProcess = null
297
+ }
298
+
299
+ async function proveWhisperPortClear(): Promise<void> {
300
+ for (let attempt = 0; attempt < 20; attempt++) {
301
+ const pids = listeningPids()
302
+ if (pids.length === 0) return
303
+ if (attempt < 19) await sleep(250)
304
+ }
305
+ const pids = listeningPids()
306
+ throw new Error(
307
+ `whisper-server port ${WHISPER_SERVER_PORT} remains occupied${pids.length ? ` by PID(s) ${pids.join(', ')}` : ''}`,
308
+ )
309
+ }
310
+
153
311
  // Check CLI availability at import time
154
312
  try {
155
313
  cliAvailable = existsSync(WHISPER_CLI) && existsSync(MODEL_PATH)
@@ -165,45 +323,38 @@ try {
165
323
  * Called from index.ts at server boot. Non-blocking.
166
324
  */
167
325
  export async function startWhisperServer(): Promise<void> {
168
- if (serverStarting) return
326
+ if (serverRestartPromise) {
327
+ const result = await serverRestartPromise
328
+ if (result.status === 'failed') {
329
+ throw new Error(result.error ?? 'whisper-server restart failed')
330
+ }
331
+ return
332
+ }
333
+ if (serverStartPromise) return serverStartPromise
334
+ if (serverAvailable && serverProcess) return
335
+
169
336
  serverStarting = true
337
+ const operation = startWhisperServerAttempt()
338
+ serverStartPromise = operation
170
339
  try {
171
- await startWhisperServerAttempt()
340
+ await operation
172
341
  } finally {
342
+ if (serverStartPromise === operation) serverStartPromise = null
173
343
  serverStarting = false
174
344
  }
175
345
  }
176
346
 
177
- async function startWhisperServerAttempt(): Promise<void> {
347
+ async function startWhisperServerAttempt(preflightCompleted = false): Promise<void> {
178
348
  if (!existsSync(WHISPER_SERVER) || !existsSync(MODEL_PATH)) {
179
349
  console.log('[whisper-local] whisper-server or model not found — using CLI fallback')
180
350
  return
181
351
  }
182
352
 
183
- // Check if already running
184
- try {
185
- const res = await fetch(`${WHISPER_SERVER_URL}/health`, { signal: AbortSignal.timeout(1000) })
186
- if (res.ok) {
187
- serverAvailable = true
188
- console.log('[whisper-local] whisper-server already running on port', WHISPER_SERVER_PORT)
189
- return
190
- }
191
- } catch {
192
- // Not running — kill any zombie processes before starting fresh
193
- try {
194
- execSync('pkill -9 -f "whisper-server"', { stdio: 'ignore' })
195
- console.log('[whisper-local] Killed stale whisper-server processes')
196
- } catch { /* none running */ }
197
-
198
- // Wait for port to actually clear (up to 5s)
199
- for (let i = 0; i < 10; i++) {
200
- try {
201
- execSync('lsof -i :8178 -t', { stdio: 'ignore' })
202
- await new Promise(r => setTimeout(r, 500))
203
- } catch {
204
- break // Port clear
205
- }
206
- }
353
+ // The API process is the sole Whisper owner. Never adopt an untracked daemon:
354
+ // reap stale trees, then prove the fixed local port is free before spawning.
355
+ if (!preflightCompleted) {
356
+ await killAndReapWhisperProcesses()
357
+ await proveWhisperPortClear()
207
358
  }
208
359
 
209
360
  // Assemble startup args. VAD only attaches if the ggml model is actually on
@@ -237,51 +388,61 @@ async function startWhisperServerAttempt(): Promise<void> {
237
388
 
238
389
  console.log('[whisper-local] Starting whisper-server...')
239
390
 
240
- serverProcess = spawn(WHISPER_SERVER, serverArgs, {
391
+ const child = spawn(WHISPER_SERVER, serverArgs, {
241
392
  stdio: ['ignore', 'pipe', 'pipe'],
242
393
  detached: false, // Dies with parent
243
394
  })
395
+ serverProcess = child
396
+ ownedServerChildren.add(child)
244
397
 
245
- // Wait for server to be ready — poll /health every 2s (large models take ~20s to load)
246
- return new Promise<void>((resolve) => {
247
- const maxWaitMs = 45_000
248
- const pollIntervalMs = 2_000
249
- const startTime = Date.now()
250
-
251
- const pollTimer = setInterval(async () => {
252
- try {
253
- const res = await fetch(`${WHISPER_SERVER_URL}/health`, { signal: AbortSignal.timeout(1000) })
254
- if (res.ok) {
255
- clearInterval(pollTimer)
256
- serverAvailable = true
257
- const loadTime = ((Date.now() - startTime) / 1000).toFixed(1)
258
- console.log(`[whisper-local] whisper-server ready on port ${WHISPER_SERVER_PORT} (loaded in ${loadTime}s)`)
259
- resolve()
260
- }
261
- } catch {
262
- // Not ready yet — keep polling
263
- if (Date.now() - startTime > maxWaitMs) {
264
- clearInterval(pollTimer)
265
- console.warn(`[whisper-local] whisper-server startup timeout (${maxWaitMs / 1000}s) — using CLI fallback`)
266
- resolve()
267
- }
268
- }
269
- }, pollIntervalMs)
270
-
271
- serverProcess!.on('error', (err) => {
272
- clearInterval(pollTimer)
273
- console.error('[whisper-local] whisper-server failed to start:', err.message)
274
- resolve()
275
- })
398
+ child.once('close', (code) => {
399
+ ownedServerChildren.delete(child)
400
+ if (serverProcess !== child) return
401
+ serverAvailable = false
402
+ serverProcess = null
403
+ if (code !== null && code !== 0) {
404
+ console.warn(`[whisper-local] whisper-server exited with code ${code}`)
405
+ }
406
+ })
276
407
 
277
- serverProcess!.on('close', (code) => {
278
- serverAvailable = false
279
- serverProcess = null
280
- if (code !== null && code !== 0) {
281
- console.warn(`[whisper-local] whisper-server exited with code ${code}`)
408
+ // Wait for server to be ready — poll /health every 2s (large models take ~20s to load).
409
+ // Polls are sequential, so a slow health request cannot overlap the next one.
410
+ const maxWaitMs = 45_000
411
+ const pollIntervalMs = 2_000
412
+ const startTime = Date.now()
413
+ let spawnError: Error | null = null
414
+ let childClosed = false
415
+ child.once('error', err => { spawnError = err })
416
+ child.once('close', () => { childClosed = true })
417
+
418
+ while (Date.now() - startTime <= maxWaitMs && !spawnError && !childClosed) {
419
+ try {
420
+ const res = await fetch(`${WHISPER_SERVER_URL}/health`, { signal: AbortSignal.timeout(1000) })
421
+ if (res.ok) {
422
+ serverAvailable = true
423
+ const loadTime = ((Date.now() - startTime) / 1000).toFixed(1)
424
+ console.log(`[whisper-local] whisper-server ready on port ${WHISPER_SERVER_PORT} (loaded in ${loadTime}s)`)
425
+ return
282
426
  }
283
- })
284
- })
427
+ } catch {
428
+ // Model is still loading.
429
+ }
430
+ if (!spawnError && !childClosed && Date.now() - startTime <= maxWaitMs) {
431
+ await sleep(pollIntervalMs)
432
+ }
433
+ }
434
+
435
+ // Event-listener assignment is opaque to TypeScript's control-flow analysis.
436
+ const caughtSpawnError = spawnError as Error | null
437
+ const failure = caughtSpawnError
438
+ ? `whisper-server failed to start: ${caughtSpawnError.message}`
439
+ : childClosed
440
+ ? 'whisper-server exited before becoming healthy'
441
+ : `whisper-server startup timeout (${maxWaitMs / 1000}s)`
442
+ console.error(`[whisper-local] ${failure} — reaping child and keeping local backend unavailable`)
443
+ await killAndReapWhisperProcesses()
444
+ await proveWhisperPortClear()
445
+ throw new Error(failure)
285
446
  }
286
447
 
287
448
  /**
@@ -671,7 +832,7 @@ export async function transcribeLocal(audioBuffer: Buffer, context?: string, isQ
671
832
  if (serverConsecutiveFailures >= SERVER_FAILURE_THRESHOLD && !serverRestarting) {
672
833
  console.error(`[whisper-local] ⚠ CIRCUIT BREAKER OPEN — ${serverConsecutiveFailures} consecutive failures. Auto-restarting server...`)
673
834
  // Non-blocking restart in background
674
- restartWhisperServer()
835
+ void restartWhisperServer()
675
836
  } else if (serverConsecutiveFailures < SERVER_FAILURE_THRESHOLD) {
676
837
  console.warn(`[whisper-local] Server failed (${serverConsecutiveFailures}/${SERVER_FAILURE_THRESHOLD} before restart): ${err.message}`)
677
838
  }
@@ -687,7 +848,7 @@ export async function transcribeLocal(audioBuffer: Buffer, context?: string, isQ
687
848
  serverConsecutiveFailures++
688
849
  if (serverConsecutiveFailures >= SERVER_FAILURE_THRESHOLD && !serverRestarting) {
689
850
  console.error(`[whisper-local] CIRCUIT BREAKER OPEN — ${serverConsecutiveFailures} consecutive failures (server unavailable). Auto-restarting...`)
690
- restartWhisperServer()
851
+ void restartWhisperServer()
691
852
  }
692
853
 
693
854
  // Throw so the caller applies the configured recovery policy. CLI is
@@ -700,48 +861,46 @@ export async function transcribeLocal(audioBuffer: Buffer, context?: string, isQ
700
861
  * Non-blocking — runs in background while callers preserve audio or apply the
701
862
  * explicitly configured fallback policy.
702
863
  */
703
- async function restartWhisperServer(): Promise<void> {
704
- if (serverRestarting) return
705
- serverRestarting = true
864
+ export async function restartWhisperServer(): Promise<WhisperRestartResult> {
865
+ if (serverRestartPromise) return serverRestartPromise
706
866
 
707
- try {
708
- // Kill any existing server process
709
- if (serverProcess) {
710
- try { serverProcess.kill('SIGKILL') } catch {}
711
- serverProcess = null
712
- }
713
- // Also kill any zombie processes
867
+ serverRestarting = true
868
+ const priorStart = serverStartPromise
869
+ const operation = (async (): Promise<WhisperRestartResult> => {
714
870
  try {
715
- execSync('pkill -9 -f "whisper-server"', { stdio: 'ignore' })
716
- } catch { /* none running */ }
717
-
718
- // Wait for port to clear
719
- for (let i = 0; i < 6; i++) {
720
- try {
721
- execSync('lsof -i :8178 -t', { stdio: 'ignore' })
722
- await new Promise(r => setTimeout(r, 500))
723
- } catch {
724
- break
871
+ // A restart requested during model load runs immediately after that single
872
+ // start attempt completes, then owns the lifecycle until recovery finishes.
873
+ if (priorStart) {
874
+ try { await priorStart } catch { /* restart performs its own clean recovery */ }
725
875
  }
726
- }
727
876
 
728
- console.log('[whisper-local] Restarting whisper-server (model load ~20s)...')
729
- await startWhisperServer()
877
+ await killAndReapWhisperProcesses()
878
+ await proveWhisperPortClear()
730
879
 
731
- if (serverAvailable) {
880
+ console.log('[whisper-local] Restarting whisper-server (model load ~20s)...')
881
+ await startWhisperServerAttempt(true)
882
+ if (!serverAvailable) {
883
+ throw new Error('whisper-server did not become healthy')
884
+ }
732
885
  serverConsecutiveFailures = 0
733
886
  console.log('[whisper-local] Server restarted successfully — circuit breaker CLOSED')
734
- } else {
735
- // Reset counter so the next N failures can trigger another restart attempt
736
- // Without this, the counter stays >= threshold but serverRestarting is false,
737
- // so every subsequent call would re-trigger restart in a tight loop
887
+ return { status: 'recovered' }
888
+ } catch (err: any) {
889
+ serverAvailable = false
890
+ // Preserve the existing retry cadence: one failed recovery consumes this
891
+ // breaker cycle, and the next three failed calls may request one new cycle.
738
892
  serverConsecutiveFailures = 0
739
- console.error('[whisper-local] Server restart failed reset counter, will retry after next 3 failures. Caller recovery policy remains active.')
893
+ const message = err?.message ?? String(err)
894
+ console.error(`[whisper-local] Server restart error: ${message} — will retry after next 3 failures`)
895
+ return { status: 'failed', error: message }
740
896
  }
741
- } catch (err: any) {
742
- serverConsecutiveFailures = 0 // Same reset — allow future retry cycle
743
- console.error(`[whisper-local] Server restart error: ${err.message} — will retry after next 3 failures`)
897
+ })()
898
+
899
+ serverRestartPromise = operation
900
+ try {
901
+ return await operation
744
902
  } finally {
903
+ if (serverRestartPromise === operation) serverRestartPromise = null
745
904
  serverRestarting = false
746
905
  }
747
906
  }
@@ -120,6 +120,16 @@ function publicSaveResponse(saved: SavedMeeting, replayed = false): Record<strin
120
120
  }
121
121
  }
122
122
 
123
+ function suppliedServerPin(
124
+ req: { get: (name: string) => string | undefined },
125
+ fallback: unknown,
126
+ ): string | null {
127
+ const header = req.get('X-COS-Server-Instance')?.trim() ?? ''
128
+ const secondary = typeof fallback === 'string' ? fallback.trim() : ''
129
+ if (header && secondary && header !== secondary) return '__conflict__'
130
+ return header || secondary || null
131
+ }
132
+
123
133
  export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router {
124
134
  const store = deps.store ?? getMeetingStore()
125
135
  const sessions = deps.sessions ?? defaultSessionSource
@@ -140,6 +150,11 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
140
150
  res.status(503).json({ error: 'Server identity unavailable', reason: 'server_identity_unavailable' })
141
151
  return
142
152
  }
153
+ const pin = suppliedServerPin(req, req.query.serverInstanceId)
154
+ if (pin && pin !== serverInstanceId) {
155
+ res.status(409).json({ error: 'Server identity mismatch', reason: 'server_instance_mismatch' })
156
+ return
157
+ }
143
158
  const saved = store.findBySessionId(sessionId)
144
159
  const live = getMeetingSessionStatus(sessionId)
145
160
  res.set('Cache-Control', 'private, no-store')
@@ -149,6 +164,10 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
149
164
  serverInstanceId,
150
165
  receivedRanges: live.receivedRanges,
151
166
  receivedCount: live.receivedCount,
167
+ asrCompletedRanges: live.asrCompletedRanges,
168
+ asrCompletedCount: live.asrCompletedCount,
169
+ canonicalRanges: live.canonicalRanges,
170
+ canonicalCount: live.canonicalCount,
152
171
  maxChunkIndex: live.maxChunkIndex,
153
172
  lastActivityAt: live.lastActivityAt,
154
173
  retainedUntil: saved ? null : live.retainedUntil,
@@ -177,6 +196,16 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
177
196
  res.status(400).json({ error: 'Invalid domain', reason: 'invalid_domain' })
178
197
  return
179
198
  }
199
+ const serverInstanceId = getServerInstanceId()
200
+ if (!serverInstanceId) {
201
+ res.status(503).json({ error: 'Server identity unavailable', reason: 'server_identity_unavailable' })
202
+ return
203
+ }
204
+ const pin = suppliedServerPin(req, body?.serverInstanceId)
205
+ if (pin && pin !== serverInstanceId) {
206
+ res.status(409).json({ error: 'Server identity mismatch', reason: 'server_instance_mismatch' })
207
+ return
208
+ }
180
209
 
181
210
  // A response can be lost after both durable files were committed. Find
182
211
  // the sidecar by session ID so client retry/restart is idempotent.
@@ -42,6 +42,7 @@ import {
42
42
  retainedUntilIso,
43
43
  type IndexRange,
44
44
  } from '../lib/local-first-meetings-contract.js'
45
+ import { getServerInstanceId } from '../lib/server-instance-id.js'
45
46
 
46
47
  function ensurePrivateDirectory(path: string): void {
47
48
  if (!existsSync(path)) mkdirSync(path, { recursive: true, mode: 0o700 })
@@ -209,6 +210,10 @@ interface TranscriptSession {
209
210
  // never arrived = a chunk lost in transit) is the only thing flagged as a gap.
210
211
  // Sorted, de-duplicated. See computeGapReport()/analyzeTranscriptGaps().
211
212
  receivedIndices?: number[]
213
+ /** Durable ASR terminal outcomes. Raw receive alone never implies completion. */
214
+ asrCompletedIndices?: number[]
215
+ /** Exact replay records for silent/filtered ASR completions. */
216
+ emptyCompletions?: Record<string, EmptyTranscriptCompletion>
212
217
  maxChunkIndex?: number
213
218
  /** Persisted idle-retention clock. Meeting date/duration still use startTime. */
214
219
  lastActivityAt: number
@@ -226,10 +231,37 @@ interface ClosedTranscriptSession {
226
231
  closedAt: number
227
232
  lastActivityAt: number
228
233
  receivedIndices: number[]
234
+ asrCompletedIndices: number[]
235
+ canonicalIndices: number[]
229
236
  maxChunkIndex: number
230
237
  reason: 'saved' | 'expired' | 'closed'
231
238
  }
232
239
 
240
+ interface EmptyTranscriptCompletion {
241
+ text: ''
242
+ speaker: string
243
+ elapsed: number
244
+ backend?: string
245
+ asrProvider?: string
246
+ fallbackReason?: string
247
+ audioSha256?: string
248
+ canonical: false
249
+ }
250
+
251
+ interface StreamChunkCompletionResponse {
252
+ text: string
253
+ speaker: string
254
+ chunkIndex: number
255
+ elapsed: number
256
+ sessionId: string
257
+ serverInstanceId: string
258
+ asrCompleted: true
259
+ canonical: boolean
260
+ backend?: string
261
+ asrProvider?: string
262
+ fallbackReason?: string
263
+ }
264
+
233
265
  const closedSessionRecords = new Map<string, ClosedTranscriptSession>()
234
266
 
235
267
  // Incremental chunk persistence — survive server restarts
@@ -249,6 +281,8 @@ function readClosedSessions(): Record<string, ClosedTranscriptSession> {
249
281
  closedAt: value,
250
282
  lastActivityAt: value,
251
283
  receivedIndices: [],
284
+ asrCompletedIndices: [],
285
+ canonicalIndices: [],
252
286
  maxChunkIndex: -1,
253
287
  reason: 'closed',
254
288
  }
@@ -269,8 +303,26 @@ function readClosedSessions(): Record<string, ClosedTranscriptSession> {
269
303
  const maxChunkIndex = typeof raw.maxChunkIndex === 'number' && Number.isInteger(raw.maxChunkIndex)
270
304
  ? raw.maxChunkIndex
271
305
  : (receivedIndices.at(-1) ?? -1)
306
+ const asrCompletedIndices = Array.isArray(raw.asrCompletedIndices)
307
+ ? Array.from(new Set(raw.asrCompletedIndices.filter(
308
+ (entry): entry is number => Number.isInteger(entry) && (entry as number) >= 0,
309
+ ))).sort((a, b) => a - b)
310
+ : []
311
+ const canonicalIndices = Array.isArray(raw.canonicalIndices)
312
+ ? Array.from(new Set(raw.canonicalIndices.filter(
313
+ (entry): entry is number => Number.isInteger(entry) && (entry as number) >= 0,
314
+ ))).sort((a, b) => a - b)
315
+ : []
272
316
  const reason = raw.reason === 'saved' || raw.reason === 'expired' ? raw.reason : 'closed'
273
- normalized[id] = { closedAt, lastActivityAt, receivedIndices, maxChunkIndex, reason }
317
+ normalized[id] = {
318
+ closedAt,
319
+ lastActivityAt,
320
+ receivedIndices,
321
+ asrCompletedIndices,
322
+ canonicalIndices,
323
+ maxChunkIndex,
324
+ reason,
325
+ }
274
326
  }
275
327
  return normalized
276
328
  } catch {
@@ -289,6 +341,8 @@ function persistClosedSessions(): void {
289
341
  closedAt: now,
290
342
  lastActivityAt: now,
291
343
  receivedIndices: [],
344
+ asrCompletedIndices: [],
345
+ canonicalIndices: [],
292
346
  maxChunkIndex: -1,
293
347
  reason: 'closed',
294
348
  }
@@ -345,6 +399,8 @@ function persistSessionRequired(sessionId: string): void {
345
399
  chunks: chunksIndexed.map(e => e.c),
346
400
  chunksIndexed,
347
401
  receivedIndices: session.receivedIndices ?? [],
402
+ asrCompletedIndices: session.asrCompletedIndices ?? [],
403
+ emptyCompletions: session.emptyCompletions ?? {},
348
404
  maxChunkIndex: session.maxChunkIndex ?? -1,
349
405
  providerCandidates: session.providerCandidates ?? {},
350
406
  })
@@ -387,10 +443,10 @@ function recoverSessions(): void {
387
443
  const chunks: TranscriptChunk[] = []
388
444
  if (indexed) {
389
445
  for (const e of indexed) {
390
- if (e && Number.isInteger(e.i) && e.i >= 0 && e.c) chunks[e.i] = e.c
446
+ if (e && Number.isInteger(e.i) && e.i >= 0 && e.c) chunks[e.i] = { ...e.c, canonical: true }
391
447
  }
392
448
  } else if (legacy) {
393
- for (let k = 0; k < legacy.length; k++) if (legacy[k]) chunks[k] = legacy[k]
449
+ for (let k = 0; k < legacy.length; k++) if (legacy[k]) chunks[k] = { ...legacy[k], canonical: true }
394
450
  }
395
451
  // Restore the received-index ledger. A legacy file (pre-feature)
396
452
  // has no ledger and no way to know whether a chunk was truly lost,
@@ -415,12 +471,36 @@ function recoverSessions(): void {
415
471
  for (let k = 0; k <= maxStored; k++) receivedIndices.push(k)
416
472
  maxChunkIndex = maxStored
417
473
  }
474
+ const emptyCompletions: Record<string, EmptyTranscriptCompletion> = {}
475
+ if (data.emptyCompletions && typeof data.emptyCompletions === 'object') {
476
+ for (const [key, value] of Object.entries(data.emptyCompletions as Record<string, unknown>)) {
477
+ if (!/^\d+$/.test(key) || !value || typeof value !== 'object') continue
478
+ const item = value as Partial<EmptyTranscriptCompletion>
479
+ if (item.text !== '' || typeof item.speaker !== 'string' || typeof item.elapsed !== 'number') continue
480
+ emptyCompletions[key] = { ...item, text: '', canonical: false } as EmptyTranscriptCompletion
481
+ }
482
+ }
483
+ const canonical: number[] = []
484
+ for (let k = 0; k < chunks.length; k++) if (chunks[k]?.text) canonical.push(k)
485
+ const asrCompletedIndices = Array.isArray(data.asrCompletedIndices)
486
+ ? Array.from(new Set(
487
+ (data.asrCompletedIndices as unknown[]).filter(
488
+ (n): n is number => Number.isInteger(n) && (n as number) >= 0,
489
+ ),
490
+ )).sort((a, b) => a - b)
491
+ : []
492
+ // Canonical chunks and durable empty completion records are always
493
+ // terminal ASR outcomes. Never infer completion from raw receive.
494
+ for (const index of canonical) insertSortedUnique(asrCompletedIndices, index)
495
+ for (const key of Object.keys(emptyCompletions)) insertSortedUnique(asrCompletedIndices, Number(key))
418
496
  const session: TranscriptSession = {
419
497
  chunks,
420
498
  startTime: data.startTime,
421
499
  title: data.title || '',
422
500
  lastActivityAt,
423
501
  receivedIndices,
502
+ asrCompletedIndices,
503
+ emptyCompletions,
424
504
  maxChunkIndex,
425
505
  providerCandidates: data.providerCandidates && typeof data.providerCandidates === 'object'
426
506
  ? data.providerCandidates
@@ -464,10 +544,23 @@ function recoverSessions(): void {
464
544
  const maxChunkIndex = typeof data.maxChunkIndex === 'number' && Number.isInteger(data.maxChunkIndex)
465
545
  ? data.maxChunkIndex
466
546
  : (receivedIndices.at(-1) ?? -1)
547
+ const canonicalIndices = Array.isArray(data.chunksIndexed)
548
+ ? (data.chunksIndexed as Array<{ i?: unknown; c?: TranscriptChunk }>)
549
+ .filter(entry => Number.isInteger(entry?.i) && (entry.i as number) >= 0 && entry.c?.text)
550
+ .map(entry => entry.i as number)
551
+ : []
552
+ const asrCompletedIndices = Array.isArray(data.asrCompletedIndices)
553
+ ? Array.from(new Set((data.asrCompletedIndices as unknown[]).filter(
554
+ (value): value is number => Number.isInteger(value) && (value as number) >= 0,
555
+ ))).sort((left, right) => left - right)
556
+ : [...canonicalIndices]
557
+ for (const index of canonicalIndices) insertSortedUnique(asrCompletedIndices, index)
467
558
  closedSessionRecords.set(data.sessionId, {
468
559
  closedAt: Date.now(),
469
560
  lastActivityAt,
470
561
  receivedIndices,
562
+ asrCompletedIndices,
563
+ canonicalIndices,
471
564
  maxChunkIndex,
472
565
  reason: 'expired',
473
566
  })
@@ -580,10 +673,22 @@ export function getSession(sessionId: string): TranscriptSession {
580
673
  let session = sessions.get(sessionId)
581
674
  if (!session) {
582
675
  const now = Date.now()
583
- session = { chunks: [], startTime: now, lastActivityAt: now, title: '', providerCandidates: {} }
676
+ session = {
677
+ chunks: [],
678
+ startTime: now,
679
+ lastActivityAt: now,
680
+ title: '',
681
+ providerCandidates: {},
682
+ receivedIndices: [],
683
+ asrCompletedIndices: [],
684
+ emptyCompletions: {},
685
+ }
584
686
  sessions.set(sessionId, session)
585
687
  }
586
688
  if (!session.providerCandidates) session.providerCandidates = {}
689
+ if (!session.receivedIndices) session.receivedIndices = []
690
+ if (!session.asrCompletedIndices) session.asrCompletedIndices = []
691
+ if (!session.emptyCompletions) session.emptyCompletions = {}
587
692
  return session
588
693
  }
589
694
 
@@ -616,6 +721,20 @@ function recordReceivedChunk(session: TranscriptSession, chunkIndex: number): vo
616
721
  }
617
722
  }
618
723
 
724
+ function recordAsrCompleted(session: TranscriptSession, chunkIndex: number): void {
725
+ if (!Number.isInteger(chunkIndex) || chunkIndex < 0) return
726
+ if (!session.asrCompletedIndices) session.asrCompletedIndices = []
727
+ insertSortedUnique(session.asrCompletedIndices, chunkIndex)
728
+ }
729
+
730
+ function canonicalIndices(session: TranscriptSession): number[] {
731
+ const result: number[] = []
732
+ for (let index = 0; index < session.chunks.length; index++) {
733
+ if (session.chunks[index]?.text) result.push(index)
734
+ }
735
+ return result
736
+ }
737
+
619
738
  export interface TranscriptGapReport {
620
739
  received: number // distinct chunk indices the server got
621
740
  stored: number // chunks that survived filtering (have text)
@@ -768,6 +887,10 @@ export interface MeetingSessionStatusSnapshot {
768
887
  state: 'active' | 'closed' | 'missing'
769
888
  receivedRanges: IndexRange[]
770
889
  receivedCount: number
890
+ asrCompletedRanges: IndexRange[]
891
+ asrCompletedCount: number
892
+ canonicalRanges: IndexRange[]
893
+ canonicalCount: number
771
894
  maxChunkIndex: number
772
895
  lastActivityAt: string | null
773
896
  retainedUntil: string | null
@@ -777,10 +900,16 @@ export function getMeetingSessionStatus(sessionId: string): MeetingSessionStatus
777
900
  const active = sessions.get(sessionId)
778
901
  if (active) {
779
902
  const received = active.receivedIndices ?? []
903
+ const asrCompleted = active.asrCompletedIndices ?? []
904
+ const canonical = canonicalIndices(active)
780
905
  return {
781
906
  state: 'active',
782
907
  receivedRanges: compressIndexRanges(received),
783
908
  receivedCount: received.length,
909
+ asrCompletedRanges: compressIndexRanges(asrCompleted),
910
+ asrCompletedCount: asrCompleted.length,
911
+ canonicalRanges: compressIndexRanges(canonical),
912
+ canonicalCount: canonical.length,
784
913
  maxChunkIndex: active.maxChunkIndex ?? (received.at(-1) ?? -1),
785
914
  lastActivityAt: new Date(active.lastActivityAt).toISOString(),
786
915
  retainedUntil: retainedUntilIso(active.lastActivityAt),
@@ -792,6 +921,10 @@ export function getMeetingSessionStatus(sessionId: string): MeetingSessionStatus
792
921
  state: 'closed',
793
922
  receivedRanges: compressIndexRanges(closed.receivedIndices),
794
923
  receivedCount: closed.receivedIndices.length,
924
+ asrCompletedRanges: compressIndexRanges(closed.asrCompletedIndices),
925
+ asrCompletedCount: closed.asrCompletedIndices.length,
926
+ canonicalRanges: compressIndexRanges(closed.canonicalIndices),
927
+ canonicalCount: closed.canonicalIndices.length,
795
928
  maxChunkIndex: closed.maxChunkIndex,
796
929
  lastActivityAt: new Date(closed.lastActivityAt).toISOString(),
797
930
  retainedUntil: new Date(closed.closedAt + CLOSED_SESSION_TTL_MS).toISOString(),
@@ -801,6 +934,10 @@ export function getMeetingSessionStatus(sessionId: string): MeetingSessionStatus
801
934
  state: 'missing',
802
935
  receivedRanges: [],
803
936
  receivedCount: 0,
937
+ asrCompletedRanges: [],
938
+ asrCompletedCount: 0,
939
+ canonicalRanges: [],
940
+ canonicalCount: 0,
804
941
  maxChunkIndex: -1,
805
942
  lastActivityAt: null,
806
943
  retainedUntil: null,
@@ -815,11 +952,15 @@ function closeTranscriptSession(
815
952
  const session = sessions.get(sessionId)
816
953
  const now = Date.now()
817
954
  const receivedIndices = [...(session?.receivedIndices ?? [])]
955
+ const asrCompletedIndices = [...(session?.asrCompletedIndices ?? [])]
956
+ const canonical = session ? canonicalIndices(session) : []
818
957
  const maxChunkIndex = session?.maxChunkIndex ?? (receivedIndices.at(-1) ?? -1)
819
958
  closedSessionRecords.set(sessionId, {
820
959
  closedAt: now,
821
960
  lastActivityAt: session?.lastActivityAt ?? now,
822
961
  receivedIndices,
962
+ asrCompletedIndices,
963
+ canonicalIndices: canonical,
823
964
  maxChunkIndex,
824
965
  reason,
825
966
  })
@@ -866,6 +1007,23 @@ function makeHttpError(status: number, message: string, reason?: string): Error
866
1007
  return err
867
1008
  }
868
1009
 
1010
+ function assertPinnedServerIdentity(headerValue: unknown, queryValue: unknown): string {
1011
+ const headerPin = typeof headerValue === 'string' ? headerValue.trim() : ''
1012
+ const queryPin = typeof queryValue === 'string' ? queryValue.trim() : ''
1013
+ if (headerPin && queryPin && headerPin !== queryPin) {
1014
+ throw makeHttpError(409, 'conflicting server identity pins', 'server_instance_mismatch')
1015
+ }
1016
+ const supplied = headerPin || queryPin
1017
+ const serverInstanceId = getServerInstanceId()
1018
+ // Omission is the legacy route contract. Capability-admitted clients pin.
1019
+ if (!supplied) return serverInstanceId ?? ''
1020
+ if (!serverInstanceId) throw makeHttpError(503, 'server identity unavailable', 'server_identity_unavailable')
1021
+ if (supplied !== serverInstanceId) {
1022
+ throw makeHttpError(409, 'server identity mismatch', 'server_instance_mismatch')
1023
+ }
1024
+ return serverInstanceId
1025
+ }
1026
+
869
1027
  function validateSessionId(sessionId: string): void {
870
1028
  if (!/^[A-Za-z0-9:_-]{3,96}$/.test(sessionId)) {
871
1029
  throw makeHttpError(400, 'invalid sessionId', 'invalid_session_id')
@@ -1033,19 +1191,39 @@ function canonicalChunkResponse(
1033
1191
  existing: TranscriptChunk,
1034
1192
  sessionId: string,
1035
1193
  chunkIndex: number,
1036
- ): { text: string; speaker: string; chunkIndex: number; elapsed: number; sessionId: string; backend?: string; asrProvider?: string; fallbackReason?: string } {
1194
+ ): StreamChunkCompletionResponse {
1195
+ const serverInstanceId = getServerInstanceId() ?? ''
1037
1196
  return {
1038
1197
  text: existing.text,
1039
1198
  speaker: existing.speaker,
1040
1199
  chunkIndex,
1041
1200
  elapsed: existing.elapsed,
1042
1201
  sessionId,
1202
+ serverInstanceId,
1203
+ asrCompleted: true,
1204
+ canonical: true,
1043
1205
  backend: existing.backend,
1044
1206
  asrProvider: existing.asrProvider,
1045
1207
  fallbackReason: existing.fallbackReason,
1046
1208
  }
1047
1209
  }
1048
1210
 
1211
+ function emptyChunkResponse(
1212
+ existing: EmptyTranscriptCompletion,
1213
+ sessionId: string,
1214
+ chunkIndex: number,
1215
+ ): StreamChunkCompletionResponse {
1216
+ const serverInstanceId = getServerInstanceId() ?? ''
1217
+ return {
1218
+ ...existing,
1219
+ chunkIndex,
1220
+ sessionId,
1221
+ serverInstanceId,
1222
+ asrCompleted: true,
1223
+ canonical: false,
1224
+ }
1225
+ }
1226
+
1049
1227
  async function transcribeWithServerWhisper(audioBuffer: Buffer, whisperAudio: Buffer, whisperContext: string, isQuiet: boolean): Promise<{ text: string; words?: WhisperWord[]; backend: string }> {
1050
1228
  // The worker owns reconciliation of stale health. Always attempt local ASR
1051
1229
  // once; an availability snapshot must not divert meeting audio to cloud.
@@ -1152,7 +1330,7 @@ async function processStreamChunk(opts: {
1152
1330
  clientElapsed?: number
1153
1331
  /** Original client recording start, applied only before canonical chunks. */
1154
1332
  startTimeOverride?: number
1155
- }): Promise<{ text: string; speaker: string; chunkIndex: number; elapsed: number; sessionId: string; backend?: string; asrProvider?: string; fallbackReason?: string }> {
1333
+ }): Promise<StreamChunkCompletionResponse> {
1156
1334
  const { sessionId, chunkIndex, clientSpeaker, audioBuffer, candidate } = opts
1157
1335
  const tReq = performance.now()
1158
1336
  validateSessionId(sessionId)
@@ -1166,6 +1344,7 @@ async function processStreamChunk(opts: {
1166
1344
  session.startTime = opts.startTimeOverride
1167
1345
  }
1168
1346
  const alreadyCanonical = session.chunks[chunkIndex]
1347
+ const alreadyEmpty = session.emptyCompletions?.[String(chunkIndex)]
1169
1348
 
1170
1349
  let candidateRecordKey: string | undefined
1171
1350
  if (candidate) {
@@ -1186,9 +1365,10 @@ async function processStreamChunk(opts: {
1186
1365
 
1187
1366
  // Do not let late duplicate/replayed candidates replace canonical raw audio.
1188
1367
  // Batch re-transcription relies on chunk_000N.wav matching the accepted chunk.
1189
- if (alreadyCanonical?.canonical) {
1368
+ if (alreadyCanonical?.text) {
1190
1369
  session.lastActivityAt = Date.now()
1191
1370
  recordReceivedChunk(session, chunkIndex)
1371
+ recordAsrCompleted(session, chunkIndex)
1192
1372
  if (candidate && candidateRecordKey) {
1193
1373
  session.providerCandidates![candidateRecordKey].accepted =
1194
1374
  alreadyCanonical.asrProvider === 'iphone-whisperkit-beta' && alreadyCanonical.audioSha256 === audioSha256
@@ -1198,6 +1378,13 @@ async function processStreamChunk(opts: {
1198
1378
  persistSessionRequired(sessionId)
1199
1379
  return canonicalChunkResponse(alreadyCanonical, sessionId, chunkIndex)
1200
1380
  }
1381
+ if (alreadyEmpty) {
1382
+ session.lastActivityAt = Date.now()
1383
+ recordReceivedChunk(session, chunkIndex)
1384
+ recordAsrCompleted(session, chunkIndex)
1385
+ persistSessionRequired(sessionId)
1386
+ return emptyChunkResponse(alreadyEmpty, sessionId, chunkIndex)
1387
+ }
1201
1388
 
1202
1389
  await persistRawSessionAudioChunk(sessionId, chunkIndex, audioBuffer)
1203
1390
  // Commit the received-index ledger only after the canonical raw WAV is
@@ -1276,8 +1463,21 @@ async function processStreamChunk(opts: {
1276
1463
  session.providerCandidates[candidateRecordKey].accepted = false
1277
1464
  session.providerCandidates[candidateRecordKey].fallbackReason = sanitized.fallbackReason || fallbackReason || 'empty'
1278
1465
  }
1466
+ const emptyCompletion: EmptyTranscriptCompletion = {
1467
+ text: '',
1468
+ speaker: clientSpeaker,
1469
+ elapsed,
1470
+ backend,
1471
+ asrProvider,
1472
+ fallbackReason: sanitized.fallbackReason || fallbackReason,
1473
+ audioSha256,
1474
+ canonical: false,
1475
+ }
1476
+ session.emptyCompletions ??= {}
1477
+ session.emptyCompletions[String(chunkIndex)] = emptyCompletion
1478
+ recordAsrCompleted(session, chunkIndex)
1279
1479
  persistSessionRequired(sessionId)
1280
- return { text: '', speaker: clientSpeaker, chunkIndex, elapsed, sessionId, backend, asrProvider, fallbackReason: sanitized.fallbackReason || fallbackReason }
1480
+ return emptyChunkResponse(emptyCompletion, sessionId, chunkIndex)
1281
1481
  }
1282
1482
 
1283
1483
  const chunk: TranscriptChunk = {
@@ -1296,7 +1496,7 @@ async function processStreamChunk(opts: {
1296
1496
  canonical: true,
1297
1497
  }
1298
1498
  const finalExisting = session.chunks[chunkIndex]
1299
- if (finalExisting?.canonical) {
1499
+ if (finalExisting?.text) {
1300
1500
  if (candidate && candidateRecordKey && session.providerCandidates?.[candidateRecordKey]) {
1301
1501
  session.providerCandidates[candidateRecordKey].accepted =
1302
1502
  finalExisting.asrProvider === 'iphone-whisperkit-beta' && finalExisting.audioSha256 === audioSha256
@@ -1304,10 +1504,12 @@ async function processStreamChunk(opts: {
1304
1504
  session.providerCandidates[candidateRecordKey].accepted ? undefined : 'canonical_exists'
1305
1505
  }
1306
1506
  session.lastActivityAt = Date.now()
1507
+ recordAsrCompleted(session, chunkIndex)
1307
1508
  persistSessionRequired(sessionId)
1308
1509
  return canonicalChunkResponse(finalExisting, sessionId, chunkIndex)
1309
1510
  }
1310
1511
  session.chunks[chunkIndex] = chunk
1512
+ recordAsrCompleted(session, chunkIndex)
1311
1513
  if (candidate && candidateRecordKey) {
1312
1514
  if (session.providerCandidates?.[candidateRecordKey]) {
1313
1515
  session.providerCandidates[candidateRecordKey].accepted = asrProvider === 'iphone-whisperkit-beta'
@@ -1322,7 +1524,7 @@ async function processStreamChunk(opts: {
1322
1524
  emitDisplay({ type: 'transcript_chunk', data: { text: trimmedText, speaker, chunkIndex, elapsed, sessionId } })
1323
1525
 
1324
1526
  console.log(`[perf] TOTAL request: ${(performance.now() - tReq).toFixed(1)}ms | chunk #${chunkIndex} | ${audioBuffer.length}b | rms=${Math.round(rms)} q=${isQuiet ? 1 : 0} | ${asrProvider} | "${trimmedText.slice(0, 50)}"`)
1325
- return { text: trimmedText, speaker, chunkIndex, elapsed, sessionId, backend, asrProvider, fallbackReason }
1527
+ return canonicalChunkResponse(chunk, sessionId, chunkIndex)
1326
1528
  }
1327
1529
 
1328
1530
  function sendStreamError(res: { status: (code: number) => { json: (body: unknown) => unknown } }, err: unknown): unknown {
@@ -1349,6 +1551,8 @@ function sendStreamError(res: { status: (code: number) => { json: (body: unknown
1349
1551
 
1350
1552
  transcribeStreamRouter.post('/transcribe-stream', async (req, res) => {
1351
1553
  try {
1554
+ // Reject a wrong Mac before consuming or persisting any upload bytes.
1555
+ assertPinnedServerIdentity(req.get('X-COS-Server-Instance'), req.query.serverInstanceId)
1352
1556
  const sessionId = (req.query.sessionId as string) || `g2_${Date.now()}`
1353
1557
  const chunkIndex = parseInt((req.query.chunkIndex as string) || '0', 10)
1354
1558
  const clientSpeaker = (req.query.speaker as string) || 'Unknown'