@h1v35/hivex 0.1.0 → 0.2.1

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.
@@ -1,4 +1,4 @@
1
- import { Database } from 'bun:sqlite';
1
+ import { randomUUID } from 'node:crypto';
2
2
  import {
3
3
  closeSync,
4
4
  lstatSync,
@@ -8,219 +8,597 @@ import {
8
8
  unlinkSync,
9
9
  writeFileSync,
10
10
  } from 'node:fs';
11
- import { join } from 'node:path';
12
- import { randomUUID } from 'node:crypto';
11
+ import path from 'node:path';
12
+ import { Database } from 'bun:sqlite';
13
13
  import { z } from 'zod';
14
14
  import { HivexError } from './errors.ts';
15
- import { emptyGraph, extractionSchema, graphSchema, type Graph } from './knowledge-model.ts';
15
+ import { sharedKnowledge } from './knowledge-snapshot.ts';
16
+ import { emptyGraph, extractionSchema, graphSchema } from './knowledge-model.ts';
17
+ import type { Graph } from './knowledge-model.ts';
16
18
 
17
19
  const processIdSchema = z.number().int().positive();
18
- const lockSchema = z.object({ pid: processIdSchema, id: z.string().min(1) });
20
+ const lockSchema = z.object({ id: z.string().min(1), pid: processIdSchema });
19
21
  const recoveryAcknowledgementSchema = z.object({
20
- type: z.literal('uncertain-invocation'),
21
22
  acknowledgedAt: z.string(),
22
23
  nativeProcessId: processIdSchema,
24
+ type: z.literal('uncertain-invocation'),
23
25
  });
24
-
26
+ const workConflictCode = 'WORK_CONFLICT';
27
+ const lockFilename = 'knowledge.lock';
28
+ const workByIdQuery = 'SELECT data FROM work WHERE id=?';
25
29
  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
30
  diagnostic: z.string().optional(),
32
31
  error: z.string().optional(),
33
- result: z.unknown().optional(),
32
+ inputBytes: z.number(),
33
+ inputHash: z.string(),
34
+ outputHash: z.string().optional(),
34
35
  recoveryAcknowledgement: recoveryAcknowledgementSchema.optional(),
36
+ report: z.unknown().optional(),
37
+ result: z.unknown().optional(),
38
+ stage: z.string(),
35
39
  });
36
40
  const workSchema = z.object({
41
+ attempts: z.array(attemptSchema).max(4096),
42
+ cacheHits: z.number().int().nonnegative().default(0),
43
+ calls: z.number().int().nonnegative(),
44
+ contextLimit: z
45
+ .object({
46
+ documents: z.array(z.string()),
47
+ maxBytes: z.number(),
48
+ requiredBytes: z.number(),
49
+ })
50
+ .optional(),
37
51
  id: z.string(),
38
- kind: z.enum(['update', 'ask', 'review']),
52
+ inputBytes: z.number().int().nonnegative(),
39
53
  key: z.string(),
40
- snapshot: z.string(),
41
- calls: z.number().int().nonnegative(),
54
+ kind: z.enum(['update', 'ask', 'review']),
42
55
  maxCalls: z.number().int().nonnegative(),
43
- inputBytes: z.number().int().nonnegative(),
44
56
  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
57
  nativeProcessId: processIdSchema.optional(),
58
+ ownerPid: processIdSchema.optional(),
57
59
  pending: z
58
60
  .object({
59
61
  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
62
  context: z.array(z.string()).default([]),
63
+ documents: z.array(z.string()),
64
64
  existing: z.array(z.string()).default([]),
65
65
  extraction: extractionSchema,
66
+ packet: z.record(z.string(), z.unknown()).optional(),
67
+ units: z.array(z.string()).default([]),
66
68
  })
67
69
  .nullable(),
68
- attempts: z.array(attemptSchema).max(4096),
70
+ phase: z.enum(['update', 'ask', 'review']).default('update'),
71
+ plannedUnits: z.array(z.string()).default([]),
72
+ remaining: z.array(z.string()),
69
73
  result: z.unknown().optional(),
74
+ resultKey: z.string().optional(),
75
+ snapshot: z.string(),
76
+ status: z.enum(['pending', 'running', 'budget-exhausted', 'context-limit', 'failed', 'done']),
77
+ totalTokens: z.number().int().nonnegative(),
70
78
  });
71
79
  export type Work = z.infer<typeof workSchema>;
