@gotcos/glasses-server 6.76.1 → 6.78.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.
Files changed (52) hide show
  1. package/.env.example +40 -0
  2. package/CHANGELOG.md +56 -0
  3. package/README.md +12 -1
  4. package/managed-runtime-contract.json +17 -1
  5. package/package.json +10 -1
  6. package/server/index.ts +98 -10
  7. package/server/lib/agent-session-binding-registry.ts +623 -9
  8. package/server/lib/boot-resume.ts +552 -0
  9. package/server/lib/claude-bridge.ts +152 -3
  10. package/server/lib/claude-cli-support.ts +39 -0
  11. package/server/lib/claude-steer.ts +259 -0
  12. package/server/lib/cos-bridge-notes.ts +73 -0
  13. package/server/lib/decision-provider.ts +9 -0
  14. package/server/lib/env-switch.ts +17 -0
  15. package/server/lib/fuzzy-correct.ts +77 -49
  16. package/server/lib/live-cos-ask.ts +41 -7
  17. package/server/lib/live-cues-v2-notes.ts +152 -0
  18. package/server/lib/live-cues-v2-settings.ts +63 -0
  19. package/server/lib/live-cues-v2.ts +198 -38
  20. package/server/lib/managed-restart.ts +8 -0
  21. package/server/lib/provider-actions.ts +18 -0
  22. package/server/lib/query-job-coordinator.ts +300 -0
  23. package/server/lib/query-job-feature.ts +39 -0
  24. package/server/lib/query-job-resume.ts +269 -0
  25. package/server/lib/query-job-runtime.ts +23 -3
  26. package/server/lib/query-job-store.ts +516 -7
  27. package/server/lib/query-job-types.ts +210 -1
  28. package/server/lib/queue-turn-evidence.ts +213 -0
  29. package/server/lib/thread-turn-queue-deliver.ts +6 -1
  30. package/server/lib/thread-turn-queue-store.ts +13 -4
  31. package/server/lib/thread-turn-queue.ts +151 -1
  32. package/server/lib/transcript-cleanup-context.ts +234 -0
  33. package/server/lib/transcript-cleanup-ledger.ts +304 -0
  34. package/server/lib/transcript-cleanup-model.ts +217 -0
  35. package/server/lib/transcript-cleanup-screen.ts +249 -0
  36. package/server/lib/transcript-cleanup-spans.ts +358 -0
  37. package/server/lib/transcript-cleanup-state.ts +201 -0
  38. package/server/lib/transcript-cleanup-suggestions.ts +126 -0
  39. package/server/lib/transcript-cleanup-text.ts +200 -0
  40. package/server/lib/transcript-cleanup.ts +427 -0
  41. package/server/routes/agent-session-bindings.ts +169 -11
  42. package/server/routes/glossary.ts +38 -19
  43. package/server/routes/health.ts +29 -1
  44. package/server/routes/live-cues.ts +32 -3
  45. package/server/routes/maintenance.ts +34 -0
  46. package/server/routes/meeting.ts +92 -9
  47. package/server/routes/query-jobs.ts +107 -13
  48. package/server/routes/recovery.ts +4 -0
  49. package/server/routes/thread-turn-queue.ts +371 -10
  50. package/server/routes/transcribe.ts +3 -0
  51. package/server/routes/transcript-cleanup.ts +237 -0
  52. package/shared/live-cues-v2.ts +9 -4
