taskchef 7.24.1 → 7.25.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.
package/src/dashboard.js CHANGED
@@ -26,6 +26,7 @@ import { DASHBOARD_SERVER_VERSION, TASKCHEF_VERSION } from "./version.js";
26
26
  import { taskGitHubProjection } from "./dashboard/github-links.js";
27
27
  import { CODEX_CHAT_ARCHIVE_ENABLED } from "./dashboard/state.js";
28
28
  import { createUsageTracker } from "./usage-tracker.js";
29
+ import { readUsageStore, usageStorePath } from "./usage.js";
29
30
  import {
30
31
  MAX_DASHBOARD_SESSION_PIDS,
31
32
  MAX_TRANSFERRED_DASHBOARD_SESSION_PIDS,
@@ -52,6 +53,7 @@ const LOOPBACK_HOSTS = new Set(["127.0.0.1", "::1"]);
52
53
  const DEFAULT_MAX_FILE_BYTES = 16 * 1024 * 1024;
53
54
  const DEFAULT_MAX_TASKS = 2_000;
54
55
  const DEFAULT_MAX_EVENT_CLIENTS = 16;
56
+ const DEFAULT_CALCULATING_USAGE_STALE_MS = 120_000;
55
57
  const DASHBOARD_HANDOFF_PREPARE_TTL_MS = 5_000;
56
58
  const DASHBOARD_HANDOFF_COMMIT_GRACE_MS = 250;
57
59
  const DASHBOARD_HANDOFF_COMMIT_RETRY_MS = 1_000;
@@ -235,6 +237,204 @@ function taskDetailProjection(task) {
235
237
  return { ...task, ...taskGitHubProjection(task) };
236
238
  }
237
239
 
240
+ function cachedUsageSummary(record) {
241
+ if (!record) return null;
242
+ return Object.freeze({
243
+ generationTurnRef: record.generationTurnRef ?? null,
244
+ status: record.status,
245
+ updatedAt: record.updatedAt ?? null,
246
+ ...(record.reason ? { reason: record.reason } : {}),
247
+ ...(record.task ? {
248
+ task: Object.freeze({
249
+ totalTokens: record.task.totalTokens,
250
+ estimatedCostUsd: record.task.estimatedCostUsd ?? null,
251
+ sampledAt: record.task.sampledAt ?? null,
252
+ sourceUpdatedAt: record.task.sourceUpdatedAt ?? null,
253
+ }),
254
+ } : {}),
255
+ });
256
+ }
257
+
258
+ function cachedUsageRecord(record) {
259
+ return Object.freeze({
260
+ threadId: record.threadId,
261
+ ...cachedUsageSummary(record),
262
+ });
263
+ }
264
+
265
+ function calculatingUsageIsStale(cached, now, staleMs) {
266
+ if (cached?.status !== "calculating") return false;
267
+ const updatedAt = Date.parse(cached.updatedAt ?? "");
268
+ return !Number.isFinite(updatedAt) || updatedAt + staleMs <= now;
269
+ }
270
+
271
+ export function taskListUsageProjection(task, cached, {
272
+ now = Date.now(),
273
+ calculatingStaleMs = DEFAULT_CALCULATING_USAGE_STALE_MS,
274
+ } = {}) {
275
+ if (!task.threadId) {
276
+ return { status: "unavailable", reason: "Token usage unavailable" };
277
+ }
278
+ if (!task.latestTurn) {
279
+ return { status: "unavailable", reason: "Token usage unavailable" };
280
+ }
281
+ if (!cached || cached.threadId !== task.threadId) {
282
+ return task.status === "working"
283
+ ? { generationTurnRef: task.latestTurn.turnRef ?? null, status: "pending" }
284
+ : { generationTurnRef: task.latestTurn.turnRef ?? null, status: "unavailable", reason: "Token usage unavailable" };
285
+ }
286
+ const latestTurnRef = task.latestTurn.turnRef ?? task.latestTurn.turnId ?? null;
287
+ if (task.status !== "working" && cached.generationTurnRef !== latestTurnRef) {
288
+ return {
289
+ generationTurnRef: latestTurnRef,
290
+ status: "unavailable",
291
+ reason: "No cached usage boundary is available for this task state.",
292
+ };
293
+ }
294
+ if (calculatingUsageIsStale(cached, now, calculatingStaleMs)) {
295
+ return {
296
+ generationTurnRef: cached.generationTurnRef ?? latestTurnRef,
297
+ status: "unavailable",
298
+ reason: "Token usage calculation was interrupted.",
299
+ };
300
+ }
301
+ return cachedUsageSummary(cached);
302
+ }
303
+
304
+ async function optionalFileFingerprint(filePath) {
305
+ return fileFingerprint(filePath).catch((error) => {
306
+ if (error?.code === "ENOENT") return "missing";
307
+ throw error;
308
+ });
309
+ }
310
+
311
+ export class UsageSummaryMonitor extends EventEmitter {
312
+ constructor(workspace, {
313
+ calculatingStaleMs = DEFAULT_CALCULATING_USAGE_STALE_MS,
314
+ fingerprint = optionalFileFingerprint,
315
+ now = Date.now,
316
+ pollIntervalMs = 5_000,
317
+ readStore = readUsageStore,
318
+ watchDirectory = watch,
319
+ } = {}) {
320
+ super();
321
+ this.workspace = workspace;
322
+ this.calculatingStaleMs = calculatingStaleMs;
323
+ this.fingerprint = fingerprint;
324
+ this.now = now;
325
+ this.pollIntervalMs = pollIntervalMs;
326
+ this.readStore = readStore;
327
+ this.watchDirectory = watchDirectory;
328
+ this.currentFingerprint = null;
329
+ this.records = new Map();
330
+ this.started = false;
331
+ this.refreshPromise = null;
332
+ this.refreshQueued = false;
333
+ this.watcher = null;
334
+ this.pollTimer = null;
335
+ this.debounceTimer = null;
336
+ this.lifecycleFingerprint = "";
337
+ }
338
+
339
+ async start() {
340
+ if (this.started) return;
341
+ this.workspace = await realpath(path.resolve(this.workspace));
342
+ this.file = usageStorePath(this.workspace);
343
+ await this.refresh({ force: true });
344
+ this.started = true;
345
+ try {
346
+ this.watcher = this.watchDirectory(this.workspace, { persistent: false }, (_event, fileName) => {
347
+ if (fileName !== null && String(fileName) !== path.basename(this.file)) return;
348
+ clearTimeout(this.debounceTimer);
349
+ this.debounceTimer = setTimeout(() => {
350
+ void this.refresh({ force: true }).catch(() => {});
351
+ }, 75);
352
+ this.debounceTimer.unref?.();
353
+ });
354
+ this.watcher.on("error", () => {
355
+ this.watcher?.close();
356
+ this.watcher = null;
357
+ });
358
+ } catch {
359
+ this.watcher = null;
360
+ }
361
+ this.pollTimer = setInterval(() => {
362
+ void this.refresh().catch(() => {});
363
+ }, this.pollIntervalMs);
364
+ this.pollTimer.unref?.();
365
+ }
366
+
367
+ project(task) {
368
+ return taskListUsageProjection(task, this.records.get(task.id) ?? null, {
369
+ calculatingStaleMs: this.calculatingStaleMs,
370
+ now: this.now(),
371
+ });
372
+ }
373
+
374
+ updateLifecycleFingerprint() {
375
+ const timestamp = this.now();
376
+ const next = JSON.stringify([...this.records].flatMap(([taskId, record]) => (
377
+ calculatingUsageIsStale(record, timestamp, this.calculatingStaleMs) ? [taskId] : []
378
+ )));
379
+ const changed = next !== this.lifecycleFingerprint;
380
+ this.lifecycleFingerprint = next;
381
+ return changed;
382
+ }
383
+
384
+ async refresh({ force = false } = {}) {
385
+ if (this.refreshPromise) {
386
+ this.refreshQueued ||= force;
387
+ return this.refreshPromise;
388
+ }
389
+ this.refreshPromise = this.refreshOnce({ force }).finally(async () => {
390
+ this.refreshPromise = null;
391
+ if (this.refreshQueued) {
392
+ this.refreshQueued = false;
393
+ await this.refresh({ force: true });
394
+ }
395
+ });
396
+ return this.refreshPromise;
397
+ }
398
+
399
+ async refreshOnce({ force }) {
400
+ const observed = await this.fingerprint(this.file);
401
+ if (!force && observed === this.currentFingerprint) {
402
+ const changed = this.updateLifecycleFingerprint();
403
+ if (changed) this.emit("change");
404
+ return changed;
405
+ }
406
+ let store;
407
+ try {
408
+ store = await this.readStore(this.workspace);
409
+ } catch {
410
+ store = { tasks: {} };
411
+ }
412
+ const after = await this.fingerprint(this.file);
413
+ if (observed !== after) {
414
+ this.refreshQueued = true;
415
+ return false;
416
+ }
417
+ const records = new Map(Object.entries(store.tasks ?? {}).map(
418
+ ([taskId, record]) => [taskId, cachedUsageRecord(record)],
419
+ ));
420
+ const recordsChanged = JSON.stringify([...records]) !== JSON.stringify([...this.records]);
421
+ this.records = records;
422
+ this.currentFingerprint = after;
423
+ const lifecycleChanged = this.updateLifecycleFingerprint();
424
+ const changed = recordsChanged || lifecycleChanged;
425
+ if (changed) this.emit("change");
426
+ return changed;
427
+ }
428
+
429
+ close() {
430
+ this.started = false;
431
+ this.watcher?.close();
432
+ clearTimeout(this.debounceTimer);
433
+ clearInterval(this.pollTimer);
434
+ this.watcher = null;
435
+ }
436
+ }
437
+
238
438
  export class DashboardMonitor extends EventEmitter {
239
439
  constructor(workspace, {
240
440
  debounceMs = 75,
@@ -599,6 +799,7 @@ export async function createDashboardServer({
599
799
  taskchefVersion = TASKCHEF_VERSION,
600
800
  serverVersion = DASHBOARD_SERVER_VERSION,
601
801
  usageTracker = null,
802
+ usageSummaryMonitor = null,
602
803
  control = null,
603
804
  controlReplayCache = createDashboardControlReplayCache(),
604
805
  } = {}) {
@@ -622,19 +823,30 @@ export async function createDashboardServer({
622
823
  throw new Error("dashboard control requires a valid TaskChef ownership controller");
623
824
  }
624
825
  const monitor = new DashboardMonitor(workspace, monitorOptions);
625
- await monitor.start();
626
- const taskUsageTracker = usageTracker ?? createUsageTracker({ workspace: monitor.workspace });
627
- const identity = Object.freeze({
628
- schemaVersion: 1,
629
- service: "taskchef-dashboard",
630
- taskchefVersion,
631
- serverVersion,
632
- workspace: monitor.workspace,
633
- launcher,
634
- });
635
- if (Buffer.byteLength(`${JSON.stringify(identity)}\n`) > DASHBOARD_HEALTH_MAX_BYTES) {
826
+ let taskUsageTracker;
827
+ let taskUsageSummaryMonitor;
828
+ let identity;
829
+ try {
830
+ await monitor.start();
831
+ taskUsageTracker = usageTracker ?? createUsageTracker({ workspace: monitor.workspace });
832
+ taskUsageSummaryMonitor = usageSummaryMonitor
833
+ ?? new UsageSummaryMonitor(monitor.workspace);
834
+ await taskUsageSummaryMonitor.start();
835
+ identity = Object.freeze({
836
+ schemaVersion: 1,
837
+ service: "taskchef-dashboard",
838
+ taskchefVersion,
839
+ serverVersion,
840
+ workspace: monitor.workspace,
841
+ launcher,
842
+ });
843
+ if (Buffer.byteLength(`${JSON.stringify(identity)}\n`) > DASHBOARD_HEALTH_MAX_BYTES) {
844
+ throw new Error("dashboard identity exceeds the health response limit");
845
+ }
846
+ } catch (error) {
847
+ taskUsageSummaryMonitor?.close();
636
848
  monitor.close();
637
- throw new Error("dashboard identity exceeds the health response limit");
849
+ throw error;
638
850
  }
639
851
  const clients = new Set();
640
852
  const archiveRequests = new Set();
@@ -647,10 +859,22 @@ export async function createDashboardServer({
647
859
  const payload = ssePayload(event, value);
648
860
  for (const client of clients) client.write(payload);
649
861
  };
650
- const snapshotListener = (snapshot) => broadcast("snapshot", snapshot);
862
+ const dashboardSnapshot = () => {
863
+ const snapshot = monitor.snapshot();
864
+ return {
865
+ ...snapshot,
866
+ tasks: snapshot.tasks.map((task) => ({
867
+ ...task,
868
+ usage: taskUsageSummaryMonitor.project(task),
869
+ })),
870
+ };
871
+ };
872
+ const snapshotListener = () => broadcast("snapshot", dashboardSnapshot());
873
+ const usageSummaryListener = () => broadcast("snapshot", dashboardSnapshot());
651
874
  const errorListener = () => broadcast("dashboard-error", publicMonitorError());
652
875
  monitor.on("snapshot", snapshotListener);
653
876
  monitor.on("monitorError", errorListener);
877
+ taskUsageSummaryMonitor.on("change", usageSummaryListener);
654
878
 
655
879
  const handleRequest = async (request, response) => {
656
880
  const method = request.method ?? "GET";
@@ -956,7 +1180,7 @@ export async function createDashboardServer({
956
1180
  response.writeHead(200, securityHeaders("application/json; charset=utf-8"));
957
1181
  response.end();
958
1182
  } else {
959
- sendJson(response, 200, monitor.snapshot());
1183
+ sendJson(response, 200, dashboardSnapshot());
960
1184
  }
961
1185
  return;
962
1186
  }
@@ -978,7 +1202,7 @@ export async function createDashboardServer({
978
1202
  },
979
1203
  });
980
1204
  clients.add(client);
981
- client.write(`retry: 2000\n\n${ssePayload("snapshot", monitor.snapshot())}`);
1205
+ client.write(`retry: 2000\n\n${ssePayload("snapshot", dashboardSnapshot())}`);
982
1206
  client.heartbeat = setInterval(() => {
983
1207
  if (!client.blocked) client.write(": heartbeat\n\n");
984
1208
  }, 15_000);
@@ -1203,6 +1427,7 @@ export async function createDashboardServer({
1203
1427
  server.listen(port, host, resolve);
1204
1428
  }).catch((error) => {
1205
1429
  monitor.close();
1430
+ taskUsageSummaryMonitor.close();
1206
1431
  throw error;
1207
1432
  });
1208
1433
 
@@ -1217,11 +1442,14 @@ export async function createDashboardServer({
1217
1442
  url: `${allowedOrigin}/`,
1218
1443
  identity,
1219
1444
  monitor,
1445
+ usageSummaryMonitor: taskUsageSummaryMonitor,
1220
1446
  get eventClientCount() { return clients.size; },
1221
1447
  async close() {
1222
1448
  monitor.off("snapshot", snapshotListener);
1223
1449
  monitor.off("monitorError", errorListener);
1450
+ taskUsageSummaryMonitor.off("change", usageSummaryListener);
1224
1451
  monitor.close();
1452
+ taskUsageSummaryMonitor.close();
1225
1453
  for (const client of clients) client.close();
1226
1454
  const closed = new Promise((resolve, reject) => server.close((error) =>
1227
1455
  error ? reject(error) : resolve()));
package/src/usage.js CHANGED
@@ -589,12 +589,12 @@ export async function readCcusageThreadUsage(threadId, {
589
589
  }
590
590
  }
591
591
 
592
- function usageFile(workspace) {
592
+ export function usageStorePath(workspace) {
593
593
  return path.join(workspace, USAGE_FILE_NAME);
594
594
  }
595
595
 
596
596
  export async function readUsageStore(workspace) {
597
- const filePath = usageFile(workspace);
597
+ const filePath = usageStorePath(workspace);
598
598
  const details = await lstat(filePath).catch((error) => {
599
599
  if (error.code === "ENOENT") return null;
600
600
  throw error;
@@ -649,7 +649,7 @@ export function compactUsageStore(store) {
649
649
 
650
650
  export async function writeUsageStore(workspace, store) {
651
651
  await mkdir(workspace, { recursive: true });
652
- const filePath = usageFile(workspace);
652
+ const filePath = usageStorePath(workspace);
653
653
  const temporaryPath = `${filePath}.${process.pid}.${randomUUID()}.tmp`;
654
654
  const serialized = `${JSON.stringify(compactUsageStore(store), null, 2)}\n`;
655
655
  if (Buffer.byteLength(serialized, "utf8") > MAX_USAGE_FILE_BYTES) {