taskchef 7.28.1 → 8.0.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.
@@ -17,6 +17,10 @@ function isAdministrativeTurn(turn) {
17
17
  return turn?.provenance?.kind === "dashboard_manual";
18
18
  }
19
19
 
20
+ function isInterruptedTurn(turn) {
21
+ return turn?.result?.status === "interrupted";
22
+ }
23
+
20
24
  function hasTerminalLatestTurn(task) {
21
25
  return task.latestTurn !== null
22
26
  && task.latestTurn !== undefined
@@ -128,6 +132,19 @@ function administrativeTask(task, existing = null, now = new Date().toISOString(
128
132
  };
129
133
  }
130
134
 
135
+ function interruptedTask(task, existing = null, now = new Date().toISOString()) {
136
+ const usage = calculatingTask(task, existing, now);
137
+ const hasTaskProjection = existing?.task !== null && existing?.task !== undefined;
138
+ return {
139
+ ...usage,
140
+ status: hasTaskProjection ? "available" : "unavailable",
141
+ ...(hasTaskProjection ? {} : { reason: "The turn ended without a terminal usage boundary." }),
142
+ updatedAt: now,
143
+ retryAfter: null,
144
+ task: existing?.task ?? null,
145
+ };
146
+ }
147
+
131
148
  async function updateStore(workspace, taskId, transform) {
132
149
  const release = await acquireWorkspaceLock(workspace);
133
150
  try {
@@ -167,6 +184,47 @@ function snapshotSupersedes(current, incoming) {
167
184
  ].every((field) => incoming[field] >= current[field]);
168
185
  }
169
186
 
187
+ function snapshotCorrects(current, incoming) {
188
+ if (!current) return false;
189
+ const currentSampledAt = Date.parse(current.sampledAt ?? 0);
190
+ const incomingSampledAt = Date.parse(incoming.sampledAt ?? 0);
191
+ return Number.isFinite(incomingSampledAt)
192
+ && incomingSampledAt >= currentSampledAt
193
+ && [
194
+ "inputTokens",
195
+ "cachedInputTokens",
196
+ "outputTokens",
197
+ "reasoningOutputTokens",
198
+ "totalTokens",
199
+ ].some((field) => incoming[field] < current[field]);
200
+ }
201
+
202
+ function correctedRecord(task, snapshot) {
203
+ const now = snapshot.sampledAt;
204
+ const version = snapshot.provenance.version ? ` ${snapshot.provenance.version}` : "";
205
+ const reason = `ccusage${version} corrected an older cumulative total; reliable per-turn usage is unavailable.`;
206
+ const turns = Object.fromEntries(task.turns.slice(-MAX_TRACKED_TURNS).map((turn) => [
207
+ turn.turnRef,
208
+ isAdministrativeTurn(turn)
209
+ ? { status: "unavailable", reason: ADMINISTRATIVE_USAGE_REASON, updatedAt: now }
210
+ : { status: "unavailable", reason, updatedAt: now },
211
+ ]));
212
+ const generation = lifecycleGeneration(task);
213
+ return {
214
+ threadId: task.threadId,
215
+ generationTurnRef: task.latestTurn?.turnRef ?? null,
216
+ generationTurnCount: generation.turnCount,
217
+ generationTerminal: generation.terminal,
218
+ zeroBaselineTurnRef: null,
219
+ status: "available",
220
+ updatedAt: now,
221
+ retryAfter: null,
222
+ task: snapshot,
223
+ turns,
224
+ boundaries: { [task.latestTurn.turnRef]: snapshot },
225
+ };
226
+ }
227
+
170
228
  function candidateHasAdvanced(task, existing, snapshot) {
171
229
  const latestIndex = task.turns.findIndex((turn) => turn.turnRef === task.latestTurn?.turnRef);
172
230
  if (latestIndex <= 0) return true;
@@ -269,14 +327,22 @@ function reconcileRecord(task, existing, snapshot, { boundaryReliable = true } =
269
327
 
270
328
  export function createUsageTracker({
271
329
  workspace,
330
+ maxConcurrentJobs = 2,
272
331
  readThreadUsage = readCcusageThreadUsage,
273
332
  retryDelaysMs = [2_000, 3_000, 4_000, 1_000],
274
333
  retryCooldownMs = 60_000,
275
334
  setTimer = setTimeout,
276
335
  } = {}) {
336
+ if (!Number.isSafeInteger(maxConcurrentJobs) || maxConcurrentJobs < 1 || maxConcurrentJobs > 8) {
337
+ throw new Error("usage tracker concurrency must be an integer from 1 to 8");
338
+ }
277
339
  const jobs = new Map();
278
340
  const observationChains = new Map();
341
+ const readyJobs = [];
342
+ let activeJobs = 0;
279
343
  let canonicalWorkspace = null;
344
+ let closed = false;
345
+ let preloadChain = Promise.resolve();
280
346
 
281
347
  const root = async () => {
282
348
  canonicalWorkspace ??= await realpath(path.resolve(workspace));
@@ -289,6 +355,9 @@ export function createUsageTracker({
289
355
  const markAdministrative = async (task) => updateStore(await root(), task.id, (existing) => (
290
356
  generationIsOlder(task, existing) ? existing : administrativeTask(task, existing)
291
357
  ));
358
+ const markInterrupted = async (task) => updateStore(await root(), task.id, (existing) => (
359
+ generationIsOlder(task, existing) ? existing : interruptedTask(task, existing)
360
+ ));
292
361
 
293
362
  const reconcile = async (task, {
294
363
  finalAttempt = false,
@@ -332,6 +401,9 @@ export function createUsageTracker({
332
401
  return updateStore(await root(), task.id, (existing) => {
333
402
  const latestTurnRef = task.latestTurn?.turnRef;
334
403
  if (existing?.generationTurnRef !== latestTurnRef) return existing;
404
+ if (stable && snapshotCorrects(existing?.task, snapshot)) {
405
+ return correctedRecord(task, snapshot);
406
+ }
335
407
  const supersedes = snapshotSupersedes(existing?.task, snapshot);
336
408
  if (existing?.boundaries?.[latestTurnRef]
337
409
  || existing?.turns?.[latestTurnRef]?.status === "available") {
@@ -344,15 +416,49 @@ export function createUsageTracker({
344
416
  });
345
417
  };
346
418
 
419
+ const drainReadyJobs = () => {
420
+ while (!closed && activeJobs < maxConcurrentJobs && readyJobs.length > 0) {
421
+ const job = readyJobs.shift();
422
+ job.queued = false;
423
+ if (job.cancelled || jobs.get(job.task.id) !== job) {
424
+ for (const resolve of job.waiters.splice(0)) resolve();
425
+ continue;
426
+ }
427
+ const waiters = job.waiters.splice(0);
428
+ activeJobs += 1;
429
+ void job.run().finally(() => {
430
+ activeJobs -= 1;
431
+ for (const resolve of waiters) resolve();
432
+ drainReadyJobs();
433
+ });
434
+ }
435
+ };
436
+
437
+ const queueJob = (job) => {
438
+ if (closed || job.cancelled || jobs.get(job.task.id) !== job) return Promise.resolve();
439
+ return new Promise((resolve) => {
440
+ job.waiters.push(resolve);
441
+ if (!job.queued) {
442
+ job.queued = true;
443
+ readyJobs.push(job);
444
+ drainReadyJobs();
445
+ }
446
+ });
447
+ };
448
+
347
449
  const schedule = (task, { immediate = false } = {}) => {
348
- if (!task.threadId || jobs.has(task.id)) return;
450
+ if (closed || !task.threadId || jobs.has(task.id)) return;
349
451
  const job = {
350
452
  cancelled: false,
351
453
  previousFingerprint: null,
454
+ queued: false,
455
+ run: null,
456
+ task,
352
457
  turnRef: task.latestTurn?.turnRef ?? null,
458
+ waiters: [],
353
459
  };
354
460
  let attempt = 0;
355
- const run = async () => {
461
+ job.run = async () => {
356
462
  if (job.cancelled || jobs.get(task.id) !== job) return;
357
463
  let complete = false;
358
464
  try {
@@ -372,36 +478,89 @@ export function createUsageTracker({
372
478
  }
373
479
  const delay = retryDelaysMs[attempt] ?? 0;
374
480
  attempt += 1;
375
- const timer = setTimer(run, delay);
481
+ const timer = setTimer(() => queueJob(job), delay);
376
482
  timer?.unref?.();
377
483
  };
378
484
  jobs.set(task.id, job);
379
- const timer = setTimer(run, immediate ? 0 : retryDelaysMs[attempt++]);
485
+ const timer = setTimer(() => queueJob(job), immediate ? 0 : retryDelaysMs[attempt++]);
380
486
  timer?.unref?.();
381
487
  };
382
488
 
489
+ const observe = (task) => {
490
+ if (closed) return Promise.resolve(null);
491
+ const previous = observationChains.get(task.id) ?? Promise.resolve();
492
+ const observation = previous.catch(() => {}).then(async () => {
493
+ if (closed || !task.threadId) return null;
494
+ const active = jobs.get(task.id);
495
+ const latestTurnRef = task.latestTurn?.turnRef ?? null;
496
+ if (active && active.turnRef !== latestTurnRef) {
497
+ active.cancelled = true;
498
+ jobs.delete(task.id);
499
+ }
500
+ const usage = isAdministrativeTurn(task.latestTurn)
501
+ ? await markAdministrative(task)
502
+ : isInterruptedTurn(task.latestTurn)
503
+ ? await markInterrupted(task)
504
+ : await markCalculating(task);
505
+ if (hasTerminalLatestTurn(task)) schedule(task);
506
+ return usage;
507
+ });
508
+ observationChains.set(task.id, observation);
509
+ void observation.finally(() => {
510
+ if (observationChains.get(task.id) === observation) observationChains.delete(task.id);
511
+ }).catch(() => {});
512
+ return observation;
513
+ };
514
+
515
+ const needsPreload = (task, usage, now = Date.now()) => {
516
+ if (!task.threadId || !task.latestTurn) return false;
517
+ const latestTurnRef = task.latestTurn.turnRef ?? null;
518
+ if (isAdministrativeTurn(task.latestTurn)) {
519
+ return !usage
520
+ || usage.threadId !== task.threadId
521
+ || usage.generationTurnRef !== latestTurnRef;
522
+ }
523
+ if (isInterruptedTurn(task.latestTurn)) {
524
+ const latestUsage = usage?.turns?.[latestTurnRef];
525
+ const expectedStatus = usage?.task ? "available" : "unavailable";
526
+ return !usage
527
+ || usage.threadId !== task.threadId
528
+ || usage.generationTurnRef !== latestTurnRef
529
+ || usage.status !== expectedStatus
530
+ || latestUsage?.status !== "unavailable";
531
+ }
532
+ if (!usage || usage.threadId !== task.threadId || usage.generationTurnRef !== latestTurnRef) {
533
+ return true;
534
+ }
535
+ if (!hasTerminalLatestTurn(task)) return false;
536
+ if (usage.status === "available") return false;
537
+ if (usage.status === "calculating") return true;
538
+ if (usage.status === "unavailable") {
539
+ return !usage.retryAfter || Date.parse(usage.retryAfter) <= now;
540
+ }
541
+ return false;
542
+ };
543
+
383
544
  return {
384
- observe(task) {
385
- const previous = observationChains.get(task.id) ?? Promise.resolve();
386
- const observation = previous.catch(() => {}).then(async () => {
387
- if (!task.threadId) return null;
388
- const active = jobs.get(task.id);
389
- const latestTurnRef = task.latestTurn?.turnRef ?? null;
390
- if (active && active.turnRef !== latestTurnRef) {
391
- active.cancelled = true;
392
- jobs.delete(task.id);
545
+ close() {
546
+ closed = true;
547
+ readyJobs.length = 0;
548
+ for (const job of jobs.values()) {
549
+ job.cancelled = true;
550
+ for (const resolve of job.waiters.splice(0)) resolve();
551
+ }
552
+ jobs.clear();
553
+ },
554
+ observe,
555
+ preload(tasks) {
556
+ preloadChain = preloadChain.catch(() => {}).then(async () => {
557
+ if (closed) return;
558
+ const store = await readUsageStore(await root());
559
+ for (const task of tasks) {
560
+ if (needsPreload(task, store.tasks[task.id] ?? null)) await observe(task);
393
561
  }
394
- const usage = isAdministrativeTurn(task.latestTurn)
395
- ? await markAdministrative(task)
396
- : await markCalculating(task);
397
- if (hasTerminalLatestTurn(task)) schedule(task);
398
- return usage;
399
562
  });
400
- observationChains.set(task.id, observation);
401
- void observation.finally(() => {
402
- if (observationChains.get(task.id) === observation) observationChains.delete(task.id);
403
- }).catch(() => {});
404
- return observation;
563
+ return preloadChain;
405
564
  },
406
565
  async get(task) {
407
566
  const store = await readUsageStore(await root());
@@ -433,6 +592,16 @@ export function createUsageTracker({
433
592
  void markAdministrative(task).catch(() => {});
434
593
  return administrative;
435
594
  }
595
+ if (isInterruptedTurn(task.latestTurn)) {
596
+ const latestUsage = usage?.turns?.[task.latestTurn.turnRef];
597
+ const expectedStatus = usage?.task ? "available" : "unavailable";
598
+ if (usage?.generationTurnRef === task.latestTurn.turnRef
599
+ && usage.status === expectedStatus
600
+ && latestUsage?.status === "unavailable") return usage;
601
+ const interrupted = interruptedTask(task, usage);
602
+ void markInterrupted(task).catch(() => {});
603
+ return interrupted;
604
+ }
436
605
  if (!usage || usage.threadId !== task.threadId) {
437
606
  const calculating = calculatingTask(task);
438
607
  void markCalculating(task)
package/src/usage.js CHANGED
@@ -538,18 +538,7 @@ export async function readCcusageThreadUsage(threadId, {
538
538
  [...resolvedInvocation.args, ...args],
539
539
  options,
540
540
  );
541
- let version = resolvedInvocation.version;
542
- if (version === null) {
543
- try {
544
- const result = await invoke(["--version"], {
545
- timeout: Math.min(timeoutMs, 2_000),
546
- maxBuffer: 64 * 1024,
547
- });
548
- version = String(result.stdout).trim().replace(/^ccusage\s+/i, "") || null;
549
- } catch {
550
- // Usage remains useful if a custom compatible ccusage cannot print its version.
551
- }
552
- }
541
+ const version = await readCcusageInvocationVersion(invoke, timeoutMs);
553
542
  const report = async (pricingMode) => {
554
543
  let result;
555
544
  try {
@@ -589,6 +578,45 @@ export async function readCcusageThreadUsage(threadId, {
589
578
  }
590
579
  }
591
580
 
581
+ async function readCcusageInvocationVersion(invoke, timeoutMs) {
582
+ try {
583
+ const result = await invoke(["--version"], {
584
+ timeout: Math.min(timeoutMs, 2_000),
585
+ maxBuffer: 64 * 1024,
586
+ });
587
+ const version = String(result.stdout).trim().replace(/^ccusage\s+/i, "");
588
+ return /^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/u.test(version) ? version : null;
589
+ } catch {
590
+ return null;
591
+ }
592
+ }
593
+
594
+ export async function readCcusageRuntimeInfo({
595
+ run = runBoundedProcess,
596
+ timeoutMs = 8_000,
597
+ } = {}) {
598
+ try {
599
+ const resolved = await resolveManagedCcusageInvocation(
600
+ managedCcusageInvocation(),
601
+ run,
602
+ timeoutMs,
603
+ );
604
+ const invoke = (args, options) => run(
605
+ resolved.command,
606
+ [...resolved.args, ...args],
607
+ options,
608
+ );
609
+ const version = await readCcusageInvocationVersion(invoke, timeoutMs);
610
+ return {
611
+ provider: "ccusage",
612
+ status: version === null ? "unavailable" : "available",
613
+ version,
614
+ };
615
+ } catch {
616
+ return { provider: "ccusage", status: "unavailable", version: null };
617
+ }
618
+ }
619
+
592
620
  export function usageStorePath(workspace) {
593
621
  return path.join(workspace, USAGE_FILE_NAME);
594
622
  }