@gotcos/glasses-server 6.18.0 → 6.18.1

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
@@ -117,6 +117,24 @@ BIND_HOST=0.0.0.0
117
117
  # directory contains .telegram_config.json. Enable export explicitly:
118
118
  # COS_TELEGRAM_NOTIFICATIONS=1
119
119
 
120
+ # ── POST-MEETING HQ TRANSCRIPTION DEVICE (optional) ──────────────────────
121
+ # After a meeting saves, the server re-transcribes it with whisper-cli
122
+ # large-v3 ("polish"). Since 6.14.1 that batch has run on CPU (-ng) so it can
123
+ # never fight a live meeting's ASR for the GPU — going meeting-to-meeting, the
124
+ # polish of meeting A overlaps the live capture of meeting B, and two Metal
125
+ # workloads degrade each other. The tax is that idle polish is slow too.
126
+ #
127
+ # COS_BATCH_HQ_METAL=1 opts into GPU-when-idle: each segment picks Metal only
128
+ # when nothing live is contending, and a meeting starting mid-batch preempts
129
+ # the GPU immediately (the interrupted segment is discarded and retried on CPU
130
+ # — a truncated transcript is never saved). Default is OFF; leave it unset
131
+ # until you have run a meeting-to-meeting smoke on your own machine.
132
+ # COS_BATCH_HQ_METAL=1
133
+ #
134
+ # COS_BATCH_HQ_FORCE_CPU=1 is the blunt rollback. It wins over everything and
135
+ # keeps working if the default ever flips to Metal-on.
136
+ # COS_BATCH_HQ_FORCE_CPU=1
137
+
120
138
  # ── LIVE CUES (optional — requires the FULL COS PIPELINE above) ──────────
121
139
  # Live meeting coaching cues on the lens: transcript window -> Composer
122
140
  # planner -> Qdrant -> LightRAG -> Composer insight -> coaching_nudge.
package/CHANGELOG.md CHANGED
@@ -1,3 +1,31 @@
1
+ ## 6.18.1
2
+
3
+ - **Post-meeting HQ polish can use the GPU when nothing live needs it — opt-in.**
4
+ 6.14.1 stopped batch polish from fighting a live meeting for Metal by pinning
5
+ it to CPU forever, which also taxed every idle polish. The device is now
6
+ chosen per segment: Metal only when `COS_BATCH_HQ_METAL=1` **and** nothing
7
+ live is contending. **Default is unchanged (always CPU)** until a
8
+ meeting-to-meeting smoke passes on real hardware; `COS_BATCH_HQ_FORCE_CPU=1`
9
+ remains the blunt rollback and wins over everything.
10
+ - **Live always wins the GPU, and a preempted segment is never half-saved.**
11
+ A meeting starting mid-batch preempts the Metal child two ways: on new
12
+ session creation (create only — an ordinary status read of a stale session
13
+ must not evict a healthy batch) and on `recording_chunk` lease acquire, which
14
+ covers recovery/reconnect paths that skip creation. The interrupted output is
15
+ **discarded unconditionally** — checked before the exit code, because SIGTERM
16
+ can race to a zero exit with partial stdout — and the same segment is retried
17
+ once on CPU, which cannot itself be preempted. A truncated transcript is
18
+ never written into a saved meeting; slow or failed beats silently wrong.
19
+ - **Waiting is distinguished from wedging.** A session counts as live only if
20
+ it was active within 180s. A cold orphan cannot pin batch to CPU — an
21
+ `active-sessions` entry sat untouched for 3+ hours on 2026-07-27, and an
22
+ "any session in the map" rule would have disabled the GPU path permanently
23
+ and silently. Prompt ASR and one-shot transcription count as contending
24
+ (same Metal family), and a second Metal batch never starts while one is in
25
+ flight. A failing liveness probe fails safe to CPU.
26
+ - Every batch segment logs `device`, `reason`, and `metalEnabled`, so "why was
27
+ polish slow today" is answerable after the fact.
28
+
1
29
  ## 6.18.0
2
30
 
3
31
  - **G2 save → operations sync restored on the managed public server.** After
@@ -18,7 +18,9 @@
18
18
  "COS_LIVE_CUES_MODEL",
19
19
  "COS_LIVE_CUES_GRAPH",
20
20
  "COS_LIVE_CUES_LIGHTRAG_RESERVE",