@@ -0,0 +1,552 @@
1
+ // 6.78.0 (MVP-C): crash-loop parking for work the server resumes on boot. Pi Durable's
2
+ // `restartLimit` and `retryTask`, built into COS's own code.
3
+ //
4
+ // THE LOOP IT STOPS. Two kinds of work are resumed on every boot with no counter:
5
+ // - meeting finalization (routes/meeting.ts resumeMeetingFinalizationJobs), on every boot;
6
+ // - never-started query jobs (query-job-store.ts boot requeue), handed back on every boot
7
+ // for up to 60 minutes.
8
+ // If the work itself kills the process, launchd restarts the server about every 10 s and
9
+ // every lens goes dark until it stops.
10
+ //
11
+ // THE RULE. One small ledger in its own file (<data>/boot-resume-ledger.json), so no existing
12
+ // store's schema changes and an older build simply ignores it:
13
+ // openBoot once per process, before any boot resume (startAdmittedRuntime, after
14
+ // the listeners bind): did the last process exit cleanly? Only a boot
15
+ // that follows a crash counts. The graceful-shutdown path (SIGTERM/SIGINT,
16
+ // Update Server, phone Restart) marks a clean exit, so routine restarts
17
+ // never count. A missing or quarantined ledger reads as a CLEAN previous
18
+ // boot: the guard never invents a crash it did not see.
19
+ // admit before resuming one item: the count is written BEFORE the item runs,
20
+ // so a resume that kills the process is counted. One count per item per
21
+ // boot. Counts are CONSECUTIVE: a crashed boot adds one only when the
22
+ // item's previous count is under CRASH_LOOP_WINDOW_MS old; otherwise the
23
+ // count restarts at 1, so unrelated crashes days apart never park
24
+ // anything. A new phase means progress, so the count starts over. An
25
+ // item counted on more than RESTART_LIMIT crashed boots is parked: not
26
+ // resumed, and left in its own store for Retry.
27
+ // forget when the item's own store finishes or removes it.
28
+ // retry the person's Retry: the count starts over and the item is unparked.
29
+ // markCleanShutdown first thing in the graceful-shutdown path. A clean exit means no crash
30
+ // loop, so it also resets the counts of every item admitted this boot.
31
+ // Parked items stay parked; only Retry unparks them.
32
+ //
33
+ // FAILS OPEN. The guard must never be the thing that crash-loops the server. A corrupt
34
+ // ledger is quarantined and read as empty, and any read or write error admits the item and
35
+ // logs `boot_resume.fail_open`. Bounded: unparked entries untouched for 7 days are dropped,
36
+ // and the file keeps at most 500 entries.
37
+ //
38
+ // FLAG. COS_CRASH_LOOP_PARKING, read at call time with the shared 6.78.0 reader: `1` /
39
+ // `true` / `on` force it on, `0` / `false` / `off` force it off, anything else (unset
40
+ // included) is the DEFAULT, which is ON. Off is 6.77.0 behaviour exactly: every item is admitted and the ledger is
41
+ // never read or written.
42
+ //
43
+ // This module imports only node builtins, atomic-fs and the pure env-switch, so the Pi
44
+ // Durable canaries (CL-1, CL-2) can load `createBootResumeGate` without reaching a
45
+ // live-state module.
46
+ import { mkdirSync, renameSync } from 'node:fs'
47
+ import { basename, dirname } from 'node:path'
48
+ import { durableAtomicWriteFileSync, loadJsonOrQuarantine } from './atomic-fs.js'
49
+ import { readSwitch } from './env-switch.js'
50
+
51
+ /** Consecutive crashed boots an item may be resumed on. The next one parks it. */
52
+ export const RESTART_LIMIT = 3
53
+ /** Crashed boots count as consecutive only when each comes within this of the item's last count. */
54
+ export const CRASH_LOOP_WINDOW_MS = 30 * 60_000
55
+ /**
56
+ * 6.78.0 (P7 S5): a parked meeting finalization gets ONE second chance, on the first boot at
57
+ * least this long after it was parked. A kernel panic storm can park a meeting that was never
58
+ * the cause, and Control has no Retry for it yet. A second park is permanent (until the
59
+ * person's Retry). Query jobs never auto-unpark: the app shows them and offers Retry.
60
+ */
61
+ export const AUTO_UNPARK_AFTER_MS = 30 * 60_000
62
+ /** The kinds that get the one automatic second chance. */
63
+ const AUTO_UNPARK_KINDS: ReadonlySet<string> = new Set(['meeting_finalization'])
64
+ /** The ledger's file name under the data directory. */
65
+ export const BOOT_RESUME_LEDGER_FILE = 'boot-resume-ledger.json'
66
+ /** The kinds of boot-resumed work the server gates. */
67
+ export const BOOT_RESUME_KINDS = ['meeting_finalization', 'query_job'] as const
68
+ export type BootResumeKind = typeof BOOT_RESUME_KINDS[number]
69
+ /** `error.code` and parked `reason` of an item stopped by the limit. */
70
+ export const RESTART_LIMIT_CODE = 'restart_limit'
71
+ /** `error.cause` of a query job the limit stopped. */
72
+ export const CRASH_LOOP_PARKED_CAUSE = 'crash_loop_parked'
73
+
74
+ const MAX_ENTRIES = 500
75
+ const STALE_MS = 7 * 86_400_000
76
+
77
+ export function isBootResumeKind(value: unknown): value is BootResumeKind {
78
+ return typeof value === 'string' && (BOOT_RESUME_KINDS as readonly string[]).includes(value)
79
+ }
80
+
81
+ /** Default ON, read through the one 6.78.0 switch reader (lib/env-switch.ts). */
82
+ export function isCrashLoopParkingEnabled(env: NodeJS.ProcessEnv = process.env): boolean {
83
+ return readSwitch(env.COS_CRASH_LOOP_PARKING, true)
84
+ }
85
+
86
+ /** What the person reads on the lens and phone: fits a 32-column footer lead. No dashes or arrows. */
87
+ export function restartLimitMessage(limit = RESTART_LIMIT): string {
88
+ return `Stopped after ${limit} server crashes.`
89
+ }
90
+
91
+ /** The terminal error of a query job the limit stopped (status `interrupted`). */
92
+ export function crashLoopParkedError(limit = RESTART_LIMIT): { code: string; message: string; retryable: true; cause: string } {
93
+ return { code: RESTART_LIMIT_CODE, message: restartLimitMessage(limit), retryable: true, cause: CRASH_LOOP_PARKED_CAUSE }
94
+ }
95
+
96
+ export interface BootResumeEntry {
97
+ kind: string
98
+ id: string
99
+ phase: string
100
+ attempts: number
101
+ parked: boolean
102
+ reason: string | null
103
+ parkedAt: string | null
104
+ retriedAt: string | null
105
+ /** When the last crashed boot counted it (the consecutive-window anchor). */
106
+ lastAttemptAt: string | null
107
+ /** 6.78.0 (P7 S5): when the one automatic second chance unparked it. Set once, never cleared. */
108
+ autoRetriedAt: string | null
109
+ updatedAt: string
110
+ }
111
+
112
+ /** One row of `parked[]` in /api/maintenance/status. */
113
+ export interface ParkedBootResume {
114
+ kind: string
115
+ id: string
116
+ phase: string
117
+ attempts: number
118
+ parkedAt: string | null
119
+ reason: string | null
120
+ }
121
+
122
+ export interface BootResumeStatus {
123
+ enabled: boolean
124
+ /** Null until this process has opened its boot (or when the switch is off). */
125
+ crashedBoot: boolean | null
126
+ limit: number
127
+ parked: ParkedBootResume[]
128
+ }
129
+
130
+ export interface BootResumeGate {
131
+ readonly limit: number
132
+ /** Once per process, before any boot resume. Idempotent. */
133
+ openBoot(): void
134
+ /** Before resuming one item. False: parked, do not resume it. */
135
+ admit(kind: string, id: string, phase: string): boolean
136
+ markCleanShutdown(): void
137
+ status(): BootResumeStatus
138
+ status(kind: string, id: string): BootResumeEntry | null
139
+ forget(kind: string, id: string): void
140
+ /** The person's Retry. The entry after the reset, or null when there was none. */
141
+ retry(kind: string, id: string): BootResumeEntry | null
142
+ isParked(kind: string, id: string): boolean
143
+ }
144
+
145
+ export type BootResumeLog = (level: 'info' | 'warn', event: string, detail: Record<string, unknown>) => void
146
+
147
+ export interface BootResumeGateOptions {
148
+ path: string
149
+ limit?: number
150
+ /** Read on every call (the switch is not cached). */
151
+ env?: NodeJS.ProcessEnv
152
+ now?: () => number
153
+ log?: BootResumeLog
154
+ /** Tests only: the durable writer. */
155
+ writeFile?: (path: string, data: string) => void
156
+ }
157
+
158
+ interface LedgerFile {
159
+ version: 1
160
+ cleanShutdown: boolean
161
+ entries: Record<string, BootResumeEntry>
162
+ }
163
+
164
+ /** The server's log channel, in the `[event] {json}` shape other server breadcrumbs use. */
165
+ const defaultLog: BootResumeLog = (level, event, detail) => {
166
+ const line = `[${event}] ${JSON.stringify(detail)}`
167
+ if (level === 'warn') console.warn(line)
168
+ else console.log(line)
169
+ }
170
+
171
+ const keyOf = (kind: string, id: string) => `${kind}:${id}`
172
+
173
+ function errorCode(error: unknown): string {
174
+ const code = (error as { code?: unknown } | null)?.code
175
+ return typeof code === 'string' ? code : error instanceof Error ? error.name : 'error'
176
+ }
177
+
178
+ function parseEntry(key: string, raw: unknown): BootResumeEntry | null {
179
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null
180
+ const e = raw as Record<string, unknown>
181
+ const split = key.indexOf(':')
182
+ const kind = typeof e.kind === 'string' && e.kind ? e.kind : split > 0 ? key.slice(0, split) : ''
183
+ const id = typeof e.id === 'string' && e.id ? e.id : split > 0 ? key.slice(split + 1) : ''
184
+ if (!kind || !id || keyOf(kind, id) !== key) return null
185
+ if (typeof e.phase !== 'string' || typeof e.parked !== 'boolean') return null
186
+ if (typeof e.attempts !== 'number' || !Number.isSafeInteger(e.attempts) || e.attempts < 0) return null
187
+ const text = (value: unknown) => typeof value === 'string' ? value : null
188
+ return {
189
+ kind, id, phase: e.phase, attempts: e.attempts, parked: e.parked,
190
+ reason: text(e.reason), parkedAt: text(e.parkedAt), retriedAt: text(e.retriedAt),
191
+ lastAttemptAt: text(e.lastAttemptAt),
192
+ autoRetriedAt: text(e.autoRetriedAt),
193
+ updatedAt: text(e.updatedAt) ?? new Date(0).toISOString(),
194
+ }
195
+ }
196
+
197
+ function parseLedger(raw: unknown): LedgerFile | null {
198
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null
199
+ const value = raw as Record<string, unknown>
200
+ const entries: Record<string, BootResumeEntry> = {}
201
+ if (value.entries !== undefined) {
202
+ if (!value.entries || typeof value.entries !== 'object' || Array.isArray(value.entries)) return null
203
+ for (const [key, item] of Object.entries(value.entries as Record<string, unknown>)) {
204
+ const entry = parseEntry(key, item)
205
+ if (entry) entries[key] = entry
206
+ }
207
+ }
208
+ return { version: 1, cleanShutdown: value.cleanShutdown === true, entries }
209
+ }
210
+
211
+ /** Drop unparked entries untouched for 7 days, then keep at most 500 (parked first, newest first). */
212
+ function prune(file: LedgerFile, nowMs: number): void {
213
+ for (const [key, entry] of Object.entries(file.entries)) {
214
+ if (entry.parked) continue
215
+ const touched = Date.parse(entry.updatedAt)
216
+ if (!Number.isFinite(touched) || nowMs - touched > STALE_MS) delete file.entries[key]
217
+ }
218
+ const keys = Object.keys(file.entries)
219
+ if (keys.length <= MAX_ENTRIES) return
220
+ const time = (entry: BootResumeEntry) => Date.parse(entry.updatedAt) || 0
221
+ const ranked = keys.sort((a, b) => {
222
+ const x = file.entries[a]!, y = file.entries[b]!
223
+ return Number(y.parked) - Number(x.parked) || time(y) - time(x)
224
+ })
225
+ for (const key of ranked.slice(MAX_ENTRIES)) delete file.entries[key]
226
+ }
227
+
228
+ function parkedView(entry: BootResumeEntry): ParkedBootResume {
229
+ return { kind: entry.kind, id: entry.id, phase: entry.phase, attempts: entry.attempts, parkedAt: entry.parkedAt, reason: entry.reason }
230
+ }
231
+
232
+ class LedgerGate implements BootResumeGate {
233
+ readonly limit: number
234
+ private readonly path: string
235
+ private readonly env: NodeJS.ProcessEnv
236
+ private readonly now: () => number
237
+ private readonly log: BootResumeLog
238
+ private readonly writeFile: (path: string, data: string) => void
239
+ private file: LedgerFile | null = null
240
+ private loaded: 'missing' | 'ok' | 'corrupt' | 'unreadable' = 'missing'
241
+ private opened = false
242
+ private crashedBoot: boolean | null = null
243
+ /** Items already counted (or seen parked) by this process: one count per item per boot. */
244
+ private readonly seenThisBoot = new Set<string>()
245
+
246
+ constructor(options: BootResumeGateOptions) {
247
+ this.path = options.path
248
+ this.limit = Math.max(1, Math.floor(options.limit ?? RESTART_LIMIT))
249
+ this.env = options.env ?? process.env
250
+ this.now = options.now ?? Date.now
251
+ this.log = options.log ?? defaultLog
252
+ this.writeFile = options.writeFile ?? ((path, data) => durableAtomicWriteFileSync(path, data, { mode: 0o600 }))
253
+ }
254
+
255
+ private enabled(): boolean {
256
+ return isCrashLoopParkingEnabled(this.env)
257
+ }
258
+
259
+ private iso(): string {
260
+ return new Date(this.now()).toISOString()
261
+ }
262
+
263
+ private failOpen(op: string, error: unknown): void {
264
+ try { this.log('warn', 'boot_resume.fail_open', { op, error: errorCode(error) }) } catch { /* logging never throws upward */ }
265
+ }
266
+
267
+ /** Loaded once, on first use. A corrupt or wrong-shaped file is set aside and read as empty. */
268
+ private ledger(): LedgerFile {
269
+ if (this.file) return this.file
270
+ let file: LedgerFile = { version: 1, cleanShutdown: false, entries: {} }
271
+ try {
272
+ const result = loadJsonOrQuarantine<unknown>(this.path)
273
+ if (result.status === 'ok') {
274
+ const parsed = parseLedger(result.data)
275
+ if (parsed) {
276
+ file = parsed
277
+ this.loaded = 'ok'
278
+ } else {
279
+ this.loaded = 'corrupt'
280
+ let quarantinedAs: string | null = `${this.path}.corrupt-${this.now()}`
281
+ try { renameSync(this.path, quarantinedAs) } catch { quarantinedAs = null }
282
+ this.failOpen('load', Object.assign(new Error('ledger shape'), { code: 'ledger_shape' }))
283
+ if (quarantinedAs) this.log('warn', 'boot_resume.fail_open', { op: 'quarantine', quarantinedAs: basename(quarantinedAs) })
284
+ }
285
+ } else if (result.status === 'corrupt') {
286
+ this.loaded = 'corrupt'
287
+ this.log('warn', 'boot_resume.fail_open', { op: 'quarantine', quarantinedAs: basename(result.quarantinedAs) })
288
+ }
289
+ // `missing`: no ledger yet. It reads as a CLEAN previous boot (openBoot counts a crash
290
+ // only from a ledger read back intact), so a loop already running when this build was
291
+ // installed starts counting on the next crashed boot and parks one boot later.
292
+ } catch (error) {
293
+ this.loaded = 'unreadable'
294
+ this.failOpen('load', error)
295
+ }
296
+ this.file = file
297
+ return file
298
+ }
299
+
300
+ /** Durable write. False (and logged) on any error: the caller fails open. */
301
+ private save(op: string): boolean {
302
+ try {
303
+ const file = this.ledger()
304
+ prune(file, this.now())
305
+ mkdirSync(dirname(this.path), { recursive: true, mode: 0o700 })
306
+ this.writeFile(this.path, `${JSON.stringify(file)}\n`)
307
+ return true
308
+ } catch (error) {
309
+ this.failOpen(op, error)
310
+ return false
311
+ }
312
+ }
313
+
314
+ openBoot(): void {
315
+ if (!this.enabled() || this.opened) return
316
+ this.opened = true
317
+ try {
318
+ const file = this.ledger()
319
+ // Only a ledger this build read back intact can say the last exit was a crash.
320
+ this.crashedBoot = this.loaded === 'ok' && !file.cleanShutdown
321
+ file.cleanShutdown = false
322
+ this.save('open')
323
+ const entries = Object.values(file.entries)
324
+ this.log('info', 'boot_resume.open', {
325
+ crashedBoot: this.crashedBoot,
326
+ ledger: this.loaded,
327
+ entries: entries.length,
328
+ parked: entries.filter(entry => entry.parked).length,
329
+ })
330
+ } catch (error) {
331
+ this.crashedBoot = false
332
+ this.failOpen('open', error)
333
+ }
334
+ }
335
+
336
+ admit(kind: string, id: string, phase: string): boolean {
337
+ if (!this.enabled()) return true
338
+ try {
339
+ this.openBoot()
340
+ const file = this.ledger()
341
+ const key = keyOf(kind, id)
342
+ const before = file.entries[key]
343
+ let entry: BootResumeEntry | undefined = before ? { ...before } : undefined
344
+ let changed = false
345
+ if (before?.parked) {
346
+ if (!this.secondChanceDue(before)) {
347
+ this.seenThisBoot.add(key)
348
+ this.log('warn', 'boot_resume.parked', { kind, id, phase: before.phase, attempts: before.attempts, newly: false })
349
+ return false
350
+ }
351
+ // The one automatic second chance (P7 S5): unparked with a fresh count, then counted
352
+ // below like any item, so a crash on this boot is attempt 1 of a new run of three.
353
+ const at = this.iso()
354
+ entry = { ...before, attempts: 0, parked: false, reason: null, parkedAt: null, lastAttemptAt: null, autoRetriedAt: at, updatedAt: at }
355
+ changed = true
356
+ this.log('info', 'boot_resume.auto_unpark', { kind, id, phase: before.phase, parkedAt: before.parkedAt })
357
+ }
358
+ if (entry && entry.phase !== phase) {
359
+ // Progress since the last count: it starts over.
360
+ entry.phase = phase
361
+ entry.attempts = 0
362
+ changed = true
363
+ }
364
+ const counts = this.crashedBoot === true && !this.seenThisBoot.has(key)
365
+ this.seenThisBoot.add(key)
366
+ if (counts) {
367
+ entry ??= { kind, id, phase, attempts: 0, parked: false, reason: null, parkedAt: null, retriedAt: null, lastAttemptAt: null, autoRetriedAt: null, updatedAt: this.iso() }
368
+ const last = entry.lastAttemptAt ? Date.parse(entry.lastAttemptAt) : Number.NaN
369
+ const consecutive = Number.isFinite(last) && this.now() - last < CRASH_LOOP_WINDOW_MS
370
+ entry.attempts = consecutive ? entry.attempts + 1 : 1
371
+ entry.lastAttemptAt = this.iso()
372
+ changed = true
373
+ }
374
+ if (!changed || !entry) return true
375
+ entry.updatedAt = this.iso()
376
+ if (entry.attempts > this.limit) {
377
+ entry.parked = true
378
+ entry.reason = RESTART_LIMIT_CODE
379
+ entry.parkedAt = entry.updatedAt
380
+ }
381
+ file.entries[key] = entry
382
+ if (!this.save('admit')) {
383
+ // FAILS OPEN: an unrecorded count must never park anything. (An unrecorded second
384
+ // chance still runs: failing open admits the item.)
385
+ if (before) file.entries[key] = before
386
+ else delete file.entries[key]
387
+ return true
388
+ }
389
+ if (entry.parked) {
390
+ this.log('warn', 'boot_resume.parked', { kind, id, phase, attempts: entry.attempts, limit: this.limit, newly: true })
391
+ return false
392
+ }
393
+ return true
394
+ } catch (error) {
395
+ this.failOpen('admit', error)
396
+ return true
397
+ }
398
+ }
399
+
400
+ /** A parked meeting finalization's one automatic second chance, 30 minutes after its park. */
401
+ private secondChanceDue(entry: BootResumeEntry): boolean {
402
+ if (!AUTO_UNPARK_KINDS.has(entry.kind) || entry.autoRetriedAt !== null) return false
403
+ const parkedAt = entry.parkedAt ? Date.parse(entry.parkedAt) : Number.NaN
404
+ return Number.isFinite(parkedAt) && this.now() - parkedAt >= AUTO_UNPARK_AFTER_MS
405
+ }
406
+
407
+ markCleanShutdown(): void {
408
+ // Only the process that opened its boot owns the marker. One that never resumed
409
+ // anything leaves the previous exit's record as it found it.
410
+ if (!this.enabled() || !this.opened) return
411
+ try {
412
+ const file = this.ledger()
413
+ file.cleanShutdown = true
414
+ // No crash loop: every item this boot admitted starts over. Parked items stay parked.
415
+ for (const key of this.seenThisBoot) {
416
+ const entry = file.entries[key]
417
+ if (!entry || entry.parked) continue
418
+ file.entries[key] = { ...entry, attempts: 0, lastAttemptAt: null, updatedAt: this.iso() }
419
+ }
420
+ this.save('clean_shutdown')
421
+ } catch (error) {
422
+ this.failOpen('clean_shutdown', error)
423
+ }
424
+ }
425
+
426
+ status(): BootResumeStatus
427
+ status(kind: string, id: string): BootResumeEntry | null
428
+ status(kind?: string, id?: string): BootResumeStatus | BootResumeEntry | null {
429
+ if (kind !== undefined && id !== undefined) {
430
+ if (!this.enabled()) return null
431
+ try {
432
+ const entry = this.ledger().entries[keyOf(kind, id)]
433
+ return entry ? { ...entry } : null
434
+ } catch {
435
+ return null
436
+ }
437
+ }
438
+ if (!this.enabled()) return { enabled: false, crashedBoot: null, limit: this.limit, parked: [] }
439
+ try {
440
+ const parked = Object.values(this.ledger().entries)
441
+ .filter(entry => entry.parked)
442
+ .sort((a, b) => (a.parkedAt ?? '').localeCompare(b.parkedAt ?? '') || a.id.localeCompare(b.id))
443
+ .map(parkedView)
444
+ return { enabled: true, crashedBoot: this.crashedBoot, limit: this.limit, parked }
445
+ } catch {
446
+ return { enabled: true, crashedBoot: this.crashedBoot, limit: this.limit, parked: [] }
447
+ }
448
+ }
449
+
450
+ forget(kind: string, id: string): void {
451
+ if (!this.enabled()) return
452
+ try {
453
+ const file = this.ledger()
454
+ const key = keyOf(kind, id)
455
+ if (!(key in file.entries)) return
456
+ delete file.entries[key]
457
+ this.save('forget')
458
+ } catch (error) {
459
+ this.failOpen('forget', error)
460
+ }
461
+ }
462
+
463
+ retry(kind: string, id: string): BootResumeEntry | null {
464
+ if (!this.enabled()) return null
465
+ try {
466
+ const file = this.ledger()
467
+ const key = keyOf(kind, id)
468
+ const before = file.entries[key]
469
+ if (!before) return null
470
+ const at = this.iso()
471
+ const entry: BootResumeEntry = { ...before, attempts: 0, parked: false, reason: null, parkedAt: null, retriedAt: at, lastAttemptAt: null, updatedAt: at }
472
+ file.entries[key] = entry
473
+ // A failed write still unparks in memory (fails open); the next boot may need Retry again.
474
+ this.save('retry')
475
+ this.log('info', 'boot_resume.retry', { kind, id, phase: entry.phase, wasParked: before.parked, attemptsBefore: before.attempts })
476
+ return { ...entry }
477
+ } catch (error) {
478
+ this.failOpen('retry', error)
479
+ return null
480
+ }
481
+ }
482
+
483
+ isParked(kind: string, id: string): boolean {
484
+ if (!this.enabled()) return false
485
+ try {
486
+ return this.ledger().entries[keyOf(kind, id)]?.parked === true
487
+ } catch {
488
+ return false
489
+ }
490
+ }
491
+ }
492
+
493
+ /** One gate per process at boot (index.ts); one per simulated boot in the canaries. Never throws. */
494
+ export function createBootResumeGate(options: BootResumeGateOptions): BootResumeGate {
495
+ return new LedgerGate(options)
496
+ }
497
+
498
+ // ── The process's gate ──────────────────────────────────────────────────────────────────
499
+ // index.ts installs one gate at boot. Before that (and in any test that installs none) every
500
+ // caller gets a gate that admits everything and writes nothing, which is 6.77.0 behaviour.
501
+
502
+ const NOT_INSTALLED: BootResumeGate = {
503
+ limit: RESTART_LIMIT,
504
+ openBoot: () => {},
505
+ admit: () => true,
506
+ markCleanShutdown: () => {},
507
+ status: ((kind?: string, id?: string) => kind !== undefined && id !== undefined
508
+ ? null
509
+ : { enabled: false, crashedBoot: null, limit: RESTART_LIMIT, parked: [] }) as BootResumeGate['status'],
510
+ forget: () => {},
511
+ retry: () => null,
512
+ isParked: () => false,
513
+ }
514
+
515
+ let installed: BootResumeGate | null = null
516
+ const resumers = new Map<BootResumeKind, (id: string) => void>()
517
+
518
+ /**
519
+ * Install the process's gate, and what a Retry of each kind re-runs (a meeting finalization
520
+ * is resumed again; a query job runs again when the app Retries it, so it has none). Null resets
521
+ * both, for tests.
522
+ */
523
+ export function installBootResumeGate(
524
+ gate: BootResumeGate | null,
525
+ options: { resume?: Partial<Record<BootResumeKind, (id: string) => void>> } = {},
526
+ ): BootResumeGate {
527
+ installed = gate
528
+ resumers.clear()
529
+ for (const kind of BOOT_RESUME_KINDS) {
530
+ const resume = options.resume?.[kind]
531
+ if (resume) resumers.set(kind, resume)
532
+ }
533
+ return gate ?? NOT_INSTALLED
534
+ }
535
+
536
+ /** The process's gate, or a pass-through one before index.ts installs it. */
537
+ export function bootResumeGate(): BootResumeGate {
538
+ return installed ?? NOT_INSTALLED
539
+ }
540
+
541
+ /** After a Retry: re-run the item's resume, if its kind has one. True when it ran. Never throws. */
542
+ export function resumeRetriedItem(kind: BootResumeKind, id: string): boolean {
543
+ const resume = resumers.get(kind)
544
+ if (!resume) return false
545
+ try {
546
+ resume(id)
547
+ return true
548
+ } catch (error) {
549
+ defaultLog('warn', 'boot_resume.fail_open', { op: 'resume_after_retry', kind, id, error: errorCode(error) })
550
+ return false
551
+ }
552
+ }