@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.
@@ -115,10 +115,11 @@ export function registerRuntime(program) {
115
115
  },
116
116
  });
117
117
  const candidates = discovered.candidates.filter((candidate) => !options.session || candidate.nativeSessionId === options.session);
118
+ const superseded = discovered.superseded.filter((skip) => !options.session || skip.nativeSessionId === options.session);
118
119
  const result = {
119
120
  spaceId, root, harnesses, candidates, errors: discovered.errors,
120
- imported: 0, failed: [], skipped: [], dryRun: Boolean(options.dryRun),
121
- complete: discovered.errors.length === 0,
121
+ imported: 0, pendingTurns: 0, failed: [], skipped: [], superseded, dryRun: Boolean(options.dryRun),
122
+ complete: discovered.errors.length === 0 && superseded.length === 0,
122
123
  };
123
124
  if (jsonRequested(options) && options.dryRun) {
124
125
  result.complete = result.complete && result.failed.length === 0;
@@ -132,8 +133,13 @@ export function registerRuntime(program) {
132
133
  process.exitCode = 1;
133
134
  if (jsonRequested(options))
134
135
  outJson(result);
135
- else
136
+ else {
136
137
  process.stdout.write("No existing native conversations found\n");
138
+ for (const error of discovered.errors)
139
+ process.stdout.write(`Skipped ${error.path}: ${error.message}\n`);
140
+ for (const skip of superseded)
141
+ process.stdout.write(`Superseded ${skip.path}: ${skip.message}\n`);
142
+ }
137
143
  return;
138
144
  }
139
145
  if (options.dryRun) {
@@ -145,6 +151,8 @@ export function registerRuntime(program) {
145
151
  process.stdout.write(` ${candidate.harness} ${candidate.nativeSessionId} ${candidate.turnCount} Turn${candidate.turnCount === 1 ? "" : "s"} ${candidate.path}\n`);
146
152
  for (const error of discovered.errors)
147
153
  process.stdout.write(`Skipped ${error.path}: ${error.message}\n`);
154
+ for (const skip of superseded)
155
+ process.stdout.write(`Superseded ${skip.path}: ${skip.message}\n`);
148
156
  }
149
157
  if (!result.complete)
150
158
  process.exitCode = 1;
@@ -169,26 +177,43 @@ export function registerRuntime(program) {
169
177
  rl.close();
170
178
  }
171
179
  }
180
+ let processed = 0;
172
181
  for (const candidate of candidates) {
182
+ if (controller.signal.aborted)
183
+ break;
173
184
  try {
174
185
  const response = await requestNativeDaemon({ harness: candidate.harness, cwd: root, path: candidate.path, nativeSessionId: candidate.nativeSessionId,
175
186
  sessionStartedAt: candidate.sessionStartedAt, origin: "local_import", settled: true });
176
187
  applyNativeImportResponse(result, candidate.path, response);
188
+ if (response.ok)
189
+ result.pendingTurns += response.pendingTurns;
177
190
  }
178
191
  catch (error) {
179
- result.failed.push({ path: candidate.path, message: error instanceof Error ? error.message : String(error) });
192
+ if (!controller.signal.aborted)
193
+ result.failed.push({ path: candidate.path, message: error instanceof Error ? error.message : String(error) });
194
+ }
195
+ finally {
196
+ processed += 1;
197
+ if (!jsonRequested(options) && !controller.signal.aborted) {
198
+ process.stderr.write(`\rSubmitted ${result.imported}/${candidates.length} · pending ${result.pendingTurns} Turns`);
199
+ }
180
200
  }
181
201
  }
182
- result.complete = result.complete && result.failed.length === 0;
202
+ const cancelled = controller.signal.aborted;
203
+ result.complete = result.complete && result.failed.length === 0 && !cancelled;
183
204
  if (jsonRequested(options))
184
- outJson(result);
205
+ outJson({ ...result, cancelled, processed });
185
206
  else {
186
- process.stdout.write(`Imported ${result.imported}/${candidates.length} native conversation${candidates.length === 1 ? "" : "s"}\n`);
207
+ if (processed)
208
+ process.stderr.write("\n");
209
+ process.stdout.write(`Submitted ${result.imported}/${candidates.length} native conversation${candidates.length === 1 ? "" : "s"}${cancelled ? " (interrupted)" : ""}\n`);
187
210
  for (const error of [...discovered.errors, ...result.skipped, ...result.failed])
188
211
  process.stdout.write(`Skipped ${error.path}: ${error.message}\n`);
189
- process.stdout.write("Uploads continue in the local Runtime background\n");
212
+ for (const skip of result.superseded)
213
+ process.stdout.write(`Superseded ${skip.path}: ${skip.message}\n`);
214
+ process.stdout.write("Uploads continue in the local Runtime background; use runtime status to check confirmation\n");
190
215
  }
191
- if (!result.complete)
216
+ if (!result.complete && !cancelled)
192
217
  process.exitCode = 1;
193
218
  }