21
- "COS_LIVE_CUES_AUTO"
21
+ "COS_LIVE_CUES_AUTO",
22
+ "COS_BATCH_HQ_METAL",
23
+ "COS_BATCH_HQ_FORCE_CPU"
22
24
  ],
23
25
  "maintenance": {
24
26
  "scope": "cross_boot",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gotcos/glasses-server",
3
- "version": "6.18.0",
3
+ "version": "6.18.1",
4
4
  "description": "COS Glasses — 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": {
@@ -6,6 +6,7 @@ import { existsSync, readFileSync, readdirSync, utimesSync, writeFileSync } from
6
6
  import { join, resolve } from 'node:path'
7
7
  import { enhanceAudio } from './audio-enhance.js'
8
8
  import { transcribeHighQuality, type WhisperWord } from './whisper-local.js'
9
+ import { isMetalBatchPreempted } from './whisper-metal-gate.js'
9
10
  import type { IndexedTranscriptChunk } from '../routes/transcribe-stream.js'
10
11
  import {
11
12
  evaluateBatchQuality,
@@ -172,7 +173,26 @@ async function transcribeSegments(
172
173
  const combined = concatenateWavChunks(audioDir, segment.startChunkIdx, segment.endChunkIdx)
173
174
  const enhanced = await enhanceAudio(combined)
174
175
  const previousText = results.at(-1)?.text
175
- const result = await transcribeHighQuality(enhanced, previousText?.slice(-250), { priority: 'batch' })
176
+ let result
177
+ try {
178
+ result = await transcribeHighQuality(enhanced, previousText?.slice(-250), { priority: 'batch' })
179
+ } catch (error) {
180
+ // A live meeting took the GPU mid-segment. The truncated Metal output
181
+ // was already discarded upstream (BLOCKER contract) — it is never
182
+ // accepted. Retry this SAME segment once on CPU, which cannot itself be
183
+ // preempted, so a busy day degrades to slow rather than to a silently
184
+ // missing stretch of transcript.
185
+ if (!isMetalBatchPreempted(error)) throw error
186
+ console.log(
187
+ `[meeting-batch] Segment ${segment.startChunkIdx}-${segment.endChunkIdx} preempted off Metal; `
188
+ + 'retrying once on CPU',
189
+ )
190
+ refreshPendingLease(audioDir)
191
+ result = await transcribeHighQuality(enhanced, previousText?.slice(-250), {
192
+ priority: 'batch',
193
+ forceCpu: true,
194
+ })
195
+ }
176
196
  const text = previousText ? stripOverlap(result.text, previousText) : result.text
177
197
  const words = result.words ?? []
178
198
  results.push({
@@ -14,6 +14,13 @@ import { homedir } from 'node:os'
14
14
  import crypto from 'node:crypto'
15
15
  import { getVocabulary, getOwnerName } from './profile.js'
16
16
  import { stripBrandUrls } from './hallucination-filter.js'
17
+ import {
18
+ batchHqMetalEnabled,
19
+ chooseBatchDevice,
20
+ MetalBatchPreemptedError,
21
+ registerMetalBatchChild,
22
+ unregisterMetalBatchChild,
23
+ } from './whisper-metal-gate.js'
17
24
 
18
25
  // Prompt hardening flags (transcription quality, 2026-05-29):
19
26
  // COS_PROMPT_V2 — drop the trailing '.' on the vocab prompt and join prompt+context
@@ -697,7 +704,10 @@ export function parseWhisperCliFullJson(raw: string): { text: string; words: Whi
697
704
  export async function transcribeHighQuality(
698
705
  audioBuffer: Buffer,
699
706
  context?: string,
700
- opts: { priority?: 'interactive' | 'batch' } = {},
707
+ /** forceCpu: the batch pipeline's one CPU retry after a Metal preempt. It
708
+ * bypasses the gate entirely so the retry cannot itself be preempted into
709
+ * an infinite loop. */
710
+ opts: { priority?: 'interactive' | 'batch'; forceCpu?: boolean } = {},
701
711
  ): Promise<HighQualityTranscriptionResult> {
702
712
  if (!cliAvailable) {
703
713
  // Fall back to server (no beam search available via HTTP API)
@@ -727,22 +737,33 @@ export async function transcribeHighQuality(
727
737
  try {
728
738
  writeFileSync(tmpWav, audioBuffer)
729
739
 
740
+ // Interactive HQ keeps Metal unconditionally — a short, user-blocking decode
741
+ // that is explicitly OUT of batch device policy. Only the long post-meeting
742
+ // batch is admission-controlled against live ASR.
743
+ const isBatch = opts.priority === 'batch'
744
+ const decision: { device: 'metal' | 'cpu'; reason: string; metalEnabled: boolean } = isBatch
745
+ ? (opts.forceCpu
746
+ ? { device: 'cpu', reason: 'preempt_retry', metalEnabled: batchHqMetalEnabled() }
747
+ : chooseBatchDevice())
748
+ : { device: 'metal', reason: 'interactive', metalEnabled: batchHqMetalEnabled() }
749
+ const useMetal = decision.device === 'metal'
750
+
730
751
  const text = await new Promise<string>((resolve, reject) => {
731
- const isolateBatchFromLiveMetal = opts.priority === 'batch'
732
752
  // Interactive HQ: narrower beam (default 2) for latency. Meeting batch keeps 5.
733
753
  // Override: COS_HQ_BEAM_INTERACTIVE=N
734
754
  const interactiveBeamRaw = Number.parseInt(process.env.COS_HQ_BEAM_INTERACTIVE || '2', 10)
735
755
  const interactiveBeam = Number.isFinite(interactiveBeamRaw) && interactiveBeamRaw >= 1
736
756
  ? Math.min(interactiveBeamRaw, 5)
737
757
  : 2
738
- const beam = isolateBatchFromLiveMetal ? 5 : interactiveBeam
758
+ const beam = isBatch ? 5 : interactiveBeam
739
759
  const bestOf = beam
740
760
  const args = [
741
761
  '-m', modelPath,
742
762
  '-f', tmpWav,
743
- '-t', isolateBatchFromLiveMetal ? '8' : '16',
763
+ // CPU batch stays at 8 threads so it cannot starve live work of cores.
764
+ '-t', (isBatch && !useMetal) ? '8' : '16',
744
765
  '-l', 'en',
745
- ...(isolateBatchFromLiveMetal ? ['-ng'] : ['-fa']),
766
+ ...(useMetal ? ['-fa'] : ['-ng']),
746
767
  '-bs', String(beam),
747
768
  '-bo', String(bestOf),
748
769
  '--no-timestamps',
@@ -763,6 +784,16 @@ export async function transcribeHighQuality(
763
784
  })
764
785
  ownedHqChildren.add(proc)
765
786
 
787
+ // BLOCKER contract: a preempted Metal child is a HARD FAIL. Its stdout
788
+ // and its -ojf JSON are truncated mid-decode, and writing that into a
789
+ // saved meeting is silent transcript corruption — strictly worse than a
790
+ // slow or failed batch. We record the preempt BEFORE the signal lands so
791
+ // the close handler can never mistake a truncated run for a clean exit.
792
+ let preemptedReason: string | null = null
793
+ if (isBatch && useMetal) {
794
+ registerMetalBatchChild(proc, reason => { preemptedReason = reason })
795
+ }
796
+
766
797
  let stdout = ''
767
798
  let stderr = ''
768
799
  proc.stdout.on('data', (chunk: Buffer) => { stdout += chunk.toString() })
@@ -785,8 +816,16 @@ export async function transcribeHighQuality(
785
816
 
786
817
  proc.on('close', (code) => {
787
818
  ownedHqChildren.delete(proc)
819
+ unregisterMetalBatchChild(proc)
788
820
  clearTimeout(timeout)
789
821
  if (forceKill) clearTimeout(forceKill)
822
+ // Preempt is checked FIRST and ignores the exit code: SIGTERM often
823
+ // yields a non-zero code, but a race could also let the child exit 0
824
+ // with partial output. Either way the text is discarded.
825
+ if (preemptedReason) {
826
+ reject(new MetalBatchPreemptedError(preemptedReason))
827
+ return
828
+ }
790
829
  if (timedOut) {
791
830
  reject(new Error(`whisper-cli HQ timeout (${timeoutMs / 1000}s, model=${useLargeV3 ? 'large-v3' : 'turbo'})`))
792
831
  return
@@ -800,6 +839,7 @@ export async function transcribeHighQuality(
800
839
 
801
840
  proc.on('error', (err) => {
802
841
  ownedHqChildren.delete(proc)
842
+ unregisterMetalBatchChild(proc)
803
843
  clearTimeout(timeout)
804
844
  if (forceKill) clearTimeout(forceKill)
805
845
  reject(new Error(`whisper-cli HQ spawn error: ${err.message}`))
@@ -831,7 +871,10 @@ export async function transcribeHighQuality(
831
871
  console.log(
832
872
  `[whisper-hq] Batch transcribed in ${elapsed}ms ` +
833
873
  `(${modelTag}${useVad ? '+vad' : ''}${captureBatchWords ? '+words' : ''}` +
834
- `${words ? `, ${words.length} words` : ''}): ` +
874
+ `${words ? `, ${words.length} words` : ''}` +
875
+ // Device forensics: without these, "why is polish slow today" is
876
+ // unanswerable after the fact.
877
+ `${isBatch ? `, device=${decision.device} reason=${decision.reason} metalEnabled=${decision.metalEnabled}` : ''}): ` +
835
878
  `"${corrected.slice(0, 80)}${corrected.length > 80 ? '...' : ''}"`,
836
879
  )
837
880
  const metadata = useLargeV3
@@ -0,0 +1,207 @@
1
+ // GPU admission control for post-meeting batch HQ transcription.
2
+ //
3
+ // THE PROBLEM: Miles goes meeting -> meeting. Meeting A's post-save HQ polish
4
+ // (whisper-cli large-v3) often overlaps Meeting B's live ASR. Both want Metal.
5
+ // 6.14.1 fixed the clash bluntly by pinning batch to CPU forever, which taxes
6
+ // every idle polish to protect the overlap case.
7
+ //
8
+ // THE INVARIANT: never Metal+Metal. Live always wins admission. Batch still
9
+ // progresses on busy days, just on CPU. Metal is used only when the operator
10
+ // has opted in AND nothing live is contending.
11
+ //
12
+ // NO CIRCULAR IMPORT: transcribe-stream REGISTERS its live-activity probe here;
13
+ // this module never imports the stream, and whisper-local imports only this.
14
+
15
+ import type { ChildProcess } from 'node:child_process'
16
+ import { maintenanceLifecycle } from './maintenance-lifecycle.js'
17
+
18
+ /** Recent-activity window. A session counts as live only if it has been active
19
+ * inside this window. Anything colder is treated as an ORPHAN and must NOT pin
20
+ * batch to CPU — proven necessary: an active-sessions file sat untouched for
21
+ * 3+ hours on 2026-07-27, and an "any session in the map" rule would have
22
+ * pinned batch to CPU until a server restart, silently, forever. */
23
+ export const LIVE_ACTIVITY_WINDOW_MS = 180_000
24
+
25
+ /** Maintenance kinds that own the same Metal family as batch HQ. */
26
+ const METAL_FAMILY_WORK_KINDS = [
27
+ 'recording_chunk',
28
+ 'one_shot_transcription',
29
+ 'prompt_draft_warm',
30
+ 'prompt_draft_finalize',
31
+ ] as const
32
+
33
+ export type BatchDevice = 'metal' | 'cpu'
34
+
35
+ export type ContentionReason =
36
+ | 'session_recent'
37
+ | 'recording_chunk'
38
+ | 'one_shot_transcription'
39
+ | 'prompt_draft_warm'
40
+ | 'prompt_draft_finalize'
41
+ | 'metal_batch_in_flight'
42
+
43
+ export type DeviceReason =
44
+ | 'force_cpu'
45
+ | 'metal_opt_out'
46
+ | ContentionReason
47
+ | 'idle'
48
+
49
+ export interface BatchDeviceDecision {
50
+ device: BatchDevice
51
+ reason: DeviceReason
52
+ metalEnabled: boolean
53
+ }
54
+
55
+ // ── Env ──────────────────────────────────────────────────────────────────────
56
+
57
+ /** Blunt rollback. Wins over everything, and keeps working after the default
58
+ * eventually flips to Metal-on. */
59
+ export function batchHqForceCpu(): boolean {
60
+ return process.env.COS_BATCH_HQ_FORCE_CPU === '1'
61
+ }
62
+
63
+ /** Metal batch is OPT-IN (3B). Default stays always-CPU — today's proven
64
+ * behavior — until a meeting-to-meeting smoke passes. Read live, not at module
65
+ * load, so tests and Control-updated env are visible. */
66
+ export function batchHqMetalEnabled(): boolean {
67
+ return !batchHqForceCpu() && process.env.COS_BATCH_HQ_METAL === '1'
68
+ }
69
+
70
+ // ── Injected live-activity probe ─────────────────────────────────────────────
71
+
72
+ type LiveActivityProbe = () => number | null
73
+
74
+ let liveActivityProbe: LiveActivityProbe | null = null
75
+
76
+ /** transcribe-stream calls this at module load. Returns the most recent
77
+ * lastActivityAt across in-memory sessions, or null when there are none. */
78
+ export function registerLiveActivityProbe(probe: LiveActivityProbe): void {
79
+ liveActivityProbe = probe
80
+ }
81
+
82
+ /** Test seam — clears the probe and any tracked children. */
83
+ export function resetMetalGateForTests(): void {
84
+ liveActivityProbe = null
85
+ metalChildren.clear()
86
+ }
87
+
88
+ function recentSessionActivity(now: number): boolean {
89
+ if (!liveActivityProbe) return false
90
+ let last: number | null = null
91
+ try {
92
+ last = liveActivityProbe()
93
+ } catch {
94
+ // A probe failure must fail SAFE (assume contended): a wrong "idle" starts
95
+ // a Metal batch against a live meeting, which is the one thing this whole
96
+ // module exists to prevent. A wrong "busy" only costs batch speed.
97
+ return true
98
+ }
99
+ if (last == null || !Number.isFinite(last)) return false
100
+ return now - last < LIVE_ACTIVITY_WINDOW_MS
101
+ }
102
+
103
+ function activeMetalFamilyWork(): ContentionReason | null {
104
+ let byKind: Record<string, number>
105
+ try {
106
+ byKind = maintenanceLifecycle.snapshot().activeByKind as Record<string, number>
107
+ } catch {
108
+ return 'recording_chunk' // fail safe, same reasoning as above
109
+ }
110
+ for (const kind of METAL_FAMILY_WORK_KINDS) {
111
+ if ((byKind[kind] ?? 0) > 0) return kind
112
+ }
113
+ return null
114
+ }
115
+
116
+ /** Is something live currently entitled to Metal? */
117
+ export function isLiveMetalContended(now: number = Date.now()): { contended: boolean; reason: ContentionReason | null } {
118
+ const work = activeMetalFamilyWork()
119
+ if (work) return { contended: true, reason: work }
120
+ if (recentSessionActivity(now)) return { contended: true, reason: 'session_recent' }
121
+ if (metalChildren.size > 0) return { contended: true, reason: 'metal_batch_in_flight' }
122
+ return { contended: false, reason: null }
123
+ }
124
+
125
+ /** Device for the NEXT batch segment. Re-evaluated per segment so a meeting
126
+ * that starts mid-batch moves subsequent segments to CPU without a preempt. */
127
+ export function chooseBatchDevice(now: number = Date.now()): BatchDeviceDecision {
128
+ if (batchHqForceCpu()) return { device: 'cpu', reason: 'force_cpu', metalEnabled: false }
129
+ const metalEnabled = batchHqMetalEnabled()
130
+ if (!metalEnabled) return { device: 'cpu', reason: 'metal_opt_out', metalEnabled: false }
131
+ const { contended, reason } = isLiveMetalContended(now)
132
+ if (contended) return { device: 'cpu', reason: reason ?? 'session_recent', metalEnabled: true }
133
+ return { device: 'metal', reason: 'idle', metalEnabled: true }
134
+ }
135
+
136
+ // ── Metal child registry + preempt ───────────────────────────────────────────
137
+
138
+ interface MetalChildEntry {
139
+ proc: ChildProcess
140
+ /** Set by the owner so the close handler can DISCARD partial output rather
141
+ * than resolving a truncated transcript into a saved meeting. */
142
+ markPreempted: (reason: string) => void
143
+ escalate?: ReturnType<typeof setTimeout>
144
+ }
145
+
146
+ const metalChildren = new Map<ChildProcess, MetalChildEntry>()
147
+
148
+ /** whisper-local registers ONLY Metal-device HQ children. CPU children are
149
+ * deliberately absent: they do not contend for the GPU, so preempting them
150
+ * would slow batch for no benefit. */
151
+ export function registerMetalBatchChild(proc: ChildProcess, markPreempted: (reason: string) => void): void {
152
+ metalChildren.set(proc, { proc, markPreempted })
153
+ }
154
+
155
+ export function unregisterMetalBatchChild(proc: ChildProcess): void {
156
+ const entry = metalChildren.get(proc)
157
+ if (entry?.escalate) clearTimeout(entry.escalate)
158
+ metalChildren.delete(proc)
159
+ }
160
+
161
+ export function metalBatchInFlight(): boolean {
162
+ return metalChildren.size > 0
163
+ }
164
+
165
+ /**
166
+ * Live work needs the GPU: kill every in-flight Metal batch child.
167
+ *
168
+ * Idempotent — a child already preempted is skipped, so the create-path hook
169
+ * and the recording_chunk backstop can both fire without double-killing.
170
+ * Returns how many children were preempted (0 is the overwhelmingly common
171
+ * case: no Metal batch running, or Metal not opted in at all).
172
+ */
173
+ export function preemptMetalBatchForLive(reason: string): number {
174
+ if (metalChildren.size === 0) return 0
175
+ let preempted = 0
176
+ for (const entry of [...metalChildren.values()]) {
177
+ if (entry.escalate) continue // already preempted, escalation pending
178
+ preempted++
179
+ // Mark FIRST: the close handler must see the preempt flag before the signal
180
+ // lands, or it could treat a truncated run as a clean exit.
181
+ try { entry.markPreempted(reason) } catch { /* never block the kill */ }
182
+ try { entry.proc.kill('SIGTERM') } catch { /* already exited */ }
183
+ entry.escalate = setTimeout(() => {
184
+ try { entry.proc.kill('SIGKILL') } catch { /* already exited */ }
185
+ }, 2_000)
186
+ entry.escalate.unref?.()
187
+ }
188
+ if (preempted > 0) {
189
+ console.log(`[metal-gate] Preempted ${preempted} Metal batch child(ren) for live work (${reason})`)
190
+ }
191
+ return preempted
192
+ }
193
+
194
+ /** Distinguishable error so the batch pipeline can retry the SAME segment on
195
+ * CPU instead of treating it as a generic transcription failure. */
196
+ export class MetalBatchPreemptedError extends Error {
197
+ readonly preempted = true
198
+ constructor(readonly preemptReason: string) {
199
+ super(`Metal batch preempted by live work (${preemptReason})`)
200
+ this.name = 'MetalBatchPreemptedError'
201
+ }
202
+ }
203
+
204
+ export function isMetalBatchPreempted(error: unknown): error is MetalBatchPreemptedError {
205
+ return error instanceof MetalBatchPreemptedError
206
+ || (typeof error === 'object' && error !== null && (error as { preempted?: boolean }).preempted === true)
207
+ }
@@ -50,6 +50,34 @@ import {
50
50
  type MaintenanceWorkLease,
51
51
  } from '../lib/maintenance-lifecycle.js'
52
52
  import { feedLiveCueTranscript } from '../lib/live-cues-engine.js'
53
+ import { preemptMetalBatchForLive, registerLiveActivityProbe } from '../lib/whisper-metal-gate.js'
54
+
55
+ /** Preempt hook 2 of 2 (1C): the recording_chunk backstop. Live audio is
56
+ * arriving, so any Metal batch must yield the GPU now. This covers recovery,
57
+ * reconnect, and restart adoption — paths that reach chunk upload WITHOUT
58
+ * going through session creation. Idempotent with the create hook.
59
+ *
60
+ * Every recording_chunk lease goes through here rather than calling
61
+ * acquireMaintenanceWork directly, so a future call site cannot silently skip
62
+ * the preempt. Preempt runs BEFORE acquire so the GPU frees even if the drain
63
+ * gate rejects the lease. */
64
+ function acquireRecordingChunkLease(options: { allowDuringDrain?: boolean }): MaintenanceWorkLease {
65
+ preemptMetalBatchForLive('recording_chunk')
66
+ return acquireMaintenanceWork('recording_chunk', options)
67
+ }
68
+
69
+ /** Live-activity probe for the Metal gate (2B). Reports the most recent
70
+ * lastActivityAt across in-memory sessions; the gate applies its own
71
+ * 180s window so a cold orphan cannot pin batch to CPU. Registered here to
72
+ * keep the dependency one-way (stream -> gate), avoiding a circular import. */
73
+ registerLiveActivityProbe(() => {
74
+ let newest: number | null = null
75
+ for (const session of sessions.values()) {
76
+ const at = session.lastActivityAt
77
+ if (typeof at === 'number' && Number.isFinite(at) && (newest === null || at > newest)) newest = at
78
+ }
79
+ return newest
80
+ })
53
81
 
54
82
  function ensurePrivateDirectory(path: string): void {
55
83
  if (!existsSync(path)) mkdirSync(path, { recursive: true, mode: 0o700 })
@@ -710,6 +738,10 @@ export function getSession(sessionId: string): TranscriptSession {
710
738
  emptyCompletions: {},
711
739
  }
712
740
  sessions.set(sessionId, session)
741
+ // Preempt hook 1 of 2 (1C). CREATE ONLY — deliberately inside the `!session`
742
+ // branch, never on an ordinary getSession() touch, or a status read of a
743
+ // stale session would falsely evict a healthy Metal batch.
744
+ preemptMetalBatchForLive('session_create')
713
745
  }
714
746
  if (!session.providerCandidates) session.providerCandidates = {}
715
747
  if (!session.receivedIndices) session.receivedIndices = []
@@ -1586,7 +1618,7 @@ transcribeStreamRouter.post('/transcribe-stream', async (req, res) => {
1586
1618
  // Reject a wrong Mac before consuming or persisting any upload bytes.
1587
1619
  assertPinnedServerIdentity(req.get('X-COS-Server-Instance'), req.query.serverInstanceId)
1588
1620
  const sessionId = (req.query.sessionId as string) || `g2_${Date.now()}`
1589
- maintenanceLease = acquireMaintenanceWork('recording_chunk', {
1621
+ maintenanceLease = acquireRecordingChunkLease({
1590
1622
  allowDuringDrain: sessions.has(sessionId),
1591
1623
  })
1592
1624
  const chunkIndex = parseInt((req.query.chunkIndex as string) || '0', 10)
@@ -1622,7 +1654,7 @@ transcribeStreamRouter.post('/transcribe-stream/offline-sessions/start', async (
1622
1654
  const body = req.body ?? {}
1623
1655
  const sessionId = String(body.sessionId ?? '')
1624
1656
  validateSessionId(sessionId)
1625
- maintenanceLease = acquireMaintenanceWork('recording_chunk', {
1657
+ maintenanceLease = acquireRecordingChunkLease({
1626
1658
  allowDuringDrain: sessions.has(sessionId),
1627
1659
  })
1628
1660
  if (isSessionDeleted(sessionId)) throw makeHttpError(410, 'session is closed', 'session_closed')
@@ -1648,7 +1680,7 @@ transcribeStreamRouter.post('/transcribe-stream/offline-sessions/:sessionId/chun
1648
1680
  if (!isIosAsrCandidateEnabled()) throw makeHttpError(403, 'iPhone ASR candidates disabled', 'iphone_asr_disabled')
1649
1681
  const sessionId = String(req.params.sessionId ?? '')
1650
1682
  validateSessionId(sessionId)
1651
- maintenanceLease = acquireMaintenanceWork('recording_chunk', {
1683
+ maintenanceLease = acquireRecordingChunkLease({
1652
1684
  allowDuringDrain: sessions.has(sessionId),
1653
1685
  })
1654
1686
  if (isSessionDeleted(sessionId)) throw makeHttpError(410, 'session is closed', 'session_closed')
@@ -1694,7 +1726,7 @@ transcribeStreamRouter.post('/transcribe-stream/offline-sessions/:sessionId/fina
1694
1726
  if (!isIosAsrCandidateEnabled()) throw makeHttpError(403, 'iPhone ASR candidates disabled', 'iphone_asr_disabled')
1695
1727
  const sessionId = String(req.params.sessionId ?? '')
1696
1728
  validateSessionId(sessionId)
1697
- maintenanceLease = acquireMaintenanceWork('recording_chunk', {
1729
+ maintenanceLease = acquireRecordingChunkLease({
1698
1730
  allowDuringDrain: sessions.has(sessionId),
1699
1731
  })
1700
1732
  if (isSessionDeleted(sessionId)) throw makeHttpError(410, 'session is closed', 'session_closed')
@@ -1728,7 +1760,7 @@ transcribeStreamRouter.post('/transcribe-stream/candidates', async (req, res) =>
1728
1760
  const sessionId = String(body.sessionId ?? '')
1729
1761
  const chunkIndex = Number(body.chunkIndex)
1730
1762
  validateSessionId(sessionId)
1731
- maintenanceLease = acquireMaintenanceWork('recording_chunk', {
1763
+ maintenanceLease = acquireRecordingChunkLease({
1732
1764
  allowDuringDrain: sessions.has(sessionId),
1733
1765
  })
1734
1766
  validateChunkIndex(chunkIndex)