72
-
73
- type BeginWork = {
74
- kind: Work['kind'];
80
+ interface StoreOptions {
81
+ readonly?: boolean;
82
+ update?: boolean;
83
+ }
84
+ interface BeginWork {
75
85
  key: string;
76
- snapshot: string;
86
+ kind: Work['kind'];
77
87
  maxCalls?: number;
78
88
  maxInputBytes?: number;
79
89
  remaining: string[];
80
90
  resultKey?: string;
81
- };
82
-
83
- export type RecoveryReport = {
84
- status: 'clean' | 'recovered' | 'blocked';
85
- lock: 'absent' | 'released' | 'held' | 'unreadable' | 'changed';
86
- interruptedWorks: number;
91
+ snapshot: string;
92
+ }
93
+ const recoveryLockValues = ['absent', 'released', 'held', 'unreadable', 'changed'] as const;
94
+ type RecoveryLockStatus = (typeof recoveryLockValues)[number];
95
+ export interface RecoveryReport {
87
96
  acknowledgedWorks: number;
88
97
  guidance?: string;
89
- };
90
-
91
- export type RecoveryOptions = {
98
+ interruptedWorks: number;
99
+ lock: RecoveryLockStatus;
100
+ status: 'clean' | 'recovered' | 'blocked';
101
+ }
102
+ export interface RecoveryOptions {
92
103
  acknowledgeUncertain?: boolean;
93
- };
94
-
95
- export type PruneOptions = {
96
- keepCompleted: number;
104
+ }
105
+ export interface PruneOptions {
97
106
  keepCaches: number;
98
- };
99
-
100
- export type PruneReport = {
101
- deletedCompletedWorks: number;
107
+ keepCompleted: number;
108
+ }
109
+ export interface PruneReport {
102
110
  deletedCaches: number;
103
- retainedCompletedWorks: number;
111
+ deletedCompletedWorks: number;
104
112
  retainedCaches: number;
113
+ retainedCompletedWorks: number;
105
114
  unfinishedWorks: number;
106
- };
107
-
115
+ }
108
116
  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'
117
+ interface RecoveryLock {
118
+ pid: number;
119
+ raw: string;
120
+ }
121
+ const errorCode = function errorCode(error: unknown) {
122
+ return Error.isError(error) && 'code' in error && typeof error.code === 'string'
113
123
  ? error.code
114
124
  : undefined;
115
- }
116
-
117
- function processState(pid: number): ProcessState {
125
+ };
126
+ const processState = function processState(pid: number): ProcessState {
118
127
  try {
119
128
  process.kill(pid, 0);
120
129
  return 'alive';
121
130
  } catch (error) {
122
131
  const code = errorCode(error);
123
- if (code === 'ESRCH') return 'dead';
124
- if (code === 'EPERM') return 'alive';
132
+ if (code === 'ESRCH') {
133
+ return 'dead';
134
+ }
135
+ if (code === 'EPERM') {
136
+ return 'alive';
137
+ }
125
138
  return 'unknown';
126
139
  }
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) {
140
+ };
141
+ const recordValue = function recordValue(value: unknown): Record<string, unknown> | null {
142
+ if (value === null || typeof value !== 'object' || Array.isArray(value)) {
143
+ return null;
144
+ }
145
+ return Object.fromEntries(Object.entries(value));
146
+ };
147
+ const interruptedReport = function interruptedReport(previous: unknown, nativeProcessId: number) {
136
148
  const report = recordValue(previous) ?? {};
137
149
  return {
138
150
  ...report,
139
- outcome: 'interrupted',
151
+ cleanup: 'not-observed',
140
152
  code: 'MODEL_INTERRUPTED_RECOVERED',
141
153
  interruption: 'unconfirmed',
142
- turnAccepted: typeof report.turnAccepted === 'string' ? report.turnAccepted : 'unknown',
143
- cleanup: 'not-observed',
144
- usage: report.usage ?? null,
154
+ outcome: 'interrupted',
145
155
  recovery: {
146
- nativeProcessId,
147
156
  nativeProcessEnded: true,
157
+ nativeProcessId,
148
158
  previousOutcome: typeof report.outcome === 'string' ? report.outcome : null,
149
159
  },
160
+ turnAccepted: typeof report.turnAccepted === 'string' ? report.turnAccepted : 'unknown',
161
+ usage: report.usage ?? null,
150
162
  };
151
- }
152
-
153
- function deleteRows(db: Database, table: 'work' | 'model_cache', rowids: number[]) {
154
- if (!rowids.length) return;
163
+ };
164
+ const deleteRows = function deleteRows(
165
+ database: Database,
166
+ table: 'work' | 'model_cache',
167
+ rowids: number[]
168
+ ) {
169
+ if (rowids.length === 0) {
170
+ return;
171
+ }
155
172
  const placeholders = rowids.map(() => '?').join(',');
156
- db.run(`DELETE FROM ${table} WHERE rowid IN (${placeholders})`, rowids);
157
- }
158
-
159
- function throwRecovery(
173
+ database.run(`DELETE FROM ${table} WHERE rowid IN (${placeholders})`, rowids);
174
+ };
175
+ const throwRecovery: (
160
176
  lock: RecoveryReport['lock'],
161
177
  guidance: string,
162
- interruptedWorks = 0,
163
- ): never {
178
+ interruptedWorks?: number
179
+ ) => never = function throwRecovery(lock, guidance, interruptedWorks = 0) {
164
180
  throw new HivexError({
165
181
  code: 'RECOVERY_UNSAFE',
182
+ details: { interruptedWorks, lock },
166
183
  message: guidance,
167
- details: { lock, interruptedWorks },
168
184
  });
169
- }
170
-
171
- function blockedRecovery(error: HivexError): RecoveryReport {
185
+ };
186
+ const blockedRecovery = function blockedRecovery(error: HivexError): RecoveryReport {
172
187
  const lock = error.details?.lock;
188
+ const knownLocks = recoveryLockValues.filter((candidate) => candidate !== 'released');
173
189
  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
190
  acknowledgedWorks: 0,
182
191
  guidance: error.message,
192
+ interruptedWorks:
193
+ typeof error.details?.interruptedWorks === 'number' ? error.details.interruptedWorks : 0,
194
+ lock: knownLocks.find((candidate) => candidate === lock) ?? 'unreadable',
195
+ status: 'blocked',
183
196
  };
