@neta-art/cohub-cli 8.2.0 → 8.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/commands/runtime.d.ts +0 -12
- package/dist/commands/runtime.js +129 -144
- package/dist/commands/search.js +3 -9
- package/dist/commands/spaces.js +5 -2
- package/dist/output.d.ts +6 -0
- package/dist/output.js +11 -0
- package/dist/runtime/codex-usage.d.ts +0 -2
- package/dist/runtime/codex-usage.js +0 -12
- package/dist/runtime/connection.d.ts +5 -5
- package/dist/runtime/connection.js +27 -16
- package/dist/runtime/harness.d.ts +8 -16
- package/dist/runtime/harness.js +1 -366
- package/dist/runtime/instance.d.ts +8 -1
- package/dist/runtime/instance.js +15 -8
- package/dist/runtime/launch.js +11 -5
- package/dist/runtime/native/adapters.d.ts +32 -0
- package/dist/runtime/native/adapters.js +79 -0
- package/dist/runtime/{native-archive.d.ts → native/archive.d.ts} +0 -3
- package/dist/runtime/{native-archive.js → native/archive.js} +2 -17
- package/dist/runtime/native/attach.d.ts +38 -0
- package/dist/runtime/native/attach.js +113 -0
- package/dist/runtime/native/codex-link.d.ts +40 -0
- package/dist/runtime/native/codex-link.js +249 -0
- package/dist/runtime/native/config.d.ts +16 -0
- package/dist/runtime/native/config.js +24 -0
- package/dist/runtime/native/daemon.d.ts +88 -0
- package/dist/runtime/native/daemon.js +367 -0
- package/dist/runtime/native/execution.d.ts +45 -0
- package/dist/runtime/native/execution.js +337 -0
- package/dist/runtime/native/identity.d.ts +9 -0
- package/dist/runtime/native/identity.js +15 -0
- package/dist/runtime/native/ingest.d.ts +241 -0
- package/dist/runtime/native/ingest.js +823 -0
- package/dist/runtime/native/install.d.ts +9 -0
- package/dist/runtime/native/install.js +113 -0
- package/dist/runtime/native/pi-extension.d.ts +35 -0
- package/dist/runtime/native/pi-extension.js +160 -0
- package/dist/runtime/native/pi-link.d.ts +67 -0
- package/dist/runtime/native/pi-link.js +238 -0
- package/dist/runtime/native/results.d.ts +41 -0
- package/dist/runtime/native/results.js +128 -0
- package/dist/runtime/native/sessions.d.ts +45 -0
- package/dist/runtime/native/sessions.js +92 -0
- package/dist/runtime/{native-transcript.d.ts → native/transcript.d.ts} +5 -5
- package/dist/runtime/{native-transcript.js → native/transcript.js} +37 -11
- package/dist/runtime/native/translate.d.ts +44 -0
- package/dist/runtime/native/translate.js +263 -0
- package/dist/runtime/native/transport.d.ts +7 -0
- package/dist/runtime/native/transport.js +16 -0
- package/dist/runtime/native/version.d.ts +5 -0
- package/dist/runtime/native/version.js +18 -0
- package/dist/runtime/presentation.d.ts +7 -10
- package/dist/runtime/presentation.js +35 -10
- package/dist/runtime/projection-store.d.ts +6 -13
- package/dist/runtime/projection-store.js +11 -74
- package/dist/runtime/supervisor.d.ts +2 -0
- package/dist/runtime/supervisor.js +36 -110
- package/dist/runtime/turn-projection.d.ts +1 -3
- package/dist/runtime/turn-projection.js +3 -10
- package/package.json +4 -5
- package/dist/runtime/native-attach.d.ts +0 -28
- package/dist/runtime/native-attach.js +0 -47
- package/dist/runtime/native-codex-hook.d.ts +0 -1
- package/dist/runtime/native-codex-hook.js +0 -28
- package/dist/runtime/native-install.d.ts +0 -21
- package/dist/runtime/native-install.js +0 -130
- package/dist/runtime/native-ipc.d.ts +0 -37
- package/dist/runtime/native-ipc.js +0 -118
- package/dist/runtime/native-pi-extension.d.ts +0 -20
- package/dist/runtime/native-pi-extension.js +0 -47
- package/dist/runtime/native-sync-store.d.ts +0 -116
- package/dist/runtime/native-sync-store.js +0 -495
- package/dist/runtime/native-sync.d.ts +0 -65
- package/dist/runtime/native-sync.js +0 -326
- package/dist/runtime/session-store.d.ts +0 -84
- package/dist/runtime/session-store.js +0 -601
|
@@ -0,0 +1,823 @@
|
|
|
1
|
+
import { appendFile, mkdir, open, readFile, readdir, rename, stat } from "node:fs/promises";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { NATIVE_INGEST_MAX_TURNS, SETTLED_TURN_STATUSES } from "@neta-art/cohub";
|
|
4
|
+
import { acceptsDirectory, acceptsTranscript, readSessionHeader, readTranscript, transcriptRoots } from "./adapters.js";
|
|
5
|
+
import { nativeTurnId } from "./identity.js";
|
|
6
|
+
import { ensurePlainCodexRollout } from "./transcript.js";
|
|
7
|
+
const missing = (error) => error?.code === "ENOENT";
|
|
8
|
+
/** Transcripts read in parallel per lane. The live lane never waits behind a backfill. */
|
|
9
|
+
const LIVE_CONCURRENCY = 2;
|
|
10
|
+
export const DEFAULT_IMPORT_CONCURRENCY = 4;
|
|
11
|
+
export const MAX_IMPORT_CONCURRENCY = 8;
|
|
12
|
+
/** A harness writes one Turn as many appends; filesystem events are coalesced for this long. */
|
|
13
|
+
const WATCH_DEBOUNCE_MS = 150;
|
|
14
|
+
/** A quiet Pi transcript whose last reply ended is settled after this long. */
|
|
15
|
+
const PI_QUIET_SETTLE_MS = 10_000;
|
|
16
|
+
const SETTLED_STATUSES = new Set(SETTLED_TURN_STATUSES);
|
|
17
|
+
/** Pi stop reasons that end a reply; anything else (a tool call) means more is coming. */
|
|
18
|
+
const PI_ENDED = ["stop", "length", "error", "aborted"];
|
|
19
|
+
/** A running Turn that shows signs of life tells the server at most this often. */
|
|
20
|
+
const ALIVE_INTERVAL_MS = 5 * 60_000;
|
|
21
|
+
/** A failed read is retried this many times with backoff; afterwards the next write or reconnect retries. */
|
|
22
|
+
const RETRY_ATTEMPTS = 10;
|
|
23
|
+
const RETRY_BASE_MS = 1_000;
|
|
24
|
+
const RETRY_MAX_MS = 5 * 60_000;
|
|
25
|
+
/**
|
|
26
|
+
* Turns per ingest request. The server writes each Turn in its own transactions, so a batch is kept
|
|
27
|
+
* small enough to answer well within the request timeout; the protocol allows more.
|
|
28
|
+
*/
|
|
29
|
+
const INGEST_BATCH_TURNS = Math.min(50, NATIVE_INGEST_MAX_TURNS);
|
|
30
|
+
/**
|
|
31
|
+
* Records that end or begin a Turn. New bytes of a known transcript are only searched for these;
|
|
32
|
+
* the transcript is parsed and sent when one appears.
|
|
33
|
+
*/
|
|
34
|
+
const BOUNDARIES = {
|
|
35
|
+
codex: ['"UserMessage"', '"user_message"', '"turn_complete"', '"task_complete"', '"turn_aborted"'],
|
|
36
|
+
pi: ['"role":"user"', ...PI_ENDED.map((reason) => `"stopReason":"${reason}"`)],
|
|
37
|
+
};
|
|
38
|
+
const BOUNDARY_OVERLAP = Math.max(...Object.values(BOUNDARIES).flat().map((token) => token.length));
|
|
39
|
+
const IMPORT_FAILURES_KEPT = 100;
|
|
40
|
+
/** Headers are read this many at a time; each costs one small read. */
|
|
41
|
+
const HEADER_CONCURRENCY = 16;
|
|
42
|
+
const within = (root, cwd) => cwd === root || cwd.startsWith(`${root}/`);
|
|
43
|
+
/** Whether a session in `cwd` belongs to the directory bound at `root`. */
|
|
44
|
+
export const owns = (root, cwd, nested = []) => Boolean(cwd) && within(root, cwd) && !nested.some((inner) => within(inner, cwd));
|
|
45
|
+
/** Every transcript of the bound directory and its subdirectories, newest first. */
|
|
46
|
+
export async function discoverTranscripts(cwd, harnesses, options = {}) {
|
|
47
|
+
const found = [];
|
|
48
|
+
for (const harness of harnesses) {
|
|
49
|
+
for (const root of transcriptRoots(harness)) {
|
|
50
|
+
const pending = [root];
|
|
51
|
+
while (pending.length) {
|
|
52
|
+
options.signal?.throwIfAborted();
|
|
53
|
+
const directory = pending.pop();
|
|
54
|
+
const entries = await readdir(directory, { withFileTypes: true }).catch((error) => { if (missing(error) || error.code === "ENOTDIR")
|
|
55
|
+
return []; throw error; });
|
|
56
|
+
for (const entry of entries) {
|
|
57
|
+
const path = join(directory, entry.name);
|
|
58
|
+
if (entry.isDirectory()) {
|
|
59
|
+
if (acceptsDirectory(path, root, harness, cwd))
|
|
60
|
+
pending.push(path);
|
|
61
|
+
}
|
|
62
|
+
else if (entry.isFile() && acceptsTranscript(path, harness, cwd)) {
|
|
63
|
+
const info = await stat(path).catch(() => null);
|
|
64
|
+
if (info)
|
|
65
|
+
found.push({ harness, path, size: info.size, mtimeMs: info.mtimeMs, ino: info.ino });
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
found.sort((left, right) => right.mtimeMs - left.mtimeMs);
|
|
72
|
+
if (!options.headers)
|
|
73
|
+
return found;
|
|
74
|
+
// Only user conversations of this directory remain; Codex's own work threads are not listed.
|
|
75
|
+
const kept = new Array(found.length).fill(null);
|
|
76
|
+
let next = 0;
|
|
77
|
+
await Promise.all(Array.from({ length: Math.min(HEADER_CONCURRENCY, found.length) }, async () => {
|
|
78
|
+
while (next < found.length) {
|
|
79
|
+
const index = next++;
|
|
80
|
+
const candidate = found[index];
|
|
81
|
+
const header = await readSessionHeader(candidate.path, candidate.harness, options.signal).catch(() => null);
|
|
82
|
+
if (header?.thread === "user" && owns(cwd, header.cwd, options.nested))
|
|
83
|
+
kept[index] = { ...candidate, nativeSessionId: header.nativeSessionId };
|
|
84
|
+
}
|
|
85
|
+
}));
|
|
86
|
+
return kept.filter((candidate) => candidate !== null);
|
|
87
|
+
}
|
|
88
|
+
/** Ingest batches stay well under the Runtime frame limit. */
|
|
89
|
+
const BATCH_MAX_BYTES = 8 * 1024 * 1024;
|
|
90
|
+
/** Split in order into batches of at most `count` items and the byte budget. */
|
|
91
|
+
function* chunks(items, count, size = () => 0) {
|
|
92
|
+
let batch = [];
|
|
93
|
+
let bytes = 0;
|
|
94
|
+
for (const item of items) {
|
|
95
|
+
const itemBytes = size(item);
|
|
96
|
+
if (batch.length && (batch.length >= count || bytes + itemBytes > BATCH_MAX_BYTES)) {
|
|
97
|
+
yield batch;
|
|
98
|
+
batch = [];
|
|
99
|
+
bytes = 0;
|
|
100
|
+
}
|
|
101
|
+
batch.push(item);
|
|
102
|
+
bytes += itemBytes;
|
|
103
|
+
}
|
|
104
|
+
if (batch.length)
|
|
105
|
+
yield batch;
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* The daemon's data plane. A harness's transcript is both the durable record of a conversation and
|
|
109
|
+
* the local outbox.
|
|
110
|
+
*/
|
|
111
|
+
export class NativeIngest {
|
|
112
|
+
options;
|
|
113
|
+
states = new Map();
|
|
114
|
+
live = new Map();
|
|
115
|
+
busy = new Set();
|
|
116
|
+
reads = new Set();
|
|
117
|
+
timers = new Map();
|
|
118
|
+
failures = new Map();
|
|
119
|
+
aliveAt = new Map();
|
|
120
|
+
runningPaths = new Set();
|
|
121
|
+
writing = Promise.resolve();
|
|
122
|
+
foreign = new Set();
|
|
123
|
+
importQueue = [];
|
|
124
|
+
runningLive = 0;
|
|
125
|
+
runningImport = 0;
|
|
126
|
+
job = { state: "idle", filter: {}, concurrency: DEFAULT_IMPORT_CONCURRENCY, files: 0, done: 0, turns: 0, bytes: 0, totalBytes: 0, startedAt: null, failed: [] };
|
|
127
|
+
stopped = false;
|
|
128
|
+
dirty = new Set();
|
|
129
|
+
jobDirty = false;
|
|
130
|
+
journalLines = 0;
|
|
131
|
+
writeTimer = null;
|
|
132
|
+
watchers = [];
|
|
133
|
+
transport = null;
|
|
134
|
+
harnesses;
|
|
135
|
+
enabledAt;
|
|
136
|
+
watching = null;
|
|
137
|
+
nested = [];
|
|
138
|
+
constructor(options) {
|
|
139
|
+
this.options = options;
|
|
140
|
+
this.harnesses = [...options.harnesses];
|
|
141
|
+
this.enabledAt = options.enabledAt;
|
|
142
|
+
}
|
|
143
|
+
owns(cwd) {
|
|
144
|
+
return owns(this.options.cwd, cwd, this.nested);
|
|
145
|
+
}
|
|
146
|
+
exclude(nested) {
|
|
147
|
+
this.nested = nested.filter((root) => root !== this.options.cwd && within(this.options.cwd, root));
|
|
148
|
+
}
|
|
149
|
+
configure(harnesses, enabledAt) {
|
|
150
|
+
const added = harnesses.filter((harness) => !this.harnesses.includes(harness));
|
|
151
|
+
const paused = this.harnesses.filter((harness) => !harnesses.includes(harness));
|
|
152
|
+
this.harnesses = [...harnesses];
|
|
153
|
+
this.enabledAt = enabledAt;
|
|
154
|
+
if (this.watching)
|
|
155
|
+
for (const harness of added)
|
|
156
|
+
void this.watchHarness(harness, this.watching);
|
|
157
|
+
// A paused harness's running Turn is no longer followed: the server treats it as one Cohub
|
|
158
|
+
// cannot stop, so a silence it never reports the end of asks the user.
|
|
159
|
+
for (const path of this.runningPaths) {
|
|
160
|
+
const state = this.states.get(path);
|
|
161
|
+
if (state && paused.includes(state.harness))
|
|
162
|
+
void this.reportControl(state);
|
|
163
|
+
}
|
|
164
|
+
if (this.transport)
|
|
165
|
+
void this.scan().catch((error) => this.report(this.options.cwd, error));
|
|
166
|
+
}
|
|
167
|
+
get ledgerPath() { return join(this.options.root, "native", "ledger.jsonl"); }
|
|
168
|
+
async load() {
|
|
169
|
+
let text;
|
|
170
|
+
try {
|
|
171
|
+
text = await readFile(this.ledgerPath, "utf8");
|
|
172
|
+
}
|
|
173
|
+
catch (error) {
|
|
174
|
+
if (missing(error))
|
|
175
|
+
return;
|
|
176
|
+
throw error;
|
|
177
|
+
}
|
|
178
|
+
const lines = text.split("\n").filter(Boolean);
|
|
179
|
+
for (const line of lines) {
|
|
180
|
+
let entry;
|
|
181
|
+
// A torn last line from a crash is dropped; the cache is rebuilt from transcripts and server.
|
|
182
|
+
try {
|
|
183
|
+
entry = JSON.parse(line);
|
|
184
|
+
}
|
|
185
|
+
catch {
|
|
186
|
+
continue;
|
|
187
|
+
}
|
|
188
|
+
if ("import" in entry)
|
|
189
|
+
this.job = { ...this.job, ...entry.import };
|
|
190
|
+
else if (entry.path && entry.state)
|
|
191
|
+
this.states.set(entry.path, entry.state);
|
|
192
|
+
}
|
|
193
|
+
for (const [path, state] of this.states)
|
|
194
|
+
if (state.runningTurnId)
|
|
195
|
+
this.runningPaths.add(path);
|
|
196
|
+
this.journalLines = lines.length;
|
|
197
|
+
if (this.overgrown())
|
|
198
|
+
await this.compact();
|
|
199
|
+
}
|
|
200
|
+
connect(transport) {
|
|
201
|
+
this.transport = transport;
|
|
202
|
+
if (!transport)
|
|
203
|
+
return;
|
|
204
|
+
void this.scan().then(() => this.resync()).catch((error) => this.report(this.options.cwd, error));
|
|
205
|
+
}
|
|
206
|
+
/**
|
|
207
|
+
* Record that Cohub created or chose this transcript for a Session, before any Turn of it runs.
|
|
208
|
+
*/
|
|
209
|
+
adopt(session, sessionId, through) {
|
|
210
|
+
const state = this.states.get(session.path) ?? this.blank(session.path, session.nativeSessionId, session.cwd, session.harness, "user", { size: -1, mtimeMs: 0, ino: 0 });
|
|
211
|
+
Object.assign(state, { sessionId, nativeSessionId: session.nativeSessionId });
|
|
212
|
+
if (through?.turnKey)
|
|
213
|
+
Object.assign(state, { throughTurnKey: through.turnKey, throughTurnId: through.turnId, headTurnId: through.turnId });
|
|
214
|
+
if (!this.states.has(session.path))
|
|
215
|
+
state.origin = "cohub";
|
|
216
|
+
this.states.set(session.path, state);
|
|
217
|
+
this.markDirty(session.path);
|
|
218
|
+
}
|
|
219
|
+
advance(path, turnId) {
|
|
220
|
+
const state = this.states.get(path);
|
|
221
|
+
if (!state)
|
|
222
|
+
return;
|
|
223
|
+
state.headTurnId = turnId;
|
|
224
|
+
this.markDirty(path);
|
|
225
|
+
this.touch(path);
|
|
226
|
+
}
|
|
227
|
+
touch(path) {
|
|
228
|
+
this.live.set(path, true);
|
|
229
|
+
this.drainLive();
|
|
230
|
+
}
|
|
231
|
+
running(harness, nativeSessionId) {
|
|
232
|
+
// A paused harness is neither synced nor previewed.
|
|
233
|
+
if (!this.harnesses.includes(harness))
|
|
234
|
+
return null;
|
|
235
|
+
for (const path of this.runningPaths) {
|
|
236
|
+
const state = this.states.get(path);
|
|
237
|
+
if (state?.harness === harness && state.nativeSessionId === nativeSessionId)
|
|
238
|
+
return state;
|
|
239
|
+
}
|
|
240
|
+
return null;
|
|
241
|
+
}
|
|
242
|
+
stop(sessionId, turnId) {
|
|
243
|
+
for (const path of this.runningPaths) {
|
|
244
|
+
const state = this.states.get(path);
|
|
245
|
+
if (state?.sessionId !== sessionId || state.runningTurnId !== turnId)
|
|
246
|
+
continue;
|
|
247
|
+
this.options.hooks?.abortTurn?.(state);
|
|
248
|
+
return true;
|
|
249
|
+
}
|
|
250
|
+
return false;
|
|
251
|
+
}
|
|
252
|
+
alive(harness, nativeSessionId) {
|
|
253
|
+
const state = this.running(harness, nativeSessionId);
|
|
254
|
+
if (!state?.runningTurnId)
|
|
255
|
+
return;
|
|
256
|
+
const now = Date.now();
|
|
257
|
+
if (now - (this.aliveAt.get(state.runningTurnId) ?? 0) < ALIVE_INTERVAL_MS)
|
|
258
|
+
return;
|
|
259
|
+
this.aliveAt.set(state.runningTurnId, now);
|
|
260
|
+
void this.reportControl(state, true);
|
|
261
|
+
}
|
|
262
|
+
/**
|
|
263
|
+
* A native client came or went, so whether Cohub can stop this session's running Turn may have
|
|
264
|
+
* changed.
|
|
265
|
+
*/
|
|
266
|
+
controlChanged(harness, nativeSessionId) {
|
|
267
|
+
const state = this.running(harness, nativeSessionId);
|
|
268
|
+
if (!state)
|
|
269
|
+
return;
|
|
270
|
+
void this.reportControl(state).then((controllable) => { if (controllable === false)
|
|
271
|
+
this.touch(state.path); });
|
|
272
|
+
}
|
|
273
|
+
status() {
|
|
274
|
+
return {
|
|
275
|
+
transcripts: this.states.size,
|
|
276
|
+
live: this.live.size + this.runningLive,
|
|
277
|
+
running: [...this.states.values()].filter((state) => state.runningTurnId).length,
|
|
278
|
+
import: { ...this.job, queued: this.importQueue.length, running: this.runningImport },
|
|
279
|
+
};
|
|
280
|
+
}
|
|
281
|
+
transcriptFor(sessionId, harness, headTurnId) {
|
|
282
|
+
// A paused harness's files are not read, so a terminal's may hold Turns its head does not show;
|
|
283
|
+
// only a file Cohub created, whose head Cohub itself advances, is continued then.
|
|
284
|
+
const synced = this.harnesses.includes(harness);
|
|
285
|
+
for (const state of this.states.values()) {
|
|
286
|
+
if (state.sessionId === sessionId && state.harness === harness && state.headTurnId === headTurnId && state.threadKind === "user" && (synced || state.origin === "cohub"))
|
|
287
|
+
return state;
|
|
288
|
+
}
|
|
289
|
+
return null;
|
|
290
|
+
}
|
|
291
|
+
async scan() {
|
|
292
|
+
const enabledAt = Date.parse(this.enabledAt);
|
|
293
|
+
for (const candidate of await discoverTranscripts(this.options.cwd, this.harnesses)) {
|
|
294
|
+
const state = this.states.get(candidate.path);
|
|
295
|
+
if (state ? !this.unchanged(state, candidate) : candidate.mtimeMs >= enabledAt)
|
|
296
|
+
this.live.set(candidate.path, this.live.get(candidate.path) ?? false);
|
|
297
|
+
}
|
|
298
|
+
this.drainLive();
|
|
299
|
+
if (this.job.state === "running")
|
|
300
|
+
await this.startImport(this.job.filter, this.job.concurrency);
|
|
301
|
+
}
|
|
302
|
+
async plan(filter = {}, signal) {
|
|
303
|
+
const harnesses = this.harnesses.filter((harness) => !filter.harnesses?.length || filter.harnesses.includes(harness));
|
|
304
|
+
const candidates = await discoverTranscripts(this.options.cwd, harnesses, { headers: true, nested: this.nested, signal });
|
|
305
|
+
// A transcript whose first read failed is still untracked (size -1), so a later import retries it.
|
|
306
|
+
return candidates.filter((candidate) => (this.states.get(candidate.path)?.size ?? -1) < 0 && !this.live.has(candidate.path)
|
|
307
|
+
&& (!filter.nativeSessionId || candidate.nativeSessionId === filter.nativeSessionId));
|
|
308
|
+
}
|
|
309
|
+
/**
|
|
310
|
+
* Start or resume a backfill. What was imported is tracked, so resuming simply plans again; the
|
|
311
|
+
* server recognizes any Turn it already has, so an interrupted batch is never duplicated.
|
|
312
|
+
*/
|
|
313
|
+
async startImport(filter = {}, concurrency = DEFAULT_IMPORT_CONCURRENCY) {
|
|
314
|
+
const queue = await this.plan(filter);
|
|
315
|
+
const resuming = this.job.state === "running" || this.job.state === "paused";
|
|
316
|
+
const same = resuming && JSON.stringify(this.job.filter) === JSON.stringify(filter);
|
|
317
|
+
const base = same ? this.job : { ...this.job, done: 0, turns: 0, bytes: 0, failed: [], startedAt: new Date().toISOString() };
|
|
318
|
+
this.importQueue = queue;
|
|
319
|
+
this.job = {
|
|
320
|
+
...base,
|
|
321
|
+
state: queue.length || this.runningImport ? "running" : "done",
|
|
322
|
+
filter,
|
|
323
|
+
concurrency: Math.min(MAX_IMPORT_CONCURRENCY, Math.max(1, Math.trunc(concurrency))),
|
|
324
|
+
files: base.done + queue.length,
|
|
325
|
+
totalBytes: base.bytes + queue.reduce((sum, candidate) => sum + candidate.size, 0),
|
|
326
|
+
};
|
|
327
|
+
this.markJobDirty();
|
|
328
|
+
this.drainImport();
|
|
329
|
+
return { ...this.job };
|
|
330
|
+
}
|
|
331
|
+
pauseImport() {
|
|
332
|
+
if (this.job.state === "running")
|
|
333
|
+
this.job.state = "paused";
|
|
334
|
+
this.importQueue = [];
|
|
335
|
+
this.markJobDirty();
|
|
336
|
+
return { ...this.job };
|
|
337
|
+
}
|
|
338
|
+
async watch(signal) {
|
|
339
|
+
this.watching = signal;
|
|
340
|
+
for (const harness of this.harnesses)
|
|
341
|
+
await this.watchHarness(harness, signal);
|
|
342
|
+
}
|
|
343
|
+
async watchHarness(harness, signal) {
|
|
344
|
+
const { watch } = await import("node:fs");
|
|
345
|
+
for (const root of transcriptRoots(harness)) {
|
|
346
|
+
await mkdir(root, { recursive: true, mode: 0o700 }).catch(() => undefined);
|
|
347
|
+
let timer = null;
|
|
348
|
+
try {
|
|
349
|
+
const watcher = watch(root, { recursive: true }, (_event, name) => {
|
|
350
|
+
if (!name)
|
|
351
|
+
return;
|
|
352
|
+
const path = join(root, name.toString());
|
|
353
|
+
if (this.foreign.has(path) || !acceptsTranscript(path, harness, this.options.cwd))
|
|
354
|
+
return;
|
|
355
|
+
if (!this.live.has(path))
|
|
356
|
+
this.live.set(path, false);
|
|
357
|
+
timer ??= setTimeout(() => { timer = null; this.drainLive(); }, WATCH_DEBOUNCE_MS);
|
|
358
|
+
timer.unref?.();
|
|
359
|
+
});
|
|
360
|
+
// An overflowed or failed watch cannot say what changed; a scan finds out once.
|
|
361
|
+
watcher.on("error", (error) => { this.report(root, error); void this.scan().catch(() => undefined); });
|
|
362
|
+
signal.addEventListener("abort", () => watcher.close(), { once: true });
|
|
363
|
+
this.watchers.push(watcher);
|
|
364
|
+
}
|
|
365
|
+
catch (error) {
|
|
366
|
+
// Without a watch, transcripts sync when the Runtime (re)connects or the control plane reports.
|
|
367
|
+
this.options.hooks?.onError?.(error, { path: root, harness });
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
async resync() {
|
|
372
|
+
for (const path of [...this.runningPaths]) {
|
|
373
|
+
const state = this.states.get(path);
|
|
374
|
+
if (state)
|
|
375
|
+
await this.reportControl(state, true);
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
async reportControl(state, always = false) {
|
|
379
|
+
const transport = this.transport;
|
|
380
|
+
const { sessionId, runningTurnId } = state;
|
|
381
|
+
if (!transport || !sessionId || !runningTurnId)
|
|
382
|
+
return undefined;
|
|
383
|
+
const controllable = this.harnesses.includes(state.harness) ? this.options.hooks?.controllable?.(state) : false;
|
|
384
|
+
if (!always && controllable === state.controllable)
|
|
385
|
+
return undefined;
|
|
386
|
+
state.controllable = controllable;
|
|
387
|
+
this.markDirty(state.path);
|
|
388
|
+
const result = await transport.status(sessionId, runningTurnId, controllable).catch(() => {
|
|
389
|
+
// Reported again on the next change or reconnect.
|
|
390
|
+
state.controllable = undefined;
|
|
391
|
+
return null;
|
|
392
|
+
});
|
|
393
|
+
// The server settled it (a confirmed stop): nothing is left to follow until the file changes.
|
|
394
|
+
if (result?.status && SETTLED_STATUSES.has(result.status)) {
|
|
395
|
+
if (state.runningTurnId === runningTurnId)
|
|
396
|
+
this.endRunning(state);
|
|
397
|
+
}
|
|
398
|
+
else if (result?.abortRequested)
|
|
399
|
+
this.options.hooks?.abortTurn?.(state);
|
|
400
|
+
return controllable;
|
|
401
|
+
}
|
|
402
|
+
async close(signal) {
|
|
403
|
+
this.stopped = true;
|
|
404
|
+
if (this.writeTimer)
|
|
405
|
+
clearTimeout(this.writeTimer);
|
|
406
|
+
for (const timer of this.timers.values())
|
|
407
|
+
clearTimeout(timer);
|
|
408
|
+
this.timers.clear();
|
|
409
|
+
for (const watcher of this.watchers)
|
|
410
|
+
watcher.close();
|
|
411
|
+
this.watchers.length = 0;
|
|
412
|
+
await Promise.allSettled(this.reads);
|
|
413
|
+
await this.write();
|
|
414
|
+
await this.options.archives.flush(signal).catch(() => undefined);
|
|
415
|
+
}
|
|
416
|
+
unchanged(state, info) {
|
|
417
|
+
return state.size === info.size && state.mtimeMs === info.mtimeMs && state.ino === info.ino;
|
|
418
|
+
}
|
|
419
|
+
drainLive() {
|
|
420
|
+
while (!this.stopped && this.transport && this.runningLive < LIVE_CONCURRENCY && this.live.size) {
|
|
421
|
+
const [path, force] = this.live.entries().next().value;
|
|
422
|
+
this.live.delete(path);
|
|
423
|
+
this.runningLive += 1;
|
|
424
|
+
void this.sync(path, "live", force).then(() => { this.failures.delete(path); }, (error) => this.retryLater(path, error))
|
|
425
|
+
.finally(() => { this.runningLive -= 1; this.drainLive(); });
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
retryLater(path, error) {
|
|
429
|
+
this.report(path, error);
|
|
430
|
+
if (this.stopped || !this.transport)
|
|
431
|
+
return;
|
|
432
|
+
const attempts = (this.failures.get(path) ?? 0) + 1;
|
|
433
|
+
if (attempts > RETRY_ATTEMPTS) {
|
|
434
|
+
this.failures.delete(path);
|
|
435
|
+
return;
|
|
436
|
+
}
|
|
437
|
+
this.failures.set(path, attempts);
|
|
438
|
+
this.recheck(path, Math.min(RETRY_MAX_MS, RETRY_BASE_MS * 2 ** (attempts - 1)));
|
|
439
|
+
}
|
|
440
|
+
drainImport() {
|
|
441
|
+
while (!this.stopped && this.transport && this.job.state === "running" && this.runningImport < this.job.concurrency && this.importQueue.length) {
|
|
442
|
+
const candidate = this.importQueue.shift();
|
|
443
|
+
this.runningImport += 1;
|
|
444
|
+
// A failed conversation is retried by the next `runtime import`.
|
|
445
|
+
void this.sync(candidate.path, "import", true).catch((error) => {
|
|
446
|
+
this.report(candidate.path, error);
|
|
447
|
+
if (this.job.failed.length < IMPORT_FAILURES_KEPT)
|
|
448
|
+
this.job.failed.push({ path: candidate.path, message: error instanceof Error ? error.message : String(error) });
|
|
449
|
+
}).finally(() => {
|
|
450
|
+
this.runningImport -= 1;
|
|
451
|
+
this.job.done += 1;
|
|
452
|
+
this.job.bytes += candidate.size;
|
|
453
|
+
if (this.job.state === "running" && !this.importQueue.length && !this.runningImport)
|
|
454
|
+
this.job.state = "done";
|
|
455
|
+
this.markJobDirty();
|
|
456
|
+
this.drainImport();
|
|
457
|
+
});
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
report(path, error) {
|
|
461
|
+
this.options.hooks?.onError?.(error, { path, harness: this.states.get(path)?.harness ?? this.harnesses[0] });
|
|
462
|
+
}
|
|
463
|
+
async sync(path, lane, force) {
|
|
464
|
+
// One reader per transcript: a conversation reopened during a backfill is read by one lane.
|
|
465
|
+
if (this.busy.has(path)) {
|
|
466
|
+
if (lane === "live")
|
|
467
|
+
setTimeout(() => { this.live.set(path, force || (this.live.get(path) ?? false)); this.drainLive(); }, WATCH_DEBOUNCE_MS).unref?.();
|
|
468
|
+
return;
|
|
469
|
+
}
|
|
470
|
+
this.busy.add(path);
|
|
471
|
+
const read = this.read(path, lane, force);
|
|
472
|
+
this.reads.add(read);
|
|
473
|
+
try {
|
|
474
|
+
await read;
|
|
475
|
+
}
|
|
476
|
+
finally {
|
|
477
|
+
this.busy.delete(path);
|
|
478
|
+
this.reads.delete(read);
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
async read(path, lane, force) {
|
|
482
|
+
if (this.foreign.has(path))
|
|
483
|
+
return;
|
|
484
|
+
const info = await stat(path).catch((error) => { if (missing(error))
|
|
485
|
+
return null; throw error; });
|
|
486
|
+
if (!info?.isFile())
|
|
487
|
+
return;
|
|
488
|
+
const previous = this.states.get(path);
|
|
489
|
+
// Unchanged bytes can still hold a Turn whose end the control plane has only now reported.
|
|
490
|
+
if (previous && this.unchanged(previous, info) && !(force && previous.runningTurnId))
|
|
491
|
+
return;
|
|
492
|
+
const harness = previous?.harness ?? this.harnessFor(path);
|
|
493
|
+
// A paused harness uploads nothing, not even for transcripts it already tracks.
|
|
494
|
+
if (!harness || !this.harnesses.includes(harness))
|
|
495
|
+
return;
|
|
496
|
+
if (previous && (previous.threadKind !== "user" || !this.owns(previous.cwd)))
|
|
497
|
+
return;
|
|
498
|
+
if (!previous) {
|
|
499
|
+
// A header is read once per file: it names the project and the thread's purpose.
|
|
500
|
+
const header = await readSessionHeader(path, harness);
|
|
501
|
+
if (!header)
|
|
502
|
+
return;
|
|
503
|
+
if (!this.owns(header.cwd)) {
|
|
504
|
+
this.foreign.add(path);
|
|
505
|
+
return;
|
|
506
|
+
}
|
|
507
|
+
if (header.thread !== "user") {
|
|
508
|
+
// Codex's own reviews, compaction runs and spawned agents are not chats; remember and skip them.
|
|
509
|
+
this.states.set(path, this.blank(path, header.nativeSessionId, header.cwd, harness, header.thread, info));
|
|
510
|
+
this.markDirty(path);
|
|
511
|
+
return;
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
if (previous && !force && previous.ino === info.ino && previous.size >= 0 && info.size >= previous.scannedBytes) {
|
|
515
|
+
// Nothing new since the last search (a repeated event), or new bytes without a boundary.
|
|
516
|
+
if (info.size === previous.scannedBytes)
|
|
517
|
+
return;
|
|
518
|
+
if (!await this.crossesBoundary(path, harness, previous.scannedBytes, info.size)) {
|
|
519
|
+
previous.scannedBytes = info.size;
|
|
520
|
+
if (previous.runningTurnId)
|
|
521
|
+
this.options.hooks?.onGrowth?.(previous);
|
|
522
|
+
return;
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
const transcript = await readTranscript(path, harness);
|
|
526
|
+
const state = previous ?? this.blank(path, transcript.nativeSessionId, transcript.cwd, harness, "user", { size: -1, mtimeMs: 0, ino: 0 });
|
|
527
|
+
this.states.set(path, state);
|
|
528
|
+
// The file counts as read only once the server confirmed it; a failed send is retried.
|
|
529
|
+
await this.apply(state, transcript.turns, lane, info.mtimeMs);
|
|
530
|
+
Object.assign(state, { size: info.size, mtimeMs: info.mtimeMs, ino: info.ino, scannedBytes: info.size });
|
|
531
|
+
this.markDirty(path);
|
|
532
|
+
}
|
|
533
|
+
async crossesBoundary(path, harness, from, to) {
|
|
534
|
+
const file = await open(path, "r");
|
|
535
|
+
try {
|
|
536
|
+
const chunk = Buffer.alloc(Math.min(to - from + BOUNDARY_OVERLAP, 1024 * 1024));
|
|
537
|
+
for (let position = from; position < to;) {
|
|
538
|
+
const start = Math.max(0, position - BOUNDARY_OVERLAP);
|
|
539
|
+
const { bytesRead } = await file.read(chunk, 0, Math.min(chunk.length, to - start), start);
|
|
540
|
+
if (bytesRead <= position - start)
|
|
541
|
+
break;
|
|
542
|
+
const bytes = chunk.subarray(0, bytesRead);
|
|
543
|
+
// A token that ends inside the bytes already searched was seen before; it does not count again.
|
|
544
|
+
const fresh = position - start;
|
|
545
|
+
for (const token of BOUNDARIES[harness]) {
|
|
546
|
+
for (let index = bytes.indexOf(token); index >= 0; index = bytes.indexOf(token, index + 1))
|
|
547
|
+
if (index + token.length > fresh)
|
|
548
|
+
return true;
|
|
549
|
+
}
|
|
550
|
+
position = start + bytesRead;
|
|
551
|
+
}
|
|
552
|
+
return false;
|
|
553
|
+
}
|
|
554
|
+
finally {
|
|
555
|
+
await file.close();
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
blank(path, nativeSessionId, cwd, harness, threadKind, info) {
|
|
559
|
+
return { harness, path, nativeSessionId, cwd, threadKind, size: info.size, mtimeMs: info.mtimeMs, ino: info.ino, scannedBytes: Math.max(0, info.size), throughTurnKey: null, throughTurnId: null, headTurnId: null, sessionId: null, runningTurnId: null, runningTurnKey: null, origin: "native" };
|
|
560
|
+
}
|
|
561
|
+
harnessFor(path) {
|
|
562
|
+
for (const harness of this.harnesses) {
|
|
563
|
+
if (transcriptRoots(harness).some((root) => path.startsWith(`${root}/`)) && acceptsTranscript(path, harness, this.options.cwd))
|
|
564
|
+
return harness;
|
|
565
|
+
}
|
|
566
|
+
return null;
|
|
567
|
+
}
|
|
568
|
+
tailSettled(state, tail, mtimeMs) {
|
|
569
|
+
if (state.harness === "codex")
|
|
570
|
+
return tail.result !== null;
|
|
571
|
+
const certain = this.options.hooks?.piSettled?.(state.nativeSessionId, mtimeMs);
|
|
572
|
+
if (certain != null)
|
|
573
|
+
return certain;
|
|
574
|
+
if (!PI_ENDED.includes(tail.messages.at(-1)?.stopReason ?? ""))
|
|
575
|
+
return false;
|
|
576
|
+
const idleMs = Date.now() - mtimeMs;
|
|
577
|
+
if (idleMs >= PI_QUIET_SETTLE_MS)
|
|
578
|
+
return true;
|
|
579
|
+
this.recheck(state.path, PI_QUIET_SETTLE_MS - idleMs);
|
|
580
|
+
return false;
|
|
581
|
+
}
|
|
582
|
+
recheck(path, delayMs) {
|
|
583
|
+
clearTimeout(this.timers.get(path));
|
|
584
|
+
const timer = setTimeout(() => { this.timers.delete(path); this.touch(path); }, Math.max(0, delayMs) + 50);
|
|
585
|
+
timer.unref?.();
|
|
586
|
+
this.timers.set(path, timer);
|
|
587
|
+
}
|
|
588
|
+
async known(transport, turnIds) {
|
|
589
|
+
const known = new Map();
|
|
590
|
+
for (const chunk of chunks([...new Set(turnIds)], NATIVE_INGEST_MAX_TURNS)) {
|
|
591
|
+
for (const entry of await transport.known(chunk))
|
|
592
|
+
known.set(entry.turnId, entry);
|
|
593
|
+
}
|
|
594
|
+
return known;
|
|
595
|
+
}
|
|
596
|
+
async apply(state, turns, lane, mtimeMs) {
|
|
597
|
+
const transport = this.transport;
|
|
598
|
+
if (!transport)
|
|
599
|
+
throw new Error("Runtime is offline");
|
|
600
|
+
const derived = (turn) => nativeTurnId(this.options.spaceId, state.harness, state.nativeSessionId, turn.key);
|
|
601
|
+
const from = state.throughTurnKey ? turns.findIndex((turn) => turn.key === state.throughTurnKey) + 1 : 0;
|
|
602
|
+
const pending = turns.slice(from);
|
|
603
|
+
// One question to the server replaces every local receipt: which of these do you have?
|
|
604
|
+
const known = pending.length ? await this.known(transport, pending.flatMap((turn) => turn.cloudTurnId ? [turn.cloudTurnId, derived(turn)] : [derived(turn)])) : new Map();
|
|
605
|
+
const cohub = (turn) => turn.cloudTurnId && known.has(turn.cloudTurnId) ? turn.cloudTurnId : null;
|
|
606
|
+
const tail = turns.at(-1);
|
|
607
|
+
const head = !tail ? null : pending.length ? cohub(tail) ?? derived(tail) : state.throughTurnId;
|
|
608
|
+
const candidates = [];
|
|
609
|
+
let parentTurnId = from > 0 ? state.throughTurnId : null;
|
|
610
|
+
for (const [offset, turn] of pending.entries()) {
|
|
611
|
+
const marked = cohub(turn);
|
|
612
|
+
if (marked) {
|
|
613
|
+
// Cohub started this Turn, so the server already has it; the marker exists only so the
|
|
614
|
+
// native conversation can be resumed.
|
|
615
|
+
parentTurnId = marked;
|
|
616
|
+
if (!candidates.length) {
|
|
617
|
+
state.throughTurnKey = turn.key;
|
|
618
|
+
state.throughTurnId = marked;
|
|
619
|
+
}
|
|
620
|
+
continue;
|
|
621
|
+
}
|
|
622
|
+
const settled = offset < pending.length - 1 || this.tailSettled(state, turn, mtimeMs);
|
|
623
|
+
// History that never ended is left to the live lane; its transcript reports it again.
|
|
624
|
+
if (!settled && lane === "import")
|
|
625
|
+
break;
|
|
626
|
+
// A prompt still being recorded would change the Turn's identity; it is sent once complete.
|
|
627
|
+
if (!settled && state.harness === "codex" && !turn.userFinal)
|
|
628
|
+
break;
|
|
629
|
+
const turnId = derived(turn);
|
|
630
|
+
candidates.push({ turn, turnId, parentTurnId, settled });
|
|
631
|
+
parentTurnId = turnId;
|
|
632
|
+
}
|
|
633
|
+
if (!candidates.length) {
|
|
634
|
+
state.headTurnId = head;
|
|
635
|
+
return;
|
|
636
|
+
}
|
|
637
|
+
let prefix = 0;
|
|
638
|
+
for (const item of candidates) {
|
|
639
|
+
const recorded = known.get(item.turnId);
|
|
640
|
+
if (!recorded?.settled)
|
|
641
|
+
break;
|
|
642
|
+
// The server settled it meanwhile (a confirmed stop, or an earlier send that was not answered).
|
|
643
|
+
if (state.runningTurnId === item.turnId)
|
|
644
|
+
this.endRunning(state);
|
|
645
|
+
state.sessionId = recorded.sessionId;
|
|
646
|
+
state.throughTurnKey = item.turn.key;
|
|
647
|
+
state.throughTurnId = item.turnId;
|
|
648
|
+
prefix += 1;
|
|
649
|
+
}
|
|
650
|
+
// A running Turn is registered once; until it ends, only the realtime path speaks of it.
|
|
651
|
+
const todo = candidates.slice(prefix).filter((item) => {
|
|
652
|
+
const recorded = known.get(item.turnId);
|
|
653
|
+
if (!item.settled && recorded) {
|
|
654
|
+
this.markRunning(state, recorded.sessionId, item);
|
|
655
|
+
return false;
|
|
656
|
+
}
|
|
657
|
+
return !recorded?.settled;
|
|
658
|
+
});
|
|
659
|
+
let lastSettled = null;
|
|
660
|
+
const controllable = this.options.hooks?.controllable?.(state);
|
|
661
|
+
const payloads = todo.map((item) => ({
|
|
662
|
+
item,
|
|
663
|
+
turn: {
|
|
664
|
+
turnId: item.turnId,
|
|
665
|
+
parentTurnId: item.parentTurnId,
|
|
666
|
+
userContent: item.turn.userContent,
|
|
667
|
+
startedAt: item.turn.startedAt,
|
|
668
|
+
...(this.imported(item.turn) ? { origin: "local_import" } : {}),
|
|
669
|
+
...(!item.settled && controllable !== undefined ? { controllable } : {}),
|
|
670
|
+
result: item.settled ? this.complete(item.turn.result ?? this.cutOff(item, pending)) : null,
|
|
671
|
+
},
|
|
672
|
+
}));
|
|
673
|
+
for (const batch of chunks(payloads, INGEST_BATCH_TURNS, (payload) => JSON.stringify(payload.turn).length)) {
|
|
674
|
+
const result = await transport.ingest({
|
|
675
|
+
type: "ingest",
|
|
676
|
+
input: { harness: state.harness, nativeSessionId: state.nativeSessionId, turns: batch.map((payload) => payload.turn) },
|
|
677
|
+
});
|
|
678
|
+
const outcomes = new Map(result.turns.map((entry) => [entry.turnId, entry]));
|
|
679
|
+
for (const { item } of batch) {
|
|
680
|
+
const entry = outcomes.get(item.turnId);
|
|
681
|
+
// A Turn the server did not take stops the file here; the next read retries from it.
|
|
682
|
+
if (!entry)
|
|
683
|
+
throw new Error("Server did not record a native Turn");
|
|
684
|
+
state.sessionId = entry.sessionId;
|
|
685
|
+
if (!entry.settled) {
|
|
686
|
+
state.controllable = controllable;
|
|
687
|
+
this.markRunning(state, entry.sessionId, item);
|
|
688
|
+
continue;
|
|
689
|
+
}
|
|
690
|
+
if (state.runningTurnId === entry.turnId)
|
|
691
|
+
this.endRunning(state);
|
|
692
|
+
state.throughTurnKey = item.turn.key;
|
|
693
|
+
state.throughTurnId = entry.turnId;
|
|
694
|
+
lastSettled = item;
|
|
695
|
+
if (lane === "import")
|
|
696
|
+
this.job.turns += 1;
|
|
697
|
+
}
|
|
698
|
+
this.markDirty(state.path);
|
|
699
|
+
}
|
|
700
|
+
// Only the newest settled state needs raw bytes: every older version is a prefix of it.
|
|
701
|
+
state.headTurnId = head;
|
|
702
|
+
if (lastSettled)
|
|
703
|
+
await this.archive(state, lastSettled.turn, lastSettled.turnId);
|
|
704
|
+
}
|
|
705
|
+
markRunning(state, sessionId, item) {
|
|
706
|
+
const changed = state.runningTurnId !== item.turnId;
|
|
707
|
+
state.sessionId = sessionId;
|
|
708
|
+
state.runningTurnId = item.turnId;
|
|
709
|
+
state.runningTurnKey = item.turn.key;
|
|
710
|
+
this.runningPaths.add(state.path);
|
|
711
|
+
if (!changed)
|
|
712
|
+
return;
|
|
713
|
+
this.options.hooks?.onRunning?.(state, item.turnId);
|
|
714
|
+
// Ask once: a stop may have reached the server before this Runtime knew the Turn.
|
|
715
|
+
this.aliveAt.set(item.turnId, Date.now());
|
|
716
|
+
void this.reportControl(state, true);
|
|
717
|
+
}
|
|
718
|
+
endRunning(state) {
|
|
719
|
+
if (state.runningTurnId)
|
|
720
|
+
this.aliveAt.delete(state.runningTurnId);
|
|
721
|
+
state.runningTurnId = null;
|
|
722
|
+
state.runningTurnKey = null;
|
|
723
|
+
state.controllable = undefined;
|
|
724
|
+
this.runningPaths.delete(state.path);
|
|
725
|
+
this.options.hooks?.onRunning?.(state, null);
|
|
726
|
+
}
|
|
727
|
+
cutOff(item, pending) {
|
|
728
|
+
const next = pending[pending.indexOf(item.turn) + 1];
|
|
729
|
+
return { messages: item.turn.messages, status: "interrupted", completedAt: next?.startedAt ?? item.turn.startedAt };
|
|
730
|
+
}
|
|
731
|
+
imported(turn) {
|
|
732
|
+
return Date.parse(turn.startedAt) < Date.parse(this.enabledAt);
|
|
733
|
+
}
|
|
734
|
+
async archive(state, turn, turnId) {
|
|
735
|
+
if (!state.sessionId)
|
|
736
|
+
return;
|
|
737
|
+
try {
|
|
738
|
+
const source = turn.path ?? state.path;
|
|
739
|
+
await this.options.archives.stage({
|
|
740
|
+
sessionId: state.sessionId,
|
|
741
|
+
harness: state.harness,
|
|
742
|
+
nativeSessionId: state.nativeSessionId,
|
|
743
|
+
path: state.harness === "codex" ? await ensurePlainCodexRollout(source, join(this.options.root, "native", "rollouts")) : source,
|
|
744
|
+
sizeBytes: turn.endBytes,
|
|
745
|
+
expectedChecksum: turn.sha256,
|
|
746
|
+
}, turnId);
|
|
747
|
+
}
|
|
748
|
+
catch (error) {
|
|
749
|
+
// Capture never blocks the ledger: a later pass retries, and a rewritten file starts a new
|
|
750
|
+
// archive baseline instead of failing the sync.
|
|
751
|
+
this.options.hooks?.onError?.(error, { path: state.path, harness: state.harness });
|
|
752
|
+
}
|
|
753
|
+
}
|
|
754
|
+
message(message) {
|
|
755
|
+
return {
|
|
756
|
+
content: message.content,
|
|
757
|
+
provider: message.provider ?? null,
|
|
758
|
+
model: message.model ?? null,
|
|
759
|
+
usage: (message.usage ?? null),
|
|
760
|
+
stopReason: message.stopReason ?? null,
|
|
761
|
+
errorMessage: message.errorMessage ?? null,
|
|
762
|
+
};
|
|
763
|
+
}
|
|
764
|
+
complete(result) {
|
|
765
|
+
return { messages: result.messages.map((message) => this.message(message)), status: result.status, completedAt: result.completedAt };
|
|
766
|
+
}
|
|
767
|
+
markDirty(path) {
|
|
768
|
+
this.dirty.add(path);
|
|
769
|
+
this.scheduleWrite();
|
|
770
|
+
}
|
|
771
|
+
markJobDirty() {
|
|
772
|
+
this.jobDirty = true;
|
|
773
|
+
this.scheduleWrite();
|
|
774
|
+
}
|
|
775
|
+
scheduleWrite() {
|
|
776
|
+
if (this.stopped)
|
|
777
|
+
return;
|
|
778
|
+
this.writeTimer ??= setTimeout(() => { this.writeTimer = null; void this.write().catch((error) => this.report(this.ledgerPath, error)); }, 500);
|
|
779
|
+
this.writeTimer.unref?.();
|
|
780
|
+
}
|
|
781
|
+
write() {
|
|
782
|
+
this.writing = this.writing.then(() => this.append(), () => this.append());
|
|
783
|
+
return this.writing;
|
|
784
|
+
}
|
|
785
|
+
async append() {
|
|
786
|
+
const entries = [];
|
|
787
|
+
for (const path of this.dirty) {
|
|
788
|
+
const state = this.states.get(path);
|
|
789
|
+
if (state)
|
|
790
|
+
entries.push({ path, state });
|
|
791
|
+
}
|
|
792
|
+
if (this.jobDirty)
|
|
793
|
+
entries.push({ import: this.job });
|
|
794
|
+
this.dirty.clear();
|
|
795
|
+
this.jobDirty = false;
|
|
796
|
+
if (!entries.length)
|
|
797
|
+
return;
|
|
798
|
+
await mkdir(join(this.options.root, "native"), { recursive: true, mode: 0o700 });
|
|
799
|
+
await appendFile(this.ledgerPath, entries.map((entry) => `${JSON.stringify(entry)}\n`).join(""), { mode: 0o600 });
|
|
800
|
+
this.journalLines += entries.length;
|
|
801
|
+
// A long-running Runtime keeps the journal near its content, not just a fresh one.
|
|
802
|
+
if (this.overgrown())
|
|
803
|
+
await this.compact();
|
|
804
|
+
}
|
|
805
|
+
overgrown() {
|
|
806
|
+
return this.journalLines > 2 * this.states.size + 256;
|
|
807
|
+
}
|
|
808
|
+
async compact() {
|
|
809
|
+
const entries = [...this.states].map(([path, state]) => ({ path, state }));
|
|
810
|
+
entries.push({ import: this.job });
|
|
811
|
+
const temporary = `${this.ledgerPath}.${process.pid}.tmp`;
|
|
812
|
+
const file = await open(temporary, "w", 0o600);
|
|
813
|
+
try {
|
|
814
|
+
await file.writeFile(entries.map((entry) => `${JSON.stringify(entry)}\n`).join(""));
|
|
815
|
+
await file.sync();
|
|
816
|
+
}
|
|
817
|
+
finally {
|
|
818
|
+
await file.close();
|
|
819
|
+
}
|
|
820
|
+
await rename(temporary, this.ledgerPath);
|
|
821
|
+
this.journalLines = entries.length;
|
|
822
|
+
}
|
|
823
|
+
}
|