194
219
  catch (cause) {
@@ -15,6 +15,10 @@ export type ArchiveTransport = {
15
15
  signal?: AbortSignal;
16
16
  }): Promise<RuntimeArchivePage>;
17
17
  };
18
+ /** Raised when local capture bytes fail integrity checks (checksum, truncation, boundary); retryable via cache rebuild. */
19
+ export declare class ArchiveCaptureIntegrityError extends Error {
20
+ constructor(message: string);
21
+ }
18
22
  export declare function checksumNativeFile(path: string): Promise<string>;
19
23
  export declare function atomicRuntimeJson(path: string, value: unknown): Promise<void>;
20
24
  /** Immutable local segments are the outbox. Model acknowledgements never remove them. */
@@ -4,6 +4,10 @@ import { mkdir, open, readFile, readdir, rename, rm, stat, link } from "node:fs/
4
4
  import { dirname, join } from "node:path";
5
5
  import { RUNTIME_ARCHIVE_SEGMENT_BYTES, harnessArchiveIndexSchema, validateArchiveBoundary, } from "@neta-art/cohub";
6
6
  const missing = (error) => error?.code === "ENOENT";
7
+ /** Raised when local capture bytes fail integrity checks (checksum, truncation, boundary); retryable via cache rebuild. */
8
+ export class ArchiveCaptureIntegrityError extends Error {
9
+ constructor(message) { super(message); this.name = "ArchiveCaptureIntegrityError"; }
10
+ }
7
11
  const hash = (bytes, algorithm = "sha256") => createHash(algorithm).update(bytes).digest("hex");
