@neta-art/cohub-cli 8.1.1 → 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.
@@ -1,5 +1,11 @@
1
- import { createReadStream } from "node:fs";
2
- import { createHash } from "node:crypto";
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
- const stream = createReadStream(path, { signal });
20
- let buffer = Buffer.alloc(0);
21
- try {
22
- for await (const chunk of stream) {
23
- buffer = Buffer.concat([buffer, chunk]);
24
- const newline = buffer.indexOf(10);
25
- if (newline >= 0) {
26
- const value = record(JSON.parse(buffer.subarray(0, newline).toString("utf8")));
27
- const payload = record(value.payload);
28
- const nativeSessionId = harness === "pi" ? text(value.id) : text(payload.id);
29
- const cwd = harness === "pi" ? text(value.cwd) : text(payload.cwd);
30
- if ((harness === "pi" && value.type !== "session") || (harness === "codex" && value.type !== "session_meta") || !nativeSessionId || !cwd)
31
- throw new Error("Invalid native session header");
32
- return { nativeSessionId, cwd };
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
- throw new Error("Native transcript is empty");
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
- stream.destroy();
186
+ await rm(temporary, { force: true });
41
187
  }
42
188
  }
43
- /** Partial trailing records are retried, never parsed or acknowledged as complete. */
44
- export async function readNativeTranscript(path, harness, options = {}) {
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 createReadStream(path, { signal: options.signal })) {
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
- if (!lines.length)
88
- throw new Error("Native transcript is empty");
89
- return { ...(harness === "pi" ? parsePiTranscript(lines, options) : parseCodexTranscript(lines)), prefixes };
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
- function parseCodexTranscript(lines) {
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.history_base || metadata.fork_source)
192
- throw new Error("Codex history references another rollout; retain the original and materialize its full history first");
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 { nativeSessionId: text(metadata.id), cwd: text(metadata.cwd), cloudSessionId: cloudSessionId || undefined, turns };
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
- try {
445
- const restored = await this.archives.restore(archive, rawPath, signal);
446
- Object.assign(state, await importNativeArchive({ source: rawPath, target: path, harness: input.harness, nativeSessionId: restored.nativeSessionId, id, cwd, signal }));
447
- return { state, resume: "restored" };
448
- }
449
- catch (error) {
450
- signal?.throwIfAborted();
451
- this.diagnostics?.log("warn", "archive.restore_failed", { error: serializeDiagnosticError(error) }, { component: "archive" });
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() : "";
@@ -228,15 +228,14 @@ export async function runRuntime(config, onState, externalSignal, onDiagnostic)
228
228
  let nativeRetryDelay = 1000;
229
229
  const wakeNativeSync = () => {
230
230
  nativeWakePending = true;
231
- if (nativeRetryTimer) {
232
- clearTimeout(nativeRetryTimer);
233
- nativeRetryTimer = undefined;
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;
234
235
  }
235
- nativeWake?.();
236
- nativeWake = null;
237
236
  };
238
237
  const waitForNativeWake = async () => {
239
- if (nativeWakePending) {
238
+ if (nativeWakePending && !nativeRetryTimer) {
240
239
  nativeWakePending = false;
241
240
  return;
242
241
  }
@@ -255,7 +254,9 @@ export async function runRuntime(config, onState, externalSignal, onDiagnostic)
255
254
  nativeWakePending = false;
256
255
  };
257
256
  nativeSyncTask = (async () => {
258
- const report = (error) => diagnostics.log("warn", "native.sync_pending", { error: serializeDiagnosticError(error) });
257
+ let lastFailureLogAt = 0;
258
+ let failureStartedAt = 0;
259
+ let accumulatedErrors = [];
259
260
  while (!signal.aborted) {
260
261
  await waitForNativeWake();
261
262
  if (signal.aborted)
@@ -263,17 +264,40 @@ export async function runRuntime(config, onState, externalSignal, onDiagnostic)
263
264
  if (!nativeSend)
264
265
  continue;
265
266
  let failed = false;
267
+ const cycleErrors = [];
266
268
  try {
267
- failed = 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));
268
270
  }
269
271
  catch (error) {
270
272
  if (!signal.aborted) {
271
- report(error);
273
+ cycleErrors.push(error);
272
274
  failed = true;
273
275
  }
274
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
+ }
275
297
  if (failed && !signal.aborted) {
276
- nativeRetryTimer = setTimeout(() => { nativeRetryTimer = undefined; wakeNativeSync(); }, nativeRetryDelay);
298
+ nativeWakePending = false;
299
+ if (!nativeRetryTimer)
300
+ nativeRetryTimer = setTimeout(() => { nativeRetryTimer = undefined; wakeNativeSync(); }, nativeRetryDelay);
277
301
  nativeRetryDelay = Math.min(30_000, nativeRetryDelay * 2);
278
302
  }
279
303
  else if (!failed) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@neta-art/cohub-cli",
3
- "version": "8.1.1",
3
+ "version": "8.2.0",
4
4
  "description": "CLI for Cohub — spaces, sessions, and agent collaboration.",
5
5
  "type": "module",
6
6
  "engines": {