@gotcos/glasses-server 7.0.7 → 7.0.18

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.
Files changed (87) hide show
  1. package/.env.example +19 -0
  2. package/CHANGELOG.md +170 -0
  3. package/docs/web-key-verifier-contract.md +378 -0
  4. package/managed-runtime-contract.json +9 -1
  5. package/package.json +10 -3
  6. package/server/bootstrap.ts +10 -0
  7. package/server/index.ts +126 -23
  8. package/server/lib/agent-session-recent.ts +321 -0
  9. package/server/lib/agent-session-store.ts +3 -3
  10. package/server/lib/api-auth.ts +37 -1
  11. package/server/lib/calendar-meetings.ts +70 -0
  12. package/server/lib/calendar-source.ts +54 -9
  13. package/server/lib/claude-hooks-installer.ts +111 -12
  14. package/server/lib/conversation.ts +56 -28
  15. package/server/lib/cors-reject-log.ts +51 -0
  16. package/server/lib/disk-io-meter.ts +202 -0
  17. package/server/lib/drop-service.ts +47 -12
  18. package/server/lib/drop-store.ts +21 -1
  19. package/server/lib/event-loop-stall.ts +62 -0
  20. package/server/lib/full-hold-engine.ts +114 -0
  21. package/server/lib/full-hold.ts +124 -0
  22. package/server/lib/g2-turn-provenance.ts +128 -9
  23. package/server/lib/listener-startup.ts +18 -0
  24. package/server/lib/meeting-batch-transcribe.ts +32 -5
  25. package/server/lib/permission-broker.ts +443 -11
  26. package/server/lib/secret-path-guard.ts +4 -1
  27. package/server/lib/session-cache-writer.ts +8 -1
  28. package/server/lib/session-hook-events.ts +23 -1
  29. package/server/lib/session-hooks-runtime.ts +34 -0
  30. package/server/lib/session-log.ts +313 -4
  31. package/server/lib/session-state-derive.ts +36 -0
  32. package/server/lib/session-stream-events.ts +25 -1
  33. package/server/lib/today-brief.ts +522 -58
  34. package/server/lib/turn-end-ask.ts +320 -0
  35. package/server/lib/turn-footer.ts +3 -3
  36. package/server/lib/web-control-integrity.ts +160 -0
  37. package/server/lib/web-control-offnet-page.ts +58 -0
  38. package/server/lib/web-control-static.ts +53 -9
  39. package/server/lib/web-key-authority.ts +372 -0
  40. package/server/lib/web-key-envelope.ts +98 -0
  41. package/server/lib/web-key-handoff.ts +135 -0
  42. package/server/lib/web-key-origin.ts +21 -0
  43. package/server/lib/web-key-passkey.ts +155 -0
  44. package/server/lib/web-key-registry.ts +825 -0
  45. package/server/lib/web-key-signer-app.ts +152 -0
  46. package/server/lib/web-session-auth.ts +133 -15
  47. package/server/lib/whisper-local.ts +93 -7
  48. package/server/lib/whisper-preview.ts +5 -3
  49. package/server/lib/work-orchestrator/snapshot.ts +17 -3
  50. package/server/lib/work-review-runtime.ts +17 -4
  51. package/server/routes/agent-session-bindings.ts +16 -1
  52. package/server/routes/agent-session-stream.ts +7 -0
  53. package/server/routes/drops.ts +7 -2
  54. package/server/routes/g2-turns.ts +35 -0
  55. package/server/routes/health.ts +11 -1
  56. package/server/routes/live-events.ts +6 -1
  57. package/server/routes/permission-broker.ts +26 -0
  58. package/server/routes/query-jobs.ts +3 -0
  59. package/server/routes/sessions.ts +15 -24
  60. package/server/routes/thread-turn-queue.ts +16 -9
  61. package/server/routes/today-brief.ts +91 -36
  62. package/server/routes/web-control.ts +37 -4
  63. package/server/routes/web-key.ts +284 -0
  64. package/server/routes/welcome-context.ts +6 -2
  65. package/server/routes/work-orchestrator.ts +12 -7
  66. package/server/scripts/write-web-control-manifest.mjs +43 -0
  67. package/server/web-control/.manifest.json +44 -0
  68. package/server/web-control/app.css +36 -0
  69. package/server/web-control/app.js +953 -57
  70. package/server/web-control/apple-touch-icon.png +0 -0
  71. package/server/web-control/index.html +14 -1
  72. package/server/web-control/panes/cos-chat.css +52 -6
  73. package/server/web-control/panes/cos-chat.js +364 -32
  74. package/server/web-control/panes/questions.js +135 -14
  75. package/server/web-control/panes/settings.js +1 -1
  76. package/server/web-control/panes/today-actions.css +35 -0
  77. package/server/web-control/panes/today-actions.js +75 -6
  78. package/server/web-control/panes/work.css +3 -0
  79. package/server/web-control/panes/work.js +178 -35
  80. package/server/web-signer/.manifest.json +11 -0
  81. package/server/web-signer/index.html +74 -0
  82. package/server/web-signer/signer-proof.js +348 -0
  83. package/server/web-signer/signer-store.js +39 -0
  84. package/server/web-signer/signer.css +23 -0
  85. package/server/web-signer/signer.js +341 -0
  86. package/shared/web-approval-packets.golden.json +124 -0
  87. package/shared/web-turn-envelope.ts +271 -0