184
- }
197
+ };
198
+ const releaseLockFile = function releaseLockFile(lockPath: string, token: string) {
199
+ let contents: string;
200
+ try {
201
+ contents = readFileSync(lockPath, 'utf-8');
202
+ } catch (error) {
203
+ if (errorCode(error) === 'ENOENT') {
204
+ return;
205
+ }
206
+ throw error;
207
+ }
208
+ if (contents === token) {
209
+ unlinkSync(lockPath);
210
+ }
211
+ };
212
+ const hasUnfinishedWork = function hasUnfinishedWork(database: Database) {
213
+ return database
214
+ .query<
215
+ {
216
+ data: string;
217
+ },
218
+ []
219
+ >('SELECT data FROM work')
220
+ .all()
221
+ .some((row) => workSchema.parse(JSON.parse(row.data)).status !== 'done');
222
+ };
223
+ const saveWork = function saveWork(database: Database, work: Work) {
224
+ if (work.status !== 'running') {
225
+ delete work.nativeProcessId;
226
+ }
227
+ database.run(
228
+ 'INSERT INTO work VALUES(?,?,?,?) ON CONFLICT(id) DO UPDATE SET data=excluded.data',
229
+ [work.id, work.kind, work.key, JSON.stringify(work)]
230
+ );
231
+ };
232
+ const allWorks = function allWorks(database: Database) {
233
+ return database
234
+ .query<
235
+ {
236
+ data: string;
237
+ },
238
+ []
239
+ >('SELECT data FROM work')
240
+ .all()
241
+ .map(({ data }) => workSchema.parse(JSON.parse(data)));
242
+ };
243
+ const runningWorks = function runningWorks(database: Database) {
244
+ return allWorks(database).filter((work) => work.status === 'running');
245
+ };
246
+ const uncertainFailedWorks = function uncertainFailedWorks(database: Database) {
247
+ return allWorks(database).flatMap((work) => {
248
+ const attempt = work.attempts.at(-1);
249
+ if (work.status !== 'failed' || attempt?.recoveryAcknowledgement !== undefined) {
250
+ return [];
251
+ }
252
+ const report = recordValue(attempt?.report);
253
+ if (
254
+ report === null ||
255
+ (report.interruption !== 'unconfirmed' && report.turnAccepted !== 'unknown')
256
+ ) {
257
+ return [];
258
+ }
259
+ const nativeProcessId = processIdSchema.safeParse(report.nativeProcessId);
260
+ return [
261
+ {
262
+ nativeProcessId: nativeProcessId.success ? nativeProcessId.data : undefined,
263
+ work,
264
+ },
265
+ ];
266
+ });
267
+ };
268
+ const recoveryLock = function recoveryLock(directory: string): RecoveryLock | null {
269
+ const lockPath = path.join(directory, lockFilename);
270
+ let raw: string;
271
+ try {
272
+ raw = readFileSync(lockPath, 'utf-8');
273
+ } catch (error) {
274
+ if (errorCode(error) === 'ENOENT') {
275
+ return null;
276
+ }
277
+ return throwRecovery(
278
+ 'unreadable',
279
+ 'knowledge.lock cannot be read safely; inspect the store before continuing.'
280
+ );
281
+ }
282
+ try {
283
+ const lock = lockSchema.parse(JSON.parse(raw));
284
+ return { pid: lock.pid, raw };
285
+ } catch {
286
+ return throwRecovery(
287
+ 'unreadable',
288
+ 'knowledge.lock has no verifiable PID; do not delete it and inspect the process manually.'
289
+ );
290
+ }
291
+ };
292
+ const runningWorksOrBlock = function runningWorksOrBlock(database: Database) {
293
+ try {
294
+ return runningWorks(database);
295
+ } catch {
296
+ return throwRecovery(
297
+ 'unreadable',
298
+ 'Work state cannot be validated; preserve the store and inspect it manually.'
299
+ );
300
+ }
301
+ };
302
+ const assertOwnerEnded = function assertOwnerEnded(
303
+ ownerPid: number,
304
+ lock: RecoveryReport['lock'],
305
+ label = 'The lock owner'
306
+ ) {
307
+ const state = processState(ownerPid);
308
+ if (state === 'alive') {
309
+ throwRecovery(
310
+ lock,
311
+ `${label} (PID ${ownerPid}) is still alive; no process was modified or terminated.`
312
+ );
313
+ }
314
+ if (state !== 'dead') {
315
+ throwRecovery(lock, `${label} (PID ${ownerPid}) cannot be proven dead; no state was modified.`);
316
+ }
317
+ };
318
+ const assertRecoverable = function assertRecoverable(
319
+ work: Work,
320
+ nativeProcessId: number | undefined,
321
+ lock: RecoveryReport['lock']
322
+ ) {
323
+ if (work.ownerPid === undefined) {
324
+ throwRecovery(
325
+ lock,
326
+ `Work ${work.id} has no recorded owner PID; its recovery state is unchanged.`
327
+ );
328
+ }
329
+ assertOwnerEnded(work.ownerPid, lock, `Work ${work.id} owner`);
330
+ const attempt = work.attempts.at(-1);
331
+ if (attempt === undefined) {
332
+ throwRecovery(lock, `Work ${work.id} has no reserved attempt; no state was changed.`);
333
+ }
334
+ if (nativeProcessId === undefined && work.status === 'running') {
335
+ return;
336
+ }
337
+ if (nativeProcessId === undefined) {
338
+ throwRecovery(
339
+ lock,
340
+ `Work ${work.id} has no native PID for its uncertain result; no state was changed.`
341
+ );
342
+ }
343
+ const state = processState(nativeProcessId);
344
+ if (state === 'alive') {
345
+ throwRecovery(
346
+ lock,
347
+ `Native process PID ${nativeProcessId} for work ${work.id} is still alive; no process was killed.`
348
+ );
349
+ }
350
+ if (state !== 'dead') {
351
+ throwRecovery(
352
+ lock,
353
+ `Native process PID ${nativeProcessId} for work ${work.id} cannot be checked; no state was changed.`
354
+ );
355
+ }
356
+ const { report } = attempt;
357
+ if (report !== undefined && recordValue(report) === null) {
358
+ throwRecovery(
359
+ lock,
360
+ `Work ${work.id} has an unstructured running report; recovery left it unchanged.`
361
+ );
362
+ }
363
+ };
364
+ const recordRecovery = function recordRecovery(
365
+ database: Database,
366
+ works: {
367
+ nativeProcessId: number | undefined;
368
+ work: Work;
369
+ }[]
370
+ ) {
371
+ database.transaction(() => {
372
+ for (const { nativeProcessId, work } of works) {
373
+ const row = database
374
+ .query<
375
+ {
376
+ data: string;
377
+ },
378
+ [string]
379
+ >(workByIdQuery)
380
+ .get(work.id);
381
+ const current = row?.data === undefined ? undefined : workSchema.parse(JSON.parse(row.data));
382
+ const attempt = current?.attempts.at(-1);
383
+ if (
384
+ attempt === undefined ||
385
+ current?.status !== work.status ||
386
+ current.calls !== work.calls
387
+ ) {
388
+ throwRecovery('changed', `Work ${work.id} changed during recovery; run recover again.`);
389
+ }
390
+ attempt.report ??=
391
+ nativeProcessId === undefined
392
+ ? {
393
+ cleanup: 'not-observed',
394
+ code: 'MODEL_INTERRUPTED_BEFORE_TURN',
395
+ outcome: 'interrupted',
396
+ usage: null,
397
+ }
398
+ : interruptedReport(undefined, nativeProcessId);
399
+ if (nativeProcessId !== undefined) {
400
+ const now = new Date();
401
+ const acknowledgedAt = now.toISOString();
402
+ attempt.recoveryAcknowledgement = {
403
+ acknowledgedAt,
404
+ nativeProcessId,
405
+ type: 'uncertain-invocation',
406
+ };
407
+ }
408
+ current.status = 'failed';
409
+ saveWork(database, current);
410
+ }
411
+ })();
412
+ };
413
+ const releaseLock = function releaseLock(directory: string, expected: string) {
414
+ const lockPath = path.join(directory, lockFilename);
415
+ let contents: string;
416
+ try {
417
+ contents = readFileSync(lockPath, 'utf-8');
418
+ } catch (error) {
419
+ if (errorCode(error) === 'ENOENT') {
420
+ return true;
421
+ }
422
+ throw error;
423
+ }
424
+ if (contents !== expected) {
425
+ return false;
426
+ }
427
+ unlinkSync(lockPath);
428
+ return true;
429
+ };
430
+ const releaseRecoveryLock = function releaseRecoveryLock(
431
+ directory: string,
432
+ {
433
+ acknowledgedWorks,
434
+ interruptedWorks,
435
+ raw,
436
+ }: {
437
+ acknowledgedWorks: number;
438
+ interruptedWorks: number;
439
+ raw: string | null;
440
+ }
441
+ ): RecoveryReport {
442
+ if (raw === null) {
443
+ return {
444
+ acknowledgedWorks,
445
+ ...(acknowledgedWorks > 0 && {
446
+ guidance:
447
+ 'Recovery preserved the work. Run `hivex update --retry-failed --root <project>` to retry it explicitly; recovery made zero model calls.',
448
+ }),
449
+ interruptedWorks,
450
+ lock: 'absent',
451
+ status: 'recovered',
452
+ };
453
+ }
454
+ let isReleased: boolean;
455
+ try {
456
+ isReleased = releaseLock(directory, raw);
457
+ } catch {
458
+ return throwRecovery(
459
+ 'unreadable',
460
+ acknowledgedWorks > 0
461
+ ? 'Work was acknowledged, but knowledge.lock could not be released.'
462
+ : 'The owner is dead, but knowledge.lock could not be released atomically.',
463
+ interruptedWorks
464
+ );
465
+ }
466
+ if (!isReleased) {
467
+ return throwRecovery(
468
+ 'changed',
469
+ acknowledgedWorks > 0
470
+ ? 'Work was acknowledged, but knowledge.lock changed; run recover again before continuing.'
471
+ : 'knowledge.lock changed during recovery; inspect the store before continuing.',
472
+ interruptedWorks
473
+ );
474
+ }
475
+ return {
476
+ acknowledgedWorks,
477
+ ...(acknowledgedWorks > 0 && {
478
+ guidance:
479
+ 'Recovery preserved the work. Run `hivex update --retry-failed --root <project>` to retry it explicitly; recovery made zero model calls.',
480
+ }),
481
+ interruptedWorks,
482
+ lock: 'released',
483
+ status: 'recovered',
484
+ };
485
+ };
486
+ const recoverChecked = function recoverChecked(
487
+ database: Database,
488
+ directory: string,
489
+ options: RecoveryOptions
490
+ ): RecoveryReport {
491
+ const lock = recoveryLock(directory);
492
+ if (lock !== null) {
493
+ assertOwnerEnded(lock.pid, 'held');
494
+ }
495
+ const running = runningWorksOrBlock(database);
496
+ const failed = uncertainFailedWorks(database);
497
+ if (running.length === 0 && failed.length === 0) {
498
+ return releaseRecoveryLock(directory, {
499
+ acknowledgedWorks: 0,
500
+ interruptedWorks: 0,
501
+ raw: lock?.raw ?? null,
502
+ });
503
+ }
504
+ const lockState = lock === null ? 'absent' : 'held';
505
+ for (const work of running) {
506
+ assertRecoverable(work, work.nativeProcessId, lockState);
507
+ }
508
+ for (const entry of failed) {
509
+ assertRecoverable(entry.work, entry.nativeProcessId, lockState);
510
+ }
511
+ const uncertain =
512
+ running.filter((work) => work.nativeProcessId !== undefined).length + failed.length;
513
+ if (uncertain > 0 && options.acknowledgeUncertain !== true) {
514
+ throwRecovery(
515
+ lockState,
516
+ 'Uncertain work is recoverable after its owner and native PIDs ended; rerun `hivex recover --acknowledge-uncertain --root <project>` to record an explicit acknowledgement.'
517
+ );
518
+ }
519
+ const recoverableWorks = [
520
+ ...running.map((work) => {
521
+ const { nativeProcessId } = work;
522
+ return { nativeProcessId, work };
523
+ }),
524
+ ...failed,
525
+ ];
526
+ recordRecovery(database, recoverableWorks);
527
+ return releaseRecoveryLock(directory, {
528
+ acknowledgedWorks: uncertain,
529
+ interruptedWorks: running.length,
530
+ raw: lock?.raw ?? null,
531
+ });
532
+ };
533
+ const initializeStorage = function initializeStorage(
534
+ database: Database,
535
+ resources: DisposableStack,
536
+ acquireUpdate?: () => Disposable
537
+ ) {
538
+ database.run('PRAGMA busy_timeout=1000');
539
+ database.run('PRAGMA max_page_count=16384');
540
+ database.run(
541
+ 'CREATE TABLE IF NOT EXISTS graph (id INTEGER PRIMARY KEY CHECK(id=1), data TEXT NOT NULL)'
542
+ );
543
+ database.run(
544
+ 'CREATE TABLE IF NOT EXISTS work (id TEXT PRIMARY KEY, kind TEXT NOT NULL, key TEXT NOT NULL, data TEXT NOT NULL)'
545
+ );
546
+ database.run(
547
+ 'CREATE TABLE IF NOT EXISTS model_cache (key TEXT PRIMARY KEY, value TEXT NOT NULL)'
548
+ );
549
+ database.run('CREATE INDEX IF NOT EXISTS work_key ON work(kind,key)');
550
+ if (acquireUpdate !== undefined) {
551
+ resources.use(acquireUpdate());
552
+ }
553
+ };
185
554
 
