@neta-art/cohub-cli 8.1.0 → 8.2.0
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/dist/commands/app-commerce.js +2 -2
- package/dist/commands/me.js +2 -2
- package/dist/commands/references.js +2 -2
- package/dist/commands/runtime.d.ts +12 -0
- package/dist/commands/runtime.js +46 -13
- package/dist/commands/search.js +2 -2
- package/dist/commands/space-commerce.js +2 -2
- package/dist/output.d.ts +9 -2
- package/dist/output.js +28 -4
- package/dist/runtime/archive-store.d.ts +4 -0
- package/dist/runtime/archive-store.js +10 -6
- package/dist/runtime/native-archive.d.ts +4 -0
- package/dist/runtime/native-archive.js +8 -0
- package/dist/runtime/native-codex-hook.js +1 -1
- package/dist/runtime/native-ipc.d.ts +10 -1
- package/dist/runtime/native-ipc.js +18 -3
- package/dist/runtime/native-pi-extension.js +9 -9
- package/dist/runtime/native-sync-store.d.ts +14 -0
- package/dist/runtime/native-sync-store.js +151 -24
- package/dist/runtime/native-sync.d.ts +16 -1
- package/dist/runtime/native-sync.js +149 -32
- package/dist/runtime/native-transcript.d.ts +31 -3
- package/dist/runtime/native-transcript.js +289 -31
- package/dist/runtime/session-store.js +24 -10
- package/dist/runtime/supervisor.js +87 -11
- package/package.json +1 -1
|
@@ -12,6 +12,12 @@ export type NativeTranscriptTurn = {
|
|
|
12
12
|
boundaries: Record<number, string>;
|
|
13
13
|
sha256: string;
|
|
14
14
|
result: NativeTurnComplete | null;
|
|
15
|
+
/** Codex lineage: the rollout file this Turn was read from. */
|
|
16
|
+
path?: string;
|
|
17
|
+
/** Codex lineage: the rollout id of the file this Turn was read from. */
|
|
18
|
+
rolloutId?: string;
|
|
19
|
+
/** Codex lineage: merged order across rollout files. */
|
|
20
|
+
sequence?: number;
|
|
15
21
|
};
|
|
16
22
|
export type NativeTranscript = {
|
|
17
23
|
nativeSessionId: string;
|
|
@@ -19,14 +25,36 @@ export type NativeTranscript = {
|
|
|
19
25
|
cloudSessionId?: string;
|
|
20
26
|
turns: NativeTranscriptTurn[];
|
|
21
27
|
prefixes: ReadonlyMap<number, string>;
|
|
28
|
+
/** Codex lineage: every rollout file oldest-to-leaf, including the leaf itself. */
|
|
29
|
+
lineagePaths?: string[];
|
|
30
|
+
/** Codex lineage: rollout ids matching `lineagePaths` order. */
|
|
31
|
+
lineageRolloutIds?: string[];
|
|
22
32
|
};
|
|
23
33
|
export declare const NATIVE_MAX_RECORD_BYTES: number;
|
|
24
34
|
export declare const NATIVE_MAX_TRANSCRIPT_BYTES: number;
|
|
25
|
-
|
|
26
|
-
export declare
|
|
35
|
+
export declare const codexSessionsDirectory: () => string;
|
|
36
|
+
export declare const codexArchivedSessionsDirectory: () => string;
|
|
37
|
+
/** Ordinary rollout names encode one id; reverted threads append `_rollout-id`. */
|
|
38
|
+
export declare function codexRolloutIdOf(fileName: string): string | null;
|
|
39
|
+
export type CodexHistoryBase = {
|
|
40
|
+
rolloutId: string;
|
|
41
|
+
endOrdinal: number;
|
|
42
|
+
endBytes: number;
|
|
43
|
+
};
|
|
44
|
+
export type NativeTranscriptHeader = {
|
|
27
45
|
nativeSessionId: string;
|
|
28
46
|
cwd: string;
|
|
29
|
-
|
|
47
|
+
rolloutId?: string;
|
|
48
|
+
historyBase?: CodexHistoryBase;
|
|
49
|
+
};
|
|
50
|
+
/** Read only the session header before deciding whether a global Codex file belongs to this project. */
|
|
51
|
+
export declare function readNativeTranscriptHeader(path: string, harness: "pi" | "codex", signal?: AbortSignal): Promise<NativeTranscriptHeader>;
|
|
52
|
+
/** Raw JSONL bytes of a rollout, transparently decompressing `.zst` and honoring a lineage cutoff. */
|
|
53
|
+
export declare function codexRolloutBytes(path: string, endBytes: number | null, signal?: AbortSignal): AsyncGenerator<Buffer>;
|
|
54
|
+
/** Cache location for a rollout's decompressed copy; shared by capture and archival. */
|
|
55
|
+
export declare function codexRolloutCachePath(source: string, cacheDirectory: string): string;
|
|
56
|
+
/** Archives need plain JSONL bytes; decompress a cold `.zst` rollout into a cache once. */
|
|
57
|
+
export declare function ensurePlainCodexRollout(path: string, cacheDirectory: string, signal?: AbortSignal, maxBytes?: number): Promise<string>;
|
|
30
58
|
/** Partial trailing records are retried, never parsed or acknowledged as complete. */
|
|
31
59
|
export declare function readNativeTranscript(path: string, harness: "pi" | "codex", options?: {
|
|
32
60
|
settled?: boolean;
|
|
@@ -1,5 +1,11 @@
|
|
|
1
|
-
import { createReadStream } from "node:fs";
|
|
2
|
-
import {
|
|
1
|
+
import { createReadStream, createWriteStream } from "node:fs";
|
|
2
|
+
import { Transform } from "node:stream";
|
|
3
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
4
|
+
import { link, mkdir, readdir, rm, stat } from "node:fs/promises";
|
|
5
|
+
import { homedir } from "node:os";
|
|
6
|
+
import { basename, join } from "node:path";
|
|
7
|
+
import { createZstdDecompress } from "node:zlib";
|
|
8
|
+
import { pipeline } from "node:stream/promises";
|
|
3
9
|
import { piContent } from "./harness.js";
|
|
4
10
|
import { codexTokenTotals, codexUsage } from "./codex-usage.js";
|
|
5
11
|
import { record } from "./json-rpc.js";
|
|
@@ -11,37 +17,177 @@ const iso = (value) => {
|
|
|
11
17
|
throw new Error("Invalid native timestamp");
|
|
12
18
|
return date.toISOString();
|
|
13
19
|
};
|
|
20
|
+
const missing = (error) => error?.code === "ENOENT";
|
|
14
21
|
export const NATIVE_MAX_RECORD_BYTES = 256 * 1024 * 1024;
|
|
15
22
|
export const NATIVE_MAX_TRANSCRIPT_BYTES = 1024 * 1024 * 1024;
|
|
16
23
|
const NATIVE_HEADER_MAX_BYTES = 1024 * 1024;
|
|
24
|
+
const CODEX_HISTORY_MAX_DEPTH = 64;
|
|
25
|
+
const CODEX_ROLLOUT_INDEX_TTL_MS = 2_000;
|
|
26
|
+
const CODEX_ROLLOUT_NAME = /^rollout-\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}-([0-9a-f-]{36})(?:_([0-9a-f-]{36}))?\.(?:jsonl|jsonl\.zst)$/;
|
|
27
|
+
const codexHomeDirectory = () => process.env.CODEX_HOME?.trim() || join(homedir(), ".codex");
|
|
28
|
+
export const codexSessionsDirectory = () => join(codexHomeDirectory(), "sessions");
|
|
29
|
+
export const codexArchivedSessionsDirectory = () => join(codexHomeDirectory(), "archived_sessions");
|
|
30
|
+
/** Ordinary rollout names encode one id; reverted threads append `_rollout-id`. */
|
|
31
|
+
export function codexRolloutIdOf(fileName) {
|
|
32
|
+
const match = CODEX_ROLLOUT_NAME.exec(fileName);
|
|
33
|
+
return match ? match[2] ?? match[1] ?? null : null;
|
|
34
|
+
}
|
|
35
|
+
function parseCodexHistoryBase(value) {
|
|
36
|
+
if (value == null)
|
|
37
|
+
return undefined;
|
|
38
|
+
const base = record(value);
|
|
39
|
+
const rolloutId = text(base.thread_id);
|
|
40
|
+
const endOrdinal = base.end_ordinal_exclusive, endBytes = base.end_byte_offset;
|
|
41
|
+
if (!rolloutId || typeof endOrdinal !== "number" || !Number.isSafeInteger(endOrdinal) || endOrdinal < 1
|
|
42
|
+
|| typeof endBytes !== "number" || !Number.isSafeInteger(endBytes) || endBytes < 1)
|
|
43
|
+
throw new Error("Codex history reference is invalid; retain the original files");
|
|
44
|
+
return { rolloutId, endOrdinal, endBytes };
|
|
45
|
+
}
|
|
46
|
+
/** Read exactly the first JSONL line of a stream without re-concatenating per chunk. */
|
|
47
|
+
async function readFirstLine(stream) {
|
|
48
|
+
const chunks = [];
|
|
49
|
+
let buffered = 0;
|
|
50
|
+
for await (const chunk of stream) {
|
|
51
|
+
const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
52
|
+
chunks.push(bytes);
|
|
53
|
+
buffered += bytes.length;
|
|
54
|
+
const newline = bytes.indexOf(10);
|
|
55
|
+
if (newline >= 0) {
|
|
56
|
+
const buffer = Buffer.concat(chunks, buffered);
|
|
57
|
+
// The newline position is chunk-relative; offset it into the concatenated line.
|
|
58
|
+
const line = buffer.subarray(0, buffered - bytes.length + newline);
|
|
59
|
+
return record(JSON.parse(line.toString("utf8")));
|
|
60
|
+
}
|
|
61
|
+
if (buffered > NATIVE_HEADER_MAX_BYTES)
|
|
62
|
+
throw new Error("Native session header is too large");
|
|
63
|
+
}
|
|
64
|
+
throw new Error("Native transcript is empty");
|
|
65
|
+
}
|
|
66
|
+
async function readCodexHeader(path, signal) {
|
|
67
|
+
const value = await readFirstLine(codexRolloutBytes(path, null, signal));
|
|
68
|
+
const payload = record(value.payload);
|
|
69
|
+
const threadId = text(payload.id), cwd = text(payload.cwd);
|
|
70
|
+
if (value.type !== "session_meta" || !threadId || !cwd)
|
|
71
|
+
throw new Error("Invalid native session header");
|
|
72
|
+
const historyBase = parseCodexHistoryBase(payload.history_base);
|
|
73
|
+
return { threadId, cwd, ...(historyBase ? { historyBase } : {}) };
|
|
74
|
+
}
|
|
17
75
|
/** Read only the session header before deciding whether a global Codex file belongs to this project. */
|
|
18
76
|
export async function readNativeTranscriptHeader(path, harness, signal) {
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
77
|
+
if (harness === "pi") {
|
|
78
|
+
const value = await readFirstLine(createReadStream(path, { signal }));
|
|
79
|
+
if (value.type !== "session" || !text(value.id) || !text(value.cwd))
|
|
80
|
+
throw new Error("Invalid native session header");
|
|
81
|
+
return { nativeSessionId: text(value.id), cwd: text(value.cwd) };
|
|
82
|
+
}
|
|
83
|
+
const header = await readCodexHeader(path, signal);
|
|
84
|
+
const rolloutId = codexRolloutIdOf(basename(path)) ?? undefined;
|
|
85
|
+
return { nativeSessionId: header.threadId, cwd: header.cwd, ...(rolloutId ? { rolloutId } : {}), ...(header.historyBase ? { historyBase: header.historyBase } : {}) };
|
|
86
|
+
}
|
|
87
|
+
let codexRolloutIndexCache = null;
|
|
88
|
+
/** Locate rollout files by id across active and archived storage; plain files win over `.zst`. */
|
|
89
|
+
async function scanCodexRollouts(signal) {
|
|
90
|
+
const home = codexHomeDirectory();
|
|
91
|
+
const cached = codexRolloutIndexCache;
|
|
92
|
+
if (cached && cached.home === home && Date.now() - cached.at < CODEX_ROLLOUT_INDEX_TTL_MS)
|
|
93
|
+
return cached.index;
|
|
94
|
+
const index = new Map();
|
|
95
|
+
for (const root of [codexSessionsDirectory(), codexArchivedSessionsDirectory()]) {
|
|
96
|
+
const pending = [root];
|
|
97
|
+
while (pending.length) {
|
|
98
|
+
signal?.throwIfAborted();
|
|
99
|
+
const directory = pending.pop();
|
|
100
|
+
if (!directory)
|
|
101
|
+
continue;
|
|
102
|
+
const entries = await readdir(directory, { withFileTypes: true }).catch((error) => {
|
|
103
|
+
if (missing(error))
|
|
104
|
+
return [];
|
|
105
|
+
throw error;
|
|
106
|
+
});
|
|
107
|
+
for (const entry of entries) {
|
|
108
|
+
const path = join(directory, entry.name);
|
|
109
|
+
if (entry.isDirectory())
|
|
110
|
+
pending.push(path);
|
|
111
|
+
else if (entry.isFile()) {
|
|
112
|
+
const id = codexRolloutIdOf(entry.name);
|
|
113
|
+
if (!id)
|
|
114
|
+
continue;
|
|
115
|
+
const existing = index.get(id);
|
|
116
|
+
if (!existing || (existing.endsWith(".zst") && !path.endsWith(".zst")))
|
|
117
|
+
index.set(id, path);
|
|
118
|
+
}
|
|
33
119
|
}
|
|
34
|
-
if (buffer.length > NATIVE_HEADER_MAX_BYTES)
|
|
35
|
-
throw new Error("Native session header is too large");
|
|
36
120
|
}
|
|
37
|
-
|
|
121
|
+
}
|
|
122
|
+
codexRolloutIndexCache = { at: Date.now(), home, index };
|
|
123
|
+
return index;
|
|
124
|
+
}
|
|
125
|
+
/** Raw JSONL bytes of a rollout, transparently decompressing `.zst` and honoring a lineage cutoff. */
|
|
126
|
+
export async function* codexRolloutBytes(path, endBytes, signal) {
|
|
127
|
+
// compose (not pipe) so a source error propagates and early exit destroys both streams.
|
|
128
|
+
const stream = path.endsWith(".zst")
|
|
129
|
+
? createReadStream(path, { signal }).compose(createZstdDecompress())
|
|
130
|
+
: createReadStream(path, endBytes != null ? { end: endBytes - 1, signal } : { signal });
|
|
131
|
+
let total = 0, last = -1;
|
|
132
|
+
for await (const chunk of stream) {
|
|
133
|
+
const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
134
|
+
let cut = bytes;
|
|
135
|
+
if (endBytes != null && total + bytes.length > endBytes)
|
|
136
|
+
cut = bytes.subarray(0, endBytes - total);
|
|
137
|
+
if (cut.length) {
|
|
138
|
+
total += cut.length;
|
|
139
|
+
last = cut.at(-1) ?? last;
|
|
140
|
+
yield cut;
|
|
141
|
+
}
|
|
142
|
+
if (endBytes != null && total >= endBytes)
|
|
143
|
+
break;
|
|
144
|
+
}
|
|
145
|
+
if (endBytes != null) {
|
|
146
|
+
if (total < endBytes)
|
|
147
|
+
throw new Error("Codex history prefix is past the end of its source rollout; retain the original files");
|
|
148
|
+
if (last !== 10)
|
|
149
|
+
throw new Error("Codex history prefix does not end at a record boundary; retain the original files");
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
/** Cache location for a rollout's decompressed copy; shared by capture and archival. */
|
|
153
|
+
export function codexRolloutCachePath(source, cacheDirectory) {
|
|
154
|
+
return join(cacheDirectory, `${createHash("sha256").update(source).digest("hex")}.jsonl`);
|
|
155
|
+
}
|
|
156
|
+
/** Archives need plain JSONL bytes; decompress a cold `.zst` rollout into a cache once. */
|
|
157
|
+
export async function ensurePlainCodexRollout(path, cacheDirectory, signal, maxBytes = NATIVE_MAX_TRANSCRIPT_BYTES) {
|
|
158
|
+
if (!path.endsWith(".zst"))
|
|
159
|
+
return path;
|
|
160
|
+
const target = codexRolloutCachePath(path, cacheDirectory);
|
|
161
|
+
const exists = async () => await stat(target).then(() => true, (error) => { if (missing(error))
|
|
162
|
+
return false; throw error; });
|
|
163
|
+
if (await exists())
|
|
164
|
+
return target;
|
|
165
|
+
await mkdir(cacheDirectory, { recursive: true, mode: 0o700 });
|
|
166
|
+
const temporary = `${target}.${randomUUID()}.tmp`;
|
|
167
|
+
try {
|
|
168
|
+
// Bound decompression: a corrupt or hostile archive must not fill the disk.
|
|
169
|
+
let written = 0;
|
|
170
|
+
const bounded = new Transform({
|
|
171
|
+
transform(chunk, _encoding, push) {
|
|
172
|
+
written += chunk.length;
|
|
173
|
+
if (written > maxBytes) {
|
|
174
|
+
push(new Error("Decompressed rollout exceeds the local limit"));
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
push(null, chunk);
|
|
178
|
+
},
|
|
179
|
+
});
|
|
180
|
+
await pipeline(createReadStream(path, { signal }), createZstdDecompress(), bounded, createWriteStream(temporary, { flags: "wx", mode: 0o600 }));
|
|
181
|
+
await link(temporary, target).catch((error) => { if (error.code !== "EEXIST")
|
|
182
|
+
throw error; });
|
|
183
|
+
return target;
|
|
38
184
|
}
|
|
39
185
|
finally {
|
|
40
|
-
|
|
186
|
+
await rm(temporary, { force: true });
|
|
41
187
|
}
|
|
42
188
|
}
|
|
43
|
-
/**
|
|
44
|
-
|
|
189
|
+
/** Newline framing with exact byte offsets and rolling checksums; a partial tail stays unparsed. */
|
|
190
|
+
async function readTranscriptLines(stream) {
|
|
45
191
|
const lines = [];
|
|
46
192
|
const prefixes = new Map();
|
|
47
193
|
let fragments = [], pendingBytes = 0, offset = 0;
|
|
@@ -57,7 +203,7 @@ export async function readNativeTranscript(path, harness, options = {}) {
|
|
|
57
203
|
if (offset + pendingBytes > NATIVE_MAX_TRANSCRIPT_BYTES)
|
|
58
204
|
throw new Error("Native transcript exceeds the 1 GiB local parse limit; original retained");
|
|
59
205
|
};
|
|
60
|
-
for await (const chunk of
|
|
206
|
+
for await (const chunk of stream) {
|
|
61
207
|
let start = 0;
|
|
62
208
|
for (let end = chunk.indexOf(10); end >= 0; end = chunk.indexOf(10, start)) {
|
|
63
209
|
append(chunk.subarray(start, end + 1));
|
|
@@ -84,9 +230,17 @@ export async function readNativeTranscript(path, harness, options = {}) {
|
|
|
84
230
|
}
|
|
85
231
|
append(chunk.subarray(start));
|
|
86
232
|
}
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
233
|
+
return { lines, prefixes };
|
|
234
|
+
}
|
|
235
|
+
/** Partial trailing records are retried, never parsed or acknowledged as complete. */
|
|
236
|
+
export async function readNativeTranscript(path, harness, options = {}) {
|
|
237
|
+
if (harness === "pi") {
|
|
238
|
+
const read = await readTranscriptLines(createReadStream(path, { signal: options.signal }));
|
|
239
|
+
if (!read.lines.length)
|
|
240
|
+
throw new Error("Native transcript is empty");
|
|
241
|
+
return { ...parsePiTranscript(read.lines, options), prefixes: read.prefixes };
|
|
242
|
+
}
|
|
243
|
+
return await readCodexTranscript(path, options);
|
|
90
244
|
}
|
|
91
245
|
function parsePiTranscript(lines, options) {
|
|
92
246
|
const header = lines[0]?.value ?? {};
|
|
@@ -183,13 +337,49 @@ function codexContent(value) {
|
|
|
183
337
|
return [];
|
|
184
338
|
});
|
|
185
339
|
}
|
|
186
|
-
|
|
340
|
+
/** Follow `history_base` pointers from a live rollout to its immutable ancestors, oldest first. */
|
|
341
|
+
async function resolveCodexLineage(leafPath, signal) {
|
|
342
|
+
const segments = [];
|
|
343
|
+
const visited = new Set();
|
|
344
|
+
let path = leafPath, endBytes = null, endOrdinal = null;
|
|
345
|
+
let leafCwd = null;
|
|
346
|
+
for (;;) {
|
|
347
|
+
if (segments.length >= CODEX_HISTORY_MAX_DEPTH)
|
|
348
|
+
throw new Error("Codex history chain is too deep; retain the original files");
|
|
349
|
+
const header = await readCodexHeader(path, signal);
|
|
350
|
+
// Every segment of one conversation must belong to the same project: a cross-project
|
|
351
|
+
// reference would import another workspace's history into this one.
|
|
352
|
+
if (header.cwd && leafCwd != null && header.cwd !== leafCwd)
|
|
353
|
+
throw new Error("Codex lineage crosses projects; retain the original files");
|
|
354
|
+
leafCwd ??= header.cwd;
|
|
355
|
+
if (!header.historyBase) {
|
|
356
|
+
// No reference: this file is the whole conversation. Non-canonical names stay readable.
|
|
357
|
+
segments.push({ path, rolloutId: codexRolloutIdOf(basename(path)) ?? "", threadId: header.threadId, cwd: header.cwd, endBytes, endOrdinal });
|
|
358
|
+
break;
|
|
359
|
+
}
|
|
360
|
+
const rolloutId = codexRolloutIdOf(basename(path));
|
|
361
|
+
if (!rolloutId)
|
|
362
|
+
throw new Error("Codex rollout file name is not canonical; retain the original file");
|
|
363
|
+
if (visited.has(rolloutId))
|
|
364
|
+
throw new Error("Codex history chain is cyclic; retain the original files");
|
|
365
|
+
visited.add(rolloutId);
|
|
366
|
+
segments.push({ path, rolloutId, threadId: header.threadId, cwd: header.cwd, endBytes, endOrdinal });
|
|
367
|
+
const ancestor = (await scanCodexRollouts(signal)).get(header.historyBase.rolloutId);
|
|
368
|
+
if (!ancestor)
|
|
369
|
+
throw new Error(`Codex history source ${header.historyBase.rolloutId} is missing; retain the original files`);
|
|
370
|
+
path = ancestor;
|
|
371
|
+
endBytes = header.historyBase.endBytes;
|
|
372
|
+
endOrdinal = header.historyBase.endOrdinal;
|
|
373
|
+
}
|
|
374
|
+
return segments.reverse();
|
|
375
|
+
}
|
|
376
|
+
function parseCodexLines(lines, segment) {
|
|
187
377
|
const header = lines[0]?.value ?? {};
|
|
188
378
|
const metadata = record(header.payload);
|
|
189
379
|
if (header.type !== "session_meta" || !text(metadata.id))
|
|
190
380
|
throw new Error("Invalid Codex session");
|
|
191
|
-
if (metadata.
|
|
192
|
-
throw new Error("Codex
|
|
381
|
+
if (metadata.fork_source != null)
|
|
382
|
+
throw new Error("Legacy Codex fork source is unsupported; retain the original files");
|
|
193
383
|
const turns = [];
|
|
194
384
|
let current = null;
|
|
195
385
|
let messages = [];
|
|
@@ -215,7 +405,7 @@ function parseCodexTranscript(lines) {
|
|
|
215
405
|
if (entry.type === "event_msg" && ["turn_started", "task_started"].includes(text(payload.type))) {
|
|
216
406
|
if (current)
|
|
217
407
|
turns.push(current);
|
|
218
|
-
current = { key: text(payload.turn_id), parentKey: turns.at(-1)?.key ?? null, userContent: [], messages: [], startedAt: iso(entry.timestamp), startBytes: line.startBytes, endBytes: line.endBytes, contentEndBytes: line.endBytes, boundaries: {}, sha256: line.sha256, result: null };
|
|
408
|
+
current = { key: text(payload.turn_id), parentKey: turns.at(-1)?.key ?? null, userContent: [], messages: [], startedAt: iso(entry.timestamp), startBytes: line.startBytes, endBytes: line.endBytes, contentEndBytes: line.endBytes, boundaries: {}, sha256: line.sha256, result: null, path: segment.path, rolloutId: segment.rolloutId };
|
|
219
409
|
if (!current.key)
|
|
220
410
|
throw new Error("Codex Turn identity is missing");
|
|
221
411
|
messages = current.messages;
|
|
@@ -299,6 +489,7 @@ function parseCodexTranscript(lines) {
|
|
|
299
489
|
messages = [];
|
|
300
490
|
}
|
|
301
491
|
}
|
|
492
|
+
const settled = current === null;
|
|
302
493
|
if (current && userFromResponse)
|
|
303
494
|
turns.push(current);
|
|
304
495
|
for (const turn of turns) {
|
|
@@ -306,5 +497,72 @@ function parseCodexTranscript(lines) {
|
|
|
306
497
|
if (last && usageByTurn.has(turn.key))
|
|
307
498
|
last.usage = usageByTurn.get(turn.key);
|
|
308
499
|
}
|
|
309
|
-
return {
|
|
500
|
+
return { turns, ...(cloudSessionId ? { cloudSessionId } : {}), settled };
|
|
501
|
+
}
|
|
502
|
+
const codexSegmentMemo = new Map();
|
|
503
|
+
// Ancestor prefixes are immutable, so the cache is a pure win; parsed objects inflate in
|
|
504
|
+
// memory several-fold over raw bytes, hence a conservative budget.
|
|
505
|
+
const CODEX_MEMO_MAX_BYTES = 128 * 1024 * 1024;
|
|
506
|
+
/** Ancestors are immutable; only their bounded prefixes are worth caching, bounded by bytes. */
|
|
507
|
+
async function parseCodexSegment(segment, signal) {
|
|
508
|
+
const cacheable = segment.endBytes != null;
|
|
509
|
+
const info = cacheable ? await stat(segment.path) : null;
|
|
510
|
+
const key = `${segment.path}|${segment.endBytes ?? "-"}|${segment.endOrdinal ?? "-"}|${info?.size ?? ""}|${info?.mtimeMs ?? ""}`;
|
|
511
|
+
const cached = codexSegmentMemo.get(key);
|
|
512
|
+
if (cached) {
|
|
513
|
+
// Refresh recency: eviction below must spare the segments still in use.
|
|
514
|
+
codexSegmentMemo.delete(key);
|
|
515
|
+
codexSegmentMemo.set(key, cached);
|
|
516
|
+
return cached;
|
|
517
|
+
}
|
|
518
|
+
const read = await readTranscriptLines(codexRolloutBytes(segment.path, segment.endBytes, signal));
|
|
519
|
+
if (!read.lines.length)
|
|
520
|
+
throw new Error("Native transcript is empty");
|
|
521
|
+
const parsed = parseCodexLines(read.lines, segment);
|
|
522
|
+
// A paginated ancestor stamps every record with its ordinal; the referenced prefix must end
|
|
523
|
+
// exactly at the ordinal bound. Records without the stamp cannot validate a paginated bound.
|
|
524
|
+
if (segment.endBytes != null && segment.endOrdinal != null) {
|
|
525
|
+
const ordinal = read.lines.at(-1)?.value.ordinal;
|
|
526
|
+
if (typeof ordinal !== "number" || ordinal !== segment.endOrdinal - 1)
|
|
527
|
+
throw new Error("Codex history ordinal bound does not match the prefix; retain the original files");
|
|
528
|
+
}
|
|
529
|
+
const value = { turns: parsed.turns, prefixes: read.prefixes, ...(parsed.cloudSessionId ? { cloudSessionId: parsed.cloudSessionId } : {}), settled: parsed.settled, bytes: segment.endBytes ?? 0 };
|
|
530
|
+
if (cacheable) {
|
|
531
|
+
// Evict oldest entries first until the cached bytes stay bounded.
|
|
532
|
+
let total = [...codexSegmentMemo.values()].reduce((sum, entry) => sum + entry.bytes, 0) + value.bytes;
|
|
533
|
+
for (const [existing, entry] of codexSegmentMemo) {
|
|
534
|
+
if (total <= CODEX_MEMO_MAX_BYTES)
|
|
535
|
+
break;
|
|
536
|
+
total -= entry.bytes;
|
|
537
|
+
codexSegmentMemo.delete(existing);
|
|
538
|
+
}
|
|
539
|
+
codexSegmentMemo.set(key, value);
|
|
540
|
+
}
|
|
541
|
+
return value;
|
|
542
|
+
}
|
|
543
|
+
async function readCodexTranscript(path, options) {
|
|
544
|
+
const segments = await resolveCodexLineage(path, options.signal);
|
|
545
|
+
const files = [];
|
|
546
|
+
let prefixes = new Map();
|
|
547
|
+
for (const [index, segment] of segments.entries()) {
|
|
548
|
+
const parsed = await parseCodexSegment(segment, options.signal);
|
|
549
|
+
// An ancestor contributes exactly its referenced prefix; an in-flight tail would corrupt the boundary.
|
|
550
|
+
if (index < segments.length - 1 && !parsed.settled)
|
|
551
|
+
throw new Error("Codex history boundary is not at a completed Turn; retain the original files");
|
|
552
|
+
files.push({ segment, parsed });
|
|
553
|
+
if (index === segments.length - 1)
|
|
554
|
+
prefixes = parsed.prefixes;
|
|
555
|
+
}
|
|
556
|
+
const leaf = files.at(-1);
|
|
557
|
+
if (!leaf)
|
|
558
|
+
throw new Error("Native transcript is empty");
|
|
559
|
+
const turns = [];
|
|
560
|
+
for (const file of files)
|
|
561
|
+
for (const turn of file.parsed.turns)
|
|
562
|
+
turns.push({ ...turn, parentKey: turns.at(-1)?.key ?? null, sequence: turns.length });
|
|
563
|
+
// The leaf's own cloud binding wins; ancestors only matter when the leaf has none (a native
|
|
564
|
+
// continuation of a previously managed conversation).
|
|
565
|
+
const cloudSessionId = files.at(-1)?.parsed.cloudSessionId ?? files.map((file) => file.parsed.cloudSessionId).find(Boolean);
|
|
566
|
+
return { nativeSessionId: leaf.segment.threadId, cwd: leaf.segment.cwd, ...(cloudSessionId ? { cloudSessionId } : {}), turns, prefixes,
|
|
567
|
+
lineagePaths: files.map((file) => file.segment.path), lineageRolloutIds: files.map((file) => file.segment.rolloutId) };
|
|
310
568
|
}
|
|
@@ -4,7 +4,7 @@ import { homedir } from "node:os";
|
|
|
4
4
|
import { dirname, join } from "node:path";
|
|
5
5
|
import { RUNTIME_RECOVERY_BATCH_SIZE, runtimeEventSchema, serializeProjectionRecords } from "@neta-art/cohub";
|
|
6
6
|
import { RuntimeArchiveStore, checksumNativeFile, atomicRuntimeJson as atomicJson } from "./archive-store.js";
|
|
7
|
-
import { importNativeArchive, readCodexArchiveTotals } from "./native-archive.js";
|
|
7
|
+
import { ArchiveNotRestorableError, importNativeArchive, readCodexArchiveTotals } from "./native-archive.js";
|
|
8
8
|
import { serializeDiagnosticError } from "./diagnostics.js";
|
|
9
9
|
import { ProjectionStore, rebindProjectionNativeSession } from "./projection-store.js";
|
|
10
10
|
import { confirmQuiescentProcessGroup } from "./process-group.js";
|
|
@@ -441,15 +441,29 @@ export class RuntimeSessionStore {
|
|
|
441
441
|
projectionVersion: 1, sourceSequence: null, sourceTurnId: null, sourceFingerprint: null,
|
|
442
442
|
};
|
|
443
443
|
if (restore) {
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
444
|
+
// Failed restores keep their raw bytes for diagnosis; remembering the verdict stops a
|
|
445
|
+
// post-rollover archive from being re-downloaded on every resume when it can never import.
|
|
446
|
+
const failureMarker = join(this.root, "archives", "restore-failures", `${archive.turnId}.json`);
|
|
447
|
+
const knownFailure = await readFile(failureMarker, "utf8").then(() => true, (error) => { if (missing(error))
|
|
448
|
+
return false; throw error; });
|
|
449
|
+
signal?.throwIfAborted();
|
|
450
|
+
if (!knownFailure)
|
|
451
|
+
try {
|
|
452
|
+
const restored = await this.archives.restore(archive, rawPath, signal);
|
|
453
|
+
Object.assign(state, await importNativeArchive({ source: rawPath, target: path, harness: input.harness, nativeSessionId: restored.nativeSessionId, id, cwd, signal }));
|
|
454
|
+
// The imported working copy replaces the staged download; the raw duplicate is no longer needed.
|
|
455
|
+
await rm(rawPath, { force: true }).catch(() => undefined);
|
|
456
|
+
return { state, resume: "restored" };
|
|
457
|
+
}
|
|
458
|
+
catch (error) {
|
|
459
|
+
signal?.throwIfAborted();
|
|
460
|
+
// Only structurally unrestorable archives get remembered: transient network or disk
|
|
461
|
+
// failures must stay retryable on the next resume.
|
|
462
|
+
if (error instanceof ArchiveNotRestorableError) {
|
|
463
|
+
await atomicJson(failureMarker, { turnId: archive.turnId, failedAt: new Date().toISOString(), error: serializeDiagnosticError(error) }).catch(() => undefined);
|
|
464
|
+
}
|
|
465
|
+
this.diagnostics?.log("warn", "archive.restore_failed", { error: serializeDiagnosticError(error) }, { component: "archive" });
|
|
466
|
+
}
|
|
453
467
|
}
|
|
454
468
|
return await this.syncNativeProjection(input, cwd, state, signal);
|
|
455
469
|
}
|
|
@@ -13,7 +13,7 @@ import { RuntimeDiagnostics, serializeDiagnosticError } from "./diagnostics.js";
|
|
|
13
13
|
import { ownRuntimeInstance, runtimeInstanceDirectory } from "./instance.js";
|
|
14
14
|
import { createDiagnosticConsole } from "./presentation.js";
|
|
15
15
|
import { RuntimeSessionStore } from "./session-store.js";
|
|
16
|
-
import { captureNativeSession, flushNativeSessions, nativeWebSocketTransport } from "./native-sync.js";
|
|
16
|
+
import { captureNativeSession, flushNativeSessions, nativeWebSocketTransport, summarizeNativeSyncErrors } from "./native-sync.js";
|
|
17
17
|
import { serveNativeDaemon } from "./native-ipc.js";
|
|
18
18
|
export function sandboxOutputLevel(value, stream) {
|
|
19
19
|
const level = typeof value === "string" ? value.toLowerCase() : "";
|
|
@@ -220,29 +220,105 @@ export async function runRuntime(config, onState, externalSignal, onDiagnostic)
|
|
|
220
220
|
}
|
|
221
221
|
};
|
|
222
222
|
bridgeTask = runBridge();
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
223
|
+
// Native reconciliation is event-driven: captures and channel reconnects wake it, while failures
|
|
224
|
+
// get bounded exponential retries so idle Runtimes do not scan every few seconds.
|
|
225
|
+
let nativeWakePending = true;
|
|
226
|
+
let nativeWake = null;
|
|
227
|
+
let nativeRetryTimer;
|
|
228
|
+
let nativeRetryDelay = 1000;
|
|
229
|
+
const wakeNativeSync = () => {
|
|
230
|
+
nativeWakePending = true;
|
|
231
|
+
// A new capture may wake healthy work, but it must not cancel a failed receipt's backoff.
|
|
232
|
+
if (!nativeRetryTimer) {
|
|
233
|
+
nativeWake?.();
|
|
234
|
+
nativeWake = null;
|
|
235
|
+
}
|
|
236
|
+
};
|
|
237
|
+
const waitForNativeWake = async () => {
|
|
238
|
+
if (nativeWakePending && !nativeRetryTimer) {
|
|
239
|
+
nativeWakePending = false;
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
242
|
+
await new Promise((resolve) => {
|
|
243
|
+
const finish = () => {
|
|
244
|
+
signal.removeEventListener("abort", finish);
|
|
245
|
+
if (nativeWake === finish)
|
|
246
|
+
nativeWake = null;
|
|
247
|
+
resolve();
|
|
248
|
+
};
|
|
249
|
+
nativeWake = finish;
|
|
250
|
+
signal.addEventListener("abort", finish, { once: true });
|
|
251
|
+
if (signal.aborted)
|
|
252
|
+
finish();
|
|
253
|
+
});
|
|
254
|
+
nativeWakePending = false;
|
|
255
|
+
};
|
|
226
256
|
nativeSyncTask = (async () => {
|
|
227
|
-
|
|
257
|
+
let lastFailureLogAt = 0;
|
|
258
|
+
let failureStartedAt = 0;
|
|
259
|
+
let accumulatedErrors = [];
|
|
228
260
|
while (!signal.aborted) {
|
|
261
|
+
await waitForNativeWake();
|
|
262
|
+
if (signal.aborted)
|
|
263
|
+
break;
|
|
264
|
+
if (!nativeSend)
|
|
265
|
+
continue;
|
|
266
|
+
let failed = false;
|
|
267
|
+
const cycleErrors = [];
|
|
229
268
|
try {
|
|
230
|
-
|
|
231
|
-
await flushNativeSessions(config.spaceId, config.identity, signal, report, nativeWebSocketTransport(config.spaceId, config.identity, nativeSend));
|
|
269
|
+
failed = await flushNativeSessions(config.spaceId, config.identity, signal, (error) => cycleErrors.push(error), nativeWebSocketTransport(config.spaceId, config.identity, nativeSend));
|
|
232
270
|
}
|
|
233
271
|
catch (error) {
|
|
234
|
-
if (!signal.aborted)
|
|
235
|
-
|
|
272
|
+
if (!signal.aborted) {
|
|
273
|
+
cycleErrors.push(error);
|
|
274
|
+
failed = true;
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
if (cycleErrors.length && !signal.aborted) {
|
|
278
|
+
const now = Date.now();
|
|
279
|
+
failureStartedAt ||= now;
|
|
280
|
+
accumulatedErrors.push(...cycleErrors);
|
|
281
|
+
if (!lastFailureLogAt || now - lastFailureLogAt >= 60_000) {
|
|
282
|
+
diagnostics.log("warn", "native.sync_pending", {
|
|
283
|
+
failedStores: accumulatedErrors.length,
|
|
284
|
+
retryInMs: nativeRetryDelay,
|
|
285
|
+
errors: summarizeNativeSyncErrors(accumulatedErrors),
|
|
286
|
+
});
|
|
287
|
+
accumulatedErrors = [];
|
|
288
|
+
lastFailureLogAt = now;
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
else if (!signal.aborted && !failed && failureStartedAt) {
|
|
292
|
+
diagnostics.log("info", "native.sync_recovered", { durationMs: Date.now() - failureStartedAt });
|
|
293
|
+
failureStartedAt = 0;
|
|
294
|
+
lastFailureLogAt = 0;
|
|
295
|
+
accumulatedErrors = [];
|
|
296
|
+
}
|
|
297
|
+
if (failed && !signal.aborted) {
|
|
298
|
+
nativeWakePending = false;
|
|
299
|
+
if (!nativeRetryTimer)
|
|
300
|
+
nativeRetryTimer = setTimeout(() => { nativeRetryTimer = undefined; wakeNativeSync(); }, nativeRetryDelay);
|
|
301
|
+
nativeRetryDelay = Math.min(30_000, nativeRetryDelay * 2);
|
|
302
|
+
}
|
|
303
|
+
else if (!failed) {
|
|
304
|
+
nativeRetryDelay = 1000;
|
|
236
305
|
}
|
|
237
|
-
await delay(5000, undefined, { signal }).catch(() => undefined);
|
|
238
306
|
}
|
|
307
|
+
if (nativeRetryTimer)
|
|
308
|
+
clearTimeout(nativeRetryTimer);
|
|
239
309
|
})();
|
|
310
|
+
closeNativeDaemon = await serveNativeDaemon({ runtimeRoot: store.root, handle: async (request) => {
|
|
311
|
+
const result = { store: await captureNativeSession(request) };
|
|
312
|
+
if (result.store)
|
|
313
|
+
wakeNativeSync();
|
|
314
|
+
return result;
|
|
315
|
+
} });
|
|
240
316
|
await serveRuntime({
|
|
241
317
|
spaceId: config.spaceId, cwd: config.root, url: url.toString(), capabilities,
|
|
242
318
|
harnesses: config.executables, runtimeId: status.runtimeId, diagnostics, token, signal, store,
|
|
243
319
|
onReady: () => update({ harnessConnected: true }),
|
|
244
320
|
onDisconnected: () => { nativeSend = null; update({ harnessConnected: false }); },
|
|
245
|
-
onNativeChannel: (send) => { nativeSend = send; },
|
|
321
|
+
onNativeChannel: (send) => { nativeSend = send; wakeNativeSync(); },
|
|
246
322
|
});
|
|
247
323
|
}
|
|
248
324
|
catch (error) {
|