@@ -56,6 +56,16 @@
56
56
  // even one with no origin left to carry, so the drain is the one caller allowed to add to the
57
57
  // parked turn's ledger record.
58
58
  //
59
+ // WEB-KEY AUTHORITY (6.99.31, behind COS_WEB_KEY_AUTHORITY, default off). A web turn that also
60
+ // carries a `cos-web-turn/1` envelope signed by an enrolled phone key (lib/web-key-authority.ts)
61
+ // can be vouched as `authority: 'web-key'`. originOf only NOTES the envelope headers on a web
62
+ // verdict (`webKeyCandidate`); nothing is vouched until the route, knowing its real target, the
63
+ // exact words and the files, calls acceptWebKey. A route that never calls it leaves the web turn
64
+ // exactly as 6.99.29 left it. Off, no candidate is ever noted, so every web turn is recorded with
65
+ // WEB_UNVOUCHED_REASON and no footer, byte for byte as 6.99.29. A web-key record is self-proving
66
+ // (envelope + signature), so the endpoint can vouch for it after a restart, unlike the in-memory
67
+ // seal; a queued web-key origin is re-checked at its drain (expiry, revocation, epoch, words).
68
+ //
59
69
  // TURN IDS ARE NOT REUSABLE. A record this process holds is never replaced by a different turn
60
70
  // under the same id (a client-chosen id could otherwise overwrite a vouched record: a denial of
61
71
  // service on verification). Only an identical retry (same target, same text, same origin) or the
@@ -67,6 +77,8 @@ import { appendFileSync, existsSync, renameSync, statSync } from 'node:fs'
67
77
  import { readFile } from 'node:fs/promises'
68
78
  import { networkInterfaces } from 'node:os'
69
79
  import { turnFooter } from './turn-footer.js'
80
+ import { webKeyCandidateOf, type WebKeyAuthority, type WebKeyCandidate, type WebKeyContext, type WebKeyProof } from './web-key-authority.js'
81
+ import { MESSAGE_SCOPE_MEANING, notAuthorizedFor } from '../../shared/web-turn-envelope.js'
70
82
 
71
83
  export { FOOTER_LINE_REFUSED_COPY, G2_TURN_FOOTER_RE, g2TurnFooter, hasFooterShapedLine, stripG2TurnFooter, TURN_FOOTER_SHAPED_RE, TURN_ID_IN_USE_COPY, turnFooter, turnFooterTag, withoutTurnFooter } from './turn-footer.js'
72
84
 
@@ -134,6 +146,14 @@ export interface OriginVerdict {
134
146
  reason?: string | null
135
147
  /** When this origin was judged (ms). A queued origin lapses from here, never later. */
136
148
  judgedAt?: number
149
+ /** 6.99.31: set only by acceptWebKey on a signed, accepted web turn. */
150
+ authority?: 'web-key'
151
+ /** 6.99.31: the accepted envelope and signature (self-proving). Present only with `authority`. */
152
+ webKey?: WebKeyProof
153
+ /** 6.99.31: envelope headers a web request carried, not yet judged (flag on only). Vouches for nothing. */
154
+ webKeyCandidate?: WebKeyCandidate
155
+ /** 6.99.31: why a signed web turn was not vouched (the route's acceptWebKey or a drain re-check). */
156
+ webKeyReason?: string
137
157
  }
