@neta-art/cohub-cli 8.2.0 → 8.3.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.
Files changed (76) hide show
  1. package/dist/commands/runtime.d.ts +0 -12
  2. package/dist/commands/runtime.js +129 -144
  3. package/dist/commands/search.js +3 -9
  4. package/dist/commands/spaces.js +5 -2
  5. package/dist/output.d.ts +6 -0
  6. package/dist/output.js +11 -0
  7. package/dist/runtime/codex-usage.d.ts +0 -2
  8. package/dist/runtime/codex-usage.js +0 -12
  9. package/dist/runtime/connection.d.ts +5 -5
  10. package/dist/runtime/connection.js +27 -16
  11. package/dist/runtime/harness.d.ts +8 -16
  12. package/dist/runtime/harness.js +1 -366
  13. package/dist/runtime/instance.d.ts +8 -1
  14. package/dist/runtime/instance.js +15 -8
  15. package/dist/runtime/launch.js +10 -5
  16. package/dist/runtime/native/adapters.d.ts +32 -0
  17. package/dist/runtime/native/adapters.js +79 -0
  18. package/dist/runtime/{native-archive.d.ts → native/archive.d.ts} +0 -3
  19. package/dist/runtime/{native-archive.js → native/archive.js} +2 -17
  20. package/dist/runtime/native/attach.d.ts +33 -0
  21. package/dist/runtime/native/attach.js +103 -0
  22. package/dist/runtime/native/codex-link.d.ts +40 -0
  23. package/dist/runtime/native/codex-link.js +249 -0
  24. package/dist/runtime/native/config.d.ts +16 -0
  25. package/dist/runtime/native/config.js +24 -0
  26. package/dist/runtime/native/daemon.d.ts +88 -0
  27. package/dist/runtime/native/daemon.js +367 -0
  28. package/dist/runtime/native/execution.d.ts +45 -0
  29. package/dist/runtime/native/execution.js +337 -0
  30. package/dist/runtime/native/identity.d.ts +9 -0
  31. package/dist/runtime/native/identity.js +15 -0
  32. package/dist/runtime/native/ingest.d.ts +241 -0
  33. package/dist/runtime/native/ingest.js +823 -0
  34. package/dist/runtime/native/install.d.ts +9 -0
  35. package/dist/runtime/native/install.js +105 -0
  36. package/dist/runtime/native/pi-extension.d.ts +35 -0
  37. package/dist/runtime/native/pi-extension.js +160 -0
  38. package/dist/runtime/native/pi-link.d.ts +67 -0
  39. package/dist/runtime/native/pi-link.js +238 -0
  40. package/dist/runtime/native/results.d.ts +41 -0
  41. package/dist/runtime/native/results.js +128 -0
  42. package/dist/runtime/native/sessions.d.ts +45 -0
  43. package/dist/runtime/native/sessions.js +92 -0
  44. package/dist/runtime/{native-transcript.d.ts → native/transcript.d.ts} +5 -5
  45. package/dist/runtime/{native-transcript.js → native/transcript.js} +37 -11
  46. package/dist/runtime/native/translate.d.ts +44 -0
  47. package/dist/runtime/native/translate.js +263 -0
  48. package/dist/runtime/native/transport.d.ts +7 -0
  49. package/dist/runtime/native/transport.js +16 -0
  50. package/dist/runtime/native/version.d.ts +5 -0
  51. package/dist/runtime/native/version.js +18 -0
  52. package/dist/runtime/presentation.d.ts +7 -10
  53. package/dist/runtime/presentation.js +35 -10
  54. package/dist/runtime/projection-store.d.ts +6 -13
  55. package/dist/runtime/projection-store.js +11 -74
  56. package/dist/runtime/supervisor.d.ts +2 -0
  57. package/dist/runtime/supervisor.js +36 -110
  58. package/dist/runtime/turn-projection.d.ts +1 -3
  59. package/dist/runtime/turn-projection.js +3 -10
  60. package/package.json +4 -5
  61. package/dist/runtime/native-attach.d.ts +0 -28
  62. package/dist/runtime/native-attach.js +0 -47
  63. package/dist/runtime/native-codex-hook.d.ts +0 -1
  64. package/dist/runtime/native-codex-hook.js +0 -28
  65. package/dist/runtime/native-install.d.ts +0 -21
  66. package/dist/runtime/native-install.js +0 -130
  67. package/dist/runtime/native-ipc.d.ts +0 -37
  68. package/dist/runtime/native-ipc.js +0 -118
  69. package/dist/runtime/native-pi-extension.d.ts +0 -20
  70. package/dist/runtime/native-pi-extension.js +0 -47
  71. package/dist/runtime/native-sync-store.d.ts +0 -116
  72. package/dist/runtime/native-sync-store.js +0 -495
  73. package/dist/runtime/native-sync.d.ts +0 -65
  74. package/dist/runtime/native-sync.js +0 -326
  75. package/dist/runtime/session-store.d.ts +0 -84
  76. package/dist/runtime/session-store.js +0 -601
