@gotcos/glasses-server 6.12.7 → 6.14.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.
@@ -0,0 +1,153 @@
1
+ // Transcription glossary — runtime-editable positive vocabulary, exact
2
+ // corrections, and negative cleanup rules. Persists into .cos-profile.json and
3
+ // busts the profile + decoder caches so edits take effect WITHOUT a server
4
+ // restart. Auto token-protected by the /api middleware (server/index.ts:124).
5
+ //
6
+ // GET /api/transcription-glossary → { vocabulary, corrections, negative_rules }
7
+ // PUT /api/transcription-glossary → same shape; PARTIAL (any omitted field is
8
+ // left unchanged). whisper_corrections is stored as a JSON STRING in the
9
+ // profile (legacy decoder contract), so the route encodes/decodes it here.
10
+
11
+ import { Router } from 'express'
12
+ import { errMsg } from '../lib/utils.js'
13
+ import {
14
+ getVocabulary,
15
+ getNegativeRules,
16
+ loadProfileField,
17
+ updateProfileFields,
18
+ } from '../lib/profile.js'
19
+ import { resetDecoderCaches } from '../lib/whisper-local.js'
20
+ import { resetVocabEchoCache } from '../lib/hallucination-filter.js'
21
+ import { validateNegativeRule } from '../lib/hallucination-filter.js'
22
+
23
+ export const glossaryRouter = Router()
24
+
25
+ const MAX_TERMS = 500
26
+ const MAX_TERM_LEN = 100
27
+ const MAX_RULES = 500
28
+
29
+ // Positive vocab is injected into the Whisper decoder prompt; URLs/emails/paths
30
+ // there induce ".com"/handle hallucinations on quiet audio — reject them.
31
+ function looksLikeUrlEmailPath(s: string): boolean {
32
+ return /(?:https?:\/\/|www\.)/i.test(s)
33
+ || /@[\w.-]+\.\w/.test(s)
34
+ || /\.(?:com|net|org|io|ai|co|gov|edu|app|dev)\b/i.test(s)
35
+ || /[/\\]/.test(s)
36
+ }
37
+
38
+ function readCorrections(): Record<string, string> {
39
+ try {
40
+ const raw = loadProfileField('whisper_corrections', '')
41
+ if (!raw) return {}
42
+ const parsed = JSON.parse(raw)
43
+ return (parsed && typeof parsed === 'object' && !Array.isArray(parsed))
44
+ ? parsed as Record<string, string>
45
+ : {}
46
+ } catch {
47
+ return {}
48
+ }
49
+ }
50
+
51
+ function currentGlossary() {
52
+ return {
53
+ vocabulary: getVocabulary(),
54
+ corrections: readCorrections(),
55
+ negative_rules: getNegativeRules(),
56
+ }
57
+ }
58
+
59
+ // Serialize PUTs so two writers can't clobber the read-modify-write. (The RMW in
60
+ // updateProfileFields is synchronous today, but this guards against future async
61
+ // drift and satisfies the write-lock contract.)
62
+ let putChain: Promise<unknown> = Promise.resolve()
63
+
64
+ glossaryRouter.get('/transcription-glossary', (_req, res) => {
65
+ try {
66
+ res.json(currentGlossary())
67
+ } catch (err) {
68
+ res.status(500).json({ error: errMsg(err) })
69
+ }
70
+ })
71
+
72
+ glossaryRouter.put('/transcription-glossary', async (req, res) => {
73
+ const body = req.body ?? {}
74
+ const patch: Record<string, unknown> = {}
75
+
76
+ // ── vocabulary (positive spellings → decoder prompt + fuzzy targets) ──
77
+ if (body.vocabulary !== undefined) {
78
+ if (!Array.isArray(body.vocabulary)) {
79
+ return res.status(400).json({ error: 'vocabulary must be an array of strings' })
80
+ }
81
+ if (body.vocabulary.length > MAX_TERMS) {
82
+ return res.status(400).json({ error: `too many vocabulary terms (max ${MAX_TERMS})` })
83
+ }
84
+ const vocab: string[] = []
85
+ for (const raw of body.vocabulary) {
86
+ if (typeof raw !== 'string') return res.status(400).json({ error: 'vocabulary entries must be strings' })
87
+ const term = raw.trim()
88
+ if (!term) continue
89
+ if (term.length > MAX_TERM_LEN) return res.status(400).json({ error: `vocabulary term too long: "${term.slice(0, 40)}…"` })
90
+ if (looksLikeUrlEmailPath(term)) {
91
+ return res.status(400).json({ error: `vocabulary cannot contain URLs/emails/paths: "${term}" — they induce hallucinations in the decoder prompt` })
92
+ }
93
+ vocab.push(term)
94
+ }
95
+ patch.vocabulary = vocab
96
+ }
97
+
98
+ // ── corrections (bad → good) — persisted as a JSON STRING ──
99
+ if (body.corrections !== undefined) {
100
+ if (typeof body.corrections !== 'object' || body.corrections === null || Array.isArray(body.corrections)) {
101
+ return res.status(400).json({ error: 'corrections must be an object { "bad": "good" }' })
102
+ }
103
+ const entries = Object.entries(body.corrections as Record<string, unknown>)
104
+ if (entries.length > MAX_TERMS) return res.status(400).json({ error: `too many corrections (max ${MAX_TERMS})` })
105
+ const map: Record<string, string> = {}
106
+ for (const [bad, good] of entries) {
107
+ const b = bad.trim()
108
+ if (!b) continue
109
+ if (typeof good !== 'string') return res.status(400).json({ error: `correction "${bad}" must map to a string` })
110
+ if (b.length > MAX_TERM_LEN || good.length > MAX_TERM_LEN) {
111
+ return res.status(400).json({ error: `correction too long near "${b.slice(0, 40)}"` })
112
+ }
113
+ map[b] = good.trim()
114
+ }
115
+ patch.whisper_corrections = JSON.stringify(map)
116
+ }
117
+
118
+ // ── negative rules (whole/strip/replace/flag) ──
119
+ if (body.negative_rules !== undefined) {
120
+ if (!Array.isArray(body.negative_rules)) {
121
+ return res.status(400).json({ error: 'negative_rules must be an array of strings' })
122
+ }
123
+ if (body.negative_rules.length > MAX_RULES) {
124
+ return res.status(400).json({ error: `too many negative rules (max ${MAX_RULES})` })
125
+ }
126
+ const rules: string[] = []
127
+ for (const raw of body.negative_rules) {
128
+ if (typeof raw !== 'string') return res.status(400).json({ error: 'negative_rules entries must be strings' })
129
+ const v = validateNegativeRule(raw)
130
+ if (!v.ok) return res.status(400).json({ error: `invalid rule "${raw.slice(0, 60)}": ${v.error}` })
131
+ rules.push(raw)
132
+ }
133
+ patch.negative_rules = rules
134
+ }
135
+
136
+ if (Object.keys(patch).length === 0) {
137
+ return res.status(400).json({ error: 'nothing to update (send vocabulary, corrections, and/or negative_rules)' })
138
+ }
139
+
140
+ try {
141
+ // Cache-bust chain: updateProfileFields() writes atomically + clears the ROOT
142
+ // profileCache; resetDecoderCaches() clears the two derived decoder snapshots.
143
+ // Both are required for an edit to reach the decoder without a restart.
144
+ await (putChain = putChain.catch(() => {}).then(() => {
145
+ updateProfileFields(patch)
146
+ resetDecoderCaches()
147
+ resetVocabEchoCache() // vocab matcher in hallucination-filter is profile-derived too
148
+ }))
149
+ return res.json(currentGlossary())
150
+ } catch (err) {
151
+ return res.status(500).json({ error: errMsg(err) })
152
+ }
153
+ })
@@ -0,0 +1,101 @@
1
+ import { Router } from 'express'
2
+ import {
3
+ buildHandoffPromptContext,
4
+ claimHandoff,
5
+ createHandoff,
6
+ getHandoff,
7
+ getLatestHandoff,
8
+ type HandoffCreateInput,
9
+ } from '../lib/handoff-store.js'
10
+ import { getAvailableCliSessionId } from '../lib/claude-bridge.js'
11
+ import { getCodexExecutionCwd, getCodexTrustMode } from '../lib/codex-run-ledger.js'
12
+ import { normalizeHandoffCode } from '../../shared/handoff-intent.js'
13
+
14
+ export const handoffsRouter = Router()
15
+
16
+ function runtimeExpiresAt(): string {
17
+ return new Date(Date.now() + 2 * 60 * 60_000).toISOString()
18
+ }
19
+
20
+ function enrichRuntime(body: Record<string, any>): HandoffCreateInput {
21
+ const input: HandoffCreateInput = { ...body }
22
+ const runtime = body.runtime && typeof body.runtime === 'object' ? { ...body.runtime } : {}
23
+
24
+ if (runtime.codex?.codexThreadId) {
25
+ runtime.codex = {
26
+ ...runtime.codex,
27
+ cwd: runtime.codex.cwd ?? getCodexExecutionCwd(),
28
+ trustMode: runtime.codex.trustMode ?? getCodexTrustMode(),
29
+ expiresAt: runtime.codex.expiresAt ?? runtimeExpiresAt(),
30
+ }
31
+ }
32
+
33
+ if (runtime.claude?.cliSessionId) {
34
+ runtime.claude = {
35
+ ...runtime.claude,
36
+ expiresAt: runtime.claude.expiresAt ?? runtimeExpiresAt(),
37
+ }
38
+ } else if (typeof body.sessionId === 'string') {
39
+ const cliSessionId = getAvailableCliSessionId(body.sessionId)
40
+ if (cliSessionId) {
41
+ runtime.claude = {
42
+ cliSessionId,
43
+ model: typeof body.model === 'string' ? body.model : undefined,
44
+ expiresAt: runtimeExpiresAt(),
45
+ }
46
+ }
47
+ }
48
+
49
+ if (runtime.codex || runtime.claude) input.runtime = runtime
50
+ return input
51
+ }
52
+
53
+ handoffsRouter.post('/handoffs', async (req, res) => {
54
+ try {
55
+ const body = req.body && typeof req.body === 'object' ? req.body : {}
56
+ const handoff = await createHandoff(enrichRuntime(body))
57
+ res.status(201).json({ handoff, context: buildHandoffPromptContext(handoff) })
58
+ } catch (err: any) {
59
+ res.status(500).json({ error: err?.message ?? 'handoff create failed' })
60
+ }
61
+ })
62
+
63
+ handoffsRouter.get('/handoffs/latest', async (req, res) => {
64
+ try {
65
+ const handoff = await getLatestHandoff({
66
+ source: typeof req.query.source === 'string' ? req.query.source : undefined,
67
+ target: typeof req.query.target === 'string' ? req.query.target : undefined,
68
+ createdBy: typeof req.query.createdBy === 'string' ? req.query.createdBy : undefined,
69
+ deviceId: typeof req.query.deviceId === 'string' ? req.query.deviceId : undefined,
70
+ })
71
+ if (!handoff) return res.status(404).json({ error: 'handoff not found' })
72
+ res.json({ handoff, context: buildHandoffPromptContext(handoff) })
73
+ } catch (err: any) {
74
+ res.status(500).json({ error: err?.message ?? 'handoff lookup failed' })
75
+ }
76
+ })
77
+
78
+ handoffsRouter.get('/handoffs/:code', async (req, res) => {
79
+ const code = normalizeHandoffCode(req.params.code)
80
+ if (!code) return res.status(404).json({ error: 'handoff not found' })
81
+ try {
82
+ const handoff = await getHandoff(code)
83
+ if (!handoff) return res.status(404).json({ error: 'handoff not found' })
84
+ res.json({ handoff, context: buildHandoffPromptContext(handoff) })
85
+ } catch (err: any) {
86
+ res.status(500).json({ error: err?.message ?? 'handoff lookup failed' })
87
+ }
88
+ })
89
+
90
+ handoffsRouter.post('/handoffs/:code/claim', async (req, res) => {
91
+ const code = normalizeHandoffCode(req.params.code)
92
+ if (!code) return res.status(404).json({ error: 'handoff not found' })
93
+ try {
94
+ const claimedBy = typeof req.body?.claimedBy === 'string' ? req.body.claimedBy : 'unknown'
95
+ const handoff = await claimHandoff(code, claimedBy)
96
+ if (!handoff) return res.status(404).json({ error: 'handoff not found' })
97
+ res.json({ handoff, context: buildHandoffPromptContext(handoff) })
98
+ } catch (err: any) {
99
+ res.status(500).json({ error: err?.message ?? 'handoff claim failed' })
100
+ }
101
+ })
@@ -21,6 +21,9 @@ import { durableQueryJobsCapability } from '../lib/query-job-feature.js'
21
21
  import { getQueryJobRuntimeHealth } from '../lib/query-job-runtime.js'