138
158
 
139
159
  export interface G2TurnRecord {
@@ -155,6 +175,10 @@ export interface G2TurnRecord {
155
175
  * re-audit of 910eb90): the copy is judged by the request that sent it again, nothing is inherited.
156
176
  */
157
177
  resendOf?: string
178
+ /** 6.99.31: a vouched web turn signed by an enrolled phone key. */
179
+ authority?: 'web-key'
180
+ /** 6.99.31: the envelope bytes and signature that vouch for it (self-proving, so durable). */
181
+ webKey?: WebKeyProof
158
182
  }
159
183
 
160
184
  /** The shape of an Express request this module reads. Nothing else. */
@@ -186,12 +210,29 @@ export function turnTextSha256(text: string): string {
186
210
  export function footerApplies(verdict: OriginVerdict): boolean {
187
211
  if (verdict.verifiedOrigin !== true) return false
188
212
  const kind = originKindOf(verdict.origin.client)
213
+ // 6.99.31: a web turn is footed (COS-WEB) only when an enrolled phone key's envelope was accepted.
214
+ if (kind === 'web') return verdict.authority === 'web-key' && verdict.webKey !== undefined
189
215
  return kind === 'g2' || kind === 'phone'
190
216
  }
191
217
 
192
- /** The same verdict, never vouched, with the reason why. */
218
+ /** Whether a verdict is a vouched web-key turn (the only vouched web turn there is). */
219
+ function isWebKeyVerdict(verdict: OriginVerdict): boolean {
220
+ return verdict.verifiedOrigin === true && verdict.authority === 'web-key' && verdict.webKey !== undefined
221
+ }
222
+
223
+ /**
224
+ * The same verdict, never vouched, with the reason why. Any web-key authority is dropped; envelope
225
+ * headers not yet judged stay, so a Send again or an edit can still be judged on its own signature.
226
+ */
193
227
  export function unvouched(verdict: OriginVerdict, reason: string): OriginVerdict {
194
- return { ...verdict, verifiedOrigin: false, reason }
228
+ const { authority: _a, webKey: _w, ...rest } = verdict
229
+ return { ...rest, verifiedOrigin: false, reason }
230
+ }
231
+
232
+ /** A signed web turn that was not vouched: unvouched, its envelope spent or dropped, the reason kept. */
233
+ function webKeyRefused(verdict: OriginVerdict, reason: string): OriginVerdict {
234
+ const { authority: _a, webKey: _w, webKeyCandidate: _c, ...rest } = verdict
235
+ return { ...rest, verifiedOrigin: false, reason, webKeyReason: reason }
195
236
  }
196
237
 
197
238
  /** The vias of a turn still parked in the queue: the only records a continuation may extend. */
@@ -354,6 +395,11 @@ export interface G2TurnProvenanceDeps {
354
395
  * request with no X-Cos-Token header. Absent, or a throw: no turn is ever a web turn.
355
396
  */
356
397
  webSession?: (req: OriginRequest) => boolean
398
+ /**
399
+ * 6.99.31: the enrolled phone key authority. Absent, or its flag off: no envelope is ever read and
400
+ * every web turn is what 6.99.29 made it.
401
+ */
402
+ webKey?: WebKeyAuthority
357
403
  now?: () => number
358
404
  randomToken?: () => string
359
405
  }
@@ -448,14 +494,56 @@ export class G2TurnProvenance {
448
494
  verifiedOrigin = paired !== null && paired === clientInstance
449
495
  if (!verifiedOrigin) reason = 'not_ring_owner'
450
496
  }
497
+ // 6.99.31: a web request (cookie path, no token, not this Mac) that carries an envelope has it
498
+ // NOTED, not judged: acceptWebKey judges it once the route knows the target. Flag off: never noted.
499
+ let webKeyCandidate: WebKeyCandidate | null = null
500
+ if (client === 'web' && this.webKeyOn()) {
501
+ try { webKeyCandidate = webKeyCandidateOf(req.headers) } catch { webKeyCandidate = null }
502
+ }
451
503
  return {
452
504
  origin: { remote: known ? remote : 'unknown', loopback, ownAddress, clientInstance, client, tokenPresented, webSession },
453
505
  verifiedOrigin,
454
506
  reason,
455
507
  judgedAt: this.now(),
508
+ ...(webKeyCandidate ? { webKeyCandidate } : {}),
456
509
  }
457
510
  }
