@gotcos/glasses-server 6.12.7 → 6.13.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,44 @@
1
+ export const MANAGED_RUNTIME_CONTRACT_VERSION = 2
2
+
3
+ export interface ManagedRuntimeCapability {
4
+ status: boolean
5
+ restartWhisper: boolean
6
+ restartServer: boolean
7
+ maintenanceDrain: boolean
8
+ lifecycleProof: boolean
9
+ managed: boolean
10
+ contractVersion: number
11
+ }
12
+
13
+ export function isManagedRuntime(): boolean {
14
+ return process.env.COS_MANAGED === '1'
15
+ }
16
+
17
+ export function managedRuntimeCapability(): ManagedRuntimeCapability {
18
+ const managed = isManagedRuntime()
19
+ return {
20
+ status: managed,
21
+ // Whisper lifecycle is private to the local controller. It is never
22
+ // exposed as a network-reachable mutation capability.
23
+ restartWhisper: false,
24
+ // Server restart is performed by the trusted local helper through launchd,
25
+ // never by an HTTP endpoint. This flag tells clients that managed recovery
26
+ // exists without widening the network attack surface.
27
+ restartServer: managed,
28
+ maintenanceDrain: managed,
29
+ lifecycleProof: managed,
30
+ managed,
31
+ contractVersion: MANAGED_RUNTIME_CONTRACT_VERSION,
32
+ }
33
+ }
34
+
35
+ export function managedServerVersion(): string | null {
36
+ const value = process.env.COS_SERVER_VERSION?.trim()
37
+ return value || null
38
+ }
39
+
40
+ /** Deployment generation expected by the trusted local controller. */
41
+ export function getServerGenerationId(): string | null {
42
+ const explicit = process.env.COS_SERVER_GENERATION_ID?.trim()
43
+ return explicit && /^[A-Za-z0-9._:-]{1,160}$/.test(explicit) ? explicit : null
44
+ }
@@ -16,6 +16,7 @@ import {
16
16
  type QueryJobSnapshot,
17
17
  type QueryJobStoreHealth,
18
18
  } from './query-job-types.js'
19
+ import type { MaintenanceWorkLease } from './maintenance-lifecycle.js'
19
20
 
20
21
  const CLIENT_JOB_ID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i
21
22
 
@@ -68,6 +69,9 @@ export interface QueryJobCoordinatorOptions {
68
69
  /** Idempotent projection of a terminal journal record into canonical
69
70
  * conversation state. The journal remains authoritative if this fails. */
70
71
  projectTerminal?: (job: QueryJobSnapshot, request: QueryJobRequest) => void | Promise<void>
72
+ /** Acquired synchronously before serialized admission and retained through
73
+ * the provider terminal, so maintenance proof covers queued transitions. */
74
+ acquireMaintenanceWork?: () => MaintenanceWorkLease
71
75
  }
72
76
 
