@gotcos/glasses-server 6.21.7 → 6.21.9

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
@@ -153,6 +153,20 @@ BIND_HOST=0.0.0.0
153
153
  # keeps working if the default ever flips to Metal-on.
154
154
  # COS_BATCH_HQ_FORCE_CPU=1
155
155
 
156
+ # Private 6.21.8 canaries can precompute reusable Large-v3 checkpoints for
157
+ # sealed 30-second windows while a meeting is still running. Work is CPU-only,
158
+ # global-single-flight, preemptible, and never canonical until Stop/save accepts
159
+ # it. Balanced is hard-capped at 2 threads for fanless M1/M2 MacBook Airs; Max
160
+ # defaults to 6 and is still capped by available CPUs. Leave OFF for public use
161
+ # until the canary latency/thermal gate passes.
162
+ # COS_MEETING_PROGRESSIVE_HQ=1
163
+ # COS_MEETING_PROGRESSIVE_HQ_THREADS=2 # optional lower override; cannot raise the tier cap
164
+
165
+ # Early Sync writes stable meeting identity to the Operations pipeline before
166
+ # HQ finishes, then enriches that same artifact in place. It is I/O-only and
167
+ # deliberately independent from the progressive-HQ compute switch.
168
+ # COS_MEETING_EARLY_SYNC=1
169
+
156
170
  # ── UNSAVED-CAPTURE QUARANTINE (6.19.0) ─────────────────────────────────
157
171
  # Meeting audio whose save never landed is QUARANTINED, never deleted. It
158
172
  # surfaces on /api/health (unsaved_captures) and, with COS Control 0.3.1+,
package/CHANGELOG.md CHANGED
@@ -1,3 +1,26 @@
1
+ ## 6.21.9
2
+
3
+ - Prevent an unclosed or abandoned recording from monopolizing progressive HQ.
4
+ Each live session now receives one sealed window per FIFO turn, and sessions
5
+ idle for 45 seconds yield only disposable checkpoint compute while preserving
6
+ raw audio, recovery ledgers, completed checkpoints, and save behavior.
7
+ - Resume a yielded session automatically when a new canonical chunk arrives,
8
+ and expose `paused_idle` in the progressive health snapshot for diagnosis.
9
+
10
+ ## 6.21.8
11
+
12
+ - Decouple durable G2 sync identity from post-meeting HQ so a saved meeting can
13
+ enter Operations immediately and be enriched in place when Large-v3 finishes.
14
+ - Add default-off progressive Large-v3 checkpoints for sealed 30-second meeting
15
+ windows. Stop reuses only cache entries whose audio, context, model, and session
16
+ identities still match; provisional text never becomes canonical on its own.
17
+ - Make progressive CPU admission tier-aware. Balanced is capped at two background
18
+ threads for fanless M1/M2-class Macs; Max defaults to six and remains capped by
19
+ available CPUs. Both stay global-single-flight, preemptible, and separately
20
+ kill-switched from Early Sync.
21
+ - Publish requested/effective tier, thread policy, sealed-window progress, early
22
+ sync outcomes, and durable finalization recovery through health for COS Control.
23
+
1
24
  ## 6.21.7
2
25
 
3
26
  - Add a default-off, authenticated meeting-preview endpoint for private canaries.
package/README.md CHANGED
@@ -270,6 +270,15 @@ to Turbo rather than making transcription unavailable. COS Control is the
270
270
  supported owner of the machine-wide tier; the per-lane environment variables
271
271
  remain advanced overrides.
272
272
 
273
+ Server 6.21.8 adds a default-off meeting-completion canary. With
274
+ `COS_MEETING_PROGRESSIVE_HQ=1`, sealed meeting windows can be polished ahead of
275
+ Stop/save on a CPU-only, single-flight lane; finalization reuses only matching
276
+ audio/model/context checkpoints. Balanced is capped at two background threads
277
+ for fanless M1/M2 MacBook Airs, while Max defaults to six and remains capped by
278
+ available CPUs. `COS_MEETING_EARLY_SYNC=1` separately gives the Operations sync
279
+ pipeline a stable meeting identity before HQ completes. Either switch can be
280
+ disabled without changing canonical live transcription or raw meeting audio.
281
+
273
282
  The first server start downloads the real-time turbo model. True HQ additionally
274
283
  requires the full `ggml-large-v3.bin` model (about 3.1 GB):
275
284
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gotcos/glasses-server",
3
- "version": "6.21.7",
3
+ "version": "6.21.9",
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": {
package/server/index.ts CHANGED
@@ -19,7 +19,7 @@ import { providerProofRouter } from './routes/provider-proof.js'
19
19
  import { transcribeRouter } from './routes/transcribe.js'
20
20
  import { displayRouter } from './routes/display.js'
21
21
  import { transcribeStreamRouter } from './routes/transcribe-stream.js'
22
- import { meetingRouter } from './routes/meeting.js'
22
+ import { meetingRouter, resumeMeetingFinalizationJobs } from './routes/meeting.js'
23
23
  import { meetingsRouter } from './routes/meetings.js'
24
24
  import { openaiCompatRouter } from './routes/openai-compat.js'
25
25
  import { openaiKeyRouter } from './routes/openai-key.js'
@@ -439,6 +439,10 @@ listenRequiredServers(listeners).then(() => {
439
439
  if (admittedRuntimeStarted) return
440
440
  admittedRuntimeStarted = true
441
441
 
442
+ // Resume already-saved meetings whose post-response HQ/operations work
443
+ // was interrupted by a prior server update or process exit.
444
+ resumeMeetingFinalizationJobs()
445
+
442
446
  void initQueryJobRuntime().then(health => {
443
447
  if (process.env.COS_DURABLE_QUERY_JOBS !== '0') {
444
448
  console.log(`[COS API] Durable query jobs: ${health.store.state} · ${health.store.retainedIdentities} retained`)
@@ -12,7 +12,7 @@ export interface BatchResult {
12
12
  segment: BatchSegment
13
13
  text: string
14
14
  words: WhisperWord[]
15
- speakerWords: Array<{ word: string; start: number; end: number; speaker: string }>
15
+ speakerWords: Array<{ word: string; start: number; end: number; speaker: string; similarity: number }>
16
16
  }
17
17
 
18
18
  export interface BatchTranscription {
@@ -5,7 +5,7 @@ import { isAbsolute } from 'node:path'
5
5
  export const G2_RESULT_PREFIX = 'COS_G2_RESULT='
6
6
 
7
7
  export interface G2EnrichmentOutcome {
8
- status: 'enriched' | 'already-enriched' | 'blended'
8
+ status: 'claimed' | 'retained' | 'enriched' | 'already-enriched' | 'blended'
9
9
  path: string
10
10
  title: string
11
11
  }
@@ -34,6 +34,8 @@ export interface G2EnrichmentOptions {
34
34
  timeoutMs?: number
35
35
  killGraceMs?: number
36
36
  onAttempt?: (message: string) => void
37
+ claimOnly?: boolean
38
+ importLocal?: boolean
37
39
  }
38
40
 
39
41
  export interface G2EnrichmentDependencies {
@@ -57,7 +59,7 @@ export function parseG2EnrichmentOutcome(stdout: string): G2EnrichmentOutcome |
57
59
  try {
58
60
  const parsed = JSON.parse(line.slice(G2_RESULT_PREFIX.length)) as Partial<G2EnrichmentOutcome>
59
61
  if (
60
- (parsed.status === 'enriched' || parsed.status === 'already-enriched' || parsed.status === 'blended')
62
+ (parsed.status === 'claimed' || parsed.status === 'retained' || parsed.status === 'enriched' || parsed.status === 'already-enriched' || parsed.status === 'blended')
61
63
  && typeof parsed.path === 'string'
62
64
  && parsed.path.length > 0
63
65
  && typeof parsed.title === 'string'
@@ -72,8 +74,19 @@ export function parseG2EnrichmentOutcome(stdout: string): G2EnrichmentOutcome |
72
74
  return null
73
75
  }
74
76
 
75
- export function buildExactG2SyncArgs(syncScript: string, meetingFile: string): string[] {
76
- return [syncScript, '--g2-only', '--g2-file', meetingFile, '--quiet']
77
+ export function buildExactG2SyncArgs(
78
+ syncScript: string,
79
+ meetingFile: string,
80
+ claimOnly = false,
81
+ importLocal = false,
82
+ ): string[] {
83
+ return [
84
+ syncScript,
85
+ '--g2-only',
86
+ importLocal ? '--g2-import-file' : '--g2-file', meetingFile,
87
+ ...(claimOnly ? ['--g2-claim-only'] : []),
88
+ '--quiet',
89
+ ]
77
90
  }
78
91
 
79
92
  function signalChildTree(child: ReturnType<typeof spawn>, signal: NodeJS.Signals): boolean {
@@ -91,7 +104,12 @@ export async function spawnG2SyncAttempt(options: G2EnrichmentOptions): Promise<
91
104
  return await new Promise(resolve => {
92
105
  const child = spawn(
93
106
  options.pythonBin,
94
- buildExactG2SyncArgs(options.syncScript, options.meetingFile),
107
+ buildExactG2SyncArgs(
108
+ options.syncScript,
109
+ options.meetingFile,
110
+ options.claimOnly,
111
+ options.importLocal,
112
+ ),
95
113
  {
96
114
  cwd: options.scriptsDir,
97
115
  stdio: ['ignore', 'pipe', 'pipe'],
@@ -176,4 +194,3 @@ export async function runG2EnrichmentWithRetry(
176
194
 
177
195
  return { ok: false, attempts: retryDelaysMs.length, error: lastError }
178
196
  }
179
-
@@ -10,16 +10,74 @@
10
10
  // lets sync_meetings reclassify).
11
11
  // 3. Run sync_meetings.py --g2-only --g2-file with the private-app retry helper.
12
12
 
13
- import { copyFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
13
+ import { existsSync, mkdirSync, readFileSync } from 'node:fs'
14
14
  import { basename, dirname, join } from 'node:path'
15
+ import { durableAtomicWriteFileSync } from './atomic-fs.js'
15
16
  import { resolveCosOperationsDir } from './cos-operations-meetings.js'
16
17
  import { runG2EnrichmentWithRetry } from './g2-enrichment-runner.js'
17
18
  import { COS_SCRIPTS_DIR, PYTHON_BIN } from './python-bridge.js'
19
+ import { getServerInstanceId } from './server-instance-id.js'
18
20
 
19
21
  const PENDING_SUMMARY = '*G2 recording — summary pending pipeline processing.*'
20
22
  const DOMAIN_REVIEW_MARKER = '<!-- g2-needs-domain-review -->'
23
+ const HQ_PENDING_MARKER = '<!-- g2-hq-state: pending -->'
24
+ const OPERATIONS_OWNED_SIDECAR_FIELDS = new Set([
25
+ 'blended_into',
26
+ 'dedupEvidence',
27
+ 'enrichmentState',
28
+ 'finalPath',
29
+ 'operationsPath',
30
+ ])
21
31
 
22
- export function patchRecordingForG2Pipeline(markdown: string): string {
32
+ export function earlyMeetingSyncEnabled(): boolean {
33
+ return process.env.COS_MEETING_EARLY_SYNC === '1'
34
+ }
35
+
36
+ interface EarlySyncRuntimeState {
37
+ inFlight: boolean
38
+ pendingCount: number
39
+ lastOutcome: 'claimed' | 'finalized' | 'failed' | null
40
+ lastAt: string | null
41
+ lastError: string | null
42
+ }
43
+
44
+ const earlySyncRuntime: EarlySyncRuntimeState = {
45
+ inFlight: false,
46
+ pendingCount: 0,
47
+ lastOutcome: null,
48
+ lastAt: null,
49
+ lastError: null,
50
+ }
51
+
52
+ export function getEarlyMeetingSyncSnapshot(): EarlySyncRuntimeState & {
53
+ requested: boolean
54
+ enabled: boolean
55
+ available: boolean
56
+ reason: string | null
57
+ } {
58
+ const requested = earlyMeetingSyncEnabled()
59
+ const scriptsDir = process.env.COS_SCRIPTS_DIR?.trim() || COS_SCRIPTS_DIR
60
+ const syncScript = scriptsDir ? join(scriptsDir, 'sync_meetings.py') : ''
61
+ const python = process.env.COS_PYTHON_BIN?.trim() || PYTHON_BIN
62
+ let reason: string | null = null
63
+ if (!scriptsDir) reason = 'operations_not_configured'
64
+ else if (!python || !existsSync(python)) reason = 'python_unavailable'
65
+ else if (!syncScript || !existsSync(syncScript)) reason = 'sync_script_missing'
66
+ else if (!syncScriptSupportsLockedImport(syncScript)) reason = 'sync_script_upgrade_required'
67
+ const available = reason === null
68
+ return { requested, enabled: requested && available, available, reason, ...earlySyncRuntime }
69
+ }
70
+
71
+ export interface G2StageOptions {
72
+ phase?: 'claim' | 'final'
73
+ hqState?: 'queued' | 'running' | 'accepted' | 'rejected' | 'failed' | 'unavailable'
74
+ revision?: number
75
+ }
76
+
77
+ export function patchRecordingForG2Pipeline(
78
+ markdown: string,
79
+ options: G2StageOptions = {},
80
+ ): string {
23
81
  let text = markdown.replace(/\r\n?/g, '\n')
24
82
  if (!/\|\s*\*\*Source\*\*\s*\|\s*G2 Glasses\s*\|/i.test(text) && !text.includes('| G2 Glasses')) {
25
83
  text = text.replace(
@@ -45,11 +103,71 @@ export function patchRecordingForG2Pipeline(markdown: string): string {
45
103
  text = `${text.trimEnd()}\n\n${DOMAIN_REVIEW_MARKER}\n`
46
104
  }
47
105
  }
106
+ text = text.replace(/\n?<!-- g2-hq-state: pending -->\n?/g, '\n')
107
+ if (options.phase === 'claim') {
108
+ text = text.includes(DOMAIN_REVIEW_MARKER)
109
+ ? text.replace(DOMAIN_REVIEW_MARKER, `${HQ_PENDING_MARKER}\n${DOMAIN_REVIEW_MARKER}`)
110
+ : `${text.trimEnd()}\n\n${HQ_PENDING_MARKER}\n`
111
+ }
48
112
  return text
49
113
  }
50
114
 
115
+ function parseSidecar(path: string): Record<string, unknown> | null {
116
+ if (!existsSync(path)) return null
117
+ try {
118
+ const parsed = JSON.parse(readFileSync(path, 'utf8')) as unknown
119
+ return parsed && typeof parsed === 'object' && !Array.isArray(parsed)
120
+ ? parsed as Record<string, unknown>
121
+ : null
122
+ } catch {
123
+ return null
124
+ }
125
+ }
126
+
127
+ /** Merge server-owned capture truth while preserving operations-owned match state. */
128
+ export function mergeG2OperationsSidecar(
129
+ sourcePath: string,
130
+ destinationPath: string,
131
+ options: G2StageOptions = {},
132
+ ): void {
133
+ const source = parseSidecar(sourcePath)
134
+ if (!source) return
135
+ const existing = parseSidecar(destinationPath) ?? {}
136
+ const sourceSession = typeof source.sessionId === 'string' ? source.sessionId : ''
137
+ const existingSession = typeof existing.sessionId === 'string' ? existing.sessionId : ''
138
+ if (sourceSession && existingSession && sourceSession !== existingSession) {
139
+ throw new Error(`Refusing G2 sidecar merge across sessions (${existingSession} != ${sourceSession})`)
140
+ }
141
+
142
+ const priorRevision = Number(existing.lifecycleRevision ?? 0)
143
+ const nextRevision = Math.max(Number(options.revision ?? 0), Number(source.lifecycleRevision ?? 0))
144
+ if (Number.isFinite(priorRevision) && Number.isFinite(nextRevision) && nextRevision < priorRevision) {
145
+ throw new Error(`Refusing regressing G2 sidecar revision ${nextRevision} < ${priorRevision}`)
146
+ }
147
+
148
+ const merged: Record<string, unknown> = { ...existing, ...source }
149
+ for (const field of OPERATIONS_OWNED_SIDECAR_FIELDS) {
150
+ if (Object.prototype.hasOwnProperty.call(existing, field)) merged[field] = existing[field]
151
+ }
152
+ merged.lifecycleRevision = Math.max(priorRevision || 0, nextRevision || 0)
153
+ const inferredFinalHqState = source.batchApplied === true
154
+ ? 'accepted'
155
+ : source.batchQualityReport
156
+ ? 'rejected'
157
+ : 'unavailable'
158
+ merged.hqState = options.hqState
159
+ ?? (options.phase === 'claim' ? 'queued' : inferredFinalHqState)
160
+ merged.syncState = options.phase === 'claim' ? 'claimed' : 'finalized'
161
+ merged.serverInstanceId = getServerInstanceId()
162
+ merged.lifecycleUpdatedAt = new Date().toISOString()
163
+ durableAtomicWriteFileSync(destinationPath, JSON.stringify(merged, null, 2), { mode: 0o600 })
164
+ }
165
+
51
166
  /** Stage local recording into operations/personal for exact enrichment. */
52
- export function stageRecordingIntoOperations(localMeetingPath: string): string | null {
167
+ export function stageRecordingIntoOperations(
168
+ localMeetingPath: string,
169
+ options: G2StageOptions = { phase: 'final' },
170
+ ): string | null {
53
171
  const operationsDir = resolveCosOperationsDir()
54
172
  if (!operationsDir) return null
55
173
  if (!existsSync(localMeetingPath)) {
@@ -66,71 +184,114 @@ export function stageRecordingIntoOperations(localMeetingPath: string): string |
66
184
  const destDir = join(operationsDir, 'personal', 'meetings', month)
67
185
  mkdirSync(destDir, { recursive: true })
68
186
  const destPath = join(destDir, basename(localMeetingPath))
69
- const patched = patchRecordingForG2Pipeline(readFileSync(localMeetingPath, 'utf8'))
70
- writeFileSync(destPath, patched, { encoding: 'utf8', mode: 0o600 })
187
+ const patched = patchRecordingForG2Pipeline(readFileSync(localMeetingPath, 'utf8'), options)
71
188
 
72
189
  const stem = basename(localMeetingPath, '.md')
73
190
  const localDir = dirname(localMeetingPath)
74
191
  for (const companionName of [`${stem}.g2-chunks.json`, `${stem}.json`]) {
75
192
  const companion = join(localDir, companionName)
76
193
  if (existsSync(companion)) {
77
- try {
78
- copyFileSync(companion, join(destDir, companionName))
79
- } catch (error) {
80
- console.warn(
81
- `[g2-ops-handoff] Sidecar copy failed for ${companionName}: `
82
- + `${error instanceof Error ? error.message : String(error)}`,
83
- )
84
- }
194
+ mergeG2OperationsSidecar(companion, join(destDir, companionName), options)
85
195
  }
86
196
  }
197
+ // Markdown is the visible commit marker. Publish it only after every
198
+ // identity/revision sidecar has validated and committed.
199
+ durableAtomicWriteFileSync(destPath, patched, { mode: 0o600 })
87
200
  return destPath
88
201
  }
89
202
 
90
- export async function handoffMeetingToOperations(localMeetingPath: string): Promise<void> {
91
- if (!COS_SCRIPTS_DIR || !PYTHON_BIN) {
203
+ async function runOperationsSync(localMeetingPath: string, claimOnly: boolean): Promise<void> {
204
+ if (!COS_SCRIPTS_DIR) {
92
205
  console.log('[meeting/save] Standalone mode — skipping G2 sync pipeline')
93
206
  return
94
207
  }
208
+ if (!PYTHON_BIN) throw new Error('COS operations configured but Python bridge is unavailable')
95
209
  if (!existsSync(PYTHON_BIN)) {
96
- console.warn(`[meeting/save] COS python missing at ${PYTHON_BIN} — skipping G2 sync`)
97
- return
98
- }
99
-
100
- const staged = stageRecordingIntoOperations(localMeetingPath)
101
- if (!staged) {
102
- console.warn('[meeting/save] COS operations dir unset/unavailable — G2 sync skipped')
103
- return
210
+ throw new Error(`COS python missing at ${PYTHON_BIN}`)
104
211
  }
105
212
 
106
213
  const syncScript = join(COS_SCRIPTS_DIR, 'sync_meetings.py')
107
214
  if (!existsSync(syncScript)) {
108
- console.warn(`[meeting/save] sync_meetings.py missing at ${syncScript}`)
109
- return
215
+ throw new Error(`sync_meetings.py missing at ${syncScript}`)
110
216
  }
111
217
 
112
218
  const spawnPath = process.env.PATH?.includes('/opt/homebrew/bin')
113
219
  ? process.env.PATH
114
220
  : `/opt/homebrew/bin:${process.env.PATH || ''}`
115
221
 
222
+ const supportsLockedImport = syncScriptSupportsLockedImport(syncScript)
223
+ if (claimOnly && !supportsLockedImport) {
224
+ throw new Error('Early meeting sync requires a newer sync_meetings.py with --g2-import-file')
225
+ }
226
+ const meetingFile = supportsLockedImport
227
+ ? localMeetingPath
228
+ : stageRecordingIntoOperations(localMeetingPath, { phase: 'final' })
229
+ if (!meetingFile) throw new Error('Unable to stage G2 recording into operations')
230
+
116
231
  const enrichment = await runG2EnrichmentWithRetry({
117
232
  pythonBin: PYTHON_BIN,
118
233
  syncScript,
119
234
  scriptsDir: COS_SCRIPTS_DIR,
120
- meetingFile: staged,
121
- env: { ...process.env, PYTHONUNBUFFERED: '1', PATH: spawnPath },
122
- onAttempt: message => console.log(`[meeting/save] G2 exact sync: ${message}`),
235
+ meetingFile,
236
+ env: {
237
+ ...process.env,
238
+ PYTHONUNBUFFERED: '1',
239
+ PATH: spawnPath,
240
+ COS_G2_IMPORT_ROOT: dirname(dirname(localMeetingPath)),
241
+ COS_SERVER_INSTANCE_ID: getServerInstanceId() ?? '',
242
+ },
243
+ claimOnly,
244
+ importLocal: supportsLockedImport,
245
+ retryDelaysMs: claimOnly ? [0] : undefined,
246
+ timeoutMs: claimOnly ? 30_000 : undefined,
247
+ onAttempt: message => console.log(`[meeting/save] G2 ${claimOnly ? 'claim' : 'exact sync'}: ${message}`),
123
248
  })
124
249
 
125
250
  if (enrichment.ok && enrichment.outcome) {
126
251
  console.log(
127
- `[meeting/save] G2 pipeline verified after ${enrichment.attempts} attempt(s): `
252
+ `[meeting/save] G2 ${claimOnly ? 'claim' : 'pipeline'} verified after ${enrichment.attempts} attempt(s): `
128
253
  + `${enrichment.outcome.title} → ${enrichment.outcome.path}`,
129
254
  )
130
255
  } else {
131
- console.error(
132
- `[meeting/save] G2 pipeline FAILED after ${enrichment.attempts} attempt(s): `
133
- + `${enrichment.error ?? 'unknown exact-file enrichment failure'}`,
256
+ throw new Error(
257
+ `G2 ${claimOnly ? 'claim' : 'pipeline'} failed after ${enrichment.attempts} attempt(s): `
258
+ + `${enrichment.error ?? 'unknown exact-file sync failure'}`
134
259
  )
135
260
  }
136
261
  }
262
+
263
+ function syncScriptSupportsLockedImport(syncScript: string): boolean {
264
+ try { return readFileSync(syncScript, 'utf8').includes('--g2-import-file') } catch { return false }
265
+ }
266
+
267
+ let claimQueueTail: Promise<void> = Promise.resolve()
268
+
269
+ export async function claimMeetingInOperations(localMeetingPath: string): Promise<void> {
270
+ if (!earlyMeetingSyncEnabled()) return
271
+ earlySyncRuntime.pendingCount += 1
272
+ const claim = claimQueueTail.then(async () => {
273
+ earlySyncRuntime.inFlight = true
274
+ try {
275
+ await runOperationsSync(localMeetingPath, true)
276
+ earlySyncRuntime.lastOutcome = 'claimed'
277
+ earlySyncRuntime.lastError = null
278
+ } catch (error) {
279
+ earlySyncRuntime.lastOutcome = 'failed'
280
+ earlySyncRuntime.lastError = error instanceof Error ? error.message : String(error)
281
+ throw error
282
+ } finally {
283
+ earlySyncRuntime.pendingCount -= 1
284
+ earlySyncRuntime.inFlight = earlySyncRuntime.pendingCount > 0
285
+ earlySyncRuntime.lastAt = new Date().toISOString()
286
+ }
287
+ })
288
+ claimQueueTail = claim.catch(() => undefined)
289
+ await claim
290
+ }
291
+
292
+ export async function handoffMeetingToOperations(localMeetingPath: string): Promise<void> {
293
+ await runOperationsSync(localMeetingPath, false)
294
+ earlySyncRuntime.lastOutcome = 'finalized'
295
+ earlySyncRuntime.lastError = null
296
+ earlySyncRuntime.lastAt = new Date().toISOString()
297
+ }