458
511
 
512
+ private webKeyOn(): boolean {
513
+ try { return this.deps.webKey?.enabled() === true } catch { return false }
514
+ }
515
+
516
+ /** The web-key authority, when wired (the routes and GET /api/g2-turns read it). */
517
+ get webKey(): WebKeyAuthority | null { return this.deps.webKey ?? null }
518
+
519
+ /**
520
+ * 6.99.31: judge the envelope a web verdict carries against the route's REAL target, the exact
521
+ * words and the ordered files. Accepted: vouched, `authority: 'web-key'`, footed COS-WEB. Refused:
522
+ * unvouched with the specific reason. A verdict with no envelope comes back unchanged, so every
523
+ * caller can pass every verdict. Never throws.
524
+ */
525
+ acceptWebKey(verdict: OriginVerdict, ctx: WebKeyContext): OriginVerdict {
526
+ const candidate = verdict.webKeyCandidate
527
+ if (!candidate) return verdict
528
+ // A fork's receiving child has no id the phone could have signed: never vouched (plan §4.4).
529
+ if (ctx.fork === true) return webKeyRefused(verdict, 'fork_destination_unbound')
530
+ if (verdict.origin.client !== 'web' || !this.deps.webKey) return webKeyRefused(verdict, 'web_key_authority_off')
531
+ const judged = this.deps.webKey.accept(candidate, ctx)
532
+ if (!judged.ok) {
533
+ console.warn(`[g2-turn-ledger] web-key turn not vouched turnId=${ctx.turnId} reason=${judged.reason}`)
534
+ return webKeyRefused(verdict, judged.reason)
535
+ }
536
+ const { webKeyCandidate: _c, webKeyReason: _r, ...rest } = verdict
537
+ return { ...rest, verifiedOrigin: true, reason: null, authority: 'web-key', webKey: judged.proof }
538
+ }
539
+
540
+ /** A held web-key origin still holds at its drain only while recheck passes (plan §4.5). */
541
+ private reheld(verdict: OriginVerdict, prompt: string, attachments: string): OriginVerdict {
542
+ if (verdict.authority !== 'web-key' || !verdict.webKey) return verdict
543
+ const reason = this.deps.webKey ? this.deps.webKey.recheck(verdict.webKey, prompt, attachments) : 'web_key_authority_off'
544
+ return reason === null ? verdict : webKeyRefused(verdict, reason)
545
+ }
546
+
459
547
  /**
460
548
  * The text to deliver: with the footer for a vouched glasses or phone turn, otherwise unchanged.
461
549
  * `provider` is the harness the text is delivered into (claude, codex or cursor), named in the
@@ -468,9 +556,10 @@ export class G2TurnProvenance {
468
556
 
469
557
  private build(input: { turnId: string; prompt: string; verdict: OriginVerdict; provider: string; sessionId: string; via: string; fork?: boolean; resendOf?: string }): G2TurnRecord {
470
558
  const originKind = originKindOf(input.verdict.origin.client)
471
- // A web turn is never vouched, whatever verdict reached here (header).
472
- const verifiedOrigin = input.verdict.verifiedOrigin === true && originKind !== 'web'
473
- const reason = verifiedOrigin ? null : (originKind === 'web' ? WEB_UNVOUCHED_REASON : (input.verdict.reason ?? 'unverified'))
559
+ // A web turn is never vouched unless an enrolled phone key's envelope was accepted (6.99.31).
560
+ const webKey = originKind === 'web' && isWebKeyVerdict(input.verdict)
561
+ const verifiedOrigin = input.verdict.verifiedOrigin === true && (originKind !== 'web' || webKey)
562
+ const reason = verifiedOrigin ? null : (originKind === 'web' ? (input.verdict.webKeyReason ?? WEB_UNVOUCHED_REASON) : (input.verdict.reason ?? 'unverified'))
474
563
  return {
475
564
  turnId: input.turnId,
476
565
  createdAt: new Date(this.now()).toISOString(),
@@ -483,6 +572,7 @@ export class G2TurnProvenance {
483
572
  target: { provider: input.provider, sessionId: input.sessionId, ...(input.fork ? { fork: true as const } : {}) },
484
573
  via: input.via,
485
574
  ...(typeof input.resendOf === 'string' && input.resendOf.length > 0 ? { resendOf: input.resendOf } : {}),
575
+ ...(webKey && verifiedOrigin ? { authority: 'web-key' as const, webKey: input.verdict.webKey } : {}),
486
576
  }
487
577
  }
488
578
 
@@ -571,7 +661,8 @@ export class G2TurnProvenance {
571
661
  * within QUEUED_ORIGIN_TTL_MS of when it was judged. A lapsed origin is forgotten here.
572
662
  */
