@h1v35/hivex 0.2.0 → 0.2.2

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