186
555
  export class KnowledgeStore implements Disposable {
187
556
  private readonly db: Database;
188
557
  private readonly directory: string;
189
-
190
- constructor(root: string, options: { readonly?: boolean } = {}) {
191
- const directory = join(root, '.hivex');
558
+ private readonly resources = new DisposableStack();
559
+ constructor(root: string, options: StoreOptions = {}) {
560
+ if (options.readonly === true && options.update === true) {
561
+ throw new HivexError({
562
+ code: 'INVALID_STORE',
563
+ message: 'Knowledge storage cannot be readonly and own an update lock',
564
+ });
565
+ }
566
+ const directory = path.join(root, '.hivex');
192
567
  this.directory = directory;
193
- const path = join(directory, 'knowledge.sqlite');
194
- for (const candidate of [directory, path]) {
195
- if (lstatSync(candidate, { throwIfNoEntry: false })?.isSymbolicLink())
568
+ const databasePath = path.join(directory, 'knowledge.sqlite');
569
+ for (const candidate of [directory, databasePath]) {
570
+ if (lstatSync(candidate, { throwIfNoEntry: false })?.isSymbolicLink() === true) {
196
571
  throw new HivexError({
197
572
  code: 'INVALID_STORE',
198
573
  message: 'Knowledge storage cannot be a symlink',
199
574
  });
575
+ }
200
576
  }
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)',
577
+ if (options.readonly !== true) {
578
+ mkdirSync(directory, { mode: 0o700, recursive: true });
579
+ }
580
+ this.db = this.resources.use(
581
+ options.readonly === true
582
+ ? new Database(databasePath, { readonly: true })
583
+ : new Database(databasePath)
214
584
  );
215
- this.db.run('CREATE INDEX IF NOT EXISTS work_key ON work(kind,key)');
585
+ if (options.readonly === true) {
586
+ return;
587
+ }
588
+ const acquireUpdate = options.update === true ? this.updateLease.bind(this) : undefined;
589
+ try {
590
+ initializeStorage(this.db, this.resources, acquireUpdate);
591
+ } catch (error) {
592
+ this.resources.dispose();
593
+ throw error;
594
+ }
216
595
  }
217
-
218
596
  updateLease(): Disposable {
219
- const path = join(this.directory, 'knowledge.lock');
220
- const token = JSON.stringify({ pid: process.pid, id: randomUUID() });
597
+ const lockPath = path.join(this.directory, lockFilename);
598
+ const token = JSON.stringify({ id: randomUUID(), pid: process.pid });
221
599
  let fd: number;
222
600
  try {
223
- fd = openSync(path, 'wx', 0o600);
601
+ fd = openSync(lockPath, 'wx', 0o600);
224
602
  } catch {
225
603
  throw new HivexError({
226
604
  code: 'KNOWLEDGE_LOCKED',
@@ -232,182 +610,235 @@ export class KnowledgeStore implements Disposable {
232
610
  return {
233
611
  [Symbol.dispose]() {
234
612
  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
- }
613
+ releaseLockFile(lockPath, token);
240
614
  },
241
615
  };
242
616
  }
243
-
244
617
  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();
618
+ const row = this.db
619
+ .query<
620
+ {
621
+ data: string;
622
+ },
623
+ []
624
+ >('SELECT data FROM graph WHERE id=1')
625
+ .get();
626
+ if (row !== null) {
627
+ return graphSchema.parse(JSON.parse(row.data));
628
+ }
629
+ return hasUnfinishedWork(this.db)
630
+ ? emptyGraph()
631
+ : sharedKnowledge(path.join(this.directory, '..'));
247
632
  }
248
-
249
633
  saveGraph(graph: Graph) {
250
634
  this.db.run('INSERT INTO graph VALUES(1,?) ON CONFLICT(id) DO UPDATE SET data=excluded.data', [
251
635
  JSON.stringify(graph),
252
636
  ]);
253
637
  }
254
-
638
+ importGraph(graph: Graph) {
639
+ this.db.transaction(() => {
640
+ if (hasUnfinishedWork(this.db)) {
641
+ throw new HivexError({
642
+ code: 'UNFINISHED_WORK',
643
+ message:
644
+ 'Finish or recover existing work before importing a knowledge snapshot; its attempts and budgets are preserved.',
645
+ });
646
+ }
647
+ this.saveGraph(graph);
648
+ })();
649
+ }
255
650
  begin(options: BeginWork): Work {
256
651
  const defaultMaxCalls = options.kind === 'update' ? 2 : 3;
257
652
  return this.db
258
653
  .transaction(() => {
654
+ if (this.db.query('SELECT id FROM graph WHERE id=1').get() === null) {
655
+ this.saveGraph(this.graph());
656
+ }
259
657
  const row = this.db
260
658
  .query<
261
- { data: string },
659
+ {
660
+ data: string;
661
+ },
262
662
  [string, string]
263
663
  >('SELECT data FROM work WHERE kind=? AND key=? ORDER BY rowid DESC LIMIT 1')
264
664
  .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);
665
+ const previous = row === null ? null : workSchema.parse(JSON.parse(row.data));
666
+ const isReusable =
667
+ options.kind === 'update'
668
+ ? options.remaining.length === 0
669
+ : previous?.resultKey === options.resultKey;
670
+ if (
671
+ previous === null ||
672
+ (!isReusable && options.kind === 'update' && previous.status === 'done')
673
+ ) {
674
+ const work: Work = {
675
+ ...options,
676
+ attempts: [],
677
+ cacheHits: 0,
678
+ calls: 0,
679
+ id: randomUUID(),
680
+ inputBytes: 0,
681
+ maxCalls: options.maxCalls ?? defaultMaxCalls,
682
+ maxInputBytes: options.maxInputBytes ?? 131_072,
683
+ pending: null,
684
+ phase: 'update',
685
+ plannedUnits: [...options.remaining],
686
+ status: 'pending',
687
+ totalTokens: 0,
688
+ };
689
+ this.save(work);
690
+ return work;
272
691
  }
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;
692
+ if (isReusable && previous.status === 'done') {
693
+ return previous;
694
+ }
695
+ if (previous.status === 'done') {
696
+ previous.status = 'pending';
697
+ delete previous.result;
698
+ }
699
+ if (previous.status === 'running') {
700
+ throw new HivexError({
701
+ code: 'WORK_RUNNING',
702
+ message: `Work ${previous.id} has an unfinished invocation; inspect it before retrying`,
703
+ });
704
+ }
705
+ if (options.maxCalls !== undefined) {
706
+ previous.maxCalls = options.maxCalls;
707
+ }
708
+ if (options.maxInputBytes !== undefined) {
709
+ previous.maxInputBytes = options.maxInputBytes;
710
+ }
711
+ this.save(previous);
712
+ return previous;
290
713
  })
291
714
  .immediate();
292
715
  }
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
716
  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
- );
717
+ saveWork(this.db, work);
317
718
  }
318
-
319
719
  commit(work: Work, graph: Graph) {
320
720
  this.db.transaction(() => {
321
721
  this.saveGraph(graph);
322
722
  this.save(work);
323
723
  })();
324
724
  }
325
-
326
725
  cached(key: string): unknown {
327
726
  const row = this.db
328
- .query<{ value: string }, [string]>('SELECT value FROM model_cache WHERE key=?')
727
+ .query<
728
+ {
729
+ value: string;
730
+ },
731
+ [string]
732
+ >('SELECT value FROM model_cache WHERE key=?')
329
733
  .get(key);
330
734
  return row ? JSON.parse(row.value) : undefined;
331
735
  }
332
-
333
736
  cache(key: string, value: unknown) {
334
737
  this.db.run(
335
738
  'INSERT INTO model_cache VALUES(?,?) ON CONFLICT(key) DO UPDATE SET value=excluded.value',
336
- [key, JSON.stringify(value)],
739
+ [key, JSON.stringify(value)]
337
740
  );
338
741
  }
339
-
340
742
  recordNativeProcess(work: Work, nativeProcessId: number) {
341
- if (!processIdSchema.safeParse(nativeProcessId).success)
743
+ if (!processIdSchema.safeParse(nativeProcessId).success) {
342
744
  throw new HivexError({
343
745
  code: 'INVALID_PROCESS_ID',
344
746
  message: 'Native process ID must be a positive integer',
345
747
  });
748
+ }
346
749
  this.db.transaction(() => {
347
750
  const stored = this.db
348
- .query<{ data: string }, [string]>('SELECT data FROM work WHERE id=?')
751
+ .query<
752
+ {
753
+ data: string;
754
+ },
755
+ [string]
756
+ >(workByIdQuery)
349
757
  .get(work.id);
350
- const current = stored && workSchema.parse(JSON.parse(stored.data));
351
- if (!current || current.calls !== work.calls || current.status !== 'running')
758
+ const current =
759
+ stored?.data === undefined ? undefined : workSchema.parse(JSON.parse(stored.data));
760
+ if (current?.calls !== work.calls || current.status !== 'running') {
352
761
  throw new HivexError({
353
- code: 'WORK_CONFLICT',
762
+ code: workConflictCode,
354
763
  message: 'Work was claimed or changed before the native process was recorded',
355
764
  });
765
+ }
356
766
  work.nativeProcessId = nativeProcessId;
357
767
  this.save(work);
358
768
  })();
359
769
  }
360
-
361
- reserve(work: Work, stage: string, inputHash: string, inputBytes: number) {
770
+ reserve(work: Work, input: { inputBytes: number; inputHash: string; stage: string }) {
771
+ const { inputBytes, inputHash, stage } = input;
362
772
  this.db.transaction(() => {
363
773
  const stored = this.db
364
- .query<{ data: string }, [string]>('SELECT data FROM work WHERE id=?')
774
+ .query<
775
+ {
776
+ data: string;
777
+ },
778
+ [string]
779
+ >(workByIdQuery)
365
780
  .get(work.id);
366
- const current = stored && workSchema.parse(JSON.parse(stored.data));
367
- if (!current || current.calls !== work.calls || current.status === 'running')
781
+ const current =
782
+ stored?.data === undefined ? undefined : workSchema.parse(JSON.parse(stored.data));
783
+ if (current?.calls !== work.calls || current.status === 'running') {
368
784
  throw new HivexError({
369
- code: 'WORK_CONFLICT',
785
+ code: workConflictCode,
370
786
  message: 'Work was claimed or changed by another operation',
371
787
  });
788
+ }
372
789
  work.calls += 1;
373
790
  work.inputBytes += inputBytes;
374
791
  work.status = 'running';
375
792
  work.ownerPid = process.pid;
376
793
  delete work.nativeProcessId;
377
- work.attempts.push({ stage, inputHash, inputBytes });
794
+ work.attempts.push({ inputBytes, inputHash, stage });
378
795
  this.save(work);
379
796
  })();
380
797
  }
381
-
382
798
  recover(options: RecoveryOptions = {}): RecoveryReport {
383
799
  try {
384
- return this.recoverChecked(options);
800
+ return recoverChecked(this.db, this.directory, options);
385
801
  } catch (error) {
386
- if (error instanceof HivexError && error.code === 'RECOVERY_UNSAFE')
802
+ if (error instanceof HivexError && error.code === 'RECOVERY_UNSAFE') {
387
803
  return blockedRecovery(error);
804
+ }
388
805
  throw error;
389
806
  }
390
807
  }
391
-
392
808
  prune(options: PruneOptions): PruneReport {
393
809
  if (
394
- !Number.isInteger(options.keepCompleted) ||
810
+ !Number.isSafeInteger(options.keepCompleted) ||
395
811
  options.keepCompleted < 0 ||
396
- !Number.isInteger(options.keepCaches) ||
812
+ !Number.isSafeInteger(options.keepCaches) ||
397
813
  options.keepCaches < 0
398
- )
814
+ ) {
399
815
  throw new HivexError({
400
816
  code: 'INVALID_RETENTION',
401
817
  message: 'Retention counts must be non-negative integers',
402
818
  });
819
+ }
403
820
  const works = this.db
404
- .query<{ rowid: number; data: string }, []>('SELECT rowid,data FROM work ORDER BY rowid DESC')
821
+ .query<
822
+ {
823
+ rowid: number;
824
+ data: string;
825
+ },
826
+ []
827
+ >('SELECT rowid,data FROM work ORDER BY rowid DESC')
405
828
  .all()
406
- .map((row) => ({ rowid: row.rowid, work: workSchema.parse(JSON.parse(row.data)) }));
829
+ .map((row) => {
830
+ const work = workSchema.parse(JSON.parse(row.data));
831
+ return { rowid: row.rowid, work };
832
+ });
407
833
  const completed = works.filter(({ work }) => work.status === 'done');
408
834
  const workRowsToDelete = completed.slice(options.keepCompleted).map(({ rowid }) => rowid);
409
835
  const caches = this.db
410
- .query<{ rowid: number }, []>('SELECT rowid FROM model_cache ORDER BY rowid DESC')
836
+ .query<
837
+ {
838
+ rowid: number;
839
+ },
840
+ []
841
+ >('SELECT rowid FROM model_cache ORDER BY rowid DESC')
411
842
  .all()
412
843
  .map(({ rowid }) => rowid);
413
844
  const cacheRowsToDelete = caches.slice(options.keepCaches);
@@ -416,242 +847,14 @@ export class KnowledgeStore implements Disposable {
416
847
  deleteRows(this.db, 'model_cache', cacheRowsToDelete);
417
848
  })();
418
849
  return {
419
- deletedCompletedWorks: workRowsToDelete.length,
420
850
  deletedCaches: cacheRowsToDelete.length,
421
- retainedCompletedWorks: completed.length - workRowsToDelete.length,
851
+ deletedCompletedWorks: workRowsToDelete.length,
422
852
  retainedCaches: caches.length - cacheRowsToDelete.length,
853
+ retainedCompletedWorks: completed.length - workRowsToDelete.length,
423
854
  unfinishedWorks: works.filter(({ work }) => work.status !== 'done').length,
424
855
  };
425
856
  }
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
857
  [Symbol.dispose]() {
655
- this.db.close();
858
+ this.resources.dispose();
656
859
  }
657
860
  }