573
663
  queuedVerdict(provider: string, threadId: string, clientTurnId: string, prompt: string): OriginVerdict | null {
574
- return this.queuedOrigin(provider, threadId, clientTurnId, prompt)?.verdict ?? null
664
+ const held = this.queuedOrigin(provider, threadId, clientTurnId, prompt)
665
+ return held ? this.reheld(held.verdict, prompt, held.attachments) : null
575
666
  }
576
667
 
577
668
  private queuedOrigin(provider: string, threadId: string, clientTurnId: string, prompt: string): QueuedOrigin | null {
@@ -613,7 +704,10 @@ export class G2TurnProvenance {
613
704
  // The seal rides with the origin: the turn route checks the binding's provider and thread and
614
705
  // the files it is about to compose against it, and refuses a vouched turn that no longer matches.
615
706
  const seal: QueueSeal | null = held ? { provider: turn.provider, threadId: turn.threadId, attachments: held.attachments } : null
616
- this.carries.set(token, { clientTurnId: turn.clientTurnId, verdict: held?.verdict ?? null, seal, textSha256: turnTextSha256(turn.prompt), expiresAt: at + TURN_CARRY_TTL_MS })
707
+ // 6.99.31: a parked web-key approval is re-checked here, at its drain: expired, revoked, a moved
708
+ // epoch or changed words carry it unvouched (never re-dated).
709
+ const verdict = held ? this.reheld(held.verdict, turn.prompt, held.attachments) : null
710
+ this.carries.set(token, { clientTurnId: turn.clientTurnId, verdict, seal, textSha256: turnTextSha256(turn.prompt), expiresAt: at + TURN_CARRY_TTL_MS })
617
711
  return token
618
712
  } catch {
619
713
  return null
@@ -655,8 +749,18 @@ export async function describeG2Turn(provenance: G2TurnProvenance, turnId: strin
655
749
  // Only a record this process wrote and still holds vouches for anything (see the header), never a
656
750
  // web turn (its cookie is derivable from the token), and never past G2_TURN_VOUCH_MAX_MS.
657
751
  let reason: string | null = null
658
- if (originKind === 'web') reason = WEB_UNVOUCHED_REASON
659
- else if (record.verifiedOrigin !== true) reason = typeof record.reason === 'string' ? record.reason : 'unverified'
752
+ // 6.99.31: a web-key record is self-proving (envelope + signature): re-verified here against the
753
+ // registry, so it vouches after a restart too, and reads revoked the moment its key is revoked.
754
+ // Flag off: every web record reads WEB_UNVOUCHED_REASON, exactly as 6.99.29.
755
+ const authority = provenance.webKey
756
+ const webKeyOn = authority !== null && authority.enabled()
757
+ const webKeyRecord = originKind === 'web' && record.authority === 'web-key' && record.verifiedOrigin === true && !!record.webKey
758
+ const webKeyReview = webKeyRecord && webKeyOn && authority ? authority.review(record.webKey as WebKeyProof, { turnId: record.turnId, target: record.target }) : null
759
+ if (originKind === 'web') {
760
+ if (webKeyReview === null) reason = webKeyOn && typeof record.reason === 'string' && record.verifiedOrigin !== true ? record.reason : WEB_UNVOUCHED_REASON
761
+ else if (webKeyReview.reason !== null) reason = webKeyReview.reason
762
+ else if (ageMs === null || ageMs > G2_TURN_VOUCH_MAX_MS) reason = 'record_too_old'
763
+ } else if (record.verifiedOrigin !== true) reason = typeof record.reason === 'string' ? record.reason : 'unverified'
660
764
  else if (!sealed) reason = 'not_sealed'
661
765
  else if (ageMs === null || ageMs > G2_TURN_VOUCH_MAX_MS) reason = 'record_too_old'
662
766
  return {
@@ -675,5 +779,20 @@ export async function describeG2Turn(provenance: G2TurnProvenance, turnId: strin
675
779
  target: record.target,
676
780
  via: record.via,
677
781
  ...(typeof record.resendOf === 'string' ? { resendOf: record.resendOf } : {}),
782
+ ...(webKeyReview !== null && record.webKey ? {
783
+ authority: 'web-key',
784
+ kid: record.webKey.kid,
785
+ scope: record.webKey.scope,
786
+ // Review B4: what this scope means and what it never authorizes, in the answer itself.
787
+ scopeMeaning: record.webKey.scope?.kind === 'approve' ? 'approve' : MESSAGE_SCOPE_MEANING,
788
+ notAuthorized: notAuthorizedFor(record.webKey.scope?.kind === 'approve' ? record.webKey.scope : { kind: 'message' }),
789
+ envelope: record.webKey.envelope,
790
+ signature: record.webKey.signature,
791
+ passkey: record.webKey.passkey ?? null,
792
+ authId: record.webKey.authId,
793
+ registryEntry: webKeyReview.entry,
794
+ // Anchor (B1), epoch and revocations (W5), integrity (W1), consume keys (B3): docs/web-key-verifier-contract.md section 6.
795
+ ...(webKeyReview.facts ?? {}),
796
+ } : {}),
678
797
  }
679
798
  }
@@ -27,3 +27,21 @@ export async function listenRequiredServers(listeners: RequiredListener[]): Prom
27
27
  throw error
28
28
  }
29
29
  }
30
+
31
+ export interface ListenerPlanEntry { label: 'HTTPS' | 'HTTPS signer' | 'HTTP'; port: number; tls: boolean }
32
+
33
+ /**
34
+ * Which listeners this boot REQUIRES (every one goes through listenRequiredServers, so a port another
35
+ * process holds fails the boot closed). 6.99.31: the phone key's signer origin (https, its own
36
+ * port, review B2/W2) is added only with COS_WEB_KEY_AUTHORITY on and only beside HTTPS. Flag off:
37
+ * exactly the 6.99.29 set.
38
+ */
39
+ export function listenerPlan(opts: { httpsAvailable: boolean; webKeyAuthority: boolean; httpPort: number; httpsPort: number; signerPort: number }): ListenerPlanEntry[] {
40
+ const plan: ListenerPlanEntry[] = []
41
+ if (opts.httpsAvailable) {
42
+ plan.push({ label: 'HTTPS', port: opts.httpsPort, tls: true })
43
+ if (opts.webKeyAuthority) plan.push({ label: 'HTTPS signer', port: opts.signerPort, tls: true })
44
+ }
45
+ plan.push({ label: 'HTTP', port: opts.httpPort, tls: false })
46
+ return plan
47
+ }
@@ -16,6 +16,7 @@ import {
16
16
  transcribeHighQuality,
17
17
  type HighQualityTranscriptionResult,
18
18
  type WhisperWord,
19
+ isWhisperHqTimeout,
19
20
  } from './whisper-local.js'
20
21
  import { asrFrontEnd } from './stt-live.js'
21
22
  import { measureSpeechSeconds } from './vad-silero.js'
@@ -412,6 +413,7 @@ export const __progressiveHqTesting = {
412
413
  segmentRangeIsComplete,
413
414
  nextMissingSealedSegment,
414
415
  progressiveFailureIdentity,
416
+ recordProgressiveFailure,
415
417
  resolveProgressiveHqPolicy,
416
418
  progressiveSessionIsIdle,
417
419
  yieldIdleProgressiveSessions,
@@ -1091,6 +1093,30 @@ function yieldIdleProgressiveSessions(activeSessionId: string, now = Date.now())
1091
1093
  }
1092
1094
  }
