@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
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
2
|
import { createReadStream } from "node:fs";
|
|
3
3
|
import { mkdir, readFile, readdir, stat, rm } from "node:fs/promises";
|
|
4
|
-
import { join } from "node:path";
|
|
4
|
+
import { basename, join } from "node:path";
|
|
5
5
|
import { nativeTurnCompleteSchema, nativeTurnStartSchema, nativeTurnProgressSchema, harnessArchiveIndexSchema } from "@neta-art/cohub";
|
|
6
|
-
import { RuntimeArchiveStore, atomicRuntimeJson } from "./archive-store.js";
|
|
6
|
+
import { ArchiveCaptureIntegrityError, RuntimeArchiveStore, atomicRuntimeJson } from "./archive-store.js";
|
|
7
|
+
import { codexRolloutCachePath, codexRolloutIdOf, codexRolloutBytes, ensurePlainCodexRollout } from "./native-transcript.js";
|
|
7
8
|
import { findRuntimeNativeSession } from "./session-store.js";
|
|
8
9
|
import { withRuntimeSpaceBindingsLock } from "./space-binding.js";
|
|
9
10
|
const missing = (error) => error.code === "ENOENT";
|
|
@@ -23,6 +24,26 @@ async function readJson(path) {
|
|
|
23
24
|
throw error;
|
|
24
25
|
}
|
|
25
26
|
}
|
|
27
|
+
/** Whole-Turn archive checkpoints only; native offsets never become cloud fork anchors. */
|
|
28
|
+
function matchAnchor(turn, anchors) {
|
|
29
|
+
return anchors.filter((anchor) => anchor.sizeBytes >= turn.contentEndBytes && turn.boundaries[anchor.sizeBytes] === anchor.sha256);
|
|
30
|
+
}
|
|
31
|
+
/** Archive checkpoints of one managed session, optionally bounded by a byte prefix. */
|
|
32
|
+
async function collectArchiveAnchors(runtimeRoot, managed, throughBytes = Number.POSITIVE_INFINITY) {
|
|
33
|
+
const anchors = [];
|
|
34
|
+
const versions = join(runtimeRoot, "archives", "versions");
|
|
35
|
+
const names = await readdir(versions).catch((error) => { if (missing(error))
|
|
36
|
+
return []; throw error; });
|
|
37
|
+
for (const name of names) {
|
|
38
|
+
if (!name.endsWith(".json"))
|
|
39
|
+
continue;
|
|
40
|
+
const version = harnessArchiveIndexSchema.parse(await readJson(join(versions, name)));
|
|
41
|
+
if (version.sessionId === managed.sessionId && version.harness === managed.harness && version.nativeSessionId === managed.nativeSessionId && version.sizeBytes <= throughBytes) {
|
|
42
|
+
anchors.push({ turnId: version.turnId, sizeBytes: version.sizeBytes, sha256: version.sha256 });
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
return anchors;
|
|
46
|
+
}
|
|
26
47
|
/** Turn receipts are append-only. Capture never waits for the network; network ACKs live in separate files. */
|
|
27
48
|
export class NativeSyncStore {
|
|
28
49
|
options;
|
|
@@ -50,6 +71,7 @@ export class NativeSyncStore {
|
|
|
50
71
|
acknowledgementPath(id) { return join(this.root, "acknowledged", `${id}.json`); }
|
|
51
72
|
pendingPath(id) { return join(this.root, "pending", `${id}.json`); }
|
|
52
73
|
requestPath(id) { return join(this.root, "requests", `${id}.json`); }
|
|
74
|
+
retryPath(id) { return join(this.root, "retries", `${id}.json`); }
|
|
53
75
|
cloudBindingPath(id) { return join(this.root, "bindings", `${id}.json`); }
|
|
54
76
|
async binding() {
|
|
55
77
|
const value = await readJson(this.bindingPath());
|
|
@@ -57,10 +79,31 @@ export class NativeSyncStore {
|
|
|
57
79
|
throw new Error("Native binding mismatch");
|
|
58
80
|
return value;
|
|
59
81
|
}
|
|
60
|
-
async initialize(path, transcript) {
|
|
82
|
+
async initialize(path, transcript, signal) {
|
|
61
83
|
const existing = await readJson(this.bindingPath());
|
|
62
84
|
if (existing) {
|
|
63
85
|
const binding = await this.binding();
|
|
86
|
+
// Codex rollover keeps the thread id but moves appends to a fresh rollout that references
|
|
87
|
+
// the old one as its history base. Follow the reference instead of failing — matched by
|
|
88
|
+
// rollout id, so archiving or compressing the ancestor still migrates cleanly. When the
|
|
89
|
+
// previous rollout is the leaf itself (the same file merely moved or compressed), the
|
|
90
|
+
// byte boundary stays valid: only the path changes.
|
|
91
|
+
const previousRolloutId = codexRolloutIdOf(basename(binding.path));
|
|
92
|
+
const lineageRolloutIds = transcript.lineageRolloutIds ?? [];
|
|
93
|
+
if (binding.path !== path && previousRolloutId && lineageRolloutIds.includes(previousRolloutId)) {
|
|
94
|
+
const sameRollout = lineageRolloutIds.at(-1) === previousRolloutId;
|
|
95
|
+
let moved = { ...binding, path };
|
|
96
|
+
if (!sameRollout) {
|
|
97
|
+
// Byte offsets from the old rollout no longer address the new one; the settled
|
|
98
|
+
// boundary moves into the ancestor record and the binding restarts at the leaf.
|
|
99
|
+
moved = { ...moved, throughBytes: 0, anchors: [] };
|
|
100
|
+
if (binding.throughBytes > 0)
|
|
101
|
+
moved.ancestor = { rolloutId: previousRolloutId, throughBytes: binding.throughBytes, anchors: binding.anchors };
|
|
102
|
+
}
|
|
103
|
+
await atomicRuntimeJson(join(this.options.runtimeRoot, "native-owners", `${hash(path)}.json`), { path, nativeSessionId: binding.nativeSessionId });
|
|
104
|
+
await atomicRuntimeJson(this.bindingPath(), moved);
|
|
105
|
+
return moved;
|
|
106
|
+
}
|
|
64
107
|
if (binding.path !== path)
|
|
65
108
|
throw new Error("Native path changed; original binding retained");
|
|
66
109
|
return binding;
|
|
@@ -71,6 +114,42 @@ export class NativeSyncStore {
|
|
|
71
114
|
if (managed) {
|
|
72
115
|
if (managed.pendingTurnId)
|
|
73
116
|
throw new Error("Reconcile the managed Turn before native continuation");
|
|
117
|
+
// A Codex rollover before the first native capture: the managed projection lives on an
|
|
118
|
+
// ancestor rollout now. Its byte boundary addresses the ancestor, so express it as the
|
|
119
|
+
// ancestor record instead of validating it against the new leaf.
|
|
120
|
+
const managedRolloutId = codexRolloutIdOf(basename(managed.path));
|
|
121
|
+
const lineageRolloutIds = transcript.lineageRolloutIds ?? [];
|
|
122
|
+
const managedInLineage = this.options.harness === "codex" && managedRolloutId != null && lineageRolloutIds.slice(0, -1).includes(managedRolloutId);
|
|
123
|
+
if (managedInLineage && managed.throughTurnId) {
|
|
124
|
+
anchors.push(...await collectArchiveAnchors(this.options.runtimeRoot, managed));
|
|
125
|
+
// The managed projection is the whole ancestor rollout: verify its bytes still match the
|
|
126
|
+
// recorded checksum, measured on the logical JSONL (decompressed when the file is cold).
|
|
127
|
+
const ancestorIndex = lineageRolloutIds.indexOf(managedRolloutId);
|
|
128
|
+
const ancestorPath = transcript.lineagePaths?.[ancestorIndex] ?? managed.path;
|
|
129
|
+
const checksum = createHash("sha256");
|
|
130
|
+
let ancestorBytes = 0;
|
|
131
|
+
for await (const bytes of codexRolloutBytes(ancestorPath, null, signal)) {
|
|
132
|
+
checksum.update(bytes);
|
|
133
|
+
ancestorBytes += bytes.length;
|
|
134
|
+
}
|
|
135
|
+
if (!managed.checksum)
|
|
136
|
+
throw new Error("Managed rollout has no checksum to validate its settled boundary");
|
|
137
|
+
if (checksum.digest("hex") !== managed.checksum)
|
|
138
|
+
throw new Error("Runtime history prefix changed");
|
|
139
|
+
// The verified whole-file boundary is a synthetic anchor, mirroring the regular first
|
|
140
|
+
// binding: trailing records after the last Turn (e.g. usage) make contentEndBytes
|
|
141
|
+
// undershoot the file size, and matchAnchor resolves through `boundaries`.
|
|
142
|
+
anchors.push({ turnId: managed.throughTurnId, sizeBytes: ancestorBytes, sha256: managed.checksum });
|
|
143
|
+
const binding = { version: 1, identity: this.options.identity, spaceId: this.options.spaceId, harness: this.options.harness,
|
|
144
|
+
nativeSessionId: transcript.nativeSessionId, instanceKey: this.options.instanceKey, path, originSessionId: nativeStableId(`${this.root}:archive`),
|
|
145
|
+
sessionId: managed.sessionId, throughTurnId: managed.throughTurnId, throughBytes: 0, anchors: [],
|
|
146
|
+
ancestor: { rolloutId: managedRolloutId, throughBytes: ancestorBytes, anchors } };
|
|
147
|
+
// Persist like any first binding: the owner marker keeps the managed Runtime out of this
|
|
148
|
+
// leaf file, and flush/status read binding.json before touching any receipt.
|
|
149
|
+
await atomicRuntimeJson(join(this.options.runtimeRoot, "native-owners", `${hash(path)}.json`), { path, nativeSessionId: binding.nativeSessionId });
|
|
150
|
+
await atomicRuntimeJson(this.bindingPath(), binding);
|
|
151
|
+
return binding;
|
|
152
|
+
}
|
|
74
153
|
if (managed.throughTurnId) {
|
|
75
154
|
const index = await readJson(join(this.options.runtimeRoot, "archives", "versions", `${managed.throughTurnId}.json`));
|
|
76
155
|
if (index) {
|
|
@@ -91,17 +170,7 @@ export class NativeSyncStore {
|
|
|
91
170
|
throughBytes = (await stat(path)).size;
|
|
92
171
|
anchors.push({ turnId: managed.throughTurnId, sizeBytes: throughBytes, sha256: managed.checksum });
|
|
93
172
|
}
|
|
94
|
-
|
|
95
|
-
const names = await readdir(versions).catch((error) => { if (missing(error))
|
|
96
|
-
return []; throw error; });
|
|
97
|
-
for (const name of names) {
|
|
98
|
-
if (!name.endsWith(".json"))
|
|
99
|
-
continue;
|
|
100
|
-
const version = harnessArchiveIndexSchema.parse(await readJson(join(versions, name)));
|
|
101
|
-
if (version.sessionId === managed.sessionId && version.harness === managed.harness && version.nativeSessionId === managed.nativeSessionId && version.sizeBytes <= throughBytes) {
|
|
102
|
-
anchors.push({ turnId: version.turnId, sizeBytes: version.sizeBytes, sha256: version.sha256 });
|
|
103
|
-
}
|
|
104
|
-
}
|
|
173
|
+
anchors.push(...await collectArchiveAnchors(this.options.runtimeRoot, managed, throughBytes));
|
|
105
174
|
}
|
|
106
175
|
}
|
|
107
176
|
const binding = { version: 1, identity: this.options.identity, spaceId: this.options.spaceId, harness: this.options.harness,
|
|
@@ -113,10 +182,11 @@ export class NativeSyncStore {
|
|
|
113
182
|
return binding;
|
|
114
183
|
}
|
|
115
184
|
async capture(path, transcript, context = {}) {
|
|
185
|
+
const signal = context.signal;
|
|
116
186
|
if (transcript.nativeSessionId !== this.options.nativeSessionId)
|
|
117
187
|
throw new Error("Native session identity mismatch");
|
|
118
188
|
await withRuntimeSpaceBindingsLock(async () => {
|
|
119
|
-
const binding = await this.initialize(path, transcript);
|
|
189
|
+
const binding = await this.initialize(path, transcript, signal);
|
|
120
190
|
if (binding.throughBytes > 0) {
|
|
121
191
|
const anchor = binding.anchors.find((entry) => entry.sizeBytes === binding.throughBytes);
|
|
122
192
|
if (!anchor || transcript.prefixes.get(binding.throughBytes) !== anchor.sha256)
|
|
@@ -125,10 +195,14 @@ export class NativeSyncStore {
|
|
|
125
195
|
let parentKey = null;
|
|
126
196
|
let parentCloudTurnId = binding.throughTurnId;
|
|
127
197
|
let knownBoundary = binding.throughBytes === 0;
|
|
198
|
+
const ancestor = binding.ancestor;
|
|
199
|
+
const leafRolloutId = transcript.lineageRolloutIds?.at(-1);
|
|
128
200
|
for (const turn of transcript.turns) {
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
201
|
+
// Lineage Turns keep their own rollout's offsets; only Turns from the binding's rollout
|
|
202
|
+
// (the leaf, however it is now represented on disk) address the binding's byte boundary.
|
|
203
|
+
const inLeafRollout = !turn.rolloutId || turn.rolloutId === leafRolloutId;
|
|
204
|
+
if (inLeafRollout && turn.startBytes < binding.throughBytes) {
|
|
205
|
+
const matches = matchAnchor(turn, binding.anchors ?? []);
|
|
132
206
|
if (new Set(matches.map((anchor) => anchor.turnId)).size > 1)
|
|
133
207
|
throw new Error("Ambiguous Runtime Turn boundary");
|
|
134
208
|
parentCloudTurnId = matches[0]?.turnId ?? null;
|
|
@@ -142,28 +216,65 @@ export class NativeSyncStore {
|
|
|
142
216
|
parentKey = null;
|
|
143
217
|
continue;
|
|
144
218
|
}
|
|
145
|
-
if (!parentKey && !knownBoundary)
|
|
146
|
-
throw new Error("Native continuation is not at a complete Runtime Turn boundary");
|
|
147
219
|
const turnId = this.turnId(turn.key);
|
|
148
220
|
const old = await readJson(this.receiptPath(turnId));
|
|
149
221
|
if (old?.result) {
|
|
150
222
|
if (turn.contentEndBytes < (old.contentEndBytes ?? old.endBytes) || JSON.stringify(turn.userContent) !== JSON.stringify(old.userContent) || turn.result && JSON.stringify(nativeTurnCompleteSchema.parse(turn.result)) !== JSON.stringify(old.result)) {
|
|
151
223
|
throw new Error("Native branch is inside a settled Turn; only whole-Turn forks are supported");
|
|
152
224
|
}
|
|
225
|
+
// Receipts written before lineage support lack the merged order; backfill it from the immutable chain.
|
|
226
|
+
if (turn.sequence != null && old.sequence == null)
|
|
227
|
+
await atomicRuntimeJson(this.receiptPath(turnId), { ...old, sequence: turn.sequence });
|
|
153
228
|
parentKey = turn.key;
|
|
154
229
|
continue;
|
|
155
230
|
}
|
|
231
|
+
// After a rollover, ancestor Turns below the superseded rollout's settled boundary are
|
|
232
|
+
// already durable cloud history (the retained ancestor record); anything above it is
|
|
233
|
+
// new native work — including Turns whose pending receipts still need finishing.
|
|
234
|
+
if (ancestor && turn.rolloutId === ancestor.rolloutId && turn.contentEndBytes <= ancestor.throughBytes) {
|
|
235
|
+
const matches = matchAnchor(turn, ancestor.anchors);
|
|
236
|
+
if (new Set(matches.map((anchor) => anchor.turnId)).size > 1)
|
|
237
|
+
throw new Error("Ambiguous Runtime Turn boundary");
|
|
238
|
+
parentCloudTurnId = matches[0]?.turnId ?? null;
|
|
239
|
+
knownBoundary = matches.length > 0;
|
|
240
|
+
parentKey = null;
|
|
241
|
+
continue;
|
|
242
|
+
}
|
|
243
|
+
if (!parentKey && !knownBoundary)
|
|
244
|
+
throw new Error("Native continuation is not at a complete Runtime Turn boundary");
|
|
156
245
|
const result = turn.result ? nativeTurnCompleteSchema.parse(turn.result) : null;
|
|
157
246
|
const receipt = { version: 1, turnId, key: turn.key, parentKey, parentCloudTurnId: parentKey ? null : parentCloudTurnId,
|
|
158
247
|
userContent: turn.userContent, startedAt: turn.startedAt, endBytes: turn.endBytes, contentEndBytes: turn.contentEndBytes, result,
|
|
248
|
+
...(turn.sequence != null ? { sequence: turn.sequence } : {}),
|
|
159
249
|
...(context.sessionStartedAt ? { sessionStartedAt: context.sessionStartedAt } : {}),
|
|
160
250
|
...(context.origin ? { origin: context.origin } : {}),
|
|
161
251
|
...(!result ? { progress: nativeTurnProgressSchema.parse({ revision: turn.endBytes, messages: turn.messages }) } : {}) };
|
|
162
252
|
if (old && (JSON.stringify(old.userContent) !== JSON.stringify(receipt.userContent) || old.parentKey !== receipt.parentKey))
|
|
163
253
|
throw new Error("Native Turn changed; original receipt retained");
|
|
164
254
|
// Capture immutable native bytes before publishing the completed receipt. Subsequent Turns may change the source.
|
|
165
|
-
if (result)
|
|
166
|
-
|
|
255
|
+
if (result) {
|
|
256
|
+
const cache = join(this.options.runtimeRoot, "native", "cache", "rollouts");
|
|
257
|
+
const source = turn.path ?? path;
|
|
258
|
+
const state = { sessionId: binding.originSessionId, harness: binding.harness, nativeSessionId: binding.nativeSessionId, sizeBytes: turn.endBytes, expectedChecksum: turn.sha256 };
|
|
259
|
+
const stage = async () => this.archives.stage({ ...state, path: await ensurePlainCodexRollout(source, cache, signal) }, turnId);
|
|
260
|
+
try {
|
|
261
|
+
await stage();
|
|
262
|
+
}
|
|
263
|
+
catch (error) {
|
|
264
|
+
// A corrupted `.zst` cache copy (bad checksum, truncation) is rebuilt once. A
|
|
265
|
+
// mismatch against an already-saved index is not a cache problem: that index is
|
|
266
|
+
// immutable, so rebuilding would only repeat a full decompression before failing.
|
|
267
|
+
if (!source.endsWith(".zst") || !(error instanceof ArchiveCaptureIntegrityError))
|
|
268
|
+
throw error;
|
|
269
|
+
const savedIndex = await readJson(join(this.archives.root, "versions", `${turnId}.json`)).catch(() => { throw error; });
|
|
270
|
+
if (!savedIndex) {
|
|
271
|
+
await rm(codexRolloutCachePath(source, cache), { force: true });
|
|
272
|
+
await stage();
|
|
273
|
+
}
|
|
274
|
+
else
|
|
275
|
+
throw error;
|
|
276
|
+
}
|
|
277
|
+
}
|
|
167
278
|
if (JSON.stringify(old) !== JSON.stringify(receipt)) {
|
|
168
279
|
// The pending index precedes the receipt, so a crash cannot silently strand an unacknowledged Turn.
|
|
169
280
|
await atomicRuntimeJson(this.pendingPath(turnId), { turnId });
|
|
@@ -191,7 +302,7 @@ export class NativeSyncStore {
|
|
|
191
302
|
throw new Error("Native receipt is corrupt; original retained");
|
|
192
303
|
receipts.push(receipt);
|
|
193
304
|
}
|
|
194
|
-
return receipts.sort((a, b) => a.endBytes - b.endBytes || a.turnId.localeCompare(b.turnId));
|
|
305
|
+
return receipts.sort((a, b) => (a.sequence ?? Infinity) - (b.sequence ?? Infinity) || a.endBytes - b.endBytes || a.turnId.localeCompare(b.turnId));
|
|
195
306
|
}
|
|
196
307
|
async status() {
|
|
197
308
|
const binding = await this.binding();
|
|
@@ -224,6 +335,9 @@ export class NativeSyncStore {
|
|
|
224
335
|
if (processed.has(receipt.key))
|
|
225
336
|
return false;
|
|
226
337
|
processed.add(receipt.key);
|
|
338
|
+
const retry = await readJson(this.retryPath(receipt.turnId));
|
|
339
|
+
if (retry && retry.nextAt > Date.now())
|
|
340
|
+
return false;
|
|
227
341
|
let parent = null;
|
|
228
342
|
if (receipt.parentKey) {
|
|
229
343
|
const parentId = this.turnId(receipt.parentKey);
|
|
@@ -285,6 +399,7 @@ export class NativeSyncStore {
|
|
|
285
399
|
throw new Error("Native artifacts are pending; completion replays later");
|
|
286
400
|
}
|
|
287
401
|
await rm(backoffPath, { force: true });
|
|
402
|
+
await rm(this.retryPath(receipt.turnId), { force: true });
|
|
288
403
|
await atomicRuntimeJson(this.acknowledgementPath(receipt.turnId), remote);
|
|
289
404
|
await rm(this.pendingPath(receipt.turnId), { force: true });
|
|
290
405
|
return true;
|
|
@@ -296,6 +411,11 @@ export class NativeSyncStore {
|
|
|
296
411
|
await visit(receipt);
|
|
297
412
|
}
|
|
298
413
|
catch (error) {
|
|
414
|
+
const previous = await readJson(this.retryPath(receipt.turnId));
|
|
415
|
+
const attempt = Math.min((previous?.attempt ?? 0) + 1, 6);
|
|
416
|
+
const base = Math.min(30_000, 1_000 * 2 ** (attempt - 1));
|
|
417
|
+
const jitter = base * (0.8 + Math.random() * 0.4);
|
|
418
|
+
await atomicRuntimeJson(this.retryPath(receipt.turnId), { attempt, nextAt: Date.now() + Math.round(jitter) });
|
|
299
419
|
failures.push(error);
|
|
300
420
|
}
|
|
301
421
|
}
|
|
@@ -303,6 +423,7 @@ export class NativeSyncStore {
|
|
|
303
423
|
// Resolve Session identities before the archive dependency walk. A fork baseline must not
|
|
304
424
|
// wait for an unrelated parent Session's failed upload. Immutable local versions stay untouched.
|
|
305
425
|
const archivePending = join(this.archives.root, "pending");
|
|
426
|
+
let archiveBlocked = false;
|
|
306
427
|
const indexes = await readdir(archivePending).catch((error) => { if (missing(error))
|
|
307
428
|
return []; throw error; });
|
|
308
429
|
for (const name of indexes) {
|
|
@@ -311,6 +432,11 @@ export class NativeSyncStore {
|
|
|
311
432
|
try {
|
|
312
433
|
const path = join(archivePending, name);
|
|
313
434
|
const index = harnessArchiveIndexSchema.parse(await readJson(path));
|
|
435
|
+
const retry = await readJson(this.retryPath(index.turnId));
|
|
436
|
+
if (retry && retry.nextAt > Date.now()) {
|
|
437
|
+
archiveBlocked = true;
|
|
438
|
+
continue;
|
|
439
|
+
}
|
|
314
440
|
const resolved = await this.cloudArchive(index);
|
|
315
441
|
if (JSON.stringify(index) !== JSON.stringify(resolved))
|
|
316
442
|
await atomicRuntimeJson(path, resolved);
|
|
@@ -320,7 +446,8 @@ export class NativeSyncStore {
|
|
|
320
446
|
}
|
|
321
447
|
}
|
|
322
448
|
this.archiveFailure = undefined;
|
|
323
|
-
|
|
449
|
+
if (!archiveBlocked)
|
|
450
|
+
await this.archives.flush(signal);
|
|
324
451
|
if (failures.length)
|
|
325
452
|
throw failures[0];
|
|
326
453
|
if (this.archiveFailure)
|
|
@@ -31,6 +31,11 @@ export declare function discoverNativeImportCandidates(root: string, harnesses:
|
|
|
31
31
|
path: string;
|
|
32
32
|
message: string;
|
|
33
33
|
}[];
|
|
34
|
+
superseded: {
|
|
35
|
+
path: string;
|
|
36
|
+
nativeSessionId: string;
|
|
37
|
+
message: string;
|
|
38
|
+
}[];
|
|
34
39
|
}>;
|
|
35
40
|
export declare function nativeArchiveTransport(spaceId: string, identity: string): Pick<NativeSyncTransport, "prepareRuntimeArchive" | "commitRuntimeArchive" | "getRuntimeArchive">;
|
|
36
41
|
export declare function readNativeSyncConfig(runtimeRoot: string, identity: string): Promise<NativeSyncConfig | null>;
|
|
@@ -44,7 +49,17 @@ export declare function captureNativeSession(input: {
|
|
|
44
49
|
leafId?: string | null;
|
|
45
50
|
sessionStartedAt?: string;
|
|
46
51
|
origin?: "local_import";
|
|
52
|
+
signal?: AbortSignal;
|
|
47
53
|
}): Promise<NativeSyncStore | null>;
|
|
48
54
|
/** The existing Runtime supervisor retries receipts even after the original terminal has exited. */
|
|
49
55
|
export declare function nativeWebSocketTransport(spaceId: string, identity: string, send: (event: NativeRuntimeEvent) => Promise<unknown>): NativeSyncTransport;
|
|
50
|
-
export declare
|
|
56
|
+
export declare const NATIVE_SYNC_SESSION_CONCURRENCY = 4;
|
|
57
|
+
export declare function runNativeSyncPool<T>(items: readonly T[], concurrency: number, signal: AbortSignal, processItem: (item: T) => Promise<void>): Promise<void>;
|
|
58
|
+
export declare function summarizeNativeSyncErrors(errors: unknown[]): {
|
|
59
|
+
name: string;
|
|
60
|
+
message: string;
|
|
61
|
+
code?: string;
|
|
62
|
+
status?: number;
|
|
63
|
+
count: number;
|
|
64
|
+
}[];
|
|
65
|
+
export declare function flushNativeSessions(spaceId: string, identity: string, signal: AbortSignal, report: (error: unknown) => void, transportOverride?: NativeSyncTransport): Promise<boolean>;
|
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
import { readFile, readdir, stat } from "node:fs/promises";
|
|
2
2
|
import { homedir } from "node:os";
|
|
3
|
-
import { join } from "node:path";
|
|
3
|
+
import { basename, join } from "node:path";
|
|
4
4
|
import { createClient } from "../client.js";
|
|
5
5
|
import { currentIdentityKey } from "../space.js";
|
|
6
6
|
import { canonicalRuntimeRoot, getRuntimeSpaceBinding } from "./space-binding.js";
|
|
7
|
-
import { readNativeTranscript, readNativeTranscriptHeader } from "./native-transcript.js";
|
|
7
|
+
import { readNativeTranscript, readNativeTranscriptHeader, codexSessionsDirectory, codexArchivedSessionsDirectory, codexRolloutIdOf } from "./native-transcript.js";
|
|
8
8
|
import { findRuntimeNativeSession } from "./session-store.js";
|
|
9
9
|
import { listNativeSyncStores, nativeIdentityHash, NativeSyncStore } from "./native-sync-store.js";
|
|
10
|
+
import { serializeDiagnosticError } from "./diagnostics.js";
|
|
10
11
|
export const nativeRuntimeRoot = (spaceId) => join(homedir(), ".local", "state", "cohub", "runtime", spaceId);
|
|
11
12
|
export const nativeSyncConfigPath = (runtimeRoot, identity) => join(runtimeRoot, "native", nativeIdentityHash(identity), "config.json");
|
|
12
13
|
const piSessionDirectory = (cwd) => {
|
|
@@ -17,10 +18,11 @@ const piSessionDirectory = (cwd) => {
|
|
|
17
18
|
const safePath = `--${cwd.replace(/^[/\\\\]/, "").replace(/[/\\\\:]/g, "-")}--`;
|
|
18
19
|
return join(agentDir, "sessions", safePath);
|
|
19
20
|
};
|
|
20
|
-
const codexSessionDirectory = () => join(process.env.CODEX_HOME?.trim() || join(homedir(), ".codex"), "sessions");
|
|
21
21
|
const LARGE_TRANSCRIPT_BYTES = 64 * 1024 * 1024;
|
|
22
22
|
async function nativeTranscriptPaths(root, harness, signal) {
|
|
23
|
-
|
|
23
|
+
// Codex keeps rollouts active or archived, plain or `.zst`-compressed; Pi writes plain JSONL.
|
|
24
|
+
const pending = harness === "pi" ? [piSessionDirectory(root)] : [codexSessionsDirectory(), codexArchivedSessionsDirectory()];
|
|
25
|
+
const accepts = harness === "pi" ? (name) => name.endsWith(".jsonl") : (name) => name.endsWith(".jsonl") || name.endsWith(".jsonl.zst");
|
|
24
26
|
const paths = [];
|
|
25
27
|
const errors = [];
|
|
26
28
|
while (pending.length) {
|
|
@@ -38,7 +40,7 @@ async function nativeTranscriptPaths(root, harness, signal) {
|
|
|
38
40
|
const path = join(directory, entry.name);
|
|
39
41
|
if (entry.isDirectory())
|
|
40
42
|
pending.push(path);
|
|
41
|
-
else if (entry.isFile() && entry.name
|
|
43
|
+
else if (entry.isFile() && accepts(entry.name))
|
|
42
44
|
paths.push(path);
|
|
43
45
|
}
|
|
44
46
|
}
|
|
@@ -48,10 +50,68 @@ async function nativeTranscriptPaths(root, harness, signal) {
|
|
|
48
50
|
export async function discoverNativeImportCandidates(root, harnesses, options = {}) {
|
|
49
51
|
const candidates = [];
|
|
50
52
|
const errors = [];
|
|
53
|
+
/** Same-thread rollouts left out of import; they may carry Turns no candidate contains. */
|
|
54
|
+
const superseded = [];
|
|
51
55
|
for (const harness of harnesses) {
|
|
52
56
|
options.signal?.throwIfAborted();
|
|
53
57
|
const scanned = await nativeTranscriptPaths(root, harness, options.signal);
|
|
54
58
|
errors.push(...scanned.errors);
|
|
59
|
+
// One rollout chain per thread: every rollout referenced by a same-thread file is
|
|
60
|
+
// superseded by it (rollover / revert); a parent referenced only from a different thread
|
|
61
|
+
// (fork) still owns its own Turns and stays importable. Headers are read once and reused.
|
|
62
|
+
const headers = new Map();
|
|
63
|
+
const absorbedRollouts = new Set();
|
|
64
|
+
if (harness === "codex") {
|
|
65
|
+
// Bounded concurrency: one descriptor per open file across the global Codex sessions tree.
|
|
66
|
+
let next = 0;
|
|
67
|
+
const readHeaders = async () => {
|
|
68
|
+
for (;;) {
|
|
69
|
+
const path = scanned.paths[next++];
|
|
70
|
+
if (!path)
|
|
71
|
+
return;
|
|
72
|
+
try {
|
|
73
|
+
headers.set(path, await readNativeTranscriptHeader(path, harness, options.signal));
|
|
74
|
+
}
|
|
75
|
+
catch (error) {
|
|
76
|
+
if (options.signal?.aborted)
|
|
77
|
+
throw error;
|
|
78
|
+
errors.push({ harness, path, message: error instanceof Error ? error.message : String(error) });
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
};
|
|
82
|
+
await Promise.all(Array.from({ length: Math.min(16, scanned.paths.length) }, readHeaders));
|
|
83
|
+
const threadByRollout = new Map();
|
|
84
|
+
for (const header of headers.values())
|
|
85
|
+
if (header.rolloutId)
|
|
86
|
+
threadByRollout.set(header.rolloutId, header.nativeSessionId);
|
|
87
|
+
for (const header of headers.values()) {
|
|
88
|
+
const base = header.historyBase;
|
|
89
|
+
if (base && threadByRollout.get(base.rolloutId) === header.nativeSessionId)
|
|
90
|
+
absorbedRollouts.add(base.rolloutId);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
// One candidate per thread: several same-thread rollouts can survive absorption (two
|
|
94
|
+
// siblings reverting from the same parent). The newest by rollout filename timestamp wins;
|
|
95
|
+
// the losers are reported, never imported twice under one nativeSessionId.
|
|
96
|
+
const supersededSiblings = new Set();
|
|
97
|
+
if (harness === "codex") {
|
|
98
|
+
const byThread = new Map();
|
|
99
|
+
for (const [path, header] of headers) {
|
|
100
|
+
if (absorbedRollouts.has(header.rolloutId ?? ""))
|
|
101
|
+
continue;
|
|
102
|
+
const list = byThread.get(header.nativeSessionId) ?? [];
|
|
103
|
+
list.push({ path, rolloutId: header.rolloutId ?? "", sortKey: basename(path) });
|
|
104
|
+
byThread.set(header.nativeSessionId, list);
|
|
105
|
+
}
|
|
106
|
+
for (const list of byThread.values()) {
|
|
107
|
+
if (list.length < 2)
|
|
108
|
+
continue;
|
|
109
|
+
const newest = list.reduce((a, b) => (a.sortKey > b.sortKey ? a : b));
|
|
110
|
+
for (const sibling of list)
|
|
111
|
+
if (sibling !== newest)
|
|
112
|
+
supersededSiblings.add(sibling.path);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
55
115
|
let start = 0;
|
|
56
116
|
while (start < scanned.paths.length) {
|
|
57
117
|
options.signal?.throwIfAborted();
|
|
@@ -60,9 +120,16 @@ export async function discoverNativeImportCandidates(root, harnesses, options =
|
|
|
60
120
|
const concurrency = sizes.some((size) => size > LARGE_TRANSCRIPT_BYTES) ? 1 : 2;
|
|
61
121
|
const batch = await Promise.all(paths.slice(0, concurrency).map(async (path) => {
|
|
62
122
|
try {
|
|
63
|
-
|
|
123
|
+
// Pre-scan failures are already reported; do not read the header again.
|
|
124
|
+
const header = headers.get(path) ?? (harness === "pi" ? await readNativeTranscriptHeader(path, harness, options.signal) : null);
|
|
125
|
+
if (!header)
|
|
126
|
+
return null;
|
|
64
127
|
if (await canonicalRuntimeRoot(header.cwd) !== root)
|
|
65
128
|
return null;
|
|
129
|
+
if (header.rolloutId && absorbedRollouts.has(header.rolloutId))
|
|
130
|
+
return null;
|
|
131
|
+
if (supersededSiblings.has(path))
|
|
132
|
+
return { superseded: { path, nativeSessionId: header.nativeSessionId, message: "Superseded by a newer rollout of the same conversation; its Turns are not imported" } };
|
|
66
133
|
const transcript = await readNativeTranscript(path, harness, { settled: true, signal: options.signal });
|
|
67
134
|
if (transcript.turns.length === 0)
|
|
68
135
|
return { error: { harness, path, message: "Transcript contains no importable Turns" } };
|
|
@@ -77,10 +144,10 @@ export async function discoverNativeImportCandidates(root, harnesses, options =
|
|
|
77
144
|
options.signal?.throwIfAborted();
|
|
78
145
|
if (!result)
|
|
79
146
|
continue;
|
|
80
|
-
if ("
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
147
|
+
if ("superseded" in result)
|
|
148
|
+
superseded.push(result.superseded);
|
|
149
|
+
else if ("error" in result)
|
|
150
|
+
errors.push(result.error);
|
|
84
151
|
else
|
|
85
152
|
candidates.push(result);
|
|
86
153
|
options.onProgress?.({ harness, scanned: start + batch.length, candidates: candidates.length });
|
|
@@ -88,7 +155,7 @@ export async function discoverNativeImportCandidates(root, harnesses, options =
|
|
|
88
155
|
start += concurrency;
|
|
89
156
|
}
|
|
90
157
|
}
|
|
91
|
-
return { candidates, errors };
|
|
158
|
+
return { candidates, errors, superseded };
|
|
92
159
|
}
|
|
93
160
|
export function nativeArchiveTransport(spaceId, identity) {
|
|
94
161
|
const client = createClient().space(spaceId);
|
|
@@ -120,6 +187,10 @@ export async function readNativeSyncConfig(runtimeRoot, identity) {
|
|
|
120
187
|
}
|
|
121
188
|
}
|
|
122
189
|
const nativeStores = new Map();
|
|
190
|
+
const missing = (error) => error.code === "ENOENT";
|
|
191
|
+
/** Canonical alias, but a missing target reports itself instead of failing capture. */
|
|
192
|
+
const canonicalPathOrNull = async (path) => canonicalRuntimeRoot(path).catch((error) => { if (missing(error))
|
|
193
|
+
return null; throw error; });
|
|
123
194
|
/** Local capture only. Neither Pi callbacks nor Codex hooks wait for Cohub's network. */
|
|
124
195
|
export async function captureNativeSession(input) {
|
|
125
196
|
if (process.env.COHUB_TURN_ID || process.env.COHUB_EXECUTION_TOKEN)
|
|
@@ -127,7 +198,11 @@ export async function captureNativeSession(input) {
|
|
|
127
198
|
const identity = currentIdentityKey();
|
|
128
199
|
if (!identity)
|
|
129
200
|
return null;
|
|
130
|
-
|
|
201
|
+
// Pi and Codex create their transcripts lazily, so a pending capture for a file that does
|
|
202
|
+
// not exist yet (or a workspace that has since been removed) is not a sync failure.
|
|
203
|
+
const root = await canonicalPathOrNull(input.cwd);
|
|
204
|
+
if (!root)
|
|
205
|
+
return null;
|
|
131
206
|
const space = await getRuntimeSpaceBinding(root, identity);
|
|
132
207
|
if (!space)
|
|
133
208
|
return null;
|
|
@@ -135,26 +210,36 @@ export async function captureNativeSession(input) {
|
|
|
135
210
|
const config = await readNativeSyncConfig(runtimeRoot, identity);
|
|
136
211
|
if (!config || config.root !== root || config.spaceId !== space.spaceId || !config.harnesses.includes(input.harness))
|
|
137
212
|
return null;
|
|
138
|
-
const path = await
|
|
213
|
+
const path = await canonicalPathOrNull(input.path);
|
|
214
|
+
if (!path)
|
|
215
|
+
return null;
|
|
139
216
|
const transcript = await readNativeTranscript(path, input.harness, input);
|
|
140
|
-
if (await
|
|
217
|
+
if (await canonicalPathOrNull(transcript.cwd) !== root || input.nativeSessionId && transcript.nativeSessionId !== input.nativeSessionId)
|
|
141
218
|
throw new Error("Native transcript belongs to another project or Session");
|
|
219
|
+
// The path stays in the cache key: restored Pi working copies share a native ID but not a file.
|
|
142
220
|
const key = JSON.stringify([identity, space.spaceId, input.harness, transcript.nativeSessionId, path]);
|
|
143
221
|
let store = nativeStores.get(key);
|
|
144
222
|
if (!store) {
|
|
145
223
|
const transport = nativeArchiveTransport(space.spaceId, identity);
|
|
224
|
+
const lineage = input.harness === "codex" ? transcript.lineageRolloutIds ?? [] : [];
|
|
146
225
|
const candidates = (await listNativeSyncStores(runtimeRoot, space.spaceId, identity, transport))
|
|
147
226
|
.filter((candidate) => candidate.options.harness === input.harness && candidate.options.nativeSessionId === transcript.nativeSessionId);
|
|
148
|
-
|
|
149
|
-
|
|
227
|
+
// A Codex rollover moves appends to a fresh rollout that references the old one: a store
|
|
228
|
+
// bound anywhere in the new leaf's lineage is the same conversation, so keep using it —
|
|
229
|
+
// the binding migrates inside capture. Exact-path matches win; lineage matches follow.
|
|
230
|
+
for (const candidate of candidates) {
|
|
231
|
+
const binding = await candidate.binding();
|
|
232
|
+
if (binding.path === path) {
|
|
150
233
|
store = candidate;
|
|
151
234
|
break;
|
|
152
235
|
}
|
|
236
|
+
if (!store && lineage.includes(codexRolloutIdOf(basename(binding.path)) ?? ""))
|
|
237
|
+
store = candidate;
|
|
238
|
+
}
|
|
153
239
|
if (!store) {
|
|
154
240
|
const managed = await findRuntimeNativeSession(runtimeRoot, input.harness, transcript.nativeSessionId, path);
|
|
155
|
-
const managedPath = managed ? await
|
|
156
|
-
|
|
157
|
-
if (candidates.length && managedPath !== path)
|
|
241
|
+
const managedPath = managed ? await canonicalPathOrNull(managed.path) : null;
|
|
242
|
+
if (candidates.length && managedPath !== path && !(managedPath && lineage.includes(codexRolloutIdOf(basename(managedPath)) ?? "")))
|
|
158
243
|
throw new Error("Native path changed; original bindings retained");
|
|
159
244
|
// Restored Pi working copies can share a native ID. Existing Cohub sidecars disambiguate them.
|
|
160
245
|
store = new NativeSyncStore({ runtimeRoot, spaceId: space.spaceId, identity, harness: input.harness, nativeSessionId: transcript.nativeSessionId,
|
|
@@ -164,7 +249,7 @@ export async function captureNativeSession(input) {
|
|
|
164
249
|
nativeStores.delete(nativeStores.keys().next().value ?? "");
|
|
165
250
|
nativeStores.set(key, store);
|
|
166
251
|
}
|
|
167
|
-
await store.capture(path, transcript, { sessionStartedAt: input.sessionStartedAt, origin: input.origin });
|
|
252
|
+
await store.capture(path, transcript, { sessionStartedAt: input.sessionStartedAt, origin: input.origin, signal: input.signal });
|
|
168
253
|
return store;
|
|
169
254
|
}
|
|
170
255
|
/** The existing Runtime supervisor retries receipts even after the original terminal has exited. */
|
|
@@ -178,32 +263,64 @@ export function nativeWebSocketTransport(spaceId, identity, send) {
|
|
|
178
263
|
heartbeatNativeTurn: async (sessionId, turnId) => await send({ type: "heartbeat", sessionId, turnId }),
|
|
179
264
|
};
|
|
180
265
|
}
|
|
266
|
+
export const NATIVE_SYNC_SESSION_CONCURRENCY = 4;
|
|
267
|
+
export async function runNativeSyncPool(items, concurrency, signal, processItem) {
|
|
268
|
+
let next = 0;
|
|
269
|
+
const worker = async () => {
|
|
270
|
+
while (!signal.aborted) {
|
|
271
|
+
const index = next++;
|
|
272
|
+
if (index >= items.length)
|
|
273
|
+
return;
|
|
274
|
+
await processItem(items[index]);
|
|
275
|
+
}
|
|
276
|
+
};
|
|
277
|
+
const results = await Promise.allSettled(Array.from({ length: Math.min(items.length, Math.max(1, concurrency)) }, worker));
|
|
278
|
+
const failure = results.find((result) => result.status === "rejected");
|
|
279
|
+
if (failure)
|
|
280
|
+
throw failure.reason;
|
|
281
|
+
}
|
|
282
|
+
export function summarizeNativeSyncErrors(errors) {
|
|
283
|
+
const groups = new Map();
|
|
284
|
+
for (const error of errors) {
|
|
285
|
+
const value = serializeDiagnosticError(error);
|
|
286
|
+
const key = JSON.stringify([value.name, value.code, value.status, value.causeCode, value.message]);
|
|
287
|
+
const group = groups.get(key);
|
|
288
|
+
if (group)
|
|
289
|
+
group.count += 1;
|
|
290
|
+
else
|
|
291
|
+
groups.set(key, { name: value.name ?? "Error", message: value.message, ...(value.code ? { code: value.code } : {}), ...(value.status ? { status: value.status } : {}), count: 1 });
|
|
292
|
+
}
|
|
293
|
+
return [...groups.values()];
|
|
294
|
+
}
|
|
181
295
|
export async function flushNativeSessions(spaceId, identity, signal, report, transportOverride) {
|
|
182
296
|
if (currentIdentityKey() !== identity)
|
|
183
|
-
return;
|
|
297
|
+
return false;
|
|
184
298
|
const runtimeRoot = nativeRuntimeRoot(spaceId);
|
|
185
299
|
const config = await readNativeSyncConfig(runtimeRoot, identity);
|
|
186
300
|
if (!config || config.spaceId !== spaceId)
|
|
187
|
-
return;
|
|
301
|
+
return false;
|
|
188
302
|
const transport = transportOverride;
|
|
189
303
|
if (!transport)
|
|
190
|
-
return;
|
|
304
|
+
return false;
|
|
191
305
|
const stores = await listNativeSyncStores(runtimeRoot, spaceId, identity, transport);
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
const binding = await store.binding();
|
|
195
|
-
if (!config.harnesses.includes(binding.harness))
|
|
196
|
-
continue;
|
|
306
|
+
let failed = false;
|
|
307
|
+
await runNativeSyncPool(stores, NATIVE_SYNC_SESSION_CONCURRENCY, signal, async (store) => {
|
|
197
308
|
try {
|
|
198
|
-
|
|
199
|
-
|
|
309
|
+
signal.throwIfAborted();
|
|
310
|
+
const binding = await store.binding();
|
|
311
|
+
if (!config.harnesses.includes(binding.harness))
|
|
312
|
+
return;
|
|
313
|
+
// Codex hooks trigger captures; re-reading here also closes gaps after reconnects or missed hooks.
|
|
200
314
|
if (binding.harness === "codex")
|
|
201
315
|
await store.capture(binding.path, await readNativeTranscript(binding.path, "codex"));
|
|
202
316
|
await store.flush(AbortSignal.any([signal, AbortSignal.timeout(30_000)]));
|
|
203
317
|
}
|
|
204
318
|
catch (error) {
|
|
205
|
-
if (!signal.aborted)
|
|
319
|
+
if (!signal.aborted) {
|
|
320
|
+
failed = true;
|
|
206
321
|
report(error);
|
|
322
|
+
}
|
|
207
323
|
}
|
|
208
|
-
}
|
|
324
|
+
});
|
|
325
|
+
return failed;
|
|
209
326
|
}
|