taskchef 7.24.1 → 7.25.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/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,205 @@ 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
+ ...cachedUsageSummary(cached),
297
+ generationTurnRef: cached.generationTurnRef ?? latestTurnRef,
298
+ status: "unavailable",
299
+ reason: "Token usage calculation was interrupted.",
300
+ };
301
+ }
302
+ return cachedUsageSummary(cached);
303
+ }
304
+
305
+ async function optionalFileFingerprint(filePath) {
306
+ return fileFingerprint(filePath).catch((error) => {
307
+ if (error?.code === "ENOENT") return "missing";
308
+ throw error;
309
+ });
310
+ }
311
+
312
+ export class UsageSummaryMonitor extends EventEmitter {
313
+ constructor(workspace, {
314
+ calculatingStaleMs = DEFAULT_CALCULATING_USAGE_STALE_MS,
315
+ fingerprint = optionalFileFingerprint,
316
+ now = Date.now,
317
+ pollIntervalMs = 5_000,
318
+ readStore = readUsageStore,
319
+ watchDirectory = watch,
320
+ } = {}) {
321
+ super();
322
+ this.workspace = workspace;
323
+ this.calculatingStaleMs = calculatingStaleMs;
324
+ this.fingerprint = fingerprint;
325
+ this.now = now;
326
+ this.pollIntervalMs = pollIntervalMs;
327
+ this.readStore = readStore;
328
+ this.watchDirectory = watchDirectory;
329
+ this.currentFingerprint = null;
330
+ this.records = new Map();
331
+ this.started = false;
332
+ this.refreshPromise = null;
333
+ this.refreshQueued = false;
334
+ this.watcher = null;
335
+ this.pollTimer = null;
336
+ this.debounceTimer = null;
337
+ this.lifecycleFingerprint = "";
338
+ }
339
+
340
+ async start() {
341
+ if (this.started) return;
342
+ this.workspace = await realpath(path.resolve(this.workspace));
343
+ this.file = usageStorePath(this.workspace);
344
+ await this.refresh({ force: true });
345
+ this.started = true;
346
+ try {
347
+ this.watcher = this.watchDirectory(this.workspace, { persistent: false }, (_event, fileName) => {
348
+ if (fileName !== null && String(fileName) !== path.basename(this.file)) return;
349
+ clearTimeout(this.debounceTimer);
350
+ this.debounceTimer = setTimeout(() => {
351
+ void this.refresh({ force: true }).catch(() => {});
352
+ }, 75);
353
+ this.debounceTimer.unref?.();
354
+ });
355
+ this.watcher.on("error", () => {
356
+ this.watcher?.close();
357
+ this.watcher = null;
358
+ });
359
+ } catch {
360
+ this.watcher = null;
361
+ }
362
+ this.pollTimer = setInterval(() => {
363
+ void this.refresh().catch(() => {});
364
+ }, this.pollIntervalMs);
365
+ this.pollTimer.unref?.();
366
+ }
367
+
368
+ project(task) {
369
+ return taskListUsageProjection(task, this.records.get(task.id) ?? null, {
370
+ calculatingStaleMs: this.calculatingStaleMs,
371
+ now: this.now(),
372
+ });
373
+ }
374
+
375
+ updateLifecycleFingerprint() {
376
+ const timestamp = this.now();
377
+ const next = JSON.stringify([...this.records].flatMap(([taskId, record]) => (
378
+ calculatingUsageIsStale(record, timestamp, this.calculatingStaleMs) ? [taskId] : []
379
+ )));
380
+ const changed = next !== this.lifecycleFingerprint;
381
+ this.lifecycleFingerprint = next;
382
+ return changed;
383
+ }
384
+
385
+ async refresh({ force = false } = {}) {
386
+ if (this.refreshPromise) {
387
+ this.refreshQueued ||= force;
388
+ return this.refreshPromise;
389
+ }
390
+ this.refreshPromise = this.refreshOnce({ force }).finally(async () => {
391
+ this.refreshPromise = null;
392
+ if (this.refreshQueued) {
393
+ this.refreshQueued = false;
394
+ await this.refresh({ force: true });
395
+ }
396
+ });
397
+ return this.refreshPromise;
398
+ }
399
+
400
+ async refreshOnce({ force }) {
401
+ const observed = await this.fingerprint(this.file);
402
+ if (!force && observed === this.currentFingerprint) {
403
+ const changed = this.updateLifecycleFingerprint();
404
+ if (changed) this.emit("change");
405
+ return changed;
406
+ }
407
+ let store;
408
+ try {
409
+ store = await this.readStore(this.workspace);
410
+ } catch {
411
+ store = { tasks: {} };
412
+ }
413
+ const after = await this.fingerprint(this.file);
414
+ if (observed !== after) {
415
+ this.refreshQueued = true;
416
+ return false;
417
+ }
418
+ const records = new Map(Object.entries(store.tasks ?? {}).map(
419
+ ([taskId, record]) => [taskId, cachedUsageRecord(record)],
420
+ ));
421
+ const recordsChanged = JSON.stringify([...records]) !== JSON.stringify([...this.records]);
422
+ this.records = records;
423
+ this.currentFingerprint = after;
424
+ const lifecycleChanged = this.updateLifecycleFingerprint();
425
+ const changed = recordsChanged || lifecycleChanged;
426
+ if (changed) this.emit("change");
427
+ return changed;
428
+ }
429
+
430
+ close() {
431
+ this.started = false;
432
+ this.watcher?.close();
433
+ clearTimeout(this.debounceTimer);
434
+ clearInterval(this.pollTimer);
435
+ this.watcher = null;
436
+ }
437
+ }
438
+
238
439
  export class DashboardMonitor extends EventEmitter {
239
440
  constructor(workspace, {
240
441
  debounceMs = 75,
@@ -599,6 +800,7 @@ export async function createDashboardServer({
599
800
  taskchefVersion = TASKCHEF_VERSION,
600
801
  serverVersion = DASHBOARD_SERVER_VERSION,
601
802
  usageTracker = null,
803
+ usageSummaryMonitor = null,
602
804
  control = null,
603
805
  controlReplayCache = createDashboardControlReplayCache(),
604
806
  } = {}) {
@@ -622,19 +824,30 @@ export async function createDashboardServer({
622
824
  throw new Error("dashboard control requires a valid TaskChef ownership controller");
623
825
  }
624
826
  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) {
827
+ let taskUsageTracker;
828
+ let taskUsageSummaryMonitor;
829
+ let identity;
830
+ try {
831
+ await monitor.start();
832
+ taskUsageTracker = usageTracker ?? createUsageTracker({ workspace: monitor.workspace });
833
+ taskUsageSummaryMonitor = usageSummaryMonitor
834
+ ?? new UsageSummaryMonitor(monitor.workspace);
835
+ await taskUsageSummaryMonitor.start();
836
+ identity = Object.freeze({
837
+ schemaVersion: 1,
838
+ service: "taskchef-dashboard",
839
+ taskchefVersion,
840
+ serverVersion,
841
+ workspace: monitor.workspace,
842
+ launcher,
843
+ });
844
+ if (Buffer.byteLength(`${JSON.stringify(identity)}\n`) > DASHBOARD_HEALTH_MAX_BYTES) {
845
+ throw new Error("dashboard identity exceeds the health response limit");
846
+ }
847
+ } catch (error) {
848
+ taskUsageSummaryMonitor?.close();
636
849
  monitor.close();
637
- throw new Error("dashboard identity exceeds the health response limit");
850
+ throw error;
638
851
  }
639
852
  const clients = new Set();
640
853
  const archiveRequests = new Set();
@@ -647,10 +860,22 @@ export async function createDashboardServer({
647
860
  const payload = ssePayload(event, value);
648
861
  for (const client of clients) client.write(payload);
649
862
  };
650
- const snapshotListener = (snapshot) => broadcast("snapshot", snapshot);
863
+ const dashboardSnapshot = () => {
864
+ const snapshot = monitor.snapshot();
865
+ return {
866
+ ...snapshot,
867
+ tasks: snapshot.tasks.map((task) => ({
868
+ ...task,
869
+ usage: taskUsageSummaryMonitor.project(task),
870
+ })),
871
+ };
872
+ };
873
+ const snapshotListener = () => broadcast("snapshot", dashboardSnapshot());
874
+ const usageSummaryListener = () => broadcast("snapshot", dashboardSnapshot());
651
875
  const errorListener = () => broadcast("dashboard-error", publicMonitorError());
652
876
  monitor.on("snapshot", snapshotListener);
653
877
  monitor.on("monitorError", errorListener);
878
+ taskUsageSummaryMonitor.on("change", usageSummaryListener);
654
879
 
655
880
  const handleRequest = async (request, response) => {
656
881
  const method = request.method ?? "GET";
@@ -956,7 +1181,7 @@ export async function createDashboardServer({
956
1181
  response.writeHead(200, securityHeaders("application/json; charset=utf-8"));
957
1182
  response.end();
958
1183
  } else {
959
- sendJson(response, 200, monitor.snapshot());
1184
+ sendJson(response, 200, dashboardSnapshot());
960
1185
  }
961
1186
  return;
962
1187
  }
@@ -978,7 +1203,7 @@ export async function createDashboardServer({
978
1203
  },
979
1204
  });
980
1205
  clients.add(client);
981
- client.write(`retry: 2000\n\n${ssePayload("snapshot", monitor.snapshot())}`);
1206
+ client.write(`retry: 2000\n\n${ssePayload("snapshot", dashboardSnapshot())}`);
982
1207
  client.heartbeat = setInterval(() => {
983
1208
  if (!client.blocked) client.write(": heartbeat\n\n");
984
1209
  }, 15_000);
@@ -1203,6 +1428,7 @@ export async function createDashboardServer({
1203
1428
  server.listen(port, host, resolve);
1204
1429
  }).catch((error) => {
1205
1430
  monitor.close();
1431
+ taskUsageSummaryMonitor.close();
1206
1432
  throw error;
1207
1433
  });
1208
1434
 
@@ -1217,11 +1443,14 @@ export async function createDashboardServer({
1217
1443
  url: `${allowedOrigin}/`,
1218
1444
  identity,
1219
1445
  monitor,
1446
+ usageSummaryMonitor: taskUsageSummaryMonitor,
1220
1447
  get eventClientCount() { return clients.size; },
1221
1448
  async close() {
1222
1449
  monitor.off("snapshot", snapshotListener);
1223
1450
  monitor.off("monitorError", errorListener);
1451
+ taskUsageSummaryMonitor.off("change", usageSummaryListener);
1224
1452
  monitor.close();
1453
+ taskUsageSummaryMonitor.close();
1225
1454
  for (const client of clients) client.close();
1226
1455
  const closed = new Promise((resolve, reject) => server.close((error) =>
1227
1456
  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) {