@gotcos/glasses-server 6.18.6 → 6.18.8

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/CHANGELOG.md CHANGED
@@ -1,3 +1,22 @@
1
+ ## 6.18.8
2
+
3
+ - **Prompt draft peeks for live ASR.** `POST /api/prompt-drafts/:draftId/peek`
4
+ runs Turbo locally, emits `prompt_transcript` with `provisional: true` +
5
+ `peekGen`, and does **not** advance the recovery ledger. Drop-on-busy,
6
+ `learnInline=false`, and `affectsCircuit: false` so peeks cannot trip or
7
+ success-reset the Whisper breaker.
8
+ - **Interactive HQ keeps short utterance heads.** Compose HQ skips light
9
+ enhance (highpass was truncating "device, just for…") and omits CLI `--vad`
10
+ as defense-in-depth. Meeting/batch HQ paths unchanged.
11
+
12
+ ## 6.18.7
13
+
14
+ - **Phone Restart no longer leaves the server Stopped.** Control LaunchAgent
15
+ KeepAlive is `SuccessfulExit: false`, so the old SIGTERM→exit(0) path never
16
+ came back — phone showed "Restart requested; reconnect is taking longer than
17
+ expected" while Control sat at Stopped. Restart now `launchctl kickstart -k`
18
+ (fallback `exit(1)` so KeepAlive still fires).
19
+
1
20
  ## 6.18.6
2
21
 
3
22
  - **Phone Restart works on COS Control managed installs.** Recovery status /
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gotcos/glasses-server",
3
- "version": "6.18.6",
3
+ "version": "6.18.8",
4
4
  "description": "COS Glasses \u2014 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": {
@@ -15,6 +15,7 @@ import { secureExistingPrivateFile } from './secure-user-config.js'
15
15
 
16
16
  import type { Exchange } from './conversation.js'
17
17
  import { resolveExchangePairModel } from './conversation.js'
18
+ import type { ModelPreference } from '../../shared/model-preference.js'
18
19
 
19
20
  import { dataPath } from './data-dir.js'
20
21
  const ARCHIVE_DIR = dataPath('archive')
@@ -60,6 +61,8 @@ export interface SessionToArchive {
60
61
  contextBreaks: number[]
61
62
  createdAt: number
62
63
  lastActivity: number
64
+ /** Optional session-level model fallback for pre-stamp exchange pairs. */
65
+ modelPreference?: ModelPreference | null
63
66
  }
64
67
 
65
68
  // ── Directory management ────────────────────────────────────
@@ -876,6 +876,7 @@ export function getActiveSessions(): SessionToArchive[] {
876
876
  contextBreaks: session.contextBreaks,
877
877
  createdAt: session.createdAt,
878
878
  lastActivity: session.lastActivity,
879
+ modelPreference: session.modelPreference,
879
880
  })
880
881
  }
881
882
  return result
@@ -46,6 +46,7 @@ export type MaintenanceWorkKind =
46
46
  | 'meeting_batch_finalization'
47
47
  | 'prompt_draft_write'
48
48
  | 'prompt_draft_warm'
49
+ | 'prompt_draft_peek'
49
50
  | 'prompt_draft_finalize'
50
51
  // Live Cues pipeline (planner + memory hops + insight). Held for the
51
52
  // pipeline's in-flight duration so an Update Server drain cannot unload the