73
77
  interface ActiveRun {
@@ -76,6 +80,7 @@ interface ActiveRun {
76
80
  request: QueryJobRequest
77
81
  controller: AbortController
78
82
  release?: () => void
83
+ maintenanceLease?: MaintenanceWorkLease
79
84
  released: boolean
80
85
  callbackTail: Promise<void>
81
86
  partialText: string
@@ -114,6 +119,7 @@ export class QueryJobCoordinator {
114
119
  private readonly partialFlushChars: number
115
120
  private readonly providerTimeoutMs: number
116
121
  private readonly active = new Map<string, ActiveRun>()
122
+ private readonly admittedMaintenance = new Map<string, MaintenanceWorkLease>()
117
123
  private admissionTail: Promise<void> = Promise.resolve()
118
124
  private shuttingDown = false
119
125
  private callbackPersistenceFailures = 0
@@ -148,6 +154,7 @@ export class QueryJobCoordinator {
148
154
  * two simultaneous retries without a sessionId cannot allocate two sessions
149
155
  * and conflict solely because the client had not learned the first one. */
150
156
  submit(raw: unknown): Promise<QueryJobAdmissionResult> {
157
+ const maintenanceLease = this.options.acquireMaintenanceWork?.()
151
158
  let resolve!: (value: QueryJobAdmissionResult) => void
152
159
  let reject!: (reason?: unknown) => void
153
160
  const result = new Promise<QueryJobAdmissionResult>((res, rej) => {
@@ -160,8 +167,14 @@ export class QueryJobCoordinator {
160
167
  const normalized = await this.assignSession(raw)
161
168
  const admission = await this.store.admit(normalized)
162
169
  resolve(admission)
163
- if (admission.created) queueMicrotask(() => { void this.execute(admission.job.jobId) })
170
+ if (admission.created) {
171
+ if (maintenanceLease) this.admittedMaintenance.set(admission.job.jobId, maintenanceLease)
172
+ queueMicrotask(() => { void this.execute(admission.job.jobId) })
173
+ } else {
174
+ maintenanceLease?.release()
175
+ }
164
176
  } catch (error) {
177
+ maintenanceLease?.release()
165
178
  reject(error)
166
179
  }
167
180
  })
@@ -186,14 +199,30 @@ export class QueryJobCoordinator {
186
199
  }
187
200
 
188
201
  private async execute(jobId: string): Promise<void> {
202
+ const maintenanceLease = this.admittedMaintenance.get(jobId)
203
+ this.admittedMaintenance.delete(jobId)
189
204
  const starting = await this.store.markStarting(jobId).catch(() => null)
190
- if (!starting?.applied) return
191
- const execution = await this.store.getExecution(jobId)
205
+ if (!starting?.applied) {
206
+ maintenanceLease?.release()
207
+ return
208
+ }
209
+ maintenanceLease?.setPhase('active')
210
+ let execution
211
+ try {
212
+ execution = await this.store.getExecution(jobId)
213
+ } catch {
214
+ maintenanceLease?.release()
215
+ return
216
+ }
192
217
  let release: (() => void) | undefined
193
218
  try {
194
219
  release = await this.options.acquireSessionLock?.(execution.request.sessionId)
195
220
  } catch (error) {
196
- await this.store.fail(jobId, error)
221
+ try {
222
+ await this.store.fail(jobId, error)
223
+ } finally {
224
+ maintenanceLease?.release()
225
+ }
197
226
  return
198
227
  }
199
228
 
@@ -207,6 +236,7 @@ export class QueryJobCoordinator {
207
236
  request: execution.request,
208
237
  controller: new AbortController(),
209
238
  release,
239
+ maintenanceLease,
210
240
  released: false,
211
241
  callbackTail: Promise.resolve(),
212
242
  partialText: '',
@@ -536,6 +566,7 @@ export class QueryJobCoordinator {
536
566
  if (!active.released) {
537
567
  active.released = true
538
568
  active.release?.()
569
+ active.maintenanceLease?.release()
539
570
  }
540
571
  }
541
572
 
@@ -548,6 +579,7 @@ export class QueryJobCoordinator {
548
579
  if (!active.released) {
549
580
  active.released = true
550
581
  active.release?.()
582
+ active.maintenanceLease?.release()
551
583
  }
552
584
  }
553
585
 
@@ -26,6 +26,7 @@ import {
26
26
  type QueryJobRequest,
27
27
  type QueryJobSnapshot,
28
28
  } from './query-job-types.js'
29
+ import { acquireMaintenanceWork } from './maintenance-lifecycle.js'
29
30
 
30
31
  const TOOL_STATUS_MESSAGES: Record<string, string> = {
31
32
  WebSearch: 'Searching web...',
@@ -161,6 +162,12 @@ const runner: QueryJobRunner = async ({ jobId, turnId, request, signal, callback
161
162
  resolvedAttachments.refs,
162
163
  metadata?.outputAttachments,
163
164
  )
165
+ // Acquire before the durable terminal callback can release the main
166
+ // query lease. Attachment association is a post-terminal write and
167
+ // must not create a zero-count maintenance proof gap.
168
+ const attachmentLease = resolvedAttachments.ids.length > 0
169
+ ? acquireMaintenanceWork('query_attachment_write', { allowDuringDrain: true })
170
+ : undefined
164
171
  const linkage = {
165
172
  provider: providerFor(model),
166
173
  resolvedModel: model,
@@ -171,34 +178,38 @@ const runner: QueryJobRunner = async ({ jobId, turnId, request, signal, callback
171
178
  } as const
172
179
  // Publish compatibility completion only after the durable terminal is
173
180
  // fsynced. Display subscribers can disappear without owning this job.
174
- const terminalOwned = await callbacks.onDone({
175
- text: fullText,
176
- attachments,
177
- outputImageStats: metadata?.outputImageStats,
178
- ...linkage,
179
- })
180
- if (!terminalOwned) return
181
- if (resolvedAttachments.ids.length > 0) {
182
- await getMediaStore().associate(resolvedAttachments.ids, {
181
+ try {
182
+ const terminalOwned = await callbacks.onDone({
183
+ text: fullText,
184
+ attachments,
185
+ outputImageStats: metadata?.outputImageStats,
186
+ ...linkage,
187
+ })
188
+ if (!terminalOwned) return
189
+ if (resolvedAttachments.ids.length > 0) {
190
+ await getMediaStore().associate(resolvedAttachments.ids, {
191
+ sessionId: request.sessionId,
192
+ ...(request.globalMsgNum ? { globalMsgNum: request.globalMsgNum } : {}),
193
+ }).catch(error => console.error('[query-jobs] attachment association failed:', error))
194
+ }
195
+ const { outputAttachments: _outputAttachments, ...runMetadata } = metadata ?? {}
196
+ emitDisplay({ type: 'done', data: {
197
+ jobId,
198
+ clientJobId: request.clientJobId,
199
+ generation: request.generation,
200
+ turnId,
201
+ messageEra: request.messageEra,
202
+ globalMsgNum: request.globalMsgNum,
203
+ text: fullText,
183
204
  sessionId: request.sessionId,
184
- ...(request.globalMsgNum ? { globalMsgNum: request.globalMsgNum } : {}),
185
- }).catch(error => console.error('[query-jobs] attachment association failed:', error))
205
+ model,
206
+ cliSessionId,
207
+ ...runMetadata,
208
+ ...(attachments.length > 0 ? { attachments } : {}),
209
+ } })
210
+ } finally {
211
+ attachmentLease?.release()
186
212
  }
187
- const { outputAttachments: _outputAttachments, ...runMetadata } = metadata ?? {}
188
- emitDisplay({ type: 'done', data: {
189
- jobId,
190
- clientJobId: request.clientJobId,
191
- generation: request.generation,
192
- turnId,
193
- messageEra: request.messageEra,
194
- globalMsgNum: request.globalMsgNum,
195
- text: fullText,
196
- sessionId: request.sessionId,
197
- model,
198
- cliSessionId,
199
- ...runMetadata,
200
- ...(attachments.length > 0 ? { attachments } : {}),
201
- } })
202
213
  },
203
214
  onError: async error => {
204
215
  const terminalOwned = await callbacks.onError(error)
@@ -239,6 +250,7 @@ export const queryJobStore = new QueryJobStore({
239
250
  export const queryJobCoordinator = new QueryJobCoordinator(queryJobStore, runner, {
240
251
  projectTerminal: projectPublicConversationTerminal,
241
252
  acquireSessionLock: acquireModelSessionRunLock,
253
+ acquireMaintenanceWork: () => acquireMaintenanceWork('durable_query', { phase: 'queued' }),
242
254
  })
243
255
 
244
256
  export function initQueryJobRuntime() {
@@ -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
  })