8
12
  export async function checksumNativeFile(path) {
9
13
  const digest = createHash("sha256");
@@ -118,7 +122,7 @@ export class RuntimeArchiveStore {
118
122
  if (saved.sessionId !== state.sessionId || saved.harness !== state.harness)
119
123
  throw new Error("Archive identity mismatch");
120
124
  if (state.expectedChecksum && state.expectedChecksum !== saved.sha256)
121
- throw new Error("Native Turn bytes changed; original archive retained");
125
+ throw new ArchiveCaptureIntegrityError("Native Turn bytes changed; original archive retained");
122
126
  const committed = await stat(join(this.root, "ready", `${turnId}.json`)).catch((error) => { if (missing(error))
123
127
  return null; throw error; });
124
128
  if (!committed)
@@ -133,10 +137,10 @@ export class RuntimeArchiveStore {
133
137
  try {
134
138
  const before = await file.stat();
135
139
  if (!before.isFile() || !before.size)
136
- throw new Error("Native archive is empty");
140
+ throw new ArchiveCaptureIntegrityError("Native archive is empty");
137
141
  const sizeBytes = state.sizeBytes ?? before.size;
138
142
  if (!Number.isSafeInteger(sizeBytes) || sizeBytes < 1 || sizeBytes > before.size)
139
- throw new Error("Native archive boundary is unavailable");
143
+ throw new ArchiveCaptureIntegrityError("Native archive boundary is unavailable");
140
144
  const buffer = Buffer.alloc(RUNTIME_ARCHIVE_SEGMENT_BYTES);
141
145
  let offset = 0;
142
146
  let digest = createHash("sha256");
@@ -172,14 +176,14 @@ export class RuntimeArchiveStore {
172
176
  }
173
177
  const after = await stat(state.path);
174
178
  if (before.ino !== after.ino || after.size < sizeBytes || state.sizeBytes === undefined && (before.size !== after.size || before.mtimeMs !== after.mtimeMs))
175
- throw new Error("Native file changed during capture");
179
+ throw new ArchiveCaptureIntegrityError("Native file changed during capture");
176
180
  if (state.sizeBytes !== undefined) {
177
181
  // Native clients may append while an earlier Turn is captured. Validate the exact prefix twice.
178
182
  const verified = createHash("sha256");
179
183
  for await (const bytes of createReadStream(state.path, { end: sizeBytes - 1 }))
180
184
  verified.update(bytes);
181
185
  if (verified.digest("hex") !== digest.copy().digest("hex"))
182
- throw new Error("Native prefix changed during capture");
186
+ throw new ArchiveCaptureIntegrityError("Native prefix changed during capture");
183
187
  }
184
188
  if (process.platform !== "win32") {
185
189
  const directory = await open(join(this.root, "objects"), "r");
@@ -195,7 +199,7 @@ export class RuntimeArchiveStore {
195
199
  sizeBytes, sha256: digest.digest("hex"), segments });
196
200
  validateArchiveBoundary(index, parent);
197
201
  if (state.expectedChecksum && index.sha256 !== state.expectedChecksum)
198
- throw new Error("Native Turn bytes changed during capture; original retained");
202
+ throw new ArchiveCaptureIntegrityError("Native Turn bytes changed during capture; original retained");
199
203
  }
200
204
  finally {
201
205
  await file.close();
@@ -1,4 +1,8 @@
1
1
  import { type CodexTokenTotals } from "./codex-usage.js";
2
+ /** A structurally unrestorable archive (e.g. its history reference is not carried); never retried. */
3
+ export declare class ArchiveNotRestorableError extends Error {
4
+ constructor(message: string);
5
+ }
2
6
  export declare function readCodexArchiveTotals(path: string): Promise<CodexTokenTotals | undefined>;
3
7
  /** Change only the header of a working copy. Raw archive bytes remain untouched. */
4
8
  export declare function importNativeArchive(input: {
@@ -5,6 +5,10 @@ import { dirname } from "node:path";
5
5
  import { RUNTIME_MAX_FRAME_BYTES } from "@neta-art/cohub";
6
6
  import { JsonLineDecoder } from "./json-rpc.js";
7
7
  import { codexArchiveTotals } from "./codex-usage.js";
8
+ /** A structurally unrestorable archive (e.g. its history reference is not carried); never retried. */
9
+ export class ArchiveNotRestorableError extends Error {
10
+ constructor(message) { super(message); this.name = "ArchiveNotRestorableError"; }
11
+ }
8
12
  export async function readCodexArchiveTotals(path) {
9
13
  let totals;
10
14
  const decoder = new JsonLineDecoder((row) => { totals = codexArchiveTotals([row]) ?? totals; });
@@ -35,6 +39,10 @@ export async function importNativeArchive(input) {
35
39
  else {
36
40
  if (header?.type !== "session_meta" || header.payload?.id !== input.nativeSessionId)
37
41
  throw new Error("Codex archive identity mismatch");
42
+ // A leaf archive does not carry its ancestor rollout. Importing it as-is would resume with
43
+ // silently truncated history; failing lets the caller rebuild from durable cloud Turns.
44
+ if (header.payload?.history_base != null)
45
+ throw new ArchiveNotRestorableError("Codex archive references history it does not carry; rebuild from the Session instead");
38
46
  header.payload.id = input.id;
39
47
  if (header.payload.session_id != null)
40
48
  header.payload.session_id = input.id;
@@ -20,6 +20,16 @@ type NativeBinding = {
20
20
  sizeBytes: number;
21
21
  sha256: string;
22
22
  }>;
23
+ /** After a Codex rollover: the superseded rollout's settled boundary, addressed by rollout id. */
24
+ ancestor?: {
25
+ rolloutId: string;
26
+ throughBytes: number;
27
+ anchors: Array<{
28
+ turnId: string;
29
+ sizeBytes: number;
30
+ sha256: string;
31
+ }>;
32
+ };
23
33
  };
24
34
  export type NativeTurnReceipt = {
25
35
  version: 1;
@@ -35,6 +45,8 @@ export type NativeTurnReceipt = {
35
45
  sessionStartedAt?: string;
36
46
  origin?: "local_import";
37
47
  progress?: NativeTurnProgress;
48
+ /** Merged lineage order across Codex rollout files. */
49
+ sequence?: number;
38
50
  };
39
51
  export type NativeSyncTransport = ArchiveTransport & {
40
52
  startNativeTurn?(input: NativeTurnStart, options?: {
@@ -80,12 +92,14 @@ export declare class NativeSyncStore {
80
92
  private acknowledgementPath;
81
93
  private pendingPath;
82
94
  private requestPath;
95
+ private retryPath;
83
96
  private cloudBindingPath;
84
97
  binding(): Promise<NativeBinding>;
85
98
  private initialize;
86
99
  capture(path: string, transcript: NativeTranscript, context?: {
87
100
  sessionStartedAt?: string;
88
101
  origin?: "local_import";
102
+ signal?: AbortSignal;
89
103
  }): Promise<void>;
90
104
  receipts(pendingOnly?: boolean): Promise<NativeTurnReceipt[]>;
91
105
  status(): Promise<{
@@ -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
- const versions = join(this.options.runtimeRoot, "archives", "versions");
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
- if (turn.startBytes < binding.throughBytes) {
130
- // Native offsets only validate whole-Turn archive checkpoints; they never become cloud fork anchors.
131
- const matches = (binding.anchors ?? []).filter((anchor) => anchor.sizeBytes >= turn.contentEndBytes && turn.boundaries[anchor.sizeBytes] === anchor.sha256);
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
- await this.archives.stage({ sessionId: binding.originSessionId, harness: binding.harness, nativeSessionId: binding.nativeSessionId, path, sizeBytes: turn.endBytes, expectedChecksum: turn.sha256 }, turnId);
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
- await this.archives.flush(signal);
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;
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
+ }[];
50
65
  export declare function flushNativeSessions(spaceId: string, identity: string, signal: AbortSignal, report: (error: unknown) => void, transportOverride?: NativeSyncTransport): Promise<boolean>;