@@ -0,0 +1,47 @@
1
+ import { spawn } from 'node:child_process'
2
+ import { userInfo } from 'node:os'
3
+
4
+ /** LaunchAgent label Control installs for the managed glasses server. */
5
+ export const DEFAULT_LAUNCHD_LABEL = 'com.cos.glasses-server'
6
+
7
+ /**
8
+ * COS Control's LaunchAgent uses KeepAlive SuccessfulExit:false — a clean
9
+ * SIGTERM/exit(0) leaves the service STOPPED. Phone Restart must either
10
+ * kickstart -k (kill + start) or exit non-zero so KeepAlive brings it back.
11
+ */
12
+ export function scheduleManagedServerRestart(options?: {
13
+ delayMs?: number
14
+ label?: string
15
+ kickstart?: (args: string[]) => void
16
+ exitProcess?: (code: number) => void
17
+ onError?: (error: unknown) => void
18
+ }): void {
19
+ const delayMs = options?.delayMs ?? 350
20
+ const label = (options?.label ?? process.env.COS_LAUNCHD_LABEL?.trim()) || DEFAULT_LAUNCHD_LABEL
21
+ const kickstart = options?.kickstart ?? ((args: string[]) => {
22
+ const child = spawn('launchctl', args, { detached: true, stdio: 'ignore' })
23
+ child.unref()
24
+ })
25
+ const exitProcess = options?.exitProcess ?? ((code: number) => { process.exit(code) })
26
+ const onError = options?.onError ?? ((error: unknown) => {
27
+ console.error('[recovery] Failed to schedule managed server restart:', error)
28
+ })
29
+
30
+ const timer = setTimeout(() => {
31
+ if (process.env.COS_DISABLE_SELF_RESTART === '1') return
32
+ try {
33
+ const uid = userInfo().uid
34
+ kickstart(['kickstart', '-k', `gui/${uid}/${label}`])
35
+ // If kickstart did not kill us quickly (misconfigured label), force a
36
+ // non-zero exit so KeepAlive SuccessfulExit:false still restarts.
37
+ setTimeout(() => {
38
+ if (process.env.COS_DISABLE_SELF_RESTART === '1') return
39
+ try { exitProcess(1) } catch (error) { onError(error) }
40
+ }, 2_000).unref?.()
41
+ } catch (error) {
42
+ onError(error)
43
+ try { exitProcess(1) } catch (exitError) { onError(exitError) }
44
+ }
45
+ }, delayMs)
46
+ timer.unref?.()
47
+ }
@@ -114,7 +114,7 @@ async function projectPublicConversationTerminal(
114
114
  request.globalMsgNum,
115
115
  request.attachmentRefs,
116
116
  request.messageEra,
117
- request.model,
117
+ normalizeModelPreference(request.model),
118
118
  )
119
119
  reconcileExchangeByJobIdentity(
120
120
  request.sessionId,
@@ -124,7 +124,7 @@ async function projectPublicConversationTerminal(
124
124
  request.globalMsgNum,
125
125
  mergeMediaAttachmentRefs(outputAttachments, existingOutputAttachments),
126
126
  request.messageEra,
127
- request.model,
127
+ normalizeModelPreference(request.model),
128
128
  )
129
129
  flushConversationToDisk()
130
130
  }
@@ -13,7 +13,6 @@ import {
13
13
  estimateAudioSeconds,
14
14
  OpenAIWhisperBudgetExhaustedError,
15
15
  } from './openai-whisper-budget.js'