22
22
  import { getTranscriptionPolicySnapshot } from '../lib/transcription-policy.js'
23
23
  import { CLI_DEBUG_CAPABILITY } from '../lib/cli-debug-view.js'
24
+ import { managedRuntimeCapability, managedServerVersion } from '../lib/managed-runtime.js'
25
+ import { getServerGenerationId } from '../lib/managed-runtime.js'
26
+ import { maintenanceLifecycle } from '../lib/maintenance-lifecycle.js'
24
27
 
25
28
  export const healthRouter = Router()
26
29
 
@@ -145,12 +148,8 @@ healthRouter.get('/health', async (_req, res) => {
145
148
  const durableJobs = durableQueryJobStatus()
146
149
  const localFirstMeetings = localFirstMeetingsCapability(getServerInstanceId())
147
150
  const transcription = getTranscriptionPolicySnapshot()
148
- const recovery = {
149
- status: false,
150
- restartWhisper: false,
151
- restartServer: false,
152
- managed: false,
153
- }
151
+ const recovery = managedRuntimeCapability()
152
+ const maintenance = maintenanceLifecycle.snapshot()
154
153
  const features = {
155
154
  claude: claudeAvailable,
156
155
  codex: codexAvailable,
@@ -180,6 +179,10 @@ healthRouter.get('/health', async (_req, res) => {
180
179
  const codex_models = getCodexModelCatalogSnapshot()
181
180
  res.json({
182
181
  ...checks,
182
+ server_version: managedServerVersion(),
183
+ server_instance_id: getServerInstanceId(),
184
+ boot_id: serverMetrics.bootId,
185
+ generation_id: getServerGenerationId(),
183
186
  features,
184
187
  voice,
185
188
  whisper_health,
@@ -188,6 +191,11 @@ healthRouter.get('/health', async (_req, res) => {
188
191
  capabilities: {
189
192
  transcription,
190
193
  recovery,
194
+ maintenance: {
195
+ state: maintenance.state,
196
+ admissionsOpen: maintenance.admissionsOpen,
197
+ carriedAcrossBoot: maintenance.operation?.carriedAcrossBoot ?? false,
198
+ },
191
199
  cliDebug: CLI_DEBUG_CAPABILITY,
192
200
  ...(localFirstMeetings ? { localFirstMeetings } : {}),
193
201
  },
@@ -220,12 +228,7 @@ healthRouter.get('/models', async (req, res) => {
220
228
  },
221
229
  transcription,
222
230
  cliDebug: CLI_DEBUG_CAPABILITY,
223
- recovery: {
224
- status: false,
225
- restartWhisper: false,
226
- restartServer: false,
227
- managed: false,
228
- },
231
+ recovery: managedRuntimeCapability(),
229
232
  ...(localFirstMeetings ? { localFirstMeetings } : {}),
230
233
  },
231
234
  })
@@ -0,0 +1,160 @@
1
+ import { Router, type Request, type Response } from 'express'
2
+ import {
3
+ MaintenanceLifecycleError,
4
+ maintenanceErrorPayload,
5
+ maintenanceLifecycle,
6
+ type MaintenanceDrainRequest,
7
+ type MaintenanceOperationCredentials,
8
+ type MaintenanceOperationIdentity,
9
+ type MaintenanceOperationKind,
10
+ type MaintenanceOperationScope,
11
+ type MaintenancePostcondition,
12
+ } from '../lib/maintenance-lifecycle.js'
13
+ import { managedRuntimeCapability, managedServerVersion, getServerGenerationId } from '../lib/managed-runtime.js'
14
+ import { getQueryJobRuntimeHealth } from '../lib/query-job-runtime.js'
15
+ import { serverMetrics } from '../lib/server-metrics.js'
16
+ import { getServerInstanceId } from '../lib/server-instance-id.js'
17
+ import { getWhisperHealth } from '../lib/whisper-local.js'
18
+ import { getActiveTranscriptionSessionCount } from './transcribe-stream.js'
19
+
20
+ export const maintenanceRouter = Router()
21
+
22
+ function isLoopback(req: Request): boolean {
23
+ const address = req.socket.remoteAddress ?? ''
24
+ return address === '::1' || address === '127.0.0.1' || address.startsWith('127.')
25
+ || address.startsWith('::ffff:127.')
26
+ }
27
+
28
+ function bodyRecord(body: unknown): Record<string, unknown> {
29
+ return body && typeof body === 'object' ? body as Record<string, unknown> : {}
30
+ }
31
+
32
+ function operationIdentity(body: unknown): MaintenanceOperationIdentity {
33
+ const value = bodyRecord(body)
34
+ return {
35
+ serverInstanceId: typeof value.serverInstanceId === 'string' ? value.serverInstanceId : '',
36
+ bootId: typeof value.bootId === 'string' ? value.bootId : '',
37
+ generationId: typeof value.generationId === 'string' ? value.generationId : '',
38
+ operationId: typeof value.operationId === 'string' ? value.operationId : '',
39
+ }
40
+ }
41
+
42
+ function drainRequest(body: unknown): MaintenanceDrainRequest {
43
+ const value = bodyRecord(body)
44
+ return {
45
+ ...operationIdentity(value),
46
+ operationKind: value.operationKind as MaintenanceOperationKind,
47
+ scope: value.scope as MaintenanceOperationScope,
48
+ postcondition: value.postcondition as MaintenancePostcondition,
49
+ nonceSha256: typeof value.nonceSha256 === 'string' ? value.nonceSha256 : '',
50
+ authorizedSuccessorGenerations: Array.isArray(value.authorizedSuccessorGenerations)
51
+ ? value.authorizedSuccessorGenerations.filter((item): item is string => typeof item === 'string')
52
+ : [],
53
+ ...(typeof value.ttlMs === 'number' ? { ttlMs: value.ttlMs } : {}),
54
+ }
55
+ }
56
+
57
+ function operationCredentials(req: Request): MaintenanceOperationCredentials {
58
+ return {
59
+ leaseId: typeof req.headers['x-cos-maintenance-lease'] === 'string'
60
+ ? req.headers['x-cos-maintenance-lease']
61
+ : undefined,
62
+ operationId: typeof req.headers['x-cos-maintenance-operation'] === 'string'
63
+ ? req.headers['x-cos-maintenance-operation']
64
+ : undefined,
65
+ nonce: typeof req.headers['x-cos-maintenance-nonce'] === 'string'
66
+ ? req.headers['x-cos-maintenance-nonce']
67
+ : undefined,
68
+ }
69
+ }
70
+
71
+ function statusSnapshot(credentials: MaintenanceOperationCredentials = {}) {
72
+ const jobs = getQueryJobRuntimeHealth()
73
+ const activeTranscriptionSessions = getActiveTranscriptionSessionCount()
74
+ const managed = managedRuntimeCapability()
75
+ const tracked = maintenanceLifecycle.snapshot(credentials, {
76
+ recording_session: activeTranscriptionSessions,
77
+ })
78
+ const untrackedDurableRuns = Math.max(0, jobs.activeRuns - (tracked.activeByKind.durable_query ?? 0))
79
+ const lifecycle = untrackedDurableRuns > 0
80
+ ? maintenanceLifecycle.snapshot(credentials, {
81
+ durable_query_runtime: untrackedDurableRuns,
82
+ recording_session: activeTranscriptionSessions,
83
+ })
84
+ : tracked
85
+ return {
86
+ contractVersion: managed.contractVersion,
87
+ managed: managed.managed,
88
+ serverVersion: managedServerVersion(),
89
+ generationId: getServerGenerationId(),
90
+ serverInstanceId: getServerInstanceId(),
91
+ bootId: serverMetrics.bootId,
92
+ activeJobs: jobs.activeRuns,
93
+ activeTranscriptionSessions,
94
+ shuttingDown: jobs.shuttingDown,
95
+ durableStoreState: jobs.store.state,
96
+ lifecycle,
97
+ safeToRestart: lifecycle.safeToRestart && !jobs.shuttingDown,
98
+ whisper: getWhisperHealth(),
99
+ }
100
+ }
101
+
102
+ function sendLifecycleError(res: Response, error: unknown) {
103
+ if (error instanceof MaintenanceLifecycleError) {
104
+ if (error.retryAfterSeconds != null) res.setHeader('Retry-After', String(error.retryAfterSeconds))
105
+ res.status(error.status).json(maintenanceErrorPayload(error))
106
+ return
107
+ }
108
+ res.status(500).json({ error: 'maintenance_internal_error', retryable: false })
109
+ }
110
+
111
+ function requireLoopback(req: Request, res: Response): boolean {
112
+ if (isLoopback(req)) return true
113
+ res.status(403).json({ error: 'loopback_required', retryable: false })
114
+ return false
115
+ }
116
+
117
+ maintenanceRouter.get('/maintenance/status', (req, res) => {
118
+ res.json(statusSnapshot(operationCredentials(req)))
119
+ })
120
+
121
+ maintenanceRouter.post('/maintenance/drain', (req, res) => {
122
+ if (!requireLoopback(req, res)) return
123
+ try {
124
+ const leaseId = maintenanceLifecycle.beginDrain(drainRequest(req.body))
125
+ res.json({ leaseId, ...statusSnapshot() })
126
+ } catch (error) {
127
+ sendLifecycleError(res, error)
128
+ }
129
+ })
130
+
131
+ maintenanceRouter.post('/maintenance/drain/adopt', (req, res) => {
132
+ if (!requireLoopback(req, res)) return
133
+ const credentials = operationCredentials(req)
134
+ try {
135
+ maintenanceLifecycle.adoptDrain(operationIdentity(req.body), credentials)
136
+ res.json({ adopted: true, ...statusSnapshot(credentials) })
137
+ } catch (error) {
138
+ sendLifecycleError(res, error)
139
+ }
140
+ })
141
+
142
+ maintenanceRouter.post('/maintenance/drain/release', (req, res) => {
143
+ if (!requireLoopback(req, res)) return
144
+ try {
145
+ maintenanceLifecycle.releaseDrain(operationIdentity(req.body), operationCredentials(req))
146
+ res.json({ released: true, ...statusSnapshot() })
147
+ } catch (error) {
148
+ sendLifecycleError(res, error)
149
+ }
150
+ })
151
+
152
+ maintenanceRouter.post('/maintenance/drain/cancel', (req, res) => {
153
+ if (!requireLoopback(req, res)) return
154
+ try {
155
+ maintenanceLifecycle.cancelDrain(operationIdentity(req.body), operationCredentials(req))
156
+ res.json({ canceled: true, ...statusSnapshot() })
157
+ } catch (error) {
158
+ sendLifecycleError(res, error)
159
+ }
160
+ })
@@ -40,6 +40,7 @@ import {
40
40
  type TranscriptGapReport,
41
41
  } from './transcribe-stream.js'
42
42
  import { getServerInstanceId } from '../lib/server-instance-id.js'
43
+ import { acquireMaintenanceWork, type MaintenanceWorkLease } from '../lib/maintenance-lifecycle.js'
43
44
 
44
45
  interface MeetingSessionSource {
45
46
  getTranscript(sessionId: string): string | null
@@ -177,6 +178,7 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
177
178
 
178
179
  router.post('/meeting/save', async (req, res) => {
179
180
  let lockedSessionId: string | null = null
181
+ let maintenanceLease: MaintenanceWorkLease | undefined
180
182
  try {
181
183
  const body = req.body as Record<string, unknown> | undefined
182
184
  const sessionId = typeof body?.sessionId === 'string' ? body.sessionId : ''
@@ -219,6 +221,10 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
219
221
  res.status(409).json({ error: 'Meeting save already in progress', reason: 'save_in_progress' })
220
222
  return
221
223
  }
224
+ // Saving/finalizing an already-recording session is a drain
225
+ // continuation. It must remain admitted so existing audio can reach a
226
+ // durable terminal while all genuinely new work is closed.
227
+ maintenanceLease = acquireMaintenanceWork('meeting_save', { allowDuringDrain: true })
222
228
  savingSessions.add(sessionId)
223
229
  lockedSessionId = sessionId
224
230
 
@@ -300,20 +306,30 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
300
306
  res.json(publicSaveResponse(saved))
301
307
 
302
308
  if (audioWritesReady && pendingAudioDir && chunkEntries.length > 0) {
303
- const task = finalizeBatch({
304
- audioDir: pendingAudioDir,
305
- entries: chunkEntries.map(entry => ({ ...entry, chunk: { ...entry.chunk } })),
306
- streamingWordCount: countWords(transcript),
307
- meetingPath: saved.filepath,
308
- sidecarPath: saved.sidecarPath,
309
- runBatch,
309
+ // Acquire before the foreground save lease can leave scope: there is
310
+ // no zero-count proof gap between pending-audio handoff and the queued
311
+ // background finalizer.
312
+ const batchLease = acquireMaintenanceWork('meeting_batch_finalization', {
313
+ allowDuringDrain: true,
314
+ phase: 'queued',
315
+ })
316
+ const task = Promise.resolve().then(() => {
317
+ batchLease.setPhase('active')
318
+ return finalizeBatch({
319
+ audioDir: pendingAudioDir,
320
+ entries: chunkEntries.map(entry => ({ ...entry, chunk: { ...entry.chunk } })),
321
+ streamingWordCount: countWords(transcript),
322
+ meetingPath: saved.filepath,
323
+ sidecarPath: saved.sidecarPath,
324
+ runBatch,
325
+ })
310
326
  }).catch(error => {
311
327
  // Raw audio deliberately remains for the existing two-hour cleanup.
312
328
  console.error(
313
329
  `[meeting/save] Batch finalization failed for ${sessionId}: `
314
330
  + `${error instanceof Error ? error.message : String(error)}`,
315
331
  )
316
- })
332
+ }).finally(() => batchLease.release())
317
333
  scheduleBackground(task)
318
334
  }
319
335
  } catch (error) {
@@ -324,6 +340,7 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
324
340
  console.error('[meeting/save] Finalization failed:', error)
325
341
  res.status(500).json({ error: 'Meeting save failed', reason: 'meeting_save_error' })
326
342
  } finally {
343
+ maintenanceLease?.release()
327
344
  if (lockedSessionId) savingSessions.delete(lockedSessionId)
328
345
  }
329
346
  })