@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.
- package/README.md +68 -126
- package/docs/CONTEXT.md +20 -32
- package/docs/README.md +4 -8
- package/docs/adr/0003-independent-bun-installation.md +5 -19
- package/docs/adr/0010-practical-knowledge-assistance.md +28 -81
- package/docs/adr/0011-shared-knowledge-and-selective-history.md +27 -0
- package/docs/engineering.md +46 -142
- package/package.json +31 -10
- package/skills/hivex/SKILL.md +30 -72
- package/skills/hivex/references/markdown.md +16 -31
- package/src/cli/diagnostic.ts +21 -11
- package/src/cli.ts +50 -32
- package/src/documents.ts +522 -315
- package/src/errors.ts +8 -6
- package/src/implementation.ts +185 -87
- package/src/ingestion-units.ts +107 -64
- package/src/knowledge-maintenance.ts +35 -22
- package/src/knowledge-model.ts +386 -267
- package/src/knowledge-serialization.ts +239 -0
- package/src/knowledge-snapshot.ts +163 -0
- package/src/knowledge-store.ts +645 -442
- package/src/knowledge.ts +1073 -696
- package/src/markdown.ts +107 -45
- package/src/model/connection.ts +134 -76
- package/src/model/failure.ts +46 -23
- package/src/model/invoke.ts +346 -166
- package/src/model/profile.ts +201 -103
- package/src/model/rpc-error.ts +21 -0
- package/src/model/server.ts +151 -82
- package/src/model/thread.ts +24 -14
- package/src/model/transcript.ts +87 -46
- package/src/ordering.ts +9 -0
- package/src/retrieval/lexical.ts +64 -41
- package/src/review.ts +83 -55
- package/src/runtime.d.ts +4 -0
- package/src/snapshot-command.ts +105 -0
package/src/knowledge-store.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
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
|
|
12
|
-
import {
|
|
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 {
|
|
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({
|
|
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
|
-
|
|
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
|
-
|
|
52
|
+
inputBytes: z.number().int().nonnegative(),
|
|
39
53
|
key: z.string(),
|
|
40
|
-
|
|
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
|
-
|
|
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
|
-
|
|
74
|
-
|
|
80
|
+
interface StoreOptions {
|
|
81
|
+
readonly?: boolean;
|
|
82
|
+
update?: boolean;
|
|
83
|
+
}
|
|
84
|
+
interface BeginWork {
|
|
75
85
|
key: string;
|
|
76
|
-
|
|
86
|
+
kind: Work['kind'];
|
|
77
87
|
maxCalls?: number;
|
|
78
88
|
maxInputBytes?: number;
|
|
79
89
|
remaining: string[];
|
|
80
90
|
resultKey?: string;
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
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
|
-
|
|
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
|
|
101
|
-
deletedCompletedWorks: number;
|
|
107
|
+
keepCompleted: number;
|
|
108
|
+
}
|
|
109
|
+
export interface PruneReport {
|
|
102
110
|
deletedCaches: number;
|
|
103
|
-
|
|
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
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
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')
|
|
124
|
-
|
|
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
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
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
|
-
|
|
151
|
+
cleanup: 'not-observed',
|
|
140
152
|
code: 'MODEL_INTERRUPTED_RECOVERED',
|
|
141
153
|
interruption: 'unconfirmed',
|
|
142
|
-
|
|
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
|
-
|
|
154
|
-
|
|
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
|
-
|
|
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
|
|
163
|
-
)
|
|
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:
|
|
191
|
-
|
|
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
|
|
194
|
-
for (const candidate of [directory,
|
|
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 (
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
this.db.
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
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
|
-
|
|
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
|
|
220
|
-
const token = JSON.stringify({ pid: process.pid
|
|
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(
|
|
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
|
-
|
|
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
|
|
246
|
-
|
|
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
|
-
{
|
|
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))
|
|
266
|
-
const
|
|
267
|
-
options.kind
|
|
268
|
-
?
|
|
269
|
-
:
|
|
270
|
-
if (
|
|
271
|
-
|
|
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
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
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
|
-
|
|
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<
|
|
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<
|
|
751
|
+
.query<
|
|
752
|
+
{
|
|
753
|
+
data: string;
|
|
754
|
+
},
|
|
755
|
+
[string]
|
|
756
|
+
>(workByIdQuery)
|
|
349
757
|
.get(work.id);
|
|
350
|
-
const current =
|
|
351
|
-
|
|
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:
|
|
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
|
-
|
|
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<
|
|
774
|
+
.query<
|
|
775
|
+
{
|
|
776
|
+
data: string;
|
|
777
|
+
},
|
|
778
|
+
[string]
|
|
779
|
+
>(workByIdQuery)
|
|
365
780
|
.get(work.id);
|
|
366
|
-
const current =
|
|
367
|
-
|
|
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:
|
|
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({
|
|
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.
|
|
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.
|
|
810
|
+
!Number.isSafeInteger(options.keepCompleted) ||
|
|
395
811
|
options.keepCompleted < 0 ||
|
|
396
|
-
!Number.
|
|
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<
|
|
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) =>
|
|
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<
|
|
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
|
-
|
|
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.
|
|
858
|
+
this.resources.dispose();
|
|
656
859
|
}
|
|
657
860
|
}
|