@@ -1,601 +0,0 @@
1
- import { createHash, randomUUID } from "node:crypto";
2
- import { mkdir, open, readFile, readdir, realpath, rename, rm } from "node:fs/promises";
3
- import { homedir } from "node:os";
4
- import { dirname, join } from "node:path";
5
- import { RUNTIME_RECOVERY_BATCH_SIZE, runtimeEventSchema, serializeProjectionRecords } from "@neta-art/cohub";
6
- import { RuntimeArchiveStore, checksumNativeFile, atomicRuntimeJson as atomicJson } from "./archive-store.js";
7
- import { ArchiveNotRestorableError, importNativeArchive, readCodexArchiveTotals } from "./native-archive.js";
8
- import { serializeDiagnosticError } from "./diagnostics.js";
9
- import { ProjectionStore, rebindProjectionNativeSession } from "./projection-store.js";
10
- import { confirmQuiescentProcessGroup } from "./process-group.js";
11
- import { readNativeTranscript } from "./native-transcript.js";
12
- export class ContextRequiredError extends Error {
13
- }
14
- /** Reverse lookup for native clients; the existing Runtime state remains authoritative. */
15
- export async function findRuntimeNativeSession(root, harness, nativeSessionId, path) {
16
- const directory = join(root, harness);
17
- const names = await readdir(directory).catch((error) => { if (error.code === "ENOENT")
18
- return []; throw error; });
19
- const matches = [];
20
- for (const name of names) {
21
- if (!name.endsWith(".json"))
22
- continue;
23
- const state = JSON.parse(await readFile(join(directory, name), "utf8"));
24
- if (state.version === 1 && state.harness === harness && state.nativeSessionId === nativeSessionId)
25
- matches.push(state);
26
- }
27
- if (path && matches.length) {
28
- const canonical = await realpath(path);
29
- const exact = [];
30
- for (const state of matches) {
31
- const candidate = await realpath(state.path).catch((error) => { if (error.code === "ENOENT")
32
- return null; throw error; });
33
- if (candidate === canonical)
34
- exact.push(state);
35
- }
36
- if (exact.length === 1)
37
- return exact[0] ?? null;
38
- }
39
- if (matches.length > 1)
40
- throw new Error("Native Session has ambiguous Runtime bindings");
41
- return matches[0] ?? null;
42
- }
43
- const checksum = (data) => createHash("sha256").update(data).digest("hex");
44
- const missing = (error) => error?.code === "ENOENT";
45
- class CaptureUnavailableError extends Error {
46
- }
47
- function piSessionDirectory(cwd) {
48
- const custom = process.env.PI_CODING_AGENT_SESSION_DIR?.trim();
49
- if (custom)
50
- return custom;
51
- const agentDir = process.env.PI_CODING_AGENT_DIR?.trim() || join(homedir(), ".pi", "agent");
52
- const safePath = `--${cwd.replace(/^[/\\]/, "").replace(/[/\\:]/g, "-")}--`;
53
- return join(agentDir, "sessions", safePath);
54
- }
55
- function codexSessionPath(id) {
56
- const timestamp = new Date().toISOString().replace(/\.\d{3}Z$/, "").replaceAll(":", "-");
57
- const root = process.env.CODEX_HOME?.trim() || join(homedir(), ".codex");
58
- return join(root, "sessions", timestamp.slice(0, 4), timestamp.slice(5, 7), timestamp.slice(8, 10), `rollout-${timestamp}-${id}.jsonl`);
59
- }
60
- /** Local references are hints validated against actual native files, never cloud existence claims. */
61
- export class RuntimeSessionStore {
62
- root;
63
- archives;
64
- archiveFlush = null;
65
- diagnostics = null;
66
- nativeWrites = new Map();
67
- projectionStore;
68
- constructor(spaceId, options) {
69
- this.root = join(options.stateRoot ?? join(homedir(), ".local", "state", "cohub", "runtime"), spaceId);
70
- this.archives = new RuntimeArchiveStore(join(this.root, "archives"), options.archiveTransport);
71
- this.projectionStore = new ProjectionStore(options.projectionSource);
72
- }
73
- setDiagnostics(diagnostics) {
74
- this.diagnostics = diagnostics;
75
- this.archives.setErrorReporter((error, index) => diagnostics.log("warn", "archive.upload_pending", { error: serializeDiagnosticError(error) }, {
76
- component: "archive",
77
- sessionId: index?.sessionId,
78
- turnId: index?.turnId,
79
- harness: index?.harness,
80
- }));
81
- }
82
- statePath(input) { return join(this.root, input.harness, `${input.sessionId}.json`); }
83
- nativePath(input, nativeSessionId, cwd) {
84
- if (input.harness === "pi")
85
- return join(piSessionDirectory(cwd), `${nativeSessionId}.jsonl`);
86
- return codexSessionPath(nativeSessionId);
87
- }
88
- async withNativeWrite(path, write) {
89
- const previous = this.nativeWrites.get(path) ?? Promise.resolve();
90
- const current = previous.catch(() => undefined).then(write);
91
- this.nativeWrites.set(path, current);
92
- try {
93
- await current;
94
- }
95
- finally {
96
- if (this.nativeWrites.get(path) === current)
97
- this.nativeWrites.delete(path);
98
- }
99
- }
100
- async syncParentDirectory(path) {
101
- if (process.platform === "win32")
102
- return;
103
- const directory = await open(dirname(path), "r");
104
- try {
105
- await directory.sync();
106
- }
107
- finally {
108
- await directory.close();
109
- }
110
- }
111
- async writeNativeFile(path, data) {
112
- await this.withNativeWrite(path, async () => {
113
- await mkdir(dirname(path), { recursive: true, mode: 0o700 });
114
- const temporary = `${path}.${randomUUID()}.projection`;
115
- try {
116
- const file = await open(temporary, "wx", 0o600);
117
- try {
118
- await file.writeFile(data, "utf8");
119
- await file.sync();
120
- }
121
- finally {
122
- await file.close();
123
- }
124
- await rename(temporary, path);
125
- await this.syncParentDirectory(path);
126
- }
127
- finally {
128
- await rm(temporary, { force: true });
129
- }
130
- });
131
- }
132
- async appendNativeFile(path, data, expectedChecksum) {
133
- if (!data)
134
- return;
135
- await this.withNativeWrite(path, async () => {
136
- const file = await open(path, "r+", 0o600);
137
- try {
138
- const bytes = await file.readFile();
139
- if (expectedChecksum && checksum(bytes) !== expectedChecksum)
140
- throw new Error("Native session changed outside Cohub; original data was preserved");
141
- const prefix = bytes.length > 0 && bytes.at(-1) !== 10 ? "\n" : "";
142
- await file.write(`${prefix}${data}`, bytes.length, "utf8");
143
- await file.sync();
144
- }
145
- finally {
146
- await file.close();
147
- }
148
- await this.syncParentDirectory(path);
149
- });
150
- }
151
- async lastNativeRecordId(path) {
152
- const file = await open(path, "r");
153
- try {
154
- let position = (await file.stat()).size;
155
- let tail = "";
156
- while (position > 0) {
157
- const size = Math.min(position, 64 * 1024);
158
- position -= size;
159
- const chunk = Buffer.alloc(size);
160
- await file.read(chunk, 0, size, position);
161
- tail = chunk.toString("utf8") + tail;
162
- const candidate = tail.trimEnd();
163
- const newline = candidate.lastIndexOf("\n");
164
- if (newline >= 0) {
165
- const record = JSON.parse(candidate.slice(newline + 1));
166
- return typeof record.id === "string" ? record.id : null;
167
- }
168
- }
169
- const record = JSON.parse(tail.trim());
170
- return typeof record.id === "string" ? record.id : null;
171
- }
172
- finally {
173
- await file.close();
174
- }
175
- }
176
- reportProjectionWarnings(input, warnings) {
177
- if (warnings.length === 0)
178
- return;
179
- this.diagnostics?.log("warn", "runtime.projection_loss", { warningCount: warnings.length, warnings: warnings.slice(0, 100), truncated: warnings.length > 100 }, { component: "projection", sessionId: input.sessionId, turnId: input.turnId, harness: input.harness });
180
- }
181
- async syncNativeProjection(input, cwd, previous, signal) {
182
- const existing = previous && (previous.checksum.length > 0 || previous.sourceSequence != null || previous.pendingTurnId != null) ? previous : null;
183
- const projection = await this.projectionStore.project({
184
- spaceId: input.spaceId,
185
- sessionId: input.sessionId,
186
- turnId: input.turnId,
187
- nativeSessionId: existing?.nativeSessionId ?? randomUUID(),
188
- cwd,
189
- provider: input.provider,
190
- target: input.harness,
191
- throughTurnId: input.context.throughTurnId,
192
- cursor: existing ? {
193
- throughSequence: existing.sourceSequence ?? null,
194
- throughTurnId: existing.sourceTurnId ?? null,
195
- sourceFingerprint: existing.sourceFingerprint ?? null,
196
- } : null,
197
- }, signal);
198
- if (existing && projection.append && projection.turns.length === 0)
199
- return { state: existing, resume: "native" };
200
- const replacing = existing !== null && !projection.append;
201
- const id = replacing || !existing ? randomUUID() : existing.nativeSessionId;
202
- const path = replacing || !existing ? this.nativePath(input, id, cwd) : existing.path;
203
- const materialized = replacing ? rebindProjectionNativeSession(projection, id) : projection;
204
- this.reportProjectionWarnings(input, materialized.projection.warnings);
205
- const turns = materialized.turns;
206
- const next = {
207
- ...(replacing || !existing ? { version: 1, sessionId: input.sessionId, harness: input.harness, nativeSessionId: id, path, cwd, throughTurnId: null, revision: input.context.revision, checksum: "", pendingTurnId: null } : existing),
208
- projectionVersion: 1,
209
- nativeSessionId: id,
210
- path,
211
- cwd,
212
- sourceSequence: materialized.cursor.throughSequence,
213
- sourceTurnId: materialized.cursor.throughTurnId,
214
- sourceFingerprint: materialized.cursor.sourceFingerprint,
215
- throughTurnId: input.context.throughTurnId,
216
- revision: input.context.revision,
217
- };
218
- const serialized = serializeProjectionRecords(materialized.projection.records);
219
- if (input.harness === "codex" && turns.length === 0 && !existing)
220
- return { state: next, resume: "new" };
221
- if (materialized.append && existing) {
222
- let suffix = serialized;
223
- let leafId = existing.nativeLeafId ?? null;
224
- if (input.harness === "pi") {
225
- leafId = await this.lastNativeRecordId(existing.path);
226
- if (leafId) {
227
- let rebound = false;
228
- const records = materialized.projection.records.map((entry) => {
229
- if (rebound || !("parentId" in entry.record))
230
- return entry;
231
- rebound = true;
232
- return { ...entry, record: { ...entry.record, parentId: leafId } };
233
- });
234
- suffix = serializeProjectionRecords(records);
235
- }
236
- }
237
- await this.appendNativeFile(existing.path, suffix, existing.checksum);
238
- const projectedLeaf = materialized.projection.records.at(-1)?.record.id;
239
- if (typeof projectedLeaf === "string")
240
- leafId = projectedLeaf;
241
- next.nativeLeafId = leafId;
242
- }
243
- else {
244
- await this.writeNativeFile(path, serialized);
245
- const projectedLeaf = materialized.projection.records.at(-1)?.record.id;
246
- next.nativeLeafId = typeof projectedLeaf === "string" ? projectedLeaf : null;
247
- }
248
- next.checksum = await checksumNativeFile(path);
249
- if (existing)
250
- await atomicJson(this.statePath(next), next);
251
- return { state: next, resume: existing ? "handoff" : (turns.length ? "handoff" : "new") };
252
- }
253
- async *pendingExecutionBatches() {
254
- let batch = [];
255
- for (const harness of ["pi", "codex"]) {
256
- const directory = join(this.root, harness);
257
- const names = await readdir(directory).catch((error) => { if (missing(error))
258
- return []; throw error; });
259
- for (const name of names) {
260
- if (!name.endsWith(".json"))
261
- continue;
262
- try {
263
- const state = JSON.parse(await readFile(join(directory, name), "utf8"));
264
- if (state.version !== 1 || state.harness !== harness || !state.sessionId || !state.pendingTurnId)
265
- continue;
266
- batch.push({ sessionId: state.sessionId, turnId: state.pendingTurnId, harness });
267
- if (batch.length >= RUNTIME_RECOVERY_BATCH_SIZE) {
268
- yield batch;
269
- batch = [];
270
- }
271
- }
272
- catch (error) {
273
- this.diagnostics?.log("error", "runtime.session_state_unreadable", { path: join(directory, name), error: serializeDiagnosticError(error) });
274
- }
275
- }
276
- }
277
- if (batch.length)
278
- yield batch;
279
- }
280
- async flushArchives(signal) {
281
- this.archiveFlush ??= this.flushArchiveOutbox(signal).finally(() => { this.archiveFlush = null; });
282
- return this.archiveFlush;
283
- }
284
- async flushArchiveOutbox(signal) {
285
- if (!this.archives.hasTransport && await this.archives.pendingCount() > 0) {
286
- this.diagnostics?.log("debug", "archive.transport_absent", { note: "captures stay local" }, { component: "archive" });
287
- }
288
- const captures = join(this.archives.root, "captures");
289
- const names = await readdir(captures).catch((error) => { if (missing(error))
290
- return []; throw error; });
291
- for (const name of names) {
292
- signal.throwIfAborted();
293
- if (!name.endsWith(".json"))
294
- continue;
295
- let receipt;
296
- try {
297
- receipt = await readFile(join(captures, name), "utf8");
298
- let state;
299
- try {
300
- state = JSON.parse(receipt);
301
- }
302
- catch {
303
- throw new CaptureUnavailableError("Invalid capture receipt");
304
- }
305
- const turnId = state?.archivePendingTurnId;
306
- if (typeof turnId !== "string" || typeof state?.path !== "string" || typeof state.resultChecksum !== "string") {
307
- throw new CaptureUnavailableError("Invalid capture receipt");
308
- }
309
- if (!await this.archives.hasCapture(turnId)) {
310
- const digest = await checksumNativeFile(state.path).catch((error) => {
311
- if (missing(error))
312
- throw new CaptureUnavailableError("Native session missing");
313
- throw error;
314
- });
315
- if (digest !== state.resultChecksum)
316
- throw new CaptureUnavailableError("Native session changed; original files retained");
317
- signal.throwIfAborted();
318
- await this.archives.stage(state, turnId);
319
- }
320
- await rm(join(captures, name), { force: true });
321
- }
322
- catch (error) {
323
- signal.throwIfAborted();
324
- if (error instanceof CaptureUnavailableError && receipt !== undefined) {
325
- // Preserve the exact receipt before retiring it from the retry queue. Never alter native files.
326
- await atomicJson(join(this.archives.root, "failed", "captures", `${name}.${checksum(receipt)}.json`), {
327
- receipt, reason: error.message, failedAt: new Date().toISOString(),
328
- });
329
- await rm(join(captures, name), { force: true });
330
- this.diagnostics?.log("error", "archive.capture_unavailable", { reason: error.message, receipt: true }, { component: "archive" });
331
- }
332
- else {
333
- this.diagnostics?.log("warn", "archive.capture_pending", { error: serializeDiagnosticError(error) }, { component: "archive" });
334
- }
335
- }
336
- }
337
- await this.archives.flush(signal);
338
- }
339
- async pendingTurnIds(sessionId) {
340
- const ids = [];
341
- for (const harness of ["pi", "codex"]) {
342
- try {
343
- const state = JSON.parse(await readFile(this.statePath({ sessionId, harness }), "utf8"));
344
- if (state.sessionId !== sessionId || state.harness !== harness)
345
- throw new Error("Local session identity mismatch");
346
- if (state.pendingTurnId)
347
- ids.push(state.pendingTurnId);
348
- }
349
- catch (error) {
350
- if (!missing(error))
351
- throw error;
352
- }
353
- }
354
- return ids;
355
- }
356
- async prepare(input, cwd, signal) {
357
- let previous = null;
358
- try {
359
- previous = JSON.parse(await readFile(this.statePath(input), "utf8"));
360
- }
361
- catch (error) {
362
- if (!missing(error))
363
- throw new Error("Local session state is unreadable; original files were preserved", { cause: error });
364
- }
365
- if (previous) {
366
- if (previous.sessionId !== input.sessionId || previous.harness !== input.harness)
367
- throw new Error("Local session identity mismatch");
368
- const pending = previous.pendingTurnId;
369
- if (pending) {
370
- const resolved = input.context.resolvedTurnIds?.includes(pending) === true;
371
- const settled = input.context.settledTurnIds?.includes(pending) === true;
372
- const lostAcknowledgement = Boolean(previous.resultChecksum) && pending === input.context.throughTurnId && pending !== input.turnId;
373
- // A human-confirmed stop is authoritative: rebuild from durable history instead of
374
- // resuming a native projection whose outcome the server never recorded.
375
- const retire = resolved || (settled && !lostAcknowledgement);
376
- const nativeChecksum = await checksumNativeFile(previous.path).catch((error) => { if (missing(error))
377
- return null; throw error; });
378
- if (previous.resultChecksum && (settled || resolved) && nativeChecksum && nativeChecksum !== previous.resultChecksum) {
379
- throw new Error("Native session changed outside Cohub; original data was preserved");
380
- }
381
- if (retire) {
382
- // The server reached a terminal state for this turn. Archive the local projection and
383
- // rebuild from durable context; native files are never deleted, so nothing is lost.
384
- const receipt = await readFile(this.resultPath(previous), "utf8").catch((error) => { if (missing(error))
385
- return null; throw error; });
386
- const retired = { state: previous, receipt };
387
- await atomicJson(join(this.root, "retired", `${pending}.${checksum(JSON.stringify(retired))}.json`), retired);
388
- previous = null;
389
- }
390
- else if (lostAcknowledgement) {
391
- // The server already persisted this turn; only our acknowledgement was lost.
392
- await this.acknowledge(previous, pending, input.context.revision);
393
- }
394
- else if (input.context.complete === false) {
395
- throw new ContextRequiredError("Server resolution is required for the pending native execution");
396
- }
397
- else {
398
- throw new Error(`Local turn ${pending} has unconfirmed results; reconcile it before continuing`);
399
- }
400
- }
401
- }
402
- if (previous) {
403
- const previousPath = previous.path;
404
- const canonical = await realpath(previousPath).catch((error) => { if (missing(error))
405
- return previousPath; throw error; });
406
- const externallyOwned = await readFile(join(this.root, "native-owners", `${checksum(canonical)}.json`), "utf8").then(() => true).catch((error) => { if (missing(error))
407
- return false; throw error; });
408
- // Interactive clients own their native files, including symlink aliases. Use an independent projection.
409
- if (externallyOwned)
410
- previous = null;
411
- }
412
- if (previous) {
413
- try {
414
- const nativeChecksum = await checksumNativeFile(previous.path);
415
- if (nativeChecksum !== previous.checksum) {
416
- this.diagnostics?.log("warn", "runtime.projection_rebuilt", { reason: "native file changed outside Cohub" }, { component: "projection", sessionId: input.sessionId, turnId: input.turnId, harness: input.harness });
417
- return await this.syncNativeProjection(input, cwd, null, signal);
418
- }
419
- if (previous.archivePendingTurnId) {
420
- await this.archives.stage(previous, previous.archivePendingTurnId);
421
- previous.archivePendingTurnId = undefined;
422
- await atomicJson(this.statePath(previous), previous);
423
- }
424
- if (input.harness === "codex" && !previous.codexTokenTotals)
425
- previous.codexTokenTotals = await readCodexArchiveTotals(previous.path);
426
- return await this.syncNativeProjection(input, cwd, previous?.cwd === cwd ? previous : null, signal);
427
- }
428
- catch (error) {
429
- if (!missing(error))
430
- throw error;
431
- }
432
- }
433
- const id = randomUUID();
434
- const path = this.nativePath(input, id, cwd);
435
- const archive = input.context.archive;
436
- const restore = archive?.harness === input.harness && archive.sessionId === input.sessionId && archive.turnId === input.context.throughTurnId;
437
- const rawPath = join(this.root, "archives", "restored", `${id}.jsonl`);
438
- const state = {
439
- version: 1, harness: input.harness, sessionId: input.sessionId, nativeSessionId: id,
440
- path, cwd, throughTurnId: input.context.throughTurnId, revision: input.context.revision, checksum: "", pendingTurnId: null,
441
- projectionVersion: 1, sourceSequence: null, sourceTurnId: null, sourceFingerprint: null,
442
- };
443
- if (restore) {
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
- }
467
- }
468
- return await this.syncNativeProjection(input, cwd, state, signal);
469
- }
470
- async started(state, turnId) { state.pendingTurnId = turnId; state.resultChecksum = undefined; await atomicJson(this.statePath(state), state); }
471
- resultPath(state) { return join(this.root, "results", `${state.sessionId}.${state.harness}.json`); }
472
- async recordResult(state, requestId, events, options) {
473
- if (!state.pendingTurnId)
474
- throw new Error("Cannot record a result for an idle native session");
475
- if (state.harness === "pi")
476
- state.nativeLeafId = await this.lastNativeRecordId(state.path);
477
- state.resultChecksum = await checksumNativeFile(state.path);
478
- if (state.archivePendingTurnId)
479
- await atomicJson(join(this.archives.root, "captures", `${state.archivePendingTurnId}.json`), state);
480
- await atomicJson(this.resultPath(state), { requestId, state, events, ...(options?.uncertainCleanup ? { uncertainCleanup: options.uncertainCleanup } : {}) });
481
- await atomicJson(this.statePath(state), state);
482
- }
483
- async recoverResult(input, requestId) {
484
- try {
485
- const saved = JSON.parse(await readFile(this.resultPath(input), "utf8"));
486
- if (saved.state.sessionId !== input.sessionId || saved.state.harness !== input.harness)
487
- throw new Error("Runtime result identity mismatch");
488
- if (saved.state.pendingTurnId !== input.turnId)
489
- return this.reconstructResult(input);
490
- if (requestId && saved.requestId != null && saved.requestId !== requestId)
491
- throw new Error("Runtime result execution identity mismatch");
492
- // A quiescent process group upgrades the receipt to confirmed; a live one stays uncertain.
493
- if (saved.uncertainCleanup && !saved.uncertainCleanup.confirmedAt) {
494
- if (!(await confirmQuiescentProcessGroup(saved.uncertainCleanup.processGroupId)))
495
- return null;
496
- saved.uncertainCleanup.confirmedAt = new Date().toISOString();
497
- await atomicJson(this.resultPath(input), saved);
498
- }
499
- return { state: saved.state, events: saved.events.map((event) => runtimeEventSchema.parse(event)) };
500
- }
501
- catch (error) {
502
- if (missing(error))
503
- return this.reconstructResult(input);
504
- throw error;
505
- }
506
- }
507
- /**
508
- * Receipt-less recovery: the native file is the authority. When a Turn completed but no
509
- * receipt survived (a crash between native completion and recordResult), rebuild the
510
- * result from transcript bytes appended after the Turn started, without ever rewriting
511
- * native data. Anything ambiguous stays unrecovered.
512
- */
513
- async reconstructResult(input) {
514
- const statePath = this.statePath(input);
515
- let state;
516
- try {
517
- state = JSON.parse(await readFile(statePath, "utf8"));
518
- }
519
- catch (error) {
520
- if (missing(error))
521
- return null;
522
- throw error;
523
- }
524
- if (state.sessionId !== input.sessionId || state.harness !== input.harness || state.pendingTurnId !== input.turnId)
525
- return null;
526
- let transcript;
527
- try {
528
- transcript = await readNativeTranscript(state.path, state.harness, { settled: state.harness === "pi" });
529
- }
530
- catch {
531
- return null;
532
- }
533
- // The Turn must start at the byte boundary the file had when the Turn began.
534
- const boundary = state.checksum
535
- ? [...transcript.prefixes].find(([, sha]) => sha === state.checksum)?.[0]
536
- : 0;
537
- if (boundary === undefined)
538
- return null;
539
- const turns = transcript.turns.filter((turn) => turn.startBytes >= boundary);
540
- const completed = turns.length === 1 ? turns[0] : null;
541
- const result = completed?.result ?? null;
542
- // An interrupted Turn may still be executing in a surviving native process.
543
- if (!completed || !result || result.status === "interrupted")
544
- return null;
545
- const messages = result.messages.filter((message) => message.content.length);
546
- if (!messages.length)
547
- return null;
548
- const last = messages.at(-1);
549
- const asRuntimeMessage = (message, ordinal) => ({
550
- ordinal,
551
- content: message.content,
552
- ...(message.provider != null ? { provider: message.provider } : {}),
553
- ...(message.model != null ? { model: message.model } : {}),
554
- ...(message.usage != null ? { usage: message.usage } : {}),
555
- ...(message.stopReason != null ? { stopReason: message.stopReason } : {}),
556
- ...(message.errorMessage != null ? { errorMessage: message.errorMessage } : {}),
557
- });
558
- const events = messages.slice(0, -1).map((message, index) => ({ type: "message.commit", message: asRuntimeMessage(message, index + 1) }));
559
- const archive = await this.archive(state, input.turnId, { component: "recovery", sessionId: input.sessionId, turnId: input.turnId, harness: input.harness }).catch(() => null);
560
- const final = { type: "turn.end", message: asRuntimeMessage(last, messages.length), archive, resume: "native" };
561
- await this.recordResult(state, null, [...events, final]);
562
- this.diagnostics?.log("warn", "runtime.result_reconstructed", {
563
- startBytes: completed.startBytes,
564
- messageCount: messages.length,
565
- status: result.status,
566
- }, { component: "recovery", sessionId: input.sessionId, turnId: input.turnId, harness: input.harness });
567
- return { state, events: [...events, final] };
568
- }
569
- async archive(state, turnId, diagnosticContext = {}) {
570
- if (!state.path)
571
- return null;
572
- state.archivePendingTurnId = turnId;
573
- try {
574
- const reference = await this.archives.stage(state, turnId);
575
- state.archivePendingTurnId = undefined;
576
- return reference;
577
- }
578
- catch (error) {
579
- this.diagnostics?.log("warn", "archive.capture_failed", { error: serializeDiagnosticError(error) }, { ...diagnosticContext, component: "archive", turnId, harness: state.harness });
580
- throw error;
581
- }
582
- }
583
- async acknowledge(state, turnId, revision) {
584
- if (state.pendingTurnId !== turnId)
585
- throw new Error("Runtime acknowledgement identity mismatch");
586
- const currentChecksum = await checksumNativeFile(state.path).catch((error) => { if (missing(error))
587
- return state.resultChecksum ?? state.checksum; throw error; });
588
- if (state.resultChecksum && currentChecksum !== state.resultChecksum)
589
- throw new Error("Native data changed before acknowledgement; files preserved");
590
- let sourceSequence = state.sourceSequence ?? null;
591
- let sourceTurnId = state.sourceTurnId ?? null;
592
- let sourceFingerprint = state.sourceFingerprint ?? null;
593
- const projectionCursor = await this.projectionStore.cursorForTurn(state.sessionId, turnId);
594
- sourceSequence = projectionCursor.throughSequence;
595
- sourceTurnId = projectionCursor.throughTurnId;
596
- sourceFingerprint = projectionCursor.sourceFingerprint;
597
- const acknowledged = { ...state, checksum: currentChecksum, pendingTurnId: null, resultChecksum: undefined, throughTurnId: turnId, revision, sourceSequence, sourceTurnId, sourceFingerprint };
598
- await atomicJson(this.statePath(state), acknowledged);
599
- Object.assign(state, acknowledged);
600
- }
601
- }