1093
1095
 
1096
+ /** Attempts a progressive checkpoint gets before it waits for the save-time job. */
1097
+ const PROGRESSIVE_MAX_ATTEMPTS = 3
1098
+ const PROGRESSIVE_RETRY_BACKOFF_MS = [60_000, 120_000, 300_000]
1099
+
1100
+ /** S1 (2026-11-09): what one failed checkpoint decode means for its retries.
1101
+ * A timeout is not retried. The retry would run the same audio on the same CPU lane at the
1102
+ * same background QoS against the same 240 s ceiling: 10/26 to 11/09, 112 segments timed out
1103
+ * and 85 of them timed out on all three attempts, so 178 of 290 timeouts (about 12 h of
1104
+ * CPU during meetings) were retries. The checkpoint is disposable; the save-time job decodes
1105
+ * that window. Any other failure keeps the 60/120/300 s backoff. Returns whether it is terminal. */
1106
+ function recordProgressiveFailure(
1107
+ state: Pick<ProgressiveSessionState, 'failedIdentity' | 'failedAttempts' | 'retryAfter'>,
1108
+ identity: string,
1109
+ error: unknown,
1110
+ now = Date.now(),
1111
+ ): { terminal: boolean; timedOut: boolean } {
1112
+ const timedOut = isWhisperHqTimeout(error)
1113
+ const attempts = state.failedIdentity === identity ? state.failedAttempts + 1 : 1
1114
+ state.failedAttempts = timedOut ? Math.max(attempts, PROGRESSIVE_MAX_ATTEMPTS) : attempts
1115
+ state.failedIdentity = identity
1116
+ state.retryAfter = now + PROGRESSIVE_RETRY_BACKOFF_MS[Math.min(PROGRESSIVE_RETRY_BACKOFF_MS.length - 1, state.failedAttempts - 1)]
1117
+ return { terminal: state.failedAttempts >= PROGRESSIVE_MAX_ATTEMPTS, timedOut }
1118
+ }
1119
+
1094
1120
  function segmentRangeIsComplete(segment: BatchSegment, completedIndices: number[]): boolean {
1095
1121
  const completed = new Set(completedIndices)
1096
1122
  for (let index = segment.startChunkIdx; index <= segment.endChunkIdx; index++) {
@@ -1111,7 +1137,7 @@ function nextMissingSealedSegment(
1111
1137
  if (!cached) {
1112
1138
  const identity = progressiveFailureIdentity(state.audioDir, segment, previousText)
1113
1139
  if (!identity) return null
1114
- if (state.failedIdentity === identity && (state.failedAttempts >= 3 || Date.now() < state.retryAfter)) return null
1140
+ if (state.failedIdentity === identity && (state.failedAttempts >= PROGRESSIVE_MAX_ATTEMPTS || Date.now() < state.retryAfter)) return null
1115
1141
  return { segment, previousText }
1116
1142
  }
1117
1143
  previousText = cached.text
@@ -1191,12 +1217,13 @@ function queueProgressiveWork(sessionId: string, state: ProgressiveSessionState)
1191
1217
  if ((error as Error)?.name !== 'AbortError') {
1192
1218
  const identity = progressiveFailureIdentity(state.audioDir, pending.segment, pending.previousText)
1193
1219
  if (!identity) return
1194
- state.failedAttempts = state.failedIdentity === identity ? state.failedAttempts + 1 : 1
1195
- state.failedIdentity = identity
1196
- state.retryAfter = Date.now() + [60_000, 120_000, 300_000][Math.min(2, state.failedAttempts - 1)]
1220
+ const outcome = recordProgressiveFailure(state, identity, error)
1197
1221
  console.warn(
1198
1222
  `[meeting-hq-checkpoint] ${sessionId} ${pending.segment.startChunkIdx}-${pending.segment.endChunkIdx}: `
1199
- + `${error instanceof Error ? error.message : String(error)}`,
1223
+ + `${error instanceof Error ? error.message : String(error)}`
1224
+ + (outcome.terminal
1225
+ ? (outcome.timedOut ? ' (timeout: not retried; the save-time job decodes this window)' : ' (attempts exhausted; the save-time job decodes this window)')
1226
+ : ` (attempt ${state.failedAttempts} of ${PROGRESSIVE_MAX_ATTEMPTS})`),
1200
1227
  )
1201
1228
  }
1202
1229
  } finally {