16
- import { enhanceAudio } from './audio-enhance.js'
17
16
  import {
18
17
  stripInlineHallucinationsOneShot,
19
18
  isFullHallucination,
@@ -63,12 +62,6 @@ export class NoSpeechDetectedError extends Error {
63
62
  // ceiling (anything longer is a dictation, not a query — use meetings instead).
64
63
  const HQ_MAX_SECONDS = 60
65
64
 
66
- /** Short interactive clips use light enhance (highpass only). Override via env. */
67
- function hqEnhanceLightMaxSeconds(): number {
68
- const value = Number.parseInt(process.env.COS_HQ_ENHANCE_LIGHT_MAX_SEC || '15', 10)
69
- return Number.isFinite(value) && value >= 0 ? value : 15
70
- }
71
-
72
65
  function unavailableAfterLocalFailure(): TranscriptionUnavailableError | null {
73
66
  const fallback = getTranscriptionPolicySnapshot()
74
67
  if (fallback.openaiFallbackReady) return null
@@ -146,10 +139,16 @@ export function resolveTranscribeMode(raw: unknown): TranscribeMode {
146
139
 
147
140
  export async function transcribeAudioBuffer(
148
141
  audioBuffer: Buffer,
149
- opts: { mode?: TranscribeMode; policy?: TranscriptionBackendPolicy } = {},
142
+ opts: {
143
+ mode?: TranscribeMode
144
+ policy?: TranscriptionBackendPolicy
145
+ /** When false, turbo/server failures do not move the shared meeting circuit breaker. */
146
+ affectsCircuit?: boolean
147
+ } = {},
150
148
  ): Promise<TranscribeAudioResult> {
151
149
  const requestedMode = opts.mode ?? 'hq'
152
150
  const policy = opts.policy ?? 'automatic'
151
+ const affectsCircuit = opts.affectsCircuit !== false
153
152
  const audioSeconds = estimateAudioSeconds(audioBuffer)
154
153
  const effectiveMode: TranscribeMode =
155
154
  requestedMode === 'hq' && audioSeconds > HQ_MAX_SECONDS ? 'fast' : requestedMode
@@ -166,14 +165,14 @@ export async function transcribeAudioBuffer(
166
165
 
167
166
  if (effectiveMode === 'hq') {
168
167
  try {
169
- const lightMax = hqEnhanceLightMaxSeconds()
170
- const enhanceProfile = audioSeconds < lightMax ? 'light' as const : 'full' as const
171
- const enhanced = await enhanceAudio(audioBuffer, { profile: enhanceProfile })
172
- const result = await transcribeHighQuality(enhanced)
168
+ // A0 (2026-07-30): ffmpeg enhance light (highpass=f=80) was measured dropping
169
+ // leading speech on compose ("device just for your awareness"). Meeting batch
170
+ // still enhances in meeting-batch-transcribe.ts this path is prompt/interactive only.
171
+ const result = await transcribeHighQuality(audioBuffer, undefined, { priority: 'interactive' })
173
172
  text = result.text
174
173
  actualQuality = result.actualQuality
175
174
  if (result.actualQuality === 'hq') {
176
- backend = enhanceProfile === 'light' ? 'hq-large-v3-light' : 'hq-large-v3'
175
+ backend = 'hq-large-v3'
177
176
  } else {
178
177
  backend = result.backend === 'whisper-cli' ? 'fast-cli-turbo' : 'fast-local-server'
179
178
  degradationReason = result.degradationReason ?? 'hq_unavailable'
@@ -182,7 +181,7 @@ export async function transcribeAudioBuffer(
182
181
  console.warn(`[transcribe] HQ path failed, falling back to fast: ${hqErr.message}`)
183
182
  degradationReason = 'hq_decode_failed'
184
183
  try {
185
- const result = await transcribeLocal(audioBuffer)
184
+ const result = await transcribeLocal(audioBuffer, undefined, undefined, { affectsCircuit })
186
185
  text = result.text
187
186
  backend = `fast-local-${result.backend}`
188
187
  actualQuality = 'fast'
@@ -202,7 +201,7 @@ export async function transcribeAudioBuffer(
202
201
  }
203
202
  } else if (effectiveMode === 'fast') {
204
203
  try {
205
- const result = await transcribeLocal(audioBuffer)
204
+ const result = await transcribeLocal(audioBuffer, undefined, undefined, { affectsCircuit })
206
205
  text = result.text
207
206
  backend = `fast-local-${result.backend}`
208
207
  actualQuality = 'fast'
@@ -137,6 +137,12 @@ const BATCH_LARGE_V3_ENABLED = process.env.COS_BATCH_LARGE_V3 !== '0'
137
137
  const VAD_MODEL_PATH = join(process.env.HOME ?? homedir(), '.local/share/whisper-models/ggml-silero-v5.1.2.bin')
138
138
  const VAD_ENABLED = process.env.COS_WHISPER_VAD !== '0'
139
139
 
140
+ /** Batch meeting HQ keeps CLI --vad. Interactive/prompt HQ must not — VAD was
141
+ * measured dropping leading speech on compose ("device just for your awareness"). */
142
+ export function hqCliVadEnabled(priority?: 'interactive' | 'batch'): boolean {
143
+ return priority === 'batch' && VAD_ENABLED && existsSync(VAD_MODEL_PATH)
144
+ }
145
+
140
146
  /** Pick the batch model path. Prefer large-v3 when enabled + on disk; fall
141
147
  * back to turbo otherwise. Logged once per process so we know which decoder
142
148
  * actually ran when reviewing a meeting later. */
@@ -732,7 +738,6 @@ export async function transcribeHighQuality(
732
738
 
733
739
  const modelPath = resolveBatchModel()
734
740
  const useLargeV3 = modelPath === BATCH_MODEL_LARGE_V3
735
- const useVad = VAD_ENABLED && existsSync(VAD_MODEL_PATH)
736
741
 
737
742
  try {
738
743
  writeFileSync(tmpWav, audioBuffer)
@@ -741,6 +746,9 @@ export async function transcribeHighQuality(
741
746
  // that is explicitly OUT of batch device policy. Only the long post-meeting
742
747
  // batch is admission-controlled against live ASR.
743
748
  const isBatch = opts.priority === 'batch'
749
+ // A0 (2026-07-30): CLI --vad on interactive/prompt HQ can drop leading speech
750
+ // (measured: "device just for your awareness"). Keep VAD for meeting batch only.
751
+ const useVad = hqCliVadEnabled(opts.priority)
744
752
  const decision: { device: 'metal' | 'cpu'; reason: string; metalEnabled: boolean } = isBatch
745
753
  ? (opts.forceCpu
746
754
  ? { device: 'cpu', reason: 'preempt_retry', metalEnabled: batchHqMetalEnabled() }
@@ -774,9 +782,8 @@ export async function transcribeHighQuality(
774
782
  args.push('-ojf', '-of', outBase)
775
783
  }
776
784
  if (useVad) {
777
- // Same VAD model the streaming path uses. Strips silence windows
778
- // before the decoder sees them prevents the silence-hallucination
779
- // failure mode even with large-v3's more permissive decoder.
785
+ // Batch-only: strips silence windows before decode. Interactive/prompt HQ
786
+ // omits this VAD was measured dropping real leading speech on compose.
780
787
  args.push('--vad', '--vad-model', VAD_MODEL_PATH)
781
788
  }
782
789
  const proc = spawn(WHISPER_CLI, args, {
@@ -1076,8 +1083,14 @@ export function resetDecoderCaches(): void {
1076
1083
  * auto-restart the server process in the background and throw immediately so the
1077
1084
  * caller can use cloud while the server recovers (~20s model load).
1078
1085
  */
1079
- export async function transcribeLocal(audioBuffer: Buffer, context?: string, isQuiet?: boolean): Promise<{ text: string; backend: 'server' | 'cli'; words?: WhisperWord[] }> {
1086
+ export async function transcribeLocal(
1087
+ audioBuffer: Buffer,
1088
+ context?: string,
1089
+ isQuiet?: boolean,
1090
+ opts?: { affectsCircuit?: boolean },
1091
+ ): Promise<{ text: string; backend: 'server' | 'cli'; words?: WhisperWord[] }> {
1080
1092
  const start = Date.now()
1093
+ const affectsCircuit = opts?.affectsCircuit !== false
1081
1094
 
1082
1095
  if (!serverAvailable) {
1083
1096
  await reconcileWhisperServerHealth()
@@ -1094,34 +1107,38 @@ export async function transcribeLocal(audioBuffer: Buffer, context?: string, isQ
1094
1107
  const text = applyCorrections(result.text)
1095
1108
  const words = result.words?.map(w => ({ ...w, word: applyCorrections(w.word) }))
1096
1109
  const elapsed = Date.now() - start
1097
- // Reset circuit breaker on success
1098
- if (serverConsecutiveFailures > 0) {
1110
+ // Reset circuit breaker on success (skip for peek / non-circuit callers)
1111
+ if (affectsCircuit && serverConsecutiveFailures > 0) {
1099
1112
  console.log(`[whisper-local] Server recovered after ${serverConsecutiveFailures} consecutive failure(s)`)
1100
1113
  serverConsecutiveFailures = 0
1101
1114
  }
1102
1115
  console.log(`[whisper-local] Server transcribed in ${elapsed}ms (${words?.length ?? 0} words): "${text.slice(0, 80)}${text.length > 80 ? '...' : ''}"`)
1103
1116
  return { text, backend: 'server', words }
1104
1117
  } catch (err: any) {
1105
- serverConsecutiveFailures++
1106
- const isTimeout = err.message.includes('timeout') || err.message.includes('aborted')
1107
- const isDead = err.message.includes('ECONNREFUSED') || err.message.includes('fetch failed')
1108
-
1109
- if (isDead) {
1110
- serverAvailable = false
1111
- console.error(`[whisper-local] Server DEAD (ECONNREFUSED) — marked unavailable. Consecutive failures: ${serverConsecutiveFailures}`)
1112
- } else if (isTimeout) {
1113
- // Server process exists but is hung — mark unavailable so we stop trying
1114
- serverAvailable = false
1115
- console.error(`[whisper-local] Server HUNG (timeout) — marked unavailable. Consecutive failures: ${serverConsecutiveFailures}`)
1116
- }
1118
+ if (affectsCircuit) {
1119
+ serverConsecutiveFailures++
1120
+ const isTimeout = err.message.includes('timeout') || err.message.includes('aborted')
1121
+ const isDead = err.message.includes('ECONNREFUSED') || err.message.includes('fetch failed')
1122
+
1123
+ if (isDead) {
1124
+ serverAvailable = false
1125
+ console.error(`[whisper-local] Server DEAD (ECONNREFUSED) — marked unavailable. Consecutive failures: ${serverConsecutiveFailures}`)
1126
+ } else if (isTimeout) {
1127
+ // Server process exists but is hung — mark unavailable so we stop trying
1128
+ serverAvailable = false
1129
+ console.error(`[whisper-local] Server HUNG (timeout) — marked unavailable. Consecutive failures: ${serverConsecutiveFailures}`)
1130
+ }
1117
1131
 
1118
- // Circuit breaker: auto-restart after threshold
1119
- if (serverConsecutiveFailures >= SERVER_FAILURE_THRESHOLD && !serverRestarting) {
1120
- console.error(`[whisper-local] ⚠ CIRCUIT BREAKER OPEN — ${serverConsecutiveFailures} consecutive failures. Auto-restarting server...`)
1121
- // Non-blocking restart in background
1122
- void restartWhisperServer()
1123
- } else if (serverConsecutiveFailures < SERVER_FAILURE_THRESHOLD) {
1124
- console.warn(`[whisper-local] Server failed (${serverConsecutiveFailures}/${SERVER_FAILURE_THRESHOLD} before restart): ${err.message}`)
1132
+ // Circuit breaker: auto-restart after threshold
1133
+ if (serverConsecutiveFailures >= SERVER_FAILURE_THRESHOLD && !serverRestarting) {
1134
+ console.error(`[whisper-local] ⚠ CIRCUIT BREAKER OPEN — ${serverConsecutiveFailures} consecutive failures. Auto-restarting server...`)
1135
+ // Non-blocking restart in background
1136
+ void restartWhisperServer()
1137
+ } else if (serverConsecutiveFailures < SERVER_FAILURE_THRESHOLD) {
1138
+ console.warn(`[whisper-local] Server failed (${serverConsecutiveFailures}/${SERVER_FAILURE_THRESHOLD} before restart): ${err.message}`)
1139
+ }
1140
+ } else {
1141
+ console.warn(`[whisper-local] Peek/non-circuit server failure (breaker untouched): ${err.message}`)
1125
1142
  }
1126
1143
 
1127
1144
  // Throw to let caller fall to OpenAI cloud (1-3s) — much faster than CLI cold-start (11s)
@@ -1132,10 +1149,12 @@ export async function transcribeLocal(audioBuffer: Buffer, context?: string, isQ
1132
1149
  // Server not available — still count toward circuit breaker so auto-restart can fire.
1133
1150
  // Without this, the counter stalls after the first failure marks serverAvailable=false
1134
1151
  // and subsequent calls never increment, so restart never triggers.
1135
- serverConsecutiveFailures++
1136
- if (serverConsecutiveFailures >= SERVER_FAILURE_THRESHOLD && !serverRestarting) {
1137
- console.error(`[whisper-local] CIRCUIT BREAKER OPEN — ${serverConsecutiveFailures} consecutive failures (server unavailable). Auto-restarting...`)
1138
- void restartWhisperServer()
1152
+ if (affectsCircuit) {
1153
+ serverConsecutiveFailures++
1154
+ if (serverConsecutiveFailures >= SERVER_FAILURE_THRESHOLD && !serverRestarting) {
1155
+ console.error(`[whisper-local] CIRCUIT BREAKER OPEN — ${serverConsecutiveFailures} consecutive failures (server unavailable). Auto-restarting...`)
1156
+ void restartWhisperServer()
1157
+ }
1139
1158
  }
1140
1159
 
1141
1160
  // Throw so the caller applies the configured recovery policy. CLI is
@@ -61,6 +61,9 @@ const modeQualityJobs = new Map<string, Promise<string>>()
61
61
  const finalizeJobs = new Map<string, Promise<any>>()
62
62
  let warmTail: Promise<void> = Promise.resolve()
63
63
  let hqWarmTail: Promise<void> = Promise.resolve()
64
+ /** Peek decodes — drop-while-busy; never chained onto warmTail (HOL vs HQ). */
65
+ let peekTail: Promise<void> = Promise.resolve()
66
+ let peekBusy = false
64
67
 
65
68
  /** Speculative HQ warm while speaking. Set COS_HQ_SPECULATIVE_WARM=0 to restore Fast-only warm. */
66
69
  function speculativeHqWarmEnabled(): boolean {
@@ -372,6 +375,80 @@ promptDraftsRouter.post('/prompt-drafts/start', (req, res) => {
372
375
  }
373
376
  })
374
377
 
378
+ /**
379
+ * Provisional Turbo peek — cosmetic lens UX only.
380
+ * Does NOT save chunks, advance the recovery ledger, or write warm/final transcripts.
381
+ * Drop-while-busy: overlapping peeks return 204 without queueing behind HQ warm.
382
+ */
383
+ promptDraftsRouter.post('/prompt-drafts/:draftId/peek', async (req, res) => {
384
+ try {
385
+ const meta = loadPromptDraftMeta(req.params.draftId)
386
+ if (!meta) return void res.status(404).json({ error: 'draft not found' })
387
+
388
+ const rawIndex = Array.isArray(req.query.chunkIndex) ? req.query.chunkIndex[0] : req.query.chunkIndex
389
+ const chunkIndex = Number(rawIndex)
390
+ if (!Number.isInteger(chunkIndex) || chunkIndex < 0) {
391
+ return void res.status(400).json({ error: 'chunkIndex required' })
392
+ }
393
+ const rawGen = Array.isArray(req.query.peekGen) ? req.query.peekGen[0] : req.query.peekGen
394
+ const peekGen = Number(rawGen)
395
+ if (!Number.isInteger(peekGen) || peekGen < 0) {
396
+ return void res.status(400).json({ error: 'peekGen required' })
397
+ }
398
+
399
+ if (peekBusy) {
400
+ return void res.status(204).send()
401
+ }
402
+
403
+ const audio = await readRawBody(req)
404
+ if (!audio.length) return void res.status(400).json({ error: 'empty audio' })
405
+ if (audio.length > MAX_CHUNK_BYTES) return void res.status(413).json({ error: 'audio chunk too large' })
406
+
407
+ const lease = acquireMaintenanceWork('prompt_draft_peek', {
408
+ allowDuringDrain: true,
409
+ phase: 'queued',
410
+ })
411
+
412
+ peekBusy = true
413
+ const draftId = req.params.draftId
414
+ peekTail = peekTail.then(async () => {
415
+ lease.setPhase('active')
416
+ try {
417
+ const result = await transcribeAudioBuffer(audio, {
418
+ mode: 'fast',
419
+ policy: 'local-only',
420
+ affectsCircuit: false,
421
+ })
422
+ const text = sanitizeTranscript(draftId, result.text, false)
423
+ if (!text) return
424
+ if (!loadPromptDraftMeta(draftId)) return
425
+ emitDisplay({
426
+ type: 'prompt_transcript',
427
+ data: { draftId, chunkIndex, text, provisional: true, peekGen },
428
+ })
429
+ } catch (err: any) {
430
+ if (!(err instanceof NoSpeechDetectedError)) {
431
+ console.warn(`[prompt-draft] peek failed ${draftId}/${chunkIndex}: ${err?.message ?? err}`)
432
+ }
433
+ } finally {
434
+ peekBusy = false
435
+ lease.release()
436
+ }
437
+ }).catch(() => {
438
+ peekBusy = false
439
+ lease.release()
440
+ })
441
+
442
+ res.json({ draftId, chunkIndex, peekGen, accepted: true })
443
+ } catch (err: any) {
444
+ if (err instanceof MaintenanceLifecycleError) {
445
+ if (err.retryAfterSeconds != null) res.setHeader('Retry-After', String(err.retryAfterSeconds))
446
+ return void res.status(err.status).json({ ...maintenanceErrorPayload(err), draftPreserved: true })
447
+ }
448
+ res.status(err.status ?? (err.message === 'draft not found' ? 404 : 500)).json({ error: err.message })
449
+ }
450
+ })
451
+
375
452
  promptDraftsRouter.post('/prompt-drafts/:draftId/chunks', async (req, res) => {
376
453
  let maintenanceLease: MaintenanceWorkLease | undefined
377
454
  try {
@@ -2,6 +2,7 @@ import { Router } from 'express'
2
2
  import { readFileSync } from 'node:fs'
3
3
  import { resolve } from 'node:path'
4
4
  import { atomicWriteFileSync } from '../lib/atomic-fs.js'
5
+ import { scheduleManagedServerRestart } from '../lib/managed-restart.js'
5
6
  import { isManagedRuntime } from '../lib/managed-runtime.js'
6
7
  import { acquireMaintenance, getRecoveryActivityStatus } from '../lib/recovery-activity.js'
7
8
  import { getWhisperHealth, restartWhisperServer } from '../lib/whisper-local.js'
@@ -65,15 +66,15 @@ recoveryRouter.post('/recovery/server/restart', (_req, res) => {
65
66
  res.status(202).json({ accepted: true, oldBootId: serverMetrics.bootId })
66
67
  // Schedule independently of the response socket. The phone may change
67
68
  // network/close the sheet immediately after receiving 202; that must not
68
- // cancel an accepted restart or strand maintenance forever.
69
- const timer = setTimeout(() => {
70
- if (process.env.COS_DISABLE_SELF_RESTART === '1') { gate.release(); return }
71
- try {
72
- process.kill(process.pid, 'SIGTERM')
73
- } catch (error) {
69
+ // cancel an accepted restart. Maintenance is in-process only — it dies with us.
70
+ //
71
+ // Do NOT SIGTERM→exit(0): Control's LaunchAgent KeepAlive is
72
+ // SuccessfulExit:false, so a clean exit leaves the service STOPPED and the
73
+ // phone shows "Restart requested; reconnect is taking longer than expected."
74
+ scheduleManagedServerRestart({
75
+ onError: (error) => {
74
76
  gate.release()
75
- console.error('[recovery] Failed to signal managed server restart:', error)
76
- }
77
- }, 350)
78
- timer.unref?.()
77
+ console.error('[recovery] Failed to schedule managed server restart:', error)
78
+ },
79
+ })
79
80
  })