@scotthuang/agent-knock-knock 0.11.0 → 0.11.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/CHANGELOG.md +7 -0
- package/dist/src/codex-store-adapter.d.ts +21 -1
- package/dist/src/codex-store-adapter.js +449 -26
- package/dist/src/codex-store-adapter.js.map +1 -1
- package/dist/src/openclaw-plugin-helpers.js +8 -7
- package/dist/src/openclaw-plugin-helpers.js.map +1 -1
- package/dist/src/openclaw-plugin.js +3 -0
- package/dist/src/openclaw-plugin.js.map +1 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.11.1 - 2026-08-07
|
|
4
|
+
|
|
5
|
+
### Fixed
|
|
6
|
+
|
|
7
|
+
- Harden Codex resumable-thread discovery across transient SQLite WAL/SHM creation, replacement, and checkpoint windows with one read transaction, identity-checked bounded `SQLITE_CANTOPEN` recovery, and a query-only sidecar-materialization fallback that does not use `immutable=1`.
|
|
8
|
+
- Forward configured `codexHome` to Codex lifecycle discovery, new, and resume paths in OpenClaw tools and `/akk` commands.
|
|
9
|
+
|
|
3
10
|
## 0.11.0 - 2026-08-07
|
|
4
11
|
|
|
5
12
|
### Added
|
|
@@ -7,11 +7,30 @@ export { parseLsofCwdMap, parsePsProcessSnapshots, type ProcessCommandResult as
|
|
|
7
7
|
export interface CodexStoreAdapterOptions {
|
|
8
8
|
codexHome?: string;
|
|
9
9
|
runCommand?: (command: string, args: string[]) => ProcessCommandResult;
|
|
10
|
+
runSqliteThreadQuery?: CodexSqliteThreadQueryRunner;
|
|
11
|
+
sqliteCantOpenRetryDelaysMs?: readonly number[];
|
|
12
|
+
sleep?: (milliseconds: number) => Promise<void>;
|
|
10
13
|
maxSessions?: number;
|
|
11
14
|
}
|
|
15
|
+
export type CodexSqliteOpenMode = "readonly" | "query_only";
|
|
16
|
+
export interface CodexSqliteThreadQueryRequest {
|
|
17
|
+
dbPath: string;
|
|
18
|
+
openMode: CodexSqliteOpenMode;
|
|
19
|
+
maxSessions: number;
|
|
20
|
+
nativeThreadId?: string;
|
|
21
|
+
afterSchema?: (columns: readonly string[]) => void | Promise<void>;
|
|
22
|
+
}
|
|
23
|
+
export interface CodexSqliteThreadQueryResult {
|
|
24
|
+
columns: string[];
|
|
25
|
+
rows: CodexThreadRow[];
|
|
26
|
+
}
|
|
27
|
+
export type CodexSqliteThreadQueryRunner = (request: CodexSqliteThreadQueryRequest) => Promise<CodexSqliteThreadQueryResult>;
|
|
12
28
|
export declare class CodexStoreAdapter implements CodexLocalSessionAdapter, TerminalThreadLifecycleCandidateProvider {
|
|
13
29
|
private readonly codexHome;
|
|
14
30
|
private readonly runCommand;
|
|
31
|
+
private readonly runSqliteThreadQuery;
|
|
32
|
+
private readonly sqliteCantOpenRetryDelaysMs;
|
|
33
|
+
private readonly sleep;
|
|
15
34
|
private readonly maxSessions;
|
|
16
35
|
constructor(options?: CodexStoreAdapterOptions);
|
|
17
36
|
listThreadRows(): Promise<CodexThreadRow[]>;
|
|
@@ -21,8 +40,9 @@ export declare class CodexStoreAdapter implements CodexLocalSessionAdapter, Term
|
|
|
21
40
|
readRollout(rolloutPath: string): Promise<string | undefined>;
|
|
22
41
|
listProcessSnapshots(): Promise<CodexProcessSnapshot[]>;
|
|
23
42
|
resolveActiveSessionIdentityForPid(pid: number, cwd?: string, preferredSessionId?: string, allowedCompanionIdentity?: ActiveAgentSessionIdentity, allowedAdditionalIdentities?: readonly ActiveAgentSessionIdentity[]): Promise<ActiveAgentSessionIdentity | undefined>;
|
|
24
|
-
private
|
|
43
|
+
private queryThreadRows;
|
|
25
44
|
}
|
|
45
|
+
export declare function runCodexSqliteThreadQuery(request: CodexSqliteThreadQueryRequest): Promise<CodexSqliteThreadQueryResult>;
|
|
26
46
|
export declare function latestStateDbPath(codexHome: string): string | undefined;
|
|
27
47
|
export interface LsofOpenFileRecord {
|
|
28
48
|
fd?: string;
|
|
@@ -1,36 +1,42 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
2
3
|
import fs from "node:fs";
|
|
3
4
|
import os from "node:os";
|
|
4
5
|
import path from "node:path";
|
|
6
|
+
import { pathToFileURL } from "node:url";
|
|
5
7
|
import { codexLifecycleBehaviorProfile, supportedCodexLifecycleVersions } from "./codex-lifecycle-compatibility.js";
|
|
6
8
|
import { discoverCodexProcesses } from "./codex-session-provider.js";
|
|
7
9
|
import { SystemTerminalProcessSource, runProcessCommand } from "./terminal-process-source.js";
|
|
8
10
|
export { parseLsofCwdMap, parsePsProcessSnapshots } from "./terminal-process-source.js";
|
|
9
11
|
const NATIVE_THREAD_ID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/iu;
|
|
10
12
|
const MAX_CODEX_SESSION_META_BYTES = 1024 * 1024;
|
|
13
|
+
const MAX_SQLITE_QUERY_OUTPUT_BYTES = 10 * 1024 * 1024;
|
|
14
|
+
const MAX_SQLITE_ERROR_OUTPUT_BYTES = 1024 * 1024;
|
|
15
|
+
const SQLITE_QUERY_TIMEOUT_MS = 10_000;
|
|
16
|
+
const DEFAULT_SQLITE_CANTOPEN_RETRY_DELAYS_MS = [25, 75, 150];
|
|
11
17
|
const NO_FOLLOW_FLAG = typeof fs.constants.O_NOFOLLOW === "number"
|
|
12
18
|
? fs.constants.O_NOFOLLOW
|
|
13
19
|
: 0;
|
|
14
20
|
export class CodexStoreAdapter {
|
|
15
21
|
codexHome;
|
|
16
22
|
runCommand;
|
|
23
|
+
runSqliteThreadQuery;
|
|
24
|
+
sqliteCantOpenRetryDelaysMs;
|
|
25
|
+
sleep;
|
|
17
26
|
maxSessions;
|
|
18
27
|
constructor(options = {}) {
|
|
19
28
|
this.codexHome = options.codexHome ?? path.join(os.homedir(), ".codex");
|
|
20
29
|
this.runCommand = options.runCommand ?? runProcessCommand;
|
|
30
|
+
this.runSqliteThreadQuery = options.runSqliteThreadQuery ??
|
|
31
|
+
runCodexSqliteThreadQuery;
|
|
32
|
+
this.sqliteCantOpenRetryDelaysMs =
|
|
33
|
+
options.sqliteCantOpenRetryDelaysMs ??
|
|
34
|
+
DEFAULT_SQLITE_CANTOPEN_RETRY_DELAYS_MS;
|
|
35
|
+
this.sleep = options.sleep ?? waitForMilliseconds;
|
|
21
36
|
this.maxSessions = options.maxSessions ?? 100;
|
|
22
37
|
}
|
|
23
38
|
async listThreadRows() {
|
|
24
|
-
|
|
25
|
-
if (!dbPath) {
|
|
26
|
-
throw new Error("no Codex state sqlite database found");
|
|
27
|
-
}
|
|
28
|
-
const columns = this.queryJson(dbPath, "pragma table_info(threads)")
|
|
29
|
-
.map((column) => column.name);
|
|
30
|
-
if (!columns.includes("id") || !columns.includes("cwd")) {
|
|
31
|
-
throw new Error("Codex threads table is missing required id or cwd columns");
|
|
32
|
-
}
|
|
33
|
-
return this.queryJson(dbPath, buildThreadSelect(columns, this.maxSessions));
|
|
39
|
+
return this.queryThreadRows({ maxSessions: this.maxSessions });
|
|
34
40
|
}
|
|
35
41
|
async listThreadLifecycleCandidates(request) {
|
|
36
42
|
assertCodexLifecycleCandidateRequest(request);
|
|
@@ -112,15 +118,10 @@ export class CodexStoreAdapter {
|
|
|
112
118
|
if (!NATIVE_THREAD_ID_PATTERN.test(nativeThreadId)) {
|
|
113
119
|
throw new Error("Codex thread lookup requires an exact UUID");
|
|
114
120
|
}
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
}
|
|
119
|
-
const columns = this.queryJson(dbPath, "pragma table_info(threads)").map((column) => column.name);
|
|
120
|
-
if (!columns.includes("id") || !columns.includes("cwd")) {
|
|
121
|
-
throw new Error("Codex threads table is missing required id or cwd columns");
|
|
122
|
-
}
|
|
123
|
-
return this.queryJson(dbPath, buildThreadByIdSelect(columns, nativeThreadId))[0];
|
|
121
|
+
return (await this.queryThreadRows({
|
|
122
|
+
maxSessions: 1,
|
|
123
|
+
nativeThreadId
|
|
124
|
+
}))[0];
|
|
124
125
|
}
|
|
125
126
|
async readRollout(rolloutPath) {
|
|
126
127
|
if (!fs.existsSync(rolloutPath)) {
|
|
@@ -163,13 +164,424 @@ export class CodexStoreAdapter {
|
|
|
163
164
|
lsofOutput: result.stdout
|
|
164
165
|
});
|
|
165
166
|
}
|
|
166
|
-
|
|
167
|
-
const
|
|
168
|
-
if (
|
|
169
|
-
throw new Error(
|
|
167
|
+
async queryThreadRows({ maxSessions, nativeThreadId }) {
|
|
168
|
+
const dbPath = latestStateDbPath(this.codexHome);
|
|
169
|
+
if (!dbPath) {
|
|
170
|
+
throw new Error("no Codex state sqlite database found");
|
|
171
|
+
}
|
|
172
|
+
const baseline = inspectCodexSqliteFiles(dbPath);
|
|
173
|
+
assertStableCodexSqliteMain({
|
|
174
|
+
baseline,
|
|
175
|
+
current: baseline,
|
|
176
|
+
stage: "initial"
|
|
177
|
+
});
|
|
178
|
+
let lastFailure;
|
|
179
|
+
const attempts = this.sqliteCantOpenRetryDelaysMs.length + 1;
|
|
180
|
+
for (let attempt = 0; attempt < attempts; attempt += 1) {
|
|
181
|
+
if (attempt > 0) {
|
|
182
|
+
await this.sleep(this.sqliteCantOpenRetryDelaysMs[attempt - 1]);
|
|
183
|
+
}
|
|
184
|
+
const currentPath = latestStateDbPath(this.codexHome);
|
|
185
|
+
const before = inspectCodexSqliteFiles(currentPath ?? dbPath);
|
|
186
|
+
assertStableCodexSqliteMain({
|
|
187
|
+
baseline,
|
|
188
|
+
current: before,
|
|
189
|
+
stage: `readonly_attempt_${attempt + 1}`,
|
|
190
|
+
selectedPath: currentPath
|
|
191
|
+
});
|
|
192
|
+
let result;
|
|
193
|
+
try {
|
|
194
|
+
result = await this.runSqliteThreadQuery({
|
|
195
|
+
dbPath,
|
|
196
|
+
openMode: "readonly",
|
|
197
|
+
maxSessions,
|
|
198
|
+
nativeThreadId
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
catch (error) {
|
|
202
|
+
const failedPath = latestStateDbPath(this.codexHome);
|
|
203
|
+
const failedFiles = inspectCodexSqliteFiles(failedPath ?? dbPath);
|
|
204
|
+
assertStableCodexSqliteMain({
|
|
205
|
+
baseline,
|
|
206
|
+
current: failedFiles,
|
|
207
|
+
stage: `readonly_attempt_${attempt + 1}_failed`,
|
|
208
|
+
selectedPath: failedPath
|
|
209
|
+
});
|
|
210
|
+
const failure = codexSqliteQueryFailure(error, {
|
|
211
|
+
dbPath,
|
|
212
|
+
stage: `readonly_attempt_${attempt + 1}`,
|
|
213
|
+
files: failedFiles
|
|
214
|
+
});
|
|
215
|
+
if (!isSqliteCantOpen(failure)) {
|
|
216
|
+
throw codexSqliteQueryDiagnosticError(failure);
|
|
217
|
+
}
|
|
218
|
+
lastFailure = failure;
|
|
219
|
+
continue;
|
|
220
|
+
}
|
|
221
|
+
const completedPath = latestStateDbPath(this.codexHome);
|
|
222
|
+
assertStableCodexSqliteMain({
|
|
223
|
+
baseline,
|
|
224
|
+
current: inspectCodexSqliteFiles(completedPath ?? dbPath),
|
|
225
|
+
stage: `readonly_attempt_${attempt + 1}_complete`,
|
|
226
|
+
selectedPath: completedPath
|
|
227
|
+
});
|
|
228
|
+
return validateCodexThreadQueryResult(result);
|
|
229
|
+
}
|
|
230
|
+
const currentPath = latestStateDbPath(this.codexHome);
|
|
231
|
+
const beforeMaterialization = inspectCodexSqliteFiles(currentPath ?? dbPath);
|
|
232
|
+
assertStableCodexSqliteMain({
|
|
233
|
+
baseline,
|
|
234
|
+
current: beforeMaterialization,
|
|
235
|
+
stage: "query_only_materialization",
|
|
236
|
+
selectedPath: currentPath
|
|
237
|
+
});
|
|
238
|
+
let materializedResult;
|
|
239
|
+
try {
|
|
240
|
+
materializedResult = await this.runSqliteThreadQuery({
|
|
241
|
+
dbPath,
|
|
242
|
+
openMode: "query_only",
|
|
243
|
+
maxSessions,
|
|
244
|
+
nativeThreadId
|
|
245
|
+
});
|
|
170
246
|
}
|
|
171
|
-
|
|
247
|
+
catch (error) {
|
|
248
|
+
const failedPath = latestStateDbPath(this.codexHome);
|
|
249
|
+
const failedFiles = inspectCodexSqliteFiles(failedPath ?? dbPath);
|
|
250
|
+
assertStableCodexSqliteMain({
|
|
251
|
+
baseline,
|
|
252
|
+
current: failedFiles,
|
|
253
|
+
stage: "query_only_materialization_failed",
|
|
254
|
+
selectedPath: failedPath
|
|
255
|
+
});
|
|
256
|
+
throw codexSqliteQueryDiagnosticError(codexSqliteQueryFailure(error, {
|
|
257
|
+
dbPath,
|
|
258
|
+
stage: "query_only_materialization",
|
|
259
|
+
files: failedFiles,
|
|
260
|
+
previousFailure: lastFailure
|
|
261
|
+
}));
|
|
262
|
+
}
|
|
263
|
+
const completedPath = latestStateDbPath(this.codexHome);
|
|
264
|
+
assertStableCodexSqliteMain({
|
|
265
|
+
baseline,
|
|
266
|
+
current: inspectCodexSqliteFiles(completedPath ?? dbPath),
|
|
267
|
+
stage: "query_only_materialization_complete",
|
|
268
|
+
selectedPath: completedPath
|
|
269
|
+
});
|
|
270
|
+
return validateCodexThreadQueryResult(materializedResult);
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
class CodexSqliteSessionError extends Error {
|
|
274
|
+
status;
|
|
275
|
+
stage;
|
|
276
|
+
constructor({ message, status, stage }) {
|
|
277
|
+
super(message);
|
|
278
|
+
this.name = "CodexSqliteSessionError";
|
|
279
|
+
this.status = status;
|
|
280
|
+
this.stage = stage;
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
export async function runCodexSqliteThreadQuery(request) {
|
|
284
|
+
if (request.nativeThreadId &&
|
|
285
|
+
!NATIVE_THREAD_ID_PATTERN.test(request.nativeThreadId)) {
|
|
286
|
+
throw new Error("Codex thread lookup requires an exact UUID");
|
|
287
|
+
}
|
|
288
|
+
const nonce = randomUUID();
|
|
289
|
+
const controlColumn = "__akk_sqlite_control";
|
|
290
|
+
const schemaControl = `schema:${nonce}`;
|
|
291
|
+
const rowsControl = `rows:${nonce}`;
|
|
292
|
+
const schemaMarker = JSON.stringify([{ [controlColumn]: schemaControl }]);
|
|
293
|
+
const rowsMarker = JSON.stringify([{ [controlColumn]: rowsControl }]);
|
|
294
|
+
const databaseArgument = request.openMode === "query_only"
|
|
295
|
+
? sqliteReadWriteUri(request.dbPath)
|
|
296
|
+
: request.dbPath;
|
|
297
|
+
const args = ["-batch", "-bail", "-json"];
|
|
298
|
+
if (request.openMode === "readonly") {
|
|
299
|
+
args.push("-readonly");
|
|
300
|
+
}
|
|
301
|
+
else {
|
|
302
|
+
// This is the first SQL statement for the mode=rw connection. It lets
|
|
303
|
+
// SQLite materialize WAL/SHM bookkeeping while forbidding business SQL
|
|
304
|
+
// writes for the entire AKK session.
|
|
305
|
+
args.push("-cmd", "PRAGMA query_only=ON");
|
|
306
|
+
}
|
|
307
|
+
args.push(databaseArgument);
|
|
308
|
+
return new Promise((resolve, reject) => {
|
|
309
|
+
const child = spawn("sqlite3", args, {
|
|
310
|
+
stdio: ["pipe", "pipe", "pipe"]
|
|
311
|
+
});
|
|
312
|
+
let phase = "schema";
|
|
313
|
+
let output = "";
|
|
314
|
+
let stderr = "";
|
|
315
|
+
let result;
|
|
316
|
+
let authoritativeColumns = [];
|
|
317
|
+
let terminalError;
|
|
318
|
+
let settled = false;
|
|
319
|
+
const timeout = setTimeout(() => {
|
|
320
|
+
if (!terminalError) {
|
|
321
|
+
terminalError = new CodexSqliteSessionError({
|
|
322
|
+
message: `sqlite3 thread query timed out after ${SQLITE_QUERY_TIMEOUT_MS}ms`,
|
|
323
|
+
status: null,
|
|
324
|
+
stage: phase
|
|
325
|
+
});
|
|
326
|
+
}
|
|
327
|
+
child.kill("SIGKILL");
|
|
328
|
+
}, SQLITE_QUERY_TIMEOUT_MS);
|
|
329
|
+
const stopWithError = (error) => {
|
|
330
|
+
if (!terminalError) {
|
|
331
|
+
terminalError = error instanceof CodexSqliteSessionError
|
|
332
|
+
? error
|
|
333
|
+
: new CodexSqliteSessionError({
|
|
334
|
+
message: error.message,
|
|
335
|
+
status: null,
|
|
336
|
+
stage: phase
|
|
337
|
+
});
|
|
338
|
+
}
|
|
339
|
+
child.kill("SIGKILL");
|
|
340
|
+
};
|
|
341
|
+
const appendOutput = (current, chunk, limit) => {
|
|
342
|
+
const next = current + chunk;
|
|
343
|
+
if (Buffer.byteLength(next, "utf8") > limit) {
|
|
344
|
+
throw new Error(`sqlite3 ${phase} output exceeded ${limit} bytes`);
|
|
345
|
+
}
|
|
346
|
+
return next;
|
|
347
|
+
};
|
|
348
|
+
const parseArray = (text, label) => {
|
|
349
|
+
if (!text.trim()) {
|
|
350
|
+
return [];
|
|
351
|
+
}
|
|
352
|
+
const parsed = JSON.parse(text);
|
|
353
|
+
if (!Array.isArray(parsed)) {
|
|
354
|
+
throw new Error(`sqlite3 ${label} output was not a JSON array`);
|
|
355
|
+
}
|
|
356
|
+
return parsed;
|
|
357
|
+
};
|
|
358
|
+
const writeRowsQuery = (columns) => {
|
|
359
|
+
validateCodexThreadColumns(columns);
|
|
360
|
+
authoritativeColumns = columns;
|
|
361
|
+
const sql = request.nativeThreadId
|
|
362
|
+
? buildThreadByIdSelect(columns, request.nativeThreadId)
|
|
363
|
+
: buildThreadSelect(columns, request.maxSessions);
|
|
364
|
+
phase = "rows";
|
|
365
|
+
child.stdin.write(`${sql};\nselect '${rowsControl}' as "${controlColumn}";\n`);
|
|
366
|
+
};
|
|
367
|
+
const consumeOutput = () => {
|
|
368
|
+
if (phase === "schema") {
|
|
369
|
+
const markerIndex = output.indexOf(schemaMarker);
|
|
370
|
+
if (markerIndex < 0) {
|
|
371
|
+
return;
|
|
372
|
+
}
|
|
373
|
+
const schema = parseArray(output.slice(0, markerIndex).trim(), "schema");
|
|
374
|
+
const columns = schema
|
|
375
|
+
.map((column) => typeof column.name === "string" ? column.name : "")
|
|
376
|
+
.filter(Boolean);
|
|
377
|
+
output = output.slice(markerIndex + schemaMarker.length).trimStart();
|
|
378
|
+
phase = "schema_hook";
|
|
379
|
+
void Promise.resolve(request.afterSchema?.(columns))
|
|
380
|
+
.then(() => writeRowsQuery(columns))
|
|
381
|
+
.catch((error) => stopWithError(error instanceof Error ? error : new Error(String(error))));
|
|
382
|
+
}
|
|
383
|
+
if (phase === "rows") {
|
|
384
|
+
const markerIndex = output.indexOf(rowsMarker);
|
|
385
|
+
if (markerIndex < 0) {
|
|
386
|
+
return;
|
|
387
|
+
}
|
|
388
|
+
const rows = parseArray(output.slice(0, markerIndex).trim(), "rows");
|
|
389
|
+
result = {
|
|
390
|
+
columns: authoritativeColumns,
|
|
391
|
+
rows
|
|
392
|
+
};
|
|
393
|
+
phase = "complete";
|
|
394
|
+
child.stdin.end("COMMIT;\n.quit\n");
|
|
395
|
+
}
|
|
396
|
+
};
|
|
397
|
+
child.stdout.setEncoding("utf8");
|
|
398
|
+
child.stderr.setEncoding("utf8");
|
|
399
|
+
child.stdout.on("data", (chunk) => {
|
|
400
|
+
try {
|
|
401
|
+
output = appendOutput(output, chunk, MAX_SQLITE_QUERY_OUTPUT_BYTES);
|
|
402
|
+
consumeOutput();
|
|
403
|
+
}
|
|
404
|
+
catch (error) {
|
|
405
|
+
stopWithError(error instanceof Error ? error : new Error(String(error)));
|
|
406
|
+
}
|
|
407
|
+
});
|
|
408
|
+
child.stderr.on("data", (chunk) => {
|
|
409
|
+
try {
|
|
410
|
+
stderr = appendOutput(stderr, chunk, MAX_SQLITE_ERROR_OUTPUT_BYTES);
|
|
411
|
+
}
|
|
412
|
+
catch (error) {
|
|
413
|
+
stopWithError(error instanceof Error ? error : new Error(String(error)));
|
|
414
|
+
}
|
|
415
|
+
});
|
|
416
|
+
child.on("error", (error) => {
|
|
417
|
+
if (!terminalError) {
|
|
418
|
+
terminalError = new CodexSqliteSessionError({
|
|
419
|
+
message: error.message,
|
|
420
|
+
status: null,
|
|
421
|
+
stage: phase
|
|
422
|
+
});
|
|
423
|
+
}
|
|
424
|
+
});
|
|
425
|
+
child.stdin.on("error", (error) => {
|
|
426
|
+
if (!terminalError && phase !== "complete") {
|
|
427
|
+
terminalError = error;
|
|
428
|
+
}
|
|
429
|
+
});
|
|
430
|
+
child.on("close", (status) => {
|
|
431
|
+
clearTimeout(timeout);
|
|
432
|
+
if (settled) {
|
|
433
|
+
return;
|
|
434
|
+
}
|
|
435
|
+
settled = true;
|
|
436
|
+
if (terminalError) {
|
|
437
|
+
reject(terminalError);
|
|
438
|
+
return;
|
|
439
|
+
}
|
|
440
|
+
if (status !== 0) {
|
|
441
|
+
reject(new CodexSqliteSessionError({
|
|
442
|
+
message: stderr.trim() || `sqlite3 exited with status ${status ?? "unknown"}`,
|
|
443
|
+
status,
|
|
444
|
+
stage: phase
|
|
445
|
+
}));
|
|
446
|
+
return;
|
|
447
|
+
}
|
|
448
|
+
if (!result || phase !== "complete") {
|
|
449
|
+
reject(new CodexSqliteSessionError({
|
|
450
|
+
message: "sqlite3 exited before the thread query protocol completed",
|
|
451
|
+
status,
|
|
452
|
+
stage: phase
|
|
453
|
+
}));
|
|
454
|
+
return;
|
|
455
|
+
}
|
|
456
|
+
resolve(result);
|
|
457
|
+
});
|
|
458
|
+
child.stdin.write(`BEGIN;\npragma table_info(threads);\n` +
|
|
459
|
+
`select '${schemaControl}' as "${controlColumn}";\n`);
|
|
460
|
+
});
|
|
461
|
+
}
|
|
462
|
+
function validateCodexThreadQueryResult(result) {
|
|
463
|
+
validateCodexThreadColumns(result.columns);
|
|
464
|
+
return result.rows;
|
|
465
|
+
}
|
|
466
|
+
function validateCodexThreadColumns(columns) {
|
|
467
|
+
if (!columns.includes("id") || !columns.includes("cwd")) {
|
|
468
|
+
throw new Error("Codex threads table is missing required id or cwd columns");
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
function sqliteReadWriteUri(dbPath) {
|
|
472
|
+
const uri = pathToFileURL(path.resolve(dbPath));
|
|
473
|
+
uri.searchParams.set("mode", "rw");
|
|
474
|
+
return uri.href;
|
|
475
|
+
}
|
|
476
|
+
function waitForMilliseconds(milliseconds) {
|
|
477
|
+
return new Promise((resolve) => setTimeout(resolve, Math.max(0, milliseconds)));
|
|
478
|
+
}
|
|
479
|
+
function inspectCodexSqliteFiles(dbPath) {
|
|
480
|
+
return {
|
|
481
|
+
dbPath: path.resolve(dbPath),
|
|
482
|
+
main: inspectCodexSqliteFile(dbPath),
|
|
483
|
+
wal: inspectCodexSqliteFile(`${dbPath}-wal`),
|
|
484
|
+
shm: inspectCodexSqliteFile(`${dbPath}-shm`)
|
|
485
|
+
};
|
|
486
|
+
}
|
|
487
|
+
function inspectCodexSqliteFile(filePath) {
|
|
488
|
+
try {
|
|
489
|
+
const stat = fs.statSync(filePath);
|
|
490
|
+
return {
|
|
491
|
+
path: path.resolve(filePath),
|
|
492
|
+
exists: true,
|
|
493
|
+
kind: stat.isFile()
|
|
494
|
+
? "file"
|
|
495
|
+
: stat.isDirectory()
|
|
496
|
+
? "directory"
|
|
497
|
+
: "other",
|
|
498
|
+
device: String(stat.dev),
|
|
499
|
+
inode: String(stat.ino),
|
|
500
|
+
size: stat.size,
|
|
501
|
+
mtimeMs: stat.mtimeMs
|
|
502
|
+
};
|
|
503
|
+
}
|
|
504
|
+
catch (error) {
|
|
505
|
+
const code = typeof error === "object" && error !== null && "code" in error
|
|
506
|
+
? String(error.code)
|
|
507
|
+
: undefined;
|
|
508
|
+
return {
|
|
509
|
+
path: path.resolve(filePath),
|
|
510
|
+
exists: false,
|
|
511
|
+
...(code ? { errorCode: code } : {})
|
|
512
|
+
};
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
function assertStableCodexSqliteMain({ baseline, current, stage, selectedPath = current.dbPath }) {
|
|
516
|
+
const samePath = Boolean(selectedPath &&
|
|
517
|
+
path.resolve(selectedPath) === baseline.dbPath &&
|
|
518
|
+
current.dbPath === baseline.dbPath);
|
|
519
|
+
const sameFile = Boolean(baseline.main.exists &&
|
|
520
|
+
baseline.main.kind === "file" &&
|
|
521
|
+
current.main.exists &&
|
|
522
|
+
current.main.kind === "file" &&
|
|
523
|
+
baseline.main.device === current.main.device &&
|
|
524
|
+
baseline.main.inode === current.main.inode);
|
|
525
|
+
if (samePath && sameFile) {
|
|
526
|
+
return;
|
|
172
527
|
}
|
|
528
|
+
throw new Error(`Codex SQLite main database changed during ${stage}; refusing stale ` +
|
|
529
|
+
`thread discovery (selected_db=${selectedPath ?? "missing"}, ` +
|
|
530
|
+
`baseline_db=${baseline.dbPath}, current_db=${current.dbPath}; ` +
|
|
531
|
+
`${formatCodexSqliteFiles(current)})`);
|
|
532
|
+
}
|
|
533
|
+
function codexSqliteQueryFailure(error, context) {
|
|
534
|
+
const errorRecord = typeof error === "object" && error !== null
|
|
535
|
+
? error
|
|
536
|
+
: undefined;
|
|
537
|
+
const reportedStatus = typeof errorRecord?.status === "number"
|
|
538
|
+
? errorRecord.status
|
|
539
|
+
: null;
|
|
540
|
+
const reportedStage = typeof errorRecord?.stage === "string"
|
|
541
|
+
? errorRecord.stage
|
|
542
|
+
: undefined;
|
|
543
|
+
return {
|
|
544
|
+
dbPath: path.resolve(context.dbPath),
|
|
545
|
+
stage: reportedStage
|
|
546
|
+
? `${context.stage}:${reportedStage}`
|
|
547
|
+
: context.stage,
|
|
548
|
+
status: reportedStatus !== null && Number.isInteger(reportedStatus)
|
|
549
|
+
? reportedStatus
|
|
550
|
+
: null,
|
|
551
|
+
detail: error instanceof Error ? error.message : String(error),
|
|
552
|
+
files: context.files,
|
|
553
|
+
previousFailure: context.previousFailure
|
|
554
|
+
};
|
|
555
|
+
}
|
|
556
|
+
function isSqliteCantOpen(failure) {
|
|
557
|
+
return failure.status === 14 ||
|
|
558
|
+
/(?:SQLITE_CANTOPEN|unable to open database file|\(14\))/iu.test(failure.detail);
|
|
559
|
+
}
|
|
560
|
+
function codexSqliteQueryDiagnosticError(failure) {
|
|
561
|
+
const prior = failure.previousFailure
|
|
562
|
+
? `; previous=[stage=${failure.previousFailure.stage},status=` +
|
|
563
|
+
`${failure.previousFailure.status ?? "unknown"},` +
|
|
564
|
+
`${formatCodexSqliteFiles(failure.previousFailure.files)}]`
|
|
565
|
+
: "";
|
|
566
|
+
return new Error(`Codex SQLite thread query failed ` +
|
|
567
|
+
`(stage=${failure.stage}, db=${failure.dbPath}, ` +
|
|
568
|
+
`status=${failure.status ?? "unknown"}${prior}; ` +
|
|
569
|
+
`${formatCodexSqliteFiles(failure.files)}): ` +
|
|
570
|
+
failure.detail.replace(/\s+/gu, " ").trim());
|
|
571
|
+
}
|
|
572
|
+
function formatCodexSqliteFiles(snapshot) {
|
|
573
|
+
return [
|
|
574
|
+
["main", snapshot.main],
|
|
575
|
+
["wal", snapshot.wal],
|
|
576
|
+
["shm", snapshot.shm]
|
|
577
|
+
].map(([label, value]) => {
|
|
578
|
+
const file = value;
|
|
579
|
+
if (!file.exists) {
|
|
580
|
+
return `${label}=missing${file.errorCode ? `(${file.errorCode})` : ""}`;
|
|
581
|
+
}
|
|
582
|
+
return `${label}=${file.kind}(dev=${file.device},ino=${file.inode},` +
|
|
583
|
+
`size=${file.size},mtime_ms=${Math.trunc(file.mtimeMs ?? 0)})`;
|
|
584
|
+
}).join(" ");
|
|
173
585
|
}
|
|
174
586
|
export function latestStateDbPath(codexHome) {
|
|
175
587
|
if (!fs.existsSync(codexHome)) {
|
|
@@ -178,7 +590,18 @@ export function latestStateDbPath(codexHome) {
|
|
|
178
590
|
return fs.readdirSync(codexHome)
|
|
179
591
|
.filter((entry) => /^state_\d+\.sqlite$/u.test(entry))
|
|
180
592
|
.map((entry) => path.join(codexHome, entry))
|
|
181
|
-
.
|
|
593
|
+
.flatMap((filePath) => {
|
|
594
|
+
try {
|
|
595
|
+
const stat = fs.statSync(filePath);
|
|
596
|
+
return stat.isFile() ? [{ filePath, mtimeMs: stat.mtimeMs }] : [];
|
|
597
|
+
}
|
|
598
|
+
catch {
|
|
599
|
+
// Codex may rotate a versioned state database while discovery is
|
|
600
|
+
// enumerating it. The caller will re-resolve and validate identity.
|
|
601
|
+
return [];
|
|
602
|
+
}
|
|
603
|
+
})
|
|
604
|
+
.sort((left, right) => right.mtimeMs - left.mtimeMs)[0]?.filePath;
|
|
182
605
|
}
|
|
183
606
|
export function parseLsofOpenFiles(text) {
|
|
184
607
|
const records = [];
|