@h1v35/hivex 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,657 @@
1
+ import { Database } from 'bun:sqlite';
2
+ import {
3
+ closeSync,
4
+ lstatSync,
5
+ mkdirSync,
6
+ openSync,
7
+ readFileSync,
8
+ unlinkSync,
9
+ writeFileSync,
10
+ } from 'node:fs';
11
+ import { join } from 'node:path';
12
+ import { randomUUID } from 'node:crypto';
13
+ import { z } from 'zod';
14
+ import { HivexError } from './errors.ts';
15
+ import { emptyGraph, extractionSchema, graphSchema, type Graph } from './knowledge-model.ts';
16
+
17
+ const processIdSchema = z.number().int().positive();
18
+ const lockSchema = z.object({ pid: processIdSchema, id: z.string().min(1) });
19
+ const recoveryAcknowledgementSchema = z.object({
20
+ type: z.literal('uncertain-invocation'),
21
+ acknowledgedAt: z.string(),
22
+ nativeProcessId: processIdSchema,
23
+ });
24
+
25
+ const attemptSchema = z.object({
26
+ stage: z.string(),
27
+ inputHash: z.string(),
28
+ inputBytes: z.number(),
29
+ report: z.unknown().optional(),
30
+ outputHash: z.string().optional(),
31
+ diagnostic: z.string().optional(),
32
+ error: z.string().optional(),
33
+ result: z.unknown().optional(),
34
+ recoveryAcknowledgement: recoveryAcknowledgementSchema.optional(),
35
+ });
36
+ const workSchema = z.object({
37
+ id: z.string(),
38
+ kind: z.enum(['update', 'ask', 'review']),
39
+ key: z.string(),
40
+ snapshot: z.string(),
41
+ calls: z.number().int().nonnegative(),
42
+ maxCalls: z.number().int().nonnegative(),
43
+ inputBytes: z.number().int().nonnegative(),
44
+ maxInputBytes: z.number().int().positive(),
45
+ totalTokens: z.number().int().nonnegative(),
46
+ status: z.enum(['pending', 'running', 'budget-exhausted', 'context-limit', 'failed', 'done']),
47
+ remaining: z.array(z.string()),
48
+ plannedUnits: z.array(z.string()).default([]),
49
+ phase: z.enum(['update', 'ask', 'review']).default('update'),
50
+ contextLimit: z
51
+ .object({ documents: z.array(z.string()), requiredBytes: z.number(), maxBytes: z.number() })
52
+ .optional(),
53
+ resultKey: z.string().optional(),
54
+ cacheHits: z.number().int().nonnegative().default(0),
55
+ ownerPid: processIdSchema.optional(),
56
+ nativeProcessId: processIdSchema.optional(),
57
+ pending: z
58
+ .object({
59
+ batch: z.string(),
60
+ documents: z.array(z.string()),
61
+ units: z.array(z.string()).default([]),
62
+ packet: z.record(z.string(), z.unknown()).optional(),
63
+ context: z.array(z.string()).default([]),
64
+ existing: z.array(z.string()).default([]),
65
+ extraction: extractionSchema,
66
+ })
67
+ .nullable(),
68
+ attempts: z.array(attemptSchema).max(4096),
69
+ result: z.unknown().optional(),
70
+ });
71
+ export type Work = z.infer<typeof workSchema>;
72
+
73
+ type BeginWork = {
74
+ kind: Work['kind'];
75
+ key: string;
76
+ snapshot: string;
77
+ maxCalls?: number;
78
+ maxInputBytes?: number;
79
+ remaining: string[];
80
+ resultKey?: string;
81
+ };
82
+
83
+ export type RecoveryReport = {
84
+ status: 'clean' | 'recovered' | 'blocked';
85
+ lock: 'absent' | 'released' | 'held' | 'unreadable' | 'changed';
86
+ interruptedWorks: number;
87
+ acknowledgedWorks: number;
88
+ guidance?: string;
89
+ };
90
+
91
+ export type RecoveryOptions = {
92
+ acknowledgeUncertain?: boolean;
93
+ };
94
+
95
+ export type PruneOptions = {
96
+ keepCompleted: number;
97
+ keepCaches: number;
98
+ };
99
+
100
+ export type PruneReport = {
101
+ deletedCompletedWorks: number;
102
+ deletedCaches: number;
103
+ retainedCompletedWorks: number;
104
+ retainedCaches: number;
105
+ unfinishedWorks: number;
106
+ };
107
+
108
+ type ProcessState = 'alive' | 'dead' | 'unknown';
109
+ type RecoveryLock = { raw: string; pid: number };
110
+
111
+ function errorCode(error: unknown) {
112
+ return error instanceof Error && 'code' in error && typeof error.code === 'string'
113
+ ? error.code
114
+ : undefined;
115
+ }
116
+
117
+ function processState(pid: number): ProcessState {
118
+ try {
119
+ process.kill(pid, 0);
120
+ return 'alive';
121
+ } catch (error) {
122
+ const code = errorCode(error);
123
+ if (code === 'ESRCH') return 'dead';
124
+ if (code === 'EPERM') return 'alive';
125
+ return 'unknown';
126
+ }
127
+ }
128
+
129
+ function recordValue(value: unknown): Record<string, unknown> | null {
130
+ return value !== null && typeof value === 'object' && !Array.isArray(value)
131
+ ? (value as Record<string, unknown>)
132
+ : null;
133
+ }
134
+
135
+ function interruptedReport(previous: unknown, nativeProcessId: number) {
136
+ const report = recordValue(previous) ?? {};
137
+ return {
138
+ ...report,
139
+ outcome: 'interrupted',
140
+ code: 'MODEL_INTERRUPTED_RECOVERED',
141
+ interruption: 'unconfirmed',
142
+ turnAccepted: typeof report.turnAccepted === 'string' ? report.turnAccepted : 'unknown',
143
+ cleanup: 'not-observed',
144
+ usage: report.usage ?? null,
145
+ recovery: {
146
+ nativeProcessId,
147
+ nativeProcessEnded: true,
148
+ previousOutcome: typeof report.outcome === 'string' ? report.outcome : null,
149
+ },
150
+ };
151
+ }
152
+
153
+ function deleteRows(db: Database, table: 'work' | 'model_cache', rowids: number[]) {
154
+ if (!rowids.length) return;
155
+ const placeholders = rowids.map(() => '?').join(',');
156
+ db.run(`DELETE FROM ${table} WHERE rowid IN (${placeholders})`, rowids);
157
+ }
158
+
159
+ function throwRecovery(
160
+ lock: RecoveryReport['lock'],
161
+ guidance: string,
162
+ interruptedWorks = 0,
163
+ ): never {
164
+ throw new HivexError({
165
+ code: 'RECOVERY_UNSAFE',
166
+ message: guidance,
167
+ details: { lock, interruptedWorks },
168
+ });
169
+ }
170
+
171
+ function blockedRecovery(error: HivexError): RecoveryReport {
172
+ const lock = error.details?.lock;
173
+ return {
174
+ status: 'blocked',
175
+ lock:
176
+ lock === 'absent' || lock === 'held' || lock === 'unreadable' || lock === 'changed'
177
+ ? lock
178
+ : 'unreadable',
179
+ interruptedWorks:
180
+ typeof error.details?.interruptedWorks === 'number' ? error.details.interruptedWorks : 0,
181
+ acknowledgedWorks: 0,
182
+ guidance: error.message,
183
+ };
184
+ }
185
+
186
+ export class KnowledgeStore implements Disposable {
187
+ private readonly db: Database;
188
+ private readonly directory: string;
189
+
190
+ constructor(root: string, options: { readonly?: boolean } = {}) {
191
+ const directory = join(root, '.hivex');
192
+ this.directory = directory;
193
+ const path = join(directory, 'knowledge.sqlite');
194
+ for (const candidate of [directory, path]) {
195
+ if (lstatSync(candidate, { throwIfNoEntry: false })?.isSymbolicLink())
196
+ throw new HivexError({
197
+ code: 'INVALID_STORE',
198
+ message: 'Knowledge storage cannot be a symlink',
199
+ });
200
+ }
201
+ if (!options.readonly) mkdirSync(directory, { recursive: true, mode: 0o700 });
202
+ this.db = options.readonly ? new Database(path, { readonly: true }) : new Database(path);
203
+ if (options.readonly) return;
204
+ this.db.run('PRAGMA busy_timeout=1000');
205
+ this.db.run('PRAGMA max_page_count=16384');
206
+ this.db.run(
207
+ 'CREATE TABLE IF NOT EXISTS graph (id INTEGER PRIMARY KEY CHECK(id=1), data TEXT NOT NULL)',
208
+ );
209
+ this.db.run(
210
+ 'CREATE TABLE IF NOT EXISTS work (id TEXT PRIMARY KEY, kind TEXT NOT NULL, key TEXT NOT NULL, data TEXT NOT NULL)',
211
+ );
212
+ this.db.run(
213
+ 'CREATE TABLE IF NOT EXISTS model_cache (key TEXT PRIMARY KEY, value TEXT NOT NULL)',
214
+ );
215
+ this.db.run('CREATE INDEX IF NOT EXISTS work_key ON work(kind,key)');
216
+ }
217
+
218
+ updateLease(): Disposable {
219
+ const path = join(this.directory, 'knowledge.lock');
220
+ const token = JSON.stringify({ pid: process.pid, id: randomUUID() });
221
+ let fd: number;
222
+ try {
223
+ fd = openSync(path, 'wx', 0o600);
224
+ } catch {
225
+ throw new HivexError({
226
+ code: 'KNOWLEDGE_LOCKED',
227
+ message:
228
+ 'Cannot acquire the update lock; inspect any active or interrupted update before continuing',
229
+ });
230
+ }
231
+ writeFileSync(fd, token);
232
+ return {
233
+ [Symbol.dispose]() {
234
+ closeSync(fd);
235
+ try {
236
+ if (readFileSync(path, 'utf8') === token) unlinkSync(path);
237
+ } catch (error) {
238
+ if (!(error instanceof Error && 'code' in error && error.code === 'ENOENT')) throw error;
239
+ }
240
+ },
241
+ };
242
+ }
243
+
244
+ graph(): Graph {
245
+ const row = this.db.query<{ data: string }, []>('SELECT data FROM graph WHERE id=1').get();
246
+ return row ? graphSchema.parse(JSON.parse(row.data)) : emptyGraph();
247
+ }
248
+
249
+ saveGraph(graph: Graph) {
250
+ this.db.run('INSERT INTO graph VALUES(1,?) ON CONFLICT(id) DO UPDATE SET data=excluded.data', [
251
+ JSON.stringify(graph),
252
+ ]);
253
+ }
254
+
255
+ begin(options: BeginWork): Work {
256
+ const defaultMaxCalls = options.kind === 'update' ? 2 : 3;
257
+ return this.db
258
+ .transaction(() => {
259
+ const row = this.db
260
+ .query<
261
+ { data: string },
262
+ [string, string]
263
+ >('SELECT data FROM work WHERE kind=? AND key=? ORDER BY rowid DESC LIMIT 1')
264
+ .get(options.kind, options.key);
265
+ const previous = row ? workSchema.parse(JSON.parse(row.data)) : null;
266
+ const reusable =
267
+ options.kind !== 'update'
268
+ ? previous?.resultKey === options.resultKey
269
+ : options.remaining.length === 0;
270
+ if (previous && (options.kind !== 'update' || previous.status !== 'done' || reusable)) {
271
+ return this.resume(previous, options, reusable);
272
+ }
273
+ const work: Work = {
274
+ id: randomUUID(),
275
+ ...options,
276
+ maxCalls: options.maxCalls ?? defaultMaxCalls,
277
+ maxInputBytes: options.maxInputBytes ?? 131072,
278
+ plannedUnits: [...options.remaining],
279
+ phase: 'update',
280
+ calls: 0,
281
+ cacheHits: 0,
282
+ inputBytes: 0,
283
+ totalTokens: 0,
284
+ status: 'pending',
285
+ pending: null,
286
+ attempts: [],
287
+ };
288
+ this.save(work);
289
+ return work;
290
+ })
291
+ .immediate();
292
+ }
293
+
294
+ private resume(work: Work, options: BeginWork, reusable: boolean) {
295
+ if (work.status === 'done' && reusable) return work;
296
+ if (work.status === 'done') {
297
+ work.status = 'pending';
298
+ delete work.result;
299
+ }
300
+ if (work.status === 'running')
301
+ throw new HivexError({
302
+ code: 'WORK_RUNNING',
303
+ message: `Work ${work.id} has an unfinished invocation; inspect it before retrying`,
304
+ });
305
+ if (options.maxCalls !== undefined) work.maxCalls = options.maxCalls;
306
+ if (options.maxInputBytes !== undefined) work.maxInputBytes = options.maxInputBytes;
307
+ this.save(work);
308
+ return work;
309
+ }
310
+
311
+ save(work: Work) {
312
+ if (work.status !== 'running') delete work.nativeProcessId;
313
+ this.db.run(
314
+ 'INSERT INTO work VALUES(?,?,?,?) ON CONFLICT(id) DO UPDATE SET data=excluded.data',
315
+ [work.id, work.kind, work.key, JSON.stringify(work)],
316
+ );
317
+ }
318
+
319
+ commit(work: Work, graph: Graph) {
320
+ this.db.transaction(() => {
321
+ this.saveGraph(graph);
322
+ this.save(work);
323
+ })();
324
+ }
325
+
326
+ cached(key: string): unknown {
327
+ const row = this.db
328
+ .query<{ value: string }, [string]>('SELECT value FROM model_cache WHERE key=?')
329
+ .get(key);
330
+ return row ? JSON.parse(row.value) : undefined;
331
+ }
332
+
333
+ cache(key: string, value: unknown) {
334
+ this.db.run(
335
+ 'INSERT INTO model_cache VALUES(?,?) ON CONFLICT(key) DO UPDATE SET value=excluded.value',
336
+ [key, JSON.stringify(value)],
337
+ );
338
+ }
339
+
340
+ recordNativeProcess(work: Work, nativeProcessId: number) {
341
+ if (!processIdSchema.safeParse(nativeProcessId).success)
342
+ throw new HivexError({
343
+ code: 'INVALID_PROCESS_ID',
344
+ message: 'Native process ID must be a positive integer',
345
+ });
346
+ this.db.transaction(() => {
347
+ const stored = this.db
348
+ .query<{ data: string }, [string]>('SELECT data FROM work WHERE id=?')
349
+ .get(work.id);
350
+ const current = stored && workSchema.parse(JSON.parse(stored.data));
351
+ if (!current || current.calls !== work.calls || current.status !== 'running')
352
+ throw new HivexError({
353
+ code: 'WORK_CONFLICT',
354
+ message: 'Work was claimed or changed before the native process was recorded',
355
+ });
356
+ work.nativeProcessId = nativeProcessId;
357
+ this.save(work);
358
+ })();
359
+ }
360
+
361
+ reserve(work: Work, stage: string, inputHash: string, inputBytes: number) {
362
+ this.db.transaction(() => {
363
+ const stored = this.db
364
+ .query<{ data: string }, [string]>('SELECT data FROM work WHERE id=?')
365
+ .get(work.id);
366
+ const current = stored && workSchema.parse(JSON.parse(stored.data));
367
+ if (!current || current.calls !== work.calls || current.status === 'running')
368
+ throw new HivexError({
369
+ code: 'WORK_CONFLICT',
370
+ message: 'Work was claimed or changed by another operation',
371
+ });
372
+ work.calls += 1;
373
+ work.inputBytes += inputBytes;
374
+ work.status = 'running';
375
+ work.ownerPid = process.pid;
376
+ delete work.nativeProcessId;
377
+ work.attempts.push({ stage, inputHash, inputBytes });
378
+ this.save(work);
379
+ })();
380
+ }
381
+
382
+ recover(options: RecoveryOptions = {}): RecoveryReport {
383
+ try {
384
+ return this.recoverChecked(options);
385
+ } catch (error) {
386
+ if (error instanceof HivexError && error.code === 'RECOVERY_UNSAFE')
387
+ return blockedRecovery(error);
388
+ throw error;
389
+ }
390
+ }
391
+
392
+ prune(options: PruneOptions): PruneReport {
393
+ if (
394
+ !Number.isInteger(options.keepCompleted) ||
395
+ options.keepCompleted < 0 ||
396
+ !Number.isInteger(options.keepCaches) ||
397
+ options.keepCaches < 0
398
+ )
399
+ throw new HivexError({
400
+ code: 'INVALID_RETENTION',
401
+ message: 'Retention counts must be non-negative integers',
402
+ });
403
+ const works = this.db
404
+ .query<{ rowid: number; data: string }, []>('SELECT rowid,data FROM work ORDER BY rowid DESC')
405
+ .all()
406
+ .map((row) => ({ rowid: row.rowid, work: workSchema.parse(JSON.parse(row.data)) }));
407
+ const completed = works.filter(({ work }) => work.status === 'done');
408
+ const workRowsToDelete = completed.slice(options.keepCompleted).map(({ rowid }) => rowid);
409
+ const caches = this.db
410
+ .query<{ rowid: number }, []>('SELECT rowid FROM model_cache ORDER BY rowid DESC')
411
+ .all()
412
+ .map(({ rowid }) => rowid);
413
+ const cacheRowsToDelete = caches.slice(options.keepCaches);
414
+ this.db.transaction(() => {
415
+ deleteRows(this.db, 'work', workRowsToDelete);
416
+ deleteRows(this.db, 'model_cache', cacheRowsToDelete);
417
+ })();
418
+ return {
419
+ deletedCompletedWorks: workRowsToDelete.length,
420
+ deletedCaches: cacheRowsToDelete.length,
421
+ retainedCompletedWorks: completed.length - workRowsToDelete.length,
422
+ retainedCaches: caches.length - cacheRowsToDelete.length,
423
+ unfinishedWorks: works.filter(({ work }) => work.status !== 'done').length,
424
+ };
425
+ }
426
+
427
+ private allWorks() {
428
+ return this.db
429
+ .query<{ data: string }, []>('SELECT data FROM work')
430
+ .all()
431
+ .map(({ data }) => workSchema.parse(JSON.parse(data)));
432
+ }
433
+
434
+ private runningWorks() {
435
+ return this.allWorks().filter((work) => work.status === 'running');
436
+ }
437
+
438
+ private uncertainFailedWorks() {
439
+ return this.allWorks().flatMap((work) => {
440
+ if (work.status !== 'failed' || work.attempts.at(-1)?.recoveryAcknowledgement) return [];
441
+ const report = recordValue(work.attempts.at(-1)?.report);
442
+ if (report?.interruption !== 'unconfirmed' && report?.turnAccepted !== 'unknown') return [];
443
+ const nativeProcessId = processIdSchema.safeParse(report.nativeProcessId);
444
+ return [
445
+ { work, nativeProcessId: nativeProcessId.success ? nativeProcessId.data : undefined },
446
+ ];
447
+ });
448
+ }
449
+
450
+ private recoverChecked(options: RecoveryOptions): RecoveryReport {
451
+ const lock = this.recoveryLock();
452
+ if (lock !== null) this.assertOwnerEnded(lock.pid, 'held');
453
+ const running = this.runningWorksOrBlock();
454
+ const failed = this.uncertainFailedWorks();
455
+ if (!running.length && !failed.length) return this.releaseRecoveryLock(lock?.raw ?? null, 0, 0);
456
+ const lockState = lock === null ? 'absent' : 'held';
457
+ for (const work of running) this.assertRecoverable(work, work.nativeProcessId, lockState);
458
+ for (const entry of failed)
459
+ this.assertRecoverable(entry.work, entry.nativeProcessId, lockState);
460
+ const uncertain =
461
+ running.filter((work) => work.nativeProcessId !== undefined).length + failed.length;
462
+ if (uncertain && !options.acknowledgeUncertain)
463
+ throwRecovery(
464
+ lockState,
465
+ 'Uncertain work is recoverable after its owner and native PIDs ended; rerun `hivex recover --acknowledge-uncertain --root <project>` to record an explicit acknowledgement.',
466
+ );
467
+ this.recordRecovery([
468
+ ...running.map((work) => ({ work, nativeProcessId: work.nativeProcessId })),
469
+ ...failed,
470
+ ]);
471
+ return this.releaseRecoveryLock(lock?.raw ?? null, running.length, uncertain);
472
+ }
473
+
474
+ private recoveryLock(): RecoveryLock | null {
475
+ const path = join(this.directory, 'knowledge.lock');
476
+ let raw: string;
477
+ try {
478
+ raw = readFileSync(path, 'utf8');
479
+ } catch (error) {
480
+ if (errorCode(error) === 'ENOENT') return null;
481
+ throwRecovery(
482
+ 'unreadable',
483
+ 'knowledge.lock cannot be read safely; inspect the store before continuing.',
484
+ );
485
+ }
486
+ try {
487
+ const lock = lockSchema.parse(JSON.parse(raw));
488
+ return { raw, pid: lock.pid };
489
+ } catch {
490
+ throwRecovery(
491
+ 'unreadable',
492
+ 'knowledge.lock has no verifiable PID; do not delete it and inspect the process manually.',
493
+ );
494
+ }
495
+ }
496
+
497
+ private runningWorksOrBlock() {
498
+ try {
499
+ return this.runningWorks();
500
+ } catch {
501
+ throwRecovery(
502
+ 'unreadable',
503
+ 'Work state cannot be validated; preserve the store and inspect it manually.',
504
+ );
505
+ }
506
+ }
507
+
508
+ private assertOwnerEnded(
509
+ ownerPid: number,
510
+ lock: RecoveryReport['lock'],
511
+ label = 'The lock owner',
512
+ ) {
513
+ const state = processState(ownerPid);
514
+ if (state !== 'dead')
515
+ throwRecovery(
516
+ lock,
517
+ state === 'alive'
518
+ ? `${label} (PID ${ownerPid}) is still alive; no process was modified or terminated.`
519
+ : `${label} (PID ${ownerPid}) cannot be proven dead; no state was modified.`,
520
+ );
521
+ }
522
+
523
+ private assertRecoverable(
524
+ work: Work,
525
+ nativeProcessId: number | undefined,
526
+ lock: RecoveryReport['lock'],
527
+ ) {
528
+ if (work.ownerPid === undefined)
529
+ throwRecovery(
530
+ lock,
531
+ `Work ${work.id} has no recorded owner PID; its recovery state is unchanged.`,
532
+ );
533
+ this.assertOwnerEnded(work.ownerPid, lock, `Work ${work.id} owner`);
534
+ const attempt = work.attempts.at(-1);
535
+ if (!attempt)
536
+ throwRecovery(lock, `Work ${work.id} has no reserved attempt; no state was changed.`);
537
+ if (nativeProcessId === undefined && work.status === 'running') return;
538
+ if (nativeProcessId === undefined)
539
+ throwRecovery(
540
+ lock,
541
+ `Work ${work.id} has no native PID for its uncertain result; no state was changed.`,
542
+ );
543
+ const state = processState(nativeProcessId);
544
+ if (state !== 'dead')
545
+ throwRecovery(
546
+ lock,
547
+ state === 'alive'
548
+ ? `Native process PID ${nativeProcessId} for work ${work.id} is still alive; no process was killed.`
549
+ : `Native process PID ${nativeProcessId} for work ${work.id} cannot be checked; no state was changed.`,
550
+ );
551
+ const report = work.attempts.at(-1)?.report;
552
+ if (report !== undefined && recordValue(report) === null)
553
+ throwRecovery(
554
+ lock,
555
+ `Work ${work.id} has an unstructured running report; recovery left it unchanged.`,
556
+ );
557
+ }
558
+
559
+ private recordRecovery(works: Array<{ work: Work; nativeProcessId: number | undefined }>) {
560
+ this.db.transaction(() => {
561
+ for (const { work, nativeProcessId } of works) {
562
+ const row = this.db
563
+ .query<{ data: string }, [string]>('SELECT data FROM work WHERE id=?')
564
+ .get(work.id);
565
+ const current = row && workSchema.parse(JSON.parse(row.data));
566
+ const attempt = current?.attempts.at(-1);
567
+ if (!current || current.status !== work.status || current.calls !== work.calls || !attempt)
568
+ throwRecovery('changed', `Work ${work.id} changed during recovery; run recover again.`);
569
+ attempt.report ??=
570
+ nativeProcessId === undefined
571
+ ? {
572
+ outcome: 'interrupted',
573
+ code: 'MODEL_INTERRUPTED_BEFORE_TURN',
574
+ cleanup: 'not-observed',
575
+ usage: null,
576
+ }
577
+ : interruptedReport(undefined, nativeProcessId);
578
+ if (nativeProcessId !== undefined)
579
+ attempt.recoveryAcknowledgement = {
580
+ type: 'uncertain-invocation',
581
+ acknowledgedAt: new Date().toISOString(),
582
+ nativeProcessId,
583
+ };
584
+ current.status = 'failed';
585
+ this.save(current);
586
+ }
587
+ })();
588
+ }
589
+
590
+ private releaseRecoveryLock(
591
+ raw: string | null,
592
+ interruptedWorks: number,
593
+ acknowledgedWorks: number,
594
+ ): RecoveryReport {
595
+ if (raw === null)
596
+ return {
597
+ status: 'recovered',
598
+ lock: 'absent',
599
+ interruptedWorks,
600
+ acknowledgedWorks,
601
+ ...(acknowledgedWorks
602
+ ? {
603
+ guidance:
604
+ 'Recovery preserved the work. Run `hivex update --retry-failed --root <project>` to retry it explicitly; recovery made zero model calls.',
605
+ }
606
+ : {}),
607
+ };
608
+ let released: boolean;
609
+ try {
610
+ released = this.releaseLock(raw);
611
+ } catch {
612
+ throwRecovery(
613
+ 'unreadable',
614
+ acknowledgedWorks
615
+ ? 'Work was acknowledged, but knowledge.lock could not be released.'
616
+ : 'The owner is dead, but knowledge.lock could not be released atomically.',
617
+ interruptedWorks,
618
+ );
619
+ }
620
+ if (!released)
621
+ throwRecovery(
622
+ 'changed',
623
+ acknowledgedWorks
624
+ ? 'Work was acknowledged, but knowledge.lock changed; run recover again before continuing.'
625
+ : 'knowledge.lock changed during recovery; inspect the store before continuing.',
626
+ interruptedWorks,
627
+ );
628
+ return {
629
+ status: 'recovered',
630
+ lock: 'released',
631
+ interruptedWorks,
632
+ acknowledgedWorks,
633
+ ...(acknowledgedWorks
634
+ ? {
635
+ guidance:
636
+ 'Recovery preserved the work. Run `hivex update --retry-failed --root <project>` to retry it explicitly; recovery made zero model calls.',
637
+ }
638
+ : {}),
639
+ };
640
+ }
641
+
642
+ private releaseLock(expected: string) {
643
+ const path = join(this.directory, 'knowledge.lock');
644
+ try {
645
+ if (readFileSync(path, 'utf8') !== expected) return false;
646
+ unlinkSync(path);
647
+ return true;
648
+ } catch (error) {
649
+ if (errorCode(error) === 'ENOENT') return true;
650
+ throw error;
651
+ }
652
+ }
653
+
654
+ [Symbol.dispose]() {
655
+ this.db.close();
656
+ }
657
+ }