taskchef 9.0.3 → 9.0.4
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/.codex-plugin/plugin.json +1 -1
- package/docs/taskchef-next-storage.md +10 -0
- package/package.json +1 -1
- package/src/codex-rollout.js +36 -0
- package/src/codex-session-scanner.js +34 -16
- package/src/codex-session-usage.js +50 -48
|
@@ -88,6 +88,16 @@ not read again. Appended files are read from the last complete line; replaced
|
|
|
88
88
|
or truncated files are read again. Changed compressed files are decoded again;
|
|
89
89
|
unchanged compressed files use the same cache. A runtime without zstd support
|
|
90
90
|
marks compressed histories partial. Concurrent views share this reader.
|
|
91
|
+
Plain and compressed siblings share one cache identity. Reads prefer the plain
|
|
92
|
+
file when both exist and retry briefly during format transitions. A stale
|
|
93
|
+
selected path can resolve its sibling, so compression or restoration alone does
|
|
94
|
+
not mark accounting partial. Existing compressed cache identities are normalized
|
|
95
|
+
when loaded.
|
|
96
|
+
|
|
97
|
+
Optional Details message counts use the same path resolution. Compressed logs
|
|
98
|
+
are decoded as a stream, retaining bounded head and tail samples. Their
|
|
99
|
+
`fileBytes` and `sampledBytes` describe decoded JSONL bytes, as for plain files.
|
|
100
|
+
Compression does not change database-derived board status or replies.
|
|
91
101
|
|
|
92
102
|
Computed accounting results are saved under TaskChef's state directory in
|
|
93
103
|
`cache/usage/`, with one hashed file per chat and duration-view variant. Files
|
package/package.json
CHANGED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { open, stat } from "node:fs/promises";
|
|
2
|
+
import * as zlib from "node:zlib";
|
|
3
|
+
|
|
4
|
+
export function plainRolloutPath(path) { return path.replace(/\.jsonl\.zst$/, ".jsonl"); }
|
|
5
|
+
|
|
6
|
+
// Codex can replace either representation while readers are resolving a path.
|
|
7
|
+
async function resolve(path, inspect) {
|
|
8
|
+
const plain = plainRolloutPath(path);
|
|
9
|
+
for (let attempt = 0; ; attempt++) {
|
|
10
|
+
for (const candidate of [plain, `${plain}.zst`]) {
|
|
11
|
+
try { return { path: candidate, ...await inspect(candidate) }; }
|
|
12
|
+
catch (error) { if (error.code !== "ENOENT") throw error; }
|
|
13
|
+
}
|
|
14
|
+
if (attempt === 3) { const error = new Error("Rollout is missing."); error.code = "ENOENT"; throw error; }
|
|
15
|
+
await new Promise(resolve => setTimeout(resolve, 50));
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function rolloutMetadata(path) { return resolve(path, async path => ({ info: await stat(path) })); }
|
|
20
|
+
export function openRollout(path) {
|
|
21
|
+
return resolve(path, async path => {
|
|
22
|
+
const handle = await open(path, "r");
|
|
23
|
+
try { return { handle, info: await handle.stat() }; }
|
|
24
|
+
catch (error) { await handle.close(); throw error; }
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export async function* rolloutChunks({ path, handle }, options = {}) {
|
|
29
|
+
const compressed = path.endsWith(".jsonl.zst");
|
|
30
|
+
if (compressed && typeof zlib.createZstdDecompress !== "function") throw new Error("This runtime cannot decode compressed rollouts.");
|
|
31
|
+
const input = handle.createReadStream({ ...options, autoClose: false });
|
|
32
|
+
const stream = compressed ? zlib.createZstdDecompress() : input;
|
|
33
|
+
if (compressed) { input.on("error", error => stream.destroy(error)); input.pipe(stream); }
|
|
34
|
+
try { for await (const chunk of stream) yield chunk; }
|
|
35
|
+
finally { input.destroy(); if (compressed) stream.destroy(); }
|
|
36
|
+
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { openRollout, rolloutChunks } from "./codex-rollout.js";
|
|
1
2
|
import { createHash } from "node:crypto";
|
|
2
3
|
import { normalizeUri } from "micromark-util-sanitize-uri";
|
|
3
4
|
import { displayUserPrompt, responseAnnotations } from "./user-prompt.js";
|
|
@@ -7,7 +8,7 @@ import { CodexPricing } from "./codex-pricing.js";
|
|
|
7
8
|
import { replyImage, replyImages, localReplyImage } from "./reply-image.js";
|
|
8
9
|
import { homedir } from "node:os";
|
|
9
10
|
import { basename, isAbsolute, join } from "node:path";
|
|
10
|
-
import {
|
|
11
|
+
import { stat, readFile, readdir, mkdir } from "node:fs/promises";
|
|
11
12
|
import { acquireFileLock } from "./file-lock.js";
|
|
12
13
|
import { writeDurableAtomic } from "./state-store.js";
|
|
13
14
|
import { dirname } from "node:path";
|
|
@@ -392,18 +393,37 @@ async function readSlice(handle, start, bytes) {
|
|
|
392
393
|
}
|
|
393
394
|
|
|
394
395
|
async function readSession(file, now) {
|
|
395
|
-
const
|
|
396
|
+
const source = await openRollout(file.path);
|
|
397
|
+
const { handle } = source;
|
|
396
398
|
try {
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
399
|
+
let size, headEnd, head, tail, contiguous;
|
|
400
|
+
if (source.path.endsWith(".jsonl.zst")) {
|
|
401
|
+
let prefix = Buffer.alloc(0), suffix = Buffer.alloc(0);
|
|
402
|
+
size = 0;
|
|
403
|
+
for await (const chunk of rolloutChunks(source, { highWaterMark: TAIL_BYTES })) {
|
|
404
|
+
size += chunk.length;
|
|
405
|
+
if (prefix.length < META_LINE_BYTES) prefix = Buffer.concat([prefix, chunk.subarray(0, META_LINE_BYTES - prefix.length)]);
|
|
406
|
+
suffix = Buffer.concat([suffix, chunk]);
|
|
407
|
+
if (suffix.length > TAIL_BYTES) suffix = Buffer.from(suffix.subarray(suffix.length - TAIL_BYTES));
|
|
408
|
+
}
|
|
409
|
+
headEnd = Math.min(size, HEAD_BYTES);
|
|
410
|
+
if (!prefix.subarray(0, headEnd).includes(10) && headEnd < size) headEnd = Math.min(size, META_LINE_BYTES);
|
|
411
|
+
head = prefix.subarray(0, headEnd).toString("utf8");
|
|
412
|
+
const tailStart = Math.max(0, size - TAIL_BYTES);
|
|
413
|
+
contiguous = tailStart <= headEnd;
|
|
414
|
+
tail = suffix.subarray(contiguous ? headEnd - tailStart : 0).toString("utf8");
|
|
415
|
+
} else {
|
|
416
|
+
size = source.info.size;
|
|
417
|
+
headEnd = Math.min(size, HEAD_BYTES);
|
|
402
418
|
head = await readSlice(handle, 0, headEnd);
|
|
419
|
+
if (!head.includes("\n") && headEnd < size) {
|
|
420
|
+
headEnd = Math.min(size, META_LINE_BYTES);
|
|
421
|
+
head = await readSlice(handle, 0, headEnd);
|
|
422
|
+
}
|
|
423
|
+
const tailStart = Math.max(0, size - TAIL_BYTES);
|
|
424
|
+
contiguous = tailStart <= headEnd;
|
|
425
|
+
tail = contiguous ? await readSlice(handle, headEnd, size - headEnd) : await readSlice(handle, tailStart, size - tailStart);
|
|
403
426
|
}
|
|
404
|
-
const tailStart = Math.max(0, size - TAIL_BYTES);
|
|
405
|
-
const contiguous = tailStart <= headEnd;
|
|
406
|
-
const tail = contiguous ? await readSlice(handle, headEnd, size - headEnd) : await readSlice(handle, tailStart, size - tailStart);
|
|
407
427
|
let cwd = "";
|
|
408
428
|
let createdAt = "";
|
|
409
429
|
let lastEvent = "";
|
|
@@ -438,9 +458,9 @@ async function readSession(file, now) {
|
|
|
438
458
|
parseLines(tail.slice(tail.indexOf("\n") + 1), accept);
|
|
439
459
|
}
|
|
440
460
|
if (!hasSessionMeta) throw new Error("Session metadata is missing or invalid.");
|
|
441
|
-
const updatedAt = new Date(
|
|
461
|
+
const updatedAt = new Date(source.info.mtimeMs).toISOString();
|
|
442
462
|
const projectName = cwd ? basename(cwd) || cwd : "Unknown project";
|
|
443
|
-
const active = !file.archive && lastEvent === "task_started" && now -
|
|
463
|
+
const active = !file.archive && lastEvent === "task_started" && now - source.info.mtimeMs < ACTIVE_WINDOW_MS;
|
|
444
464
|
const activity = active ? "A turn started recently; live activity is unverified."
|
|
445
465
|
: lastEvent === "task_complete" ? "Latest recorded turn ended; task outcome is unknown."
|
|
446
466
|
: lastEvent === "turn_aborted" ? "Latest recorded turn stopped; task outcome is unknown."
|
|
@@ -450,7 +470,7 @@ async function readSession(file, now) {
|
|
|
450
470
|
summary: activity, status: active ? "working" : null, createdAt: createdAt || updatedAt, updatedAt,
|
|
451
471
|
updatedBy: "Local Codex log", project: { name: projectName, path: cwd, githubRepos: [] },
|
|
452
472
|
threadId: file.id, turnRef: null, turnId: null, lastResult: null, latestTurn: null,
|
|
453
|
-
observed: { archive: file.archive, lastTurnEvent: lastEvent || null, lastTurnEventAt: lastEventAt || null, recentFileActivity: !file.archive && now -
|
|
473
|
+
observed: { archive: file.archive, lastTurnEvent: lastEvent || null, lastTurnEventAt: lastEventAt || null, recentFileActivity: !file.archive && now - source.info.mtimeMs < ACTIVE_WINDOW_MS, userMessages, assistantMessages, sampledBytes: Math.min(size, headEnd + TAIL_BYTES), fileBytes: size },
|
|
454
474
|
};
|
|
455
475
|
} finally { await handle.close(); }
|
|
456
476
|
}
|
|
@@ -698,9 +718,7 @@ export class CodexSessionScanner {
|
|
|
698
718
|
try {
|
|
699
719
|
const path = this.rolloutPaths.get(id);
|
|
700
720
|
if (typeof path !== "string" || !isAbsolute(path)) return detail;
|
|
701
|
-
const
|
|
702
|
-
if (!info.isFile()) return detail;
|
|
703
|
-
const log = await readSession({ path, id, archive: task.observed.archive, mtimeMs: info.mtimeMs }, this.now());
|
|
721
|
+
const log = await readSession({ path, id, archive: task.observed.archive }, this.now());
|
|
704
722
|
return { ...detail, observed: { ...detail.observed,
|
|
705
723
|
userMessages: log.observed.userMessages,
|
|
706
724
|
assistantMessages: log.observed.assistantMessages,
|
|
@@ -1,10 +1,9 @@
|
|
|
1
1
|
import { migrateCache } from "./next-cache.js";
|
|
2
|
-
import {
|
|
3
|
-
import { readdir, stat, readFile } from "node:fs/promises";
|
|
2
|
+
import { readdir, readFile } from "node:fs/promises";
|
|
4
3
|
import { join, basename } from "node:path";
|
|
5
4
|
import { createHash } from "node:crypto";
|
|
6
5
|
import { writeDurableAtomic } from "./state-store.js";
|
|
7
|
-
import
|
|
6
|
+
import { plainRolloutPath, rolloutMetadata, openRollout, rolloutChunks } from "./codex-rollout.js";
|
|
8
7
|
import { estimateCallCost } from "./codex-pricing.js";
|
|
9
8
|
|
|
10
9
|
const FIELDS = ["input_tokens", "cached_input_tokens", "cache_write_input_tokens", "output_tokens", "reasoning_output_tokens", "total_tokens"];
|
|
@@ -77,7 +76,7 @@ async function inventory(root, paths = []) {
|
|
|
77
76
|
catch (error) { if (error.code === "ENOENT") return paths; throw error; }
|
|
78
77
|
for (const entry of entries) {
|
|
79
78
|
if (entry.isDirectory()) await inventory(join(root, entry.name), paths);
|
|
80
|
-
else if (entry.isFile() && /\.jsonl(?:\.zst)?$/.test(entry.name)) paths.push(join(root, entry.name));
|
|
79
|
+
else if (entry.isFile() && /\.jsonl(?:\.zst)?$/.test(entry.name)) paths.push(plainRolloutPath(join(root, entry.name)));
|
|
81
80
|
}
|
|
82
81
|
return paths;
|
|
83
82
|
}
|
|
@@ -113,10 +112,10 @@ export class CodexSessionUsage {
|
|
|
113
112
|
const sources = [];
|
|
114
113
|
for (const session of sessions) {
|
|
115
114
|
const paths = new Set(this.paths.filter(path => basename(path).includes(`-${session.id}`)));
|
|
116
|
-
if (session.rollout_path) paths.add(session.rollout_path);
|
|
115
|
+
if (session.rollout_path) paths.add(plainRolloutPath(session.rollout_path));
|
|
117
116
|
const versions = [];
|
|
118
117
|
for (const path of paths) {
|
|
119
|
-
try { const info = await
|
|
118
|
+
try { const { info } = await rolloutMetadata(path); versions.push([path, info.dev, info.ino, info.size, info.mtimeMs]); }
|
|
120
119
|
catch { versions.push([path, "missing"]); }
|
|
121
120
|
}
|
|
122
121
|
sources.push([session.id, versions]);
|
|
@@ -133,12 +132,20 @@ export class CodexSessionUsage {
|
|
|
133
132
|
}
|
|
134
133
|
const sourceKeys = (items) => items.flatMap(([sessionId, versions]) => versions
|
|
135
134
|
.filter(version => version.length > 2)
|
|
136
|
-
.map(version => JSON.stringify([sessionId, basename(version[0])])));
|
|
135
|
+
.map(version => JSON.stringify([sessionId, basename(plainRolloutPath(version[0]))])));
|
|
137
136
|
const currentSources = new Set(sourceKeys(sources));
|
|
138
137
|
let knownSources = [];
|
|
139
138
|
try {
|
|
140
139
|
// Older cache entries already contain source metadata in their signature.
|
|
141
140
|
knownSources = saved?.knownSources ?? sourceKeys(JSON.parse(saved.sourceSignature)[2]);
|
|
141
|
+
knownSources = [...new Set(knownSources.map(key => {
|
|
142
|
+
const [sessionId, name] = JSON.parse(key);
|
|
143
|
+
return JSON.stringify([sessionId, plainRolloutPath(name)]);
|
|
144
|
+
}))];
|
|
145
|
+
if (saved?.recordSources) saved = { ...saved, recordSources: Object.fromEntries(Object.entries(saved.recordSources).map(([key, rows]) => {
|
|
146
|
+
const [sessionId, name] = JSON.parse(key);
|
|
147
|
+
return [JSON.stringify([sessionId, plainRolloutPath(name)]), rows];
|
|
148
|
+
})) };
|
|
142
149
|
if (!Array.isArray(knownSources) || knownSources.some(key => {
|
|
143
150
|
if (typeof key !== "string") return true;
|
|
144
151
|
const parts = JSON.parse(key);
|
|
@@ -153,7 +160,7 @@ export class CodexSessionUsage {
|
|
|
153
160
|
const recordSources = {};
|
|
154
161
|
for (const session of sessions) {
|
|
155
162
|
const paths = new Set(this.paths.filter(path => basename(path).includes(`-${session.id}`)));
|
|
156
|
-
if (session.rollout_path) paths.add(session.rollout_path);
|
|
163
|
+
if (session.rollout_path) paths.add(plainRolloutPath(session.rollout_path));
|
|
157
164
|
const records = [], versions = [];
|
|
158
165
|
let failed = inventoryFailed, latestFailed = false, timePartial = inventoryFailed;
|
|
159
166
|
if (!paths.size) failed = latestFailed = timePartial = true;
|
|
@@ -169,7 +176,7 @@ export class CodexSessionUsage {
|
|
|
169
176
|
const sourceKey = JSON.stringify([session.id, basename(path)]);
|
|
170
177
|
if (Array.isArray(saved?.recordSources?.[sourceKey]) && !missingSources.includes(sourceKey)) missingSources.push(sourceKey);
|
|
171
178
|
retryRead ||= availablePaths.has(path);
|
|
172
|
-
latestFailed ||= session.id !== id || path === session.rollout_path;
|
|
179
|
+
latestFailed ||= session.id !== id || path === plainRolloutPath(session.rollout_path ?? "");
|
|
173
180
|
versions.push([path, "missing"]);
|
|
174
181
|
}
|
|
175
182
|
}
|
|
@@ -244,38 +251,42 @@ export class CodexSessionUsage {
|
|
|
244
251
|
return result;
|
|
245
252
|
}
|
|
246
253
|
async readFile(path, expectedId) {
|
|
247
|
-
const
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
cached
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
pending = Buffer.concat([pending, chunk]);
|
|
258
|
-
let end;
|
|
259
|
-
while ((end = pending.indexOf(10)) !== -1) {
|
|
260
|
-
const line = pending.subarray(0, end);
|
|
261
|
-
consumed += end + 1;
|
|
262
|
-
if (!skipping) this.line(cached, line, expectedId);
|
|
263
|
-
skipping = false;
|
|
264
|
-
pending = pending.subarray(end + 1);
|
|
265
|
-
cached.offset = consumed;
|
|
254
|
+
const source = await openRollout(path);
|
|
255
|
+
try {
|
|
256
|
+
path = plainRolloutPath(path);
|
|
257
|
+
const { info } = source;
|
|
258
|
+
if (!info.isFile() || info.size === 0) throw new Error("Rollout is empty or not a file.");
|
|
259
|
+
let cached = this.files.get(path);
|
|
260
|
+
if (cached && cached.identity === `${info.dev}:${info.ino}` && cached.size === info.size && cached.mtime === info.mtimeMs) return this.sourceView(cached, expectedId);
|
|
261
|
+
const compressed = source.path.endsWith(".zst");
|
|
262
|
+
if (!cached || compressed || cached.identity !== `${info.dev}:${info.ino}` || info.size < cached.size || info.size === cached.size) {
|
|
263
|
+
cached = { identity: `${info.dev}:${info.ino}`, offset: 0, records: [], partial: false, turn: null, root: null, model: null, valid: false };
|
|
266
264
|
}
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
265
|
+
let pending = Buffer.alloc(0), consumed = cached.offset, skipping = false;
|
|
266
|
+
for await (const chunk of rolloutChunks(source, { start: cached.offset, end: info.size - 1, highWaterMark: 256 * 1024 })) {
|
|
267
|
+
pending = Buffer.concat([pending, chunk]);
|
|
268
|
+
let end;
|
|
269
|
+
while ((end = pending.indexOf(10)) !== -1) {
|
|
270
|
+
const line = pending.subarray(0, end);
|
|
271
|
+
consumed += end + 1;
|
|
272
|
+
if (!skipping) this.line(cached, line, expectedId);
|
|
273
|
+
skipping = false;
|
|
274
|
+
pending = pending.subarray(end + 1);
|
|
275
|
+
cached.offset = consumed;
|
|
276
|
+
}
|
|
277
|
+
if (pending.length > 2 * 1024 * 1024) {
|
|
278
|
+
// Image/message bodies can be huge; accounting records are small.
|
|
279
|
+
if (/"type"\s*:\s*"(?:turn_context|session_meta|token_count)"/.test(pending.subarray(0, 256).toString())) this.issue(cached);
|
|
280
|
+
consumed += pending.length; pending = Buffer.alloc(0); skipping = true;
|
|
281
|
+
}
|
|
271
282
|
}
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
283
|
+
cached.size = info.size; cached.mtime = info.mtimeMs;
|
|
284
|
+
cached.tail = pending; cached.skipping = skipping;
|
|
285
|
+
const view = this.sourceView(cached, expectedId);
|
|
286
|
+
if (!view.valid) throw new Error("Rollout belongs to a different chat.");
|
|
287
|
+
this.files.set(path, cached);
|
|
288
|
+
return view;
|
|
289
|
+
} finally { await source.handle.close(); }
|
|
279
290
|
}
|
|
280
291
|
sourceView(cached, expectedId) {
|
|
281
292
|
// EOF may contain a complete JSON record or a partially written line.
|
|
@@ -310,15 +321,6 @@ export class CodexSessionUsage {
|
|
|
310
321
|
}
|
|
311
322
|
}
|
|
312
323
|
|
|
313
|
-
async function* readChunks(path, options, compressed) {
|
|
314
|
-
if (compressed && typeof zlib.createZstdDecompress !== "function") throw new Error("This runtime cannot decode compressed rollouts.");
|
|
315
|
-
const input = createReadStream(path, options);
|
|
316
|
-
const stream = compressed ? zlib.createZstdDecompress() : input;
|
|
317
|
-
if (compressed) { input.on("error", error => stream.destroy(error)); input.pipe(stream); }
|
|
318
|
-
try { for await (const chunk of stream) yield chunk; }
|
|
319
|
-
finally { input.destroy(); if (compressed) stream.destroy(); }
|
|
320
|
-
}
|
|
321
|
-
|
|
322
324
|
function validResult(value) {
|
|
323
325
|
return value && ["latest", "total"].every(key => {
|
|
324
326
|
const part = value[key];
|