@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.
- package/.env.example +40 -0
- package/CHANGELOG.md +56 -0
- package/README.md +12 -1
- package/managed-runtime-contract.json +17 -1
- package/package.json +10 -1
- package/server/index.ts +98 -10
- package/server/lib/agent-session-binding-registry.ts +623 -9
- package/server/lib/boot-resume.ts +552 -0
- package/server/lib/claude-bridge.ts +152 -3
- package/server/lib/claude-cli-support.ts +39 -0
- package/server/lib/claude-steer.ts +259 -0
- package/server/lib/cos-bridge-notes.ts +73 -0
- package/server/lib/decision-provider.ts +9 -0
- package/server/lib/env-switch.ts +17 -0
- package/server/lib/fuzzy-correct.ts +77 -49
- package/server/lib/live-cos-ask.ts +41 -7
- package/server/lib/live-cues-v2-notes.ts +152 -0
- package/server/lib/live-cues-v2-settings.ts +63 -0
- package/server/lib/live-cues-v2.ts +198 -38
- package/server/lib/managed-restart.ts +8 -0
- package/server/lib/provider-actions.ts +18 -0
- package/server/lib/query-job-coordinator.ts +300 -0
- package/server/lib/query-job-feature.ts +39 -0
- package/server/lib/query-job-resume.ts +269 -0
- package/server/lib/query-job-runtime.ts +23 -3
- package/server/lib/query-job-store.ts +516 -7
- package/server/lib/query-job-types.ts +210 -1
- package/server/lib/queue-turn-evidence.ts +213 -0
- package/server/lib/thread-turn-queue-deliver.ts +6 -1
- package/server/lib/thread-turn-queue-store.ts +13 -4
- package/server/lib/thread-turn-queue.ts +151 -1
- package/server/lib/transcript-cleanup-context.ts +234 -0
- package/server/lib/transcript-cleanup-ledger.ts +304 -0
- package/server/lib/transcript-cleanup-model.ts +217 -0
- package/server/lib/transcript-cleanup-screen.ts +249 -0
- package/server/lib/transcript-cleanup-spans.ts +358 -0
- package/server/lib/transcript-cleanup-state.ts +201 -0
- package/server/lib/transcript-cleanup-suggestions.ts +126 -0
- package/server/lib/transcript-cleanup-text.ts +200 -0
- package/server/lib/transcript-cleanup.ts +427 -0
- package/server/routes/agent-session-bindings.ts +169 -11
- package/server/routes/glossary.ts +38 -19
- package/server/routes/health.ts +29 -1
- package/server/routes/live-cues.ts +32 -3
- package/server/routes/maintenance.ts +34 -0
- package/server/routes/meeting.ts +92 -9
- package/server/routes/query-jobs.ts +107 -13
- package/server/routes/recovery.ts +4 -0
- package/server/routes/thread-turn-queue.ts +371 -10
- package/server/routes/transcribe.ts +3 -0
- package/server/routes/transcript-cleanup.ts +237 -0
- 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
|
+
}
|