@gotcos/glasses-server 6.94.1 → 6.95.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,727 @@
1
+ // Speaker merge shadow mode (6.95.0). OFF BY DEFAULT. LOG ONLY.
2
+ //
3
+ // WHY. Mac recordings split one person into many anonymous `Voice N` labels (45 on one
4
+ // 60-minute call). A research canary found a hybrid that fixes this safely on the
5
+ // reference call: production labels stay the base and FluidAudio's offline Community-1
6
+ // diarizer only PROPOSES merges, behind the gates in speaker-shadow-decide.ts. Shadow mode
7
+ // computes and logs those proposals for every finished Mac meeting so they can be scored
8
+ // against Miles's later corrections. There is no write path, and none may be added here.
9
+ //
10
+ // SWITCH, per Mac, in the transcript-cleanup pattern (6.77.0), off for everyone by default:
11
+ // 1. env COS_SPEAKER_SHADOW=1|true|yes|on forces it on, =0|false|no|off forces it off
12
+ // (source 'env'); a request to change it then answers 409 env_forced;
13
+ // 2. the saved setting <data>/speaker-shadow/settings.json (atomic), written through the
14
+ // token-protected POST /api/speaker-shadow/settings {"enabled":true|false}, read at use
15
+ // time, so no restart is needed (source 'setting');
16
+ // 3. off (source 'default').
17
+ // An unreadable settings file reads as no setting, which is off. Off means nothing is queued,
18
+ // nothing is spawned and no file is created. Turning it off mid-run stops the run.
19
+ //
20
+ // HOW IT STOPS.
21
+ // - the switch (remove it and restart);
22
+ // - a daily cap on runs (COS_SPEAKER_SHADOW_DAILY_RUNS, default 12);
23
+ // - a breaker: 3 consecutive failures pause it for 1 hour;
24
+ // - a hard wall clock (180 s, COS_SPEAKER_SHADOW_TIMEOUT_MS may only lower it) and an
25
+ // RSS cap on the whole process group (COS_SPEAKER_SHADOW_RSS_MB, default 3072);
26
+ // - live work: a recording, a save, a batch finalization or a one-shot transcription
27
+ // defers it, and one that starts mid-run kills the run and puts the meeting back.
28
+ //
29
+ // No LLM and no network at run time: the CLI runs under sandbox-exec with network denied,
30
+ // and only after the pinned models verify (speaker-shadow-models.ts).
31
+ //
32
+ // WHAT IT WRITES, all under <data>/speaker-shadow/: proposals.jsonl (one record per
33
+ // meeting, atomic rewrite, rotated past 8 MB), state.json (queue, cap, breaker), and the
34
+ // CLI's private HOME. Plus a temp directory the worker removes. Never a meeting file, a
35
+ // label, a name, a profile, an embedding cache or the correction ledger.
36
+
37
+ import { execFile, spawn, type ChildProcess } from 'node:child_process'
38
+ import { createHash } from 'node:crypto'
39
+ import { createReadStream, existsSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync } from 'node:fs'
40
+ import { open, readFile, readdir, rename, unlink } from 'node:fs/promises'
41
+ import { createRequire } from 'node:module'
42
+ import { setPriority, tmpdir } from 'node:os'
43
+ import { dirname, join } from 'node:path'
44
+ import { pipeline } from 'node:stream/promises'
45
+ import { Transform } from 'node:stream'
46
+ import { fileURLToPath, pathToFileURL } from 'node:url'
47
+ import { durableAtomicWriteFileSync } from './atomic-fs.js'
48
+ import { MAC_CAPTURE_ID } from './mac-recording.js'
49
+ import { maintenanceLifecycle } from './maintenance-lifecycle.js'
50
+ import { installedCliIntact, installedCliTrust, parakeetCliPath, parakeetInstalled, SANDBOX_EXEC } from './parakeet.js'
51
+ import { SHADOW_GATES } from './speaker-shadow-decide.js'
52
+ import { shadowModelsPresent, speakerShadowHome, speakerShadowModelsDir, speakerShadowRoot } from './speaker-shadow-models.js'
53
+ import { readShadowMeetingTitle, shadowEntries, shadowLabelsHash, shadowMeetingDay } from './speaker-shadow-timeline.js'
54
+ import { renderShadowRunFacts, renderShadowSkipReport, shadowReportRelativePath } from './speaker-shadow-report.js'
55
+ import { SHADOW_TMP_PREFIX, type ShadowJob, type ShadowJobOutcome, type ShadowRunFacts } from './speaker-shadow-worker.js'
56
+ import type { ShadowProposals } from './speaker-shadow-decide.js'
57
+
58
+ export const SPEAKER_SHADOW_ENV = 'COS_SPEAKER_SHADOW'
59
+ export const SPEAKER_SHADOW_RECORDS = 'proposals.jsonl'
60
+ export const SPEAKER_SHADOW_STATE = 'state.json'
61
+ /** The wall clock the brief fixes (the canary took about 20 s for 60 minutes). */
62
+ export const SPEAKER_SHADOW_MAX_TIMEOUT_MS = 180_000
63
+ export const SPEAKER_SHADOW_BREAKER_FAILURES = 3
64
+ export const SPEAKER_SHADOW_BREAKER_PAUSE_MS = 60 * 60_000
65
+ export const SPEAKER_SHADOW_RECORDS_ROTATE_BYTES = 8 * 1024 * 1024
66
+ const MAX_QUEUE = 25
67
+ const QUEUE_MAX_AGE_MS = 24 * 60 * 60_000
68
+ const DEFER_RETRY_MS = 60_000
69
+ const SAMPLE_MS = 1_000
70
+ const MAX_STDOUT = 8 * 1024 * 1024
71
+
72
+ export const SPEAKER_SHADOW_SETTINGS = 'settings.json'
73
+ const ENV_ON = new Set(['1', 'true', 'yes', 'on'])
74
+ const ENV_OFF = new Set(['0', 'false', 'no', 'off'])
75
+ export type SpeakerShadowSwitchSource = 'env' | 'setting' | 'default'
76
+
77
+ /** What the environment pins, or null when it pins nothing (unset, empty or unrecognised). */
78
+ export function speakerShadowEnvSwitch(env: NodeJS.ProcessEnv = process.env): boolean | null {
79
+ const raw = (env[SPEAKER_SHADOW_ENV] ?? '').trim().toLowerCase()
80
+ return ENV_ON.has(raw) ? true : ENV_OFF.has(raw) ? false : null
81
+ }
82
+
83
+ const settingsCache = new Map<string, { key: string; value: boolean | null }>()
84
+ /** The saved per-Mac setting: true, false, or null when there is none or it cannot be read. */
85
+ export function readSpeakerShadowSetting(root: string = speakerShadowRoot()): boolean | null {
86
+ const file = join(root, SPEAKER_SHADOW_SETTINGS)
87
+ try {
88
+ const st = statSync(file)
89
+ const key = `${st.mtimeMs}:${st.size}`
90
+ const cached = settingsCache.get(file)
91
+ if (cached?.key === key) return cached.value
92
+ const raw = JSON.parse(readFileSync(file, 'utf8')) as Record<string, unknown>
93
+ const value = raw && typeof raw === 'object' && !Array.isArray(raw) && typeof raw.enabled === 'boolean' ? raw.enabled : null
94
+ settingsCache.set(file, { key, value })
95
+ return value
96
+ } catch { return null }
97
+ }
98
+
99
+ /** Atomic and durable. The only writer of the setting. */
100
+ export function writeSpeakerShadowSetting(enabled: boolean, root: string = speakerShadowRoot(), now: Date = new Date()): void {
101
+ mkdirSync(root, { recursive: true, mode: 0o700 })
102
+ durableAtomicWriteFileSync(join(root, SPEAKER_SHADOW_SETTINGS), `${JSON.stringify({ schema: 1, enabled, updatedAt: now.toISOString() })}\n`, { mode: 0o600 })
103
+ settingsCache.delete(join(root, SPEAKER_SHADOW_SETTINGS))
104
+ }
105
+
106
+ export function resolveSpeakerShadowSwitch(env: NodeJS.ProcessEnv = process.env, root: string = speakerShadowRoot()): { enabled: boolean; source: SpeakerShadowSwitchSource } {
107
+ const pinned = speakerShadowEnvSwitch(env)
108
+ if (pinned !== null) return { enabled: pinned, source: 'env' }
109
+ const stored = readSpeakerShadowSetting(root)
110
+ if (stored !== null) return { enabled: stored, source: 'setting' }
111
+ return { enabled: false, source: 'default' }
112
+ }
113
+
114
+ export function speakerShadowEnabled(env: NodeJS.ProcessEnv = process.env, root: string = speakerShadowRoot()): boolean {
115
+ return resolveSpeakerShadowSwitch(env, root).enabled
116
+ }
117
+
118
+ function intEnv(env: NodeJS.ProcessEnv, key: string, fallback: number, min: number, max: number): number {
119
+ const raw = Number(env[key])
120
+ return Number.isFinite(raw) && raw > 0 ? Math.min(max, Math.max(min, Math.floor(raw))) : fallback
121
+ }
122
+
123
+ export interface SpeakerShadowLimits { dailyRuns: number; timeoutMs: number; rssBytes: number }
124
+ export function speakerShadowLimits(env: NodeJS.ProcessEnv = process.env): SpeakerShadowLimits {
125
+ return {
126
+ dailyRuns: intEnv(env, 'COS_SPEAKER_SHADOW_DAILY_RUNS', 12, 1, 50),
127
+ timeoutMs: intEnv(env, 'COS_SPEAKER_SHADOW_TIMEOUT_MS', SPEAKER_SHADOW_MAX_TIMEOUT_MS, 10_000, SPEAKER_SHADOW_MAX_TIMEOUT_MS),
128
+ rssBytes: intEnv(env, 'COS_SPEAKER_SHADOW_RSS_MB', 3072, 256, 8192) * 1024 * 1024,
129
+ }
130
+ }
131
+
132
+ // ── Records ──────────────────────────────────────────────────────────────────
133
+
134
+ export type SpeakerShadowStatus = 'proposed' | 'skipped' | 'failed'
135
+ export type SpeakerShadowRunnerReason =
136
+ | 'stale' | 'daily_cap' | 'cli_missing' | 'models_missing' | 'audio_missing'
137
+ | 'timeout' | 'rss_cap' | 'worker_crashed' | 'worker_spawn_failed'
138
+
139
+ export interface SpeakerShadowRecord {
140
+ schema: 1
141
+ kind: 'speaker_shadow'
142
+ /** Always true: nothing in this record was applied anywhere. */
143
+ logOnly: true
144
+ recordedAt: string
145
+ serverVersion: string
146
+ sessionId: string
147
+ enqueuedAt: string
148
+ status: SpeakerShadowStatus
149
+ reason: string | null
150
+ detail: string | null
151
+ gates: { majority: number; minCoveredFraction: number; floorSeconds: number; outlier: string; anchorRatioPostHocColumnOnly: number }
152
+ facts?: ShadowRunFacts
153
+ proposals?: ShadowProposals
154
+ resources?: { wallMs: number; peakRssBytes: number | null; cliTrust: string | null }
155
+ /** The readable report, relative to <data>/speaker-shadow/ (reports/<day>/<title>__<session>.md). */
156
+ report?: string | null
157
+ }
158
+
159
+ const GATES_RECORD = { majority: SHADOW_GATES.majority, minCoveredFraction: SHADOW_GATES.minCoveredFraction, floorSeconds: SHADOW_GATES.floorSeconds, outlier: 'leave-fragment-out cluster p95', anchorRatioPostHocColumnOnly: SHADOW_GATES.anchorRatioPostHoc }
160
+
161
+ /** Append one record: the whole file is rewritten to a temp file, fsynced and renamed, so a reader never sees half a line. */
162
+ export async function appendSpeakerShadowRecord(root: string, record: SpeakerShadowRecord): Promise<void> {
163
+ mkdirSync(root, { recursive: true, mode: 0o700 })
164
+ const path = join(root, SPEAKER_SHADOW_RECORDS)
165
+ let existing = ''
166
+ try { existing = await readFile(path, 'utf8') } catch { /* first record */ }
167
+ if (existing.length >= SPEAKER_SHADOW_RECORDS_ROTATE_BYTES) {
168
+ await rename(path, join(root, `proposals-${new Date(Date.parse(record.recordedAt)).toISOString().replace(/[:.]/g, '-')}.jsonl`))
169
+ existing = ''
170
+ }
171
+ if (existing && !existing.endsWith('\n')) existing += '\n'
172
+ const tmp = `${path}.${process.pid}.${Date.now()}.tmp`
173
+ const handle = await open(tmp, 'wx', 0o600)
174
+ try {
175
+ await handle.writeFile(`${existing}${JSON.stringify(record)}\n`)
176
+ await handle.sync()
177
+ } finally { await handle.close() }
178
+ try { await rename(tmp, path) } catch (error) { await unlink(tmp).catch(() => {}); throw error }
179
+ }
180
+
181
+ /** Newest first. Malformed lines are skipped, never fatal. */
182
+ export async function readSpeakerShadowRecords(root: string, options: { limit?: number; sessionId?: string } = {}): Promise<SpeakerShadowRecord[]> {
183
+ const limit = Math.max(1, Math.min(100, options.limit ?? 10))
184
+ let names: string[] = []
185
+ try { names = (await readdir(root)).filter(n => n === SPEAKER_SHADOW_RECORDS || /^proposals-[0-9TZ-]+\.jsonl$/.test(n)) } catch { return [] }
186
+ names.sort((a, b) => a === SPEAKER_SHADOW_RECORDS ? -1 : b === SPEAKER_SHADOW_RECORDS ? 1 : b.localeCompare(a))
187
+ const out: SpeakerShadowRecord[] = []
188
+ for (const name of names) {
189
+ let text = ''
190
+ try { text = await readFile(join(root, name), 'utf8') } catch { continue }
191
+ const lines = text.split('\n').filter(Boolean).reverse()
192
+ for (const line of lines) {
193
+ let record: SpeakerShadowRecord
194
+ try { record = JSON.parse(line) } catch { continue }
195
+ if (record?.kind !== 'speaker_shadow') continue
196
+ if (options.sessionId && record.sessionId !== options.sessionId) continue
197
+ out.push(record)
198
+ if (out.length >= limit) return out
199
+ }
200
+ }
201
+ return out
202
+ }
203
+
204
+ // ── State ────────────────────────────────────────────────────────────────────
205
+
206
+ interface QueueItem { sessionId: string; sidecarPath: string; labelsHash: string | null; enqueuedAt: number }
207
+ interface ShadowState {
208
+ schema: 1
209
+ day: string
210
+ runsToday: number
211
+ consecutiveFailures: number
212
+ breakerUntil: number | null
213
+ lastRun: { at: number; sessionId: string; status: SpeakerShadowStatus; reason: string | null } | null
214
+ lastError: { at: number; reason: string; detail: string } | null
215
+ done: string[]
216
+ queue: QueueItem[]
217
+ }
218
+
219
+ const emptyState = (day: string): ShadowState => ({ schema: 1, day, runsToday: 0, consecutiveFailures: 0, breakerUntil: null, lastRun: null, lastError: null, done: [], queue: [] })
220
+
221
+ function localDay(ms: number): string {
222
+ const d = new Date(ms)
223
+ return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
224
+ }
225
+
226
+ // ── Runner ───────────────────────────────────────────────────────────────────
227
+
228
+ export interface SpeakerShadowCli { path: string; sha256: string; trust: string | null }
229
+
230
+ export interface SpeakerShadowDeps {
231
+ env: () => NodeJS.ProcessEnv
232
+ root: () => string
233
+ now: () => number
234
+ /** True while a recording, save, finalization or one-shot transcription is live. Null = not wired: never run. */
235
+ captureActive: (() => boolean) | null
236
+ /** False during a maintenance drain. */
237
+ admissionsOpen: () => boolean
238
+ resolveCli: () => Promise<SpeakerShadowCli | null>
239
+ audioDirFor: (sessionId: string) => string
240
+ modelsDir: () => string
241
+ homeDir: () => string
242
+ sandboxExec: string
243
+ tmpRoot: string
244
+ /** Starts the worker child. Must start it as its own process group leader. */
245
+ spawnWorker: () => ChildProcess
246
+ /** Resident bytes of the process group led by pid, or null when it cannot be read. */
247
+ sampleRss: (pgid: number) => Promise<number | null>
248
+ killGroup: (pgid: number) => void
249
+ log: (line: string) => void
250
+ setTimer: (fn: () => void, ms: number) => { unref?: () => void }
251
+ /** The cheap scheduler-side model look (sizes only); the worker hashes every file. */
252
+ modelsPresent: (dir: string) => boolean
253
+ limits: () => SpeakerShadowLimits
254
+ /** Cheap, synchronous: is the CLI the runner would use installed, and how is it trusted. */
255
+ cliReadiness: () => { installed: boolean; trust: string | null }
256
+ }
257
+
258
+ export interface SpeakerShadowReadiness {
259
+ /** Every pinned model file is present with its size (the worker hashes them before each run). */
260
+ modelsPresent: boolean
261
+ /** The Parakeet install's fluidaudiocli is installed, and how it was trusted. */
262
+ cliInstalled: boolean
263
+ cliTrust: string | null
264
+ sandboxExec: boolean
265
+ }
266
+
267
+ export interface SpeakerShadowSnapshot {
268
+ enabled: boolean
269
+ /** Where the switch came from: env pins it, the saved per-Mac setting, or the default (off). */
270
+ source: SpeakerShadowSwitchSource
271
+ state: 'off' | 'idle' | 'queued' | 'running' | 'deferred' | 'breaker_open' | 'no_live_probe'
272
+ queued: number
273
+ runsToday: number
274
+ dailyCap: number
275
+ breaker: { open: boolean; failures: number; until: string | null }
276
+ lastRun: { at: string; status: SpeakerShadowStatus; reason: string | null } | null
277
+ lastError: { at: string; reason: string } | null
278
+ lastErrorDetail?: string | null
279
+ readiness?: SpeakerShadowReadiness
280
+ deferredReason: string | null
281
+ }
282
+
283
+ const WORKER_PATH = fileURLToPath(new URL('./speaker-shadow-worker.ts', import.meta.url))
284
+
285
+ function defaultSpawnWorker(): ChildProcess {
286
+ const tsx = createRequire(import.meta.url).resolve('tsx/esm')
287
+ return spawn(process.execPath, ['--import', pathToFileURL(tsx).href, WORKER_PATH], {
288
+ detached: true,
289
+ stdio: ['pipe', 'pipe', 'pipe'],
290
+ // The worker needs the data dir to resolve nothing at all (paths arrive in the job), but
291
+ // importing server modules creates DATA_DIR, so point it at the same one.
292
+ env: { PATH: process.env.PATH, HOME: process.env.HOME, TMPDIR: process.env.TMPDIR, COS_DATA_DIR: process.env.COS_DATA_DIR, LANG: process.env.LANG },
293
+ })
294
+ }
295
+
296
+ function defaultSampleRss(pgid: number): Promise<number | null> {
297
+ return new Promise(resolve => {
298
+ execFile('/bin/ps', ['-axo', 'pgid=,rss='], { timeout: 5_000, maxBuffer: 4 * 1024 * 1024 }, (error, stdout) => {
299
+ if (error) { resolve(null); return }
300
+ let kb = 0
301
+ for (const line of stdout.split('\n')) {
302
+ const [g, rss] = line.trim().split(/\s+/).map(Number)
303
+ if (g === pgid && Number.isFinite(rss)) kb += rss
304
+ }
305
+ resolve(kb * 1024)
306
+ })
307
+ })
308
+ }
309
+
310
+ async function sha256File(path: string): Promise<string> {
311
+ const hash = createHash('sha256')
312
+ await pipeline(createReadStream(path), new Transform({ transform(chunk, _e, cb) { hash.update(chunk); cb(null) } }))
313
+ return hash.digest('hex')
314
+ }
315
+
316
+ /** The server's own fluidaudiocli: the one the Parakeet install placed and verified. */
317
+ async function defaultResolveCli(): Promise<SpeakerShadowCli | null> {
318
+ try {
319
+ if (!parakeetInstalled().installed) return null
320
+ const path = parakeetCliPath()
321
+ if (!(await installedCliIntact(path))) return null
322
+ return { path, sha256: await sha256File(path), trust: installedCliTrust() }
323
+ } catch { return null }
324
+ }
325
+
326
+ export function defaultSpeakerShadowDeps(): SpeakerShadowDeps {
327
+ return {
328
+ env: () => process.env,
329
+ root: speakerShadowRoot,
330
+ now: Date.now,
331
+ captureActive: null,
332
+ admissionsOpen: () => true,
333
+ resolveCli: defaultResolveCli,
334
+ audioDirFor: sessionId => join(speakerShadowRoot(), '..', 'meeting-audio', sessionId.replace(/:/g, '_')),
335
+ modelsDir: () => speakerShadowModelsDir(process.env),
336
+ homeDir: speakerShadowHome,
337
+ sandboxExec: SANDBOX_EXEC,
338
+ tmpRoot: tmpdir(),
339
+ spawnWorker: defaultSpawnWorker,
340
+ sampleRss: defaultSampleRss,
341
+ killGroup: pgid => { try { process.kill(-pgid, 'SIGKILL') } catch { /* gone */ } },
342
+ log: line => console.log(`[speaker-shadow] ${line}`),
343
+ setTimer: (fn, ms) => setTimeout(fn, ms),
344
+ modelsPresent: shadowModelsPresent,
345
+ limits: () => speakerShadowLimits(process.env),
346
+ cliReadiness: () => { try { return { installed: parakeetInstalled().installed, trust: installedCliTrust() } } catch { return { installed: false, trust: null } } },
347
+ }
348
+ }
349
+
350
+ type Supervised = ShadowJobOutcome | { status: 'deferred'; reason: string } | { status: 'failed'; reason: SpeakerShadowRunnerReason; detail: string }
351
+
352
+ export class SpeakerShadowRunner {
353
+ private readonly deps: SpeakerShadowDeps
354
+ private running: { sessionId: string; pgid: number | null } | null = null
355
+ private pumping = false
356
+ private timer: { unref?: () => void } | null = null
357
+ private deferredReason: string | null = null
358
+ private exitHook = false
359
+
360
+ constructor(deps: Partial<SpeakerShadowDeps> = {}) {
361
+ this.deps = { ...defaultSpeakerShadowDeps(), ...deps }
362
+ }
363
+
364
+ enabled(): boolean { return speakerShadowEnabled(this.deps.env(), this.deps.root()) }
365
+
366
+ switchState(): { enabled: boolean; source: SpeakerShadowSwitchSource } { return resolveSpeakerShadowSwitch(this.deps.env(), this.deps.root()) }
367
+
368
+ /** Production wiring: what counts as live meeting work. Until it is set, nothing runs. */
369
+ setCaptureProbe(probe: () => boolean, admissionsOpen?: () => boolean): void {
370
+ this.deps.captureActive = probe
371
+ if (admissionsOpen) this.deps.admissionsOpen = admissionsOpen
372
+ }
373
+
374
+ private statePath(): string { return join(this.deps.root(), SPEAKER_SHADOW_STATE) }
375
+
376
+ private readState(): ShadowState {
377
+ const day = localDay(this.deps.now())
378
+ let state: ShadowState
379
+ try {
380
+ const parsed = JSON.parse(readFileSyncSafe(this.statePath()) ?? '') as ShadowState
381
+ state = parsed?.schema === 1 && Array.isArray(parsed.queue) ? { ...emptyState(day), ...parsed, done: Array.isArray(parsed.done) ? parsed.done : [] } : emptyState(day)
382
+ } catch { state = emptyState(day) }
383
+ if (state.day !== day) state = { ...state, day, runsToday: 0 }
384
+ return state
385
+ }
386
+
387
+ private writeState(state: ShadowState): void {
388
+ mkdirSync(this.deps.root(), { recursive: true, mode: 0o700 })
389
+ state.done = state.done.slice(-500)
390
+ durableAtomicWriteFileSync(this.statePath(), `${JSON.stringify(state, null, 1)}\n`, { mode: 0o600 })
391
+ }
392
+
393
+ /**
394
+ * A Mac recording finished finalizing. Never throws and never blocks the caller: the
395
+ * sidecar read and everything after it run in the background.
396
+ */
397
+ notifyFinalized(input: { sessionId: string; sidecarPath: string }): void {
398
+ try {
399
+ if (!this.enabled() || !MAC_CAPTURE_ID.test(input.sessionId)) return
400
+ void this.enqueue(input).catch(error => this.deps.log(`enqueue failed for ${input.sessionId}: ${describe(error)}`))
401
+ } catch (error) {
402
+ this.deps.log(`notify failed: ${describe(error)}`)
403
+ }
404
+ }
405
+
406
+ private async enqueue(input: { sessionId: string; sidecarPath: string }): Promise<void> {
407
+ let labelsHash: string | null = null
408
+ try { labelsHash = shadowLabelsHash(shadowEntries(JSON.parse(await readFile(input.sidecarPath, 'utf8')))) } catch { /* the worker reports the sidecar */ }
409
+ // Read, change and write with no await in between: the pump also rewrites this file.
410
+ const state = this.readState()
411
+ if (state.done.includes(input.sessionId) || state.queue.some(q => q.sessionId === input.sessionId)) return
412
+ state.queue.push({ sessionId: input.sessionId, sidecarPath: input.sidecarPath, labelsHash, enqueuedAt: this.deps.now() })
413
+ const dropped = state.queue.splice(0, Math.max(0, state.queue.length - MAX_QUEUE))
414
+ for (const item of dropped) state.done.push(item.sessionId)
415
+ this.writeState(state)
416
+ for (const item of dropped) await this.record(item, 'skipped', 'stale', 'the shadow queue was full')
417
+ this.deps.log(`queued ${input.sessionId}`)
418
+ void this.pump()
419
+ }
420
+
421
+ /** Boot: pick up meetings a previous process queued but never ran. */
422
+ resume(): void {
423
+ try {
424
+ if (!this.enabled() || !existsSync(this.statePath())) return
425
+ if (this.readState().queue.length) void this.pump()
426
+ } catch (error) { this.deps.log(`resume failed: ${describe(error)}`) }
427
+ }
428
+
429
+ private schedule(ms: number, reason: string): void {
430
+ if (this.deferredReason !== reason) this.deps.log(`deferred: ${reason}`)
431
+ this.deferredReason = reason
432
+ if (this.timer) return
433
+ const t = this.deps.setTimer(() => { this.timer = null; void this.pump() }, ms)
434
+ t.unref?.()
435
+ this.timer = t
436
+ }
437
+
438
+ private async record(item: QueueItem, status: SpeakerShadowStatus, reason: string | null, detail: string | null, extra: Partial<SpeakerShadowRecord> = {}): Promise<void> {
439
+ const record: SpeakerShadowRecord = {
440
+ schema: 1, kind: 'speaker_shadow', logOnly: true,
441
+ recordedAt: new Date(this.deps.now()).toISOString(),
442
+ serverVersion: process.env.COS_SERVER_VERSION?.trim() || 'development',
443
+ sessionId: item.sessionId, enqueuedAt: new Date(item.enqueuedAt).toISOString(),
444
+ status, reason, detail, gates: GATES_RECORD, ...extra,
445
+ }
446
+ await appendSpeakerShadowRecord(this.deps.root(), record)
447
+ }
448
+
449
+ /**
450
+ * The readable page for one finished meeting, written atomically under reports/<day>/. Every
451
+ * finished meeting gets one: proposals, or a short page saying why there are none. A report
452
+ * that cannot be written is logged and never blocks the record.
453
+ */
454
+ private async writeReport(item: QueueItem, status: SpeakerShadowStatus, reason: string | null, detail: string | null, report?: { title: string; markdown: string }): Promise<string | null> {
455
+ try {
456
+ const title = report?.title ?? await readShadowMeetingTitle(item.sidecarPath)
457
+ const markdown = report?.markdown ?? renderShadowSkipReport({ title, sessionId: item.sessionId, status: status === 'proposed' ? 'skipped' : status, reason, detail })
458
+ const rel = shadowReportRelativePath(title, item.sessionId, shadowMeetingDay(item.sidecarPath) ?? localDay(this.deps.now()))
459
+ const path = join(this.deps.root(), rel)
460
+ mkdirSync(dirname(path), { recursive: true, mode: 0o700 })
461
+ durableAtomicWriteFileSync(path, markdown, { mode: 0o600 })
462
+ return rel
463
+ } catch (error) {
464
+ this.deps.log(`report not written for ${item.sessionId}: ${describe(error)}`)
465
+ return null
466
+ }
467
+ }
468
+
469
+ /** Runs queued meetings one at a time until the queue is empty or something says wait. */
470
+ async pump(): Promise<void> {
471
+ if (this.pumping || this.running) return
472
+ this.pumping = true
473
+ try {
474
+ for (;;) {
475
+ if (!this.enabled()) return
476
+ const state = this.readState()
477
+ const item = state.queue[0]
478
+ if (!item) { this.deferredReason = null; return }
479
+ const now = this.deps.now()
480
+ const finish = async (status: SpeakerShadowStatus, reason: string | null, detail: string | null, extra: Partial<SpeakerShadowRecord> = {}, report?: { title: string; markdown: string }) => {
481
+ const reportPath = await this.writeReport(item, status, reason, detail, report)
482
+ await this.record(item, status, reason, detail, { ...extra, report: reportPath })
483
+ const next = this.readState()
484
+ next.queue = next.queue.filter(q => q.sessionId !== item.sessionId)
485
+ next.done.push(item.sessionId)
486
+ next.lastRun = { at: this.deps.now(), sessionId: item.sessionId, status, reason }
487
+ if (status === 'failed') {
488
+ next.consecutiveFailures += 1
489
+ next.lastError = { at: this.deps.now(), reason: reason ?? 'failed', detail: (detail ?? '').slice(0, 300) }
490
+ if (next.consecutiveFailures >= SPEAKER_SHADOW_BREAKER_FAILURES) {
491
+ next.breakerUntil = this.deps.now() + SPEAKER_SHADOW_BREAKER_PAUSE_MS
492
+ this.deps.log(`breaker open for 1 h after ${next.consecutiveFailures} failures (last: ${reason})`)
493
+ }
494
+ } else if (status === 'proposed') {
495
+ next.consecutiveFailures = 0
496
+ next.breakerUntil = null
497
+ }
498
+ this.writeState(next)
499
+ this.deps.log(`${status} ${item.sessionId}${reason ? ` (${reason})` : ''}${extra.proposals ? `: ${extra.proposals.productionVoices} voices, ${extra.proposals.acceptedMerges} of ${extra.proposals.proposedMerges} proposed merges pass` : ''}`)
500
+ }
501
+
502
+ if (now - item.enqueuedAt > QUEUE_MAX_AGE_MS) { await finish('skipped', 'stale', 'queued for more than 24 hours'); continue }
503
+ if (state.breakerUntil && now < state.breakerUntil) { this.schedule(state.breakerUntil - now, 'breaker_open'); return }
504
+ if (!this.deps.captureActive) { this.schedule(DEFER_RETRY_MS, 'no_live_probe'); return }
505
+ if (this.deps.captureActive() || !this.deps.admissionsOpen()) { this.schedule(DEFER_RETRY_MS, 'live_work'); return }
506
+ const limits = this.deps.limits()
507
+ if (state.runsToday >= limits.dailyRuns) { await finish('skipped', 'daily_cap', `${limits.dailyRuns} runs today`); continue }
508
+ const cli = await this.deps.resolveCli()
509
+ if (!cli) { await finish('skipped', 'cli_missing', 'no verified fluidaudiocli from the Parakeet install'); continue }
510
+ const modelsDir = this.deps.modelsDir()
511
+ if (!this.deps.modelsPresent(modelsDir)) { await finish('skipped', 'models_missing', 'the pinned speaker-diarization models are not on this Mac (the server never downloads them)'); continue }
512
+ const audioDir = this.deps.audioDirFor(item.sessionId)
513
+ if (!existsSync(audioDir)) { await finish('skipped', 'audio_missing', 'no retained chunk audio for this meeting'); continue }
514
+
515
+ // Fresh read: the awaits above let an enqueue rewrite the file since `state` was read.
516
+ const counted = this.readState()
517
+ counted.runsToday += 1
518
+ this.writeState(counted)
519
+ this.deferredReason = null
520
+ const job: ShadowJob = {
521
+ sessionId: item.sessionId, sidecarPath: item.sidecarPath, audioDir, expectedLabelsHash: item.labelsHash,
522
+ cliPath: cli.path, cliSha256: cli.sha256, sandboxExec: this.deps.sandboxExec, modelsDir,
523
+ homeDir: this.deps.homeDir(), tmpRoot: this.deps.tmpRoot, cliTimeoutMs: Math.max(5_000, limits.timeoutMs - 5_000),
524
+ }
525
+ this.deps.log(`start ${item.sessionId}`)
526
+ const started = this.deps.now()
527
+ const { outcome, peakRss } = await this.supervise(job, limits)
528
+ const resources = { wallMs: this.deps.now() - started, peakRssBytes: peakRss, cliTrust: cli.trust }
529
+ if (outcome.status === 'deferred') {
530
+ // Not a failure and not a run: the meeting stays at the head of the queue.
531
+ const back = this.readState()
532
+ back.runsToday = Math.max(0, back.runsToday - 1)
533
+ this.writeState(back)
534
+ this.deps.log(`stopped ${item.sessionId}: ${outcome.reason}; it runs again when the live work ends`)
535
+ this.schedule(DEFER_RETRY_MS, outcome.reason)
536
+ return
537
+ }
538
+ if (outcome.status === 'proposed') {
539
+ const f = outcome.facts
540
+ const markdown = `${outcome.report.markdown}\n${renderShadowRunFacts({ rebuildMs: f.timing.rebuildMs, nativeMs: f.timing.nativeMs, wallMs: resources.wallMs, peakRssBytes: resources.peakRssBytes, osTempDirsLeft: f.osTempDirsLeft, sidecarPath: item.sidecarPath, pcmSHA256: f.timelinePCMSHA256, cliTrust: cli.trust })}`
541
+ await finish('proposed', null, null, { facts: outcome.facts, proposals: outcome.proposals, resources }, { title: outcome.report.title, markdown })
542
+ }
543
+ else await finish(outcome.status, outcome.reason, outcome.detail, { resources })
544
+ }
545
+ } catch (error) {
546
+ this.deps.log(`pump failed: ${describe(error)}`)
547
+ } finally {
548
+ this.pumping = false
549
+ }
550
+ }
551
+
552
+ private supervise(job: ShadowJob, limits: SpeakerShadowLimits): Promise<{ outcome: Supervised; peakRss: number | null }> {
553
+ return new Promise(resolvePromise => {
554
+ let child: ChildProcess
555
+ try { child = this.deps.spawnWorker() } catch (error) {
556
+ resolvePromise({ outcome: { status: 'failed', reason: 'worker_spawn_failed', detail: describe(error) }, peakRss: null })
557
+ return
558
+ }
559
+ const pgid = child.pid ?? null
560
+ this.running = { sessionId: job.sessionId, pgid }
561
+ this.installExitHook()
562
+ if (pgid) { try { setPriority(pgid, 10) } catch { /* runs at normal priority */ } }
563
+ const started = this.deps.now()
564
+ let stdout = '', stderr = '', verdict: Supervised | null = null, peak: number | null = null, sampling = false, settled = false
565
+ const stop = (outcome: Supervised) => { if (!verdict) verdict = outcome; if (pgid) this.deps.killGroup(pgid) }
566
+ const tick = setInterval(() => {
567
+ if (verdict) return
568
+ if (this.deps.now() - started > limits.timeoutMs) { stop({ status: 'failed', reason: 'timeout', detail: `no result within ${limits.timeoutMs} ms` }); return }
569
+ if (!this.enabled()) { stop({ status: 'deferred', reason: 'switched_off' }); return }
570
+ if (this.deps.captureActive?.() || !this.deps.admissionsOpen()) { stop({ status: 'deferred', reason: 'capture_live' }); return }
571
+ if (!pgid || sampling) return
572
+ sampling = true
573
+ void this.deps.sampleRss(pgid).then(bytes => {
574
+ sampling = false
575
+ if (bytes === null || verdict) return
576
+ peak = Math.max(peak ?? 0, bytes)
577
+ if (bytes > limits.rssBytes) stop({ status: 'failed', reason: 'rss_cap', detail: `the run used ${Math.round(bytes / 1048576)} MB, over the ${Math.round(limits.rssBytes / 1048576)} MB cap` })
578
+ })
579
+ }, SAMPLE_MS)
580
+ tick.unref?.()
581
+ const done = (code: number | null, signal: NodeJS.Signals | null) => {
582
+ if (settled) return
583
+ settled = true
584
+ clearInterval(tick)
585
+ this.running = null
586
+ if (verdict) { resolvePromise({ outcome: verdict, peakRss: peak }); return }
587
+ const line = stdout.trim().split('\n').pop() ?? ''
588
+ try {
589
+ const outcome = JSON.parse(line) as ShadowJobOutcome
590
+ if (outcome && (outcome.status === 'proposed' || outcome.status === 'skipped' || outcome.status === 'failed')) { resolvePromise({ outcome, peakRss: peak }); return }
591
+ } catch { /* below */ }
592
+ resolvePromise({ outcome: { status: 'failed', reason: 'worker_crashed', detail: `worker exit ${code ?? signal}: ${stderr.trim().split('\n').slice(-2).join(' | ').slice(0, 200)}` }, peakRss: peak })
593
+ }
594
+ child.stdout?.on('data', (d: Buffer) => { if (stdout.length < MAX_STDOUT) stdout += d.toString(); else stop({ status: 'failed', reason: 'worker_crashed', detail: 'worker output over 8 MB' }) })
595
+ child.stderr?.on('data', (d: Buffer) => { if (stderr.length < 16_384) stderr += d.toString() })
596
+ child.on('error', error => { stop({ status: 'failed', reason: 'worker_spawn_failed', detail: error.message }); done(null, null) })
597
+ child.on('close', done)
598
+ child.stdin?.on('error', () => { /* a crashed worker closes stdin; close reports it */ })
599
+ child.stdin?.end(JSON.stringify(job))
600
+ })
601
+ }
602
+
603
+ private installExitHook(): void {
604
+ if (this.exitHook) return
605
+ this.exitHook = true
606
+ process.once('exit', () => { if (this.running?.pgid) this.deps.killGroup(this.running.pgid) })
607
+ }
608
+
609
+ snapshot(options: { detail?: boolean } = {}): SpeakerShadowSnapshot {
610
+ const { enabled, source } = this.switchState()
611
+ const limits = this.deps.limits()
612
+ let state: ShadowState | null = null
613
+ try { if (existsSync(this.statePath())) state = this.readState() } catch { state = null }
614
+ const now = this.deps.now()
615
+ const breakerOpen = !!state?.breakerUntil && now < state.breakerUntil
616
+ const phase: SpeakerShadowSnapshot['state'] = !enabled ? 'off'
617
+ : this.running ? 'running'
618
+ : breakerOpen ? 'breaker_open'
619
+ : !this.deps.captureActive && state?.queue.length ? 'no_live_probe'
620
+ : this.deferredReason && state?.queue.length ? 'deferred'
621
+ : state?.queue.length ? 'queued' : 'idle'
622
+ const readiness = (): SpeakerShadowReadiness => {
623
+ const cli = this.deps.cliReadiness()
624
+ let modelsPresent = false
625
+ try { modelsPresent = this.deps.modelsPresent(this.deps.modelsDir()) } catch { modelsPresent = false }
626
+ return { modelsPresent, cliInstalled: cli.installed, cliTrust: cli.trust, sandboxExec: existsSync(this.deps.sandboxExec) }
627
+ }
628
+ return {
629
+ enabled, source, state: phase,
630
+ queued: state?.queue.length ?? 0,
631
+ runsToday: state && state.day === localDay(now) ? state.runsToday : 0,
632
+ dailyCap: limits.dailyRuns,
633
+ breaker: { open: breakerOpen, failures: state?.consecutiveFailures ?? 0, until: breakerOpen ? new Date(state!.breakerUntil!).toISOString() : null },
634
+ lastRun: state?.lastRun ? { at: new Date(state.lastRun.at).toISOString(), status: state.lastRun.status, reason: state.lastRun.reason } : null,
635
+ lastError: state?.lastError ? { at: new Date(state.lastError.at).toISOString(), reason: state.lastError.reason } : null,
636
+ ...(options.detail ? { lastErrorDetail: state?.lastError?.detail ?? null, readiness: readiness() } : {}),
637
+ deferredReason: this.deferredReason,
638
+ }
639
+ }
640
+ }
641
+
642
+ /** One synchronous read, only for the small state file. */
643
+ function readFileSyncSafe(path: string): string | null {
644
+ try { return readFileSync(path, 'utf8') } catch { return null }
645
+ }
646
+
647
+ function describe(error: unknown): string {
648
+ return error instanceof Error ? error.message : String(error)
649
+ }
650
+
651
+ /** Server start: temp directories a previous worker left (a SIGKILL skips its cleanup). */
652
+ export function sweepStaleSpeakerShadowTmp(root = tmpdir(), minAgeMs = 10 * 60_000, now = Date.now()): number {
653
+ let removed = 0
654
+ try {
655
+ for (const name of readdirSync(root)) {
656
+ if (!name.startsWith(SHADOW_TMP_PREFIX)) continue
657
+ const path = join(root, name)
658
+ try { if (now - statSync(path).mtimeMs >= minAgeMs) { rmSync(path, { recursive: true, force: true }); removed++ } } catch { /* raced */ }
659
+ }
660
+ } catch { /* no tmp root */ }
661
+ return removed
662
+ }
663
+
664
+ // ── Process-wide runner ──────────────────────────────────────────────────────
665
+
666
+ let shared: SpeakerShadowRunner | null = null
667
+ export function getSpeakerShadowRunner(): SpeakerShadowRunner {
668
+ shared ??= new SpeakerShadowRunner()
669
+ return shared
670
+ }
671
+ export function resetSpeakerShadowRunnerForTests(runner: SpeakerShadowRunner | null = null): void {
672
+ shared = runner
673
+ }
674
+
675
+ /**
676
+ * Leases that mean live meeting or dictation work. The merge engine's CAPTURE_LEASE_KINDS
677
+ * (meeting-actions.ts) plus dictation's draft writes, warms and finalize: dictation runs
678
+ * Parakeet on the same Neural Engine this CLI uses. Pinned against CAPTURE_LEASE_KINDS by test.
679
+ */
680
+ export const SPEAKER_SHADOW_LIVE_LEASES = [
681
+ 'recording_chunk', 'meeting_save', 'meeting_batch_finalization', 'orphan_recovery', 'one_shot_transcription',
682
+ 'prompt_draft_write', 'prompt_draft_warm', 'prompt_draft_finalize',
683
+ ] as const
684
+
685
+ export function speakerShadowLeaseActive(activeByKind: () => Record<string, number> = () => maintenanceLifecycle.snapshot().activeByKind as Record<string, number>): boolean {
686
+ const byKind = activeByKind()
687
+ return SPEAKER_SHADOW_LIVE_LEASES.some(kind => (byKind[kind] ?? 0) > 0)
688
+ }
689
+
690
+ /** Server boot: what counts as live work, then the temp sweep and any queue a previous process left. */
691
+ export function configureSpeakerShadow(probe: { captureActive: () => boolean; admissionsOpen: () => boolean }): void {
692
+ try {
693
+ const runner = getSpeakerShadowRunner()
694
+ runner.setCaptureProbe(probe.captureActive, probe.admissionsOpen)
695
+ if (!runner.enabled()) return
696
+ const swept = sweepStaleSpeakerShadowTmp()
697
+ if (swept) console.log(`[speaker-shadow] removed ${swept} stale temp dir(s)`)
698
+ console.log('[speaker-shadow] enabled (log only)')
699
+ runner.resume()
700
+ } catch (error) {
701
+ console.warn('[speaker-shadow] configure failed:', describe(error))
702
+ }
703
+ }
704
+
705
+ /** The finalization path's call. Never throws. */
706
+ export function notifySpeakerShadowFinalized(input: { sessionId: string; sidecarPath: string }): void {
707
+ try { getSpeakerShadowRunner().notifyFinalized(input) } catch (error) { console.warn('[speaker-shadow] unavailable:', describe(error)) }
708
+ }
709
+
710
+ /**
711
+ * `/api/health`: `speaker_shadow` only while shadow mode is on. Absent, never null, when off, so
712
+ * the public health shape is unchanged by default (pinned by health-6890-parity.test.ts).
713
+ */
714
+ export function speakerShadowHealthFields(env: NodeJS.ProcessEnv = process.env, root: string = speakerShadowRoot()): { speaker_shadow?: Omit<SpeakerShadowSnapshot, 'lastErrorDetail' | 'readiness'> } {
715
+ if (!speakerShadowEnabled(env, root)) return {}
716
+ return { speaker_shadow: speakerShadowHealth() }
717
+ }
718
+
719
+ /** For /api/health (codes and counts only, no session ids, paths or error text). */
720
+ export function speakerShadowHealth(): Omit<SpeakerShadowSnapshot, 'lastErrorDetail' | 'readiness'> {
721
+ try {
722
+ const { lastErrorDetail: _detail, readiness: _readiness, ...rest } = getSpeakerShadowRunner().snapshot()
723
+ return rest
724
+ } catch {
725
+ return { enabled: false, source: 'default', state: 'off', queued: 0, runsToday: 0, dailyCap: 0, breaker: { open: false, failures: 0, until: null }, lastRun: null, lastError: null, deferredReason: null }
726
+ }
727
+ }