@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.
- package/README.md +55 -163
- package/docs/CONTEXT.md +20 -36
- package/docs/README.md +6 -12
- 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 +16 -43
- package/docs/guidelines/engineering.md +74 -0
- package/docs/procedures/self-hosted-runner.md +7 -0
- package/package.json +32 -11
- package/skills/hivex/SKILL.md +28 -92
- package/skills/hivex/references/markdown.md +12 -42
- package/src/cli/diagnostic.ts +21 -11
- package/src/cli.ts +46 -36
- package/src/documents.ts +502 -320
- 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 -268
- package/src/knowledge-serialization.ts +239 -0
- package/src/knowledge-snapshot.ts +100 -77
- package/src/knowledge-store.ts +634 -453
- package/src/knowledge.ts +1001 -758
- 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 +82 -43
- package/src/source-relocation.ts +222 -0
- package/docs/engineering.md +0 -174
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,220 +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
15
|
import { sharedKnowledge } from './knowledge-snapshot.ts';
|
|
16
|
-
import { emptyGraph, extractionSchema, graphSchema
|
|
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({
|
|
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
|
-
|
|
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
|
-
|
|
52
|
+
inputBytes: z.number().int().nonnegative(),
|
|
40
53
|
key: z.string(),
|
|
41
|
-
|
|
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
|
-
|
|
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
|
-
|
|
75
|
-
|
|
80
|
+
interface StoreOptions {
|
|
81
|
+
readonly?: boolean;
|
|
82
|
+
update?: boolean;
|
|
83
|
+
}
|
|
84
|
+
interface BeginWork {
|
|
76
85
|
key: string;
|
|
77
|
-
|
|
86
|
+
kind: Work['kind'];
|
|
78
87
|
maxCalls?: number;
|
|
79
88
|
maxInputBytes?: number;
|
|
80
89
|
remaining: string[];
|
|
81
90
|
resultKey?: string;
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
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
|
-
|
|
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
|
|
102
|
-
deletedCompletedWorks: number;
|
|
107
|
+
keepCompleted: number;
|
|
108
|
+
}
|
|
109
|
+
export interface PruneReport {
|
|
103
110
|
deletedCaches: number;
|
|
104
|
-
|
|
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
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
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')
|
|
125
|
-
|
|
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
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
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
|
-
|
|
151
|
+
cleanup: 'not-observed',
|
|
141
152
|
code: 'MODEL_INTERRUPTED_RECOVERED',
|
|
142
153
|
interruption: 'unconfirmed',
|
|
143
|
-
|
|
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
|
-
|
|
155
|
-
|
|
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
|
-
|
|
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
|
|
164
|
-
)
|
|
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:
|
|
192
|
-
|
|
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
|
|
195
|
-
for (const candidate of [directory,
|
|
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 (
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
this.db.
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
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
|
-
|
|
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
|
|
221
|
-
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 });
|
|
222
599
|
let fd: number;
|
|
223
600
|
try {
|
|
224
|
-
fd = openSync(
|
|
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
|
-
|
|
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
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
.
|
|
254
|
-
|
|
255
|
-
|
|
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.
|
|
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 (
|
|
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
|
-
{
|
|
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))
|
|
288
|
-
const
|
|
289
|
-
options.kind
|
|
290
|
-
?
|
|
291
|
-
:
|
|
292
|
-
if (
|
|
293
|
-
|
|
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
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
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
|
-
|
|
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<
|
|
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<
|
|
751
|
+
.query<
|
|
752
|
+
{
|
|
753
|
+
data: string;
|
|
754
|
+
},
|
|
755
|
+
[string]
|
|
756
|
+
>(workByIdQuery)
|
|
371
757
|
.get(work.id);
|
|
372
|
-
const current =
|
|
373
|
-
|
|
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:
|
|
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
|
-
|
|
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<
|
|
774
|
+
.query<
|
|
775
|
+
{
|
|
776
|
+
data: string;
|
|
777
|
+
},
|
|
778
|
+
[string]
|
|
779
|
+
>(workByIdQuery)
|
|
387
780
|
.get(work.id);
|
|
388
|
-
const current =
|
|
389
|
-
|
|
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:
|
|
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({
|
|
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.
|
|
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.
|
|
810
|
+
!Number.isSafeInteger(options.keepCompleted) ||
|
|
417
811
|
options.keepCompleted < 0 ||
|
|
418
|
-
!Number.
|
|
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<
|
|
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) =>
|
|
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<
|
|
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
|
-
|
|
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.
|
|
858
|
+
this.resources.dispose();
|
|
678
859
|
}
|
|
679
860
|
}
|