taskchef 7.19.1 → 7.20.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/.codex-plugin/plugin.json +1 -1
- package/README.md +19 -0
- package/docs/images/ccusage-token-consumption.png +0 -0
- package/docs/spec.md +45 -2
- package/docs/workflows.md +2 -0
- package/package.json +2 -1
- package/src/dashboard/app.js +97 -16
- package/src/dashboard/index.html +4 -0
- package/src/dashboard/styles.css +7 -0
- package/src/dashboard.js +13 -1
- package/src/mcp.js +4 -0
- package/src/usage-tracker.js +425 -0
- package/src/usage.js +388 -0
package/README.md
CHANGED
|
@@ -25,6 +25,12 @@ request -> recorded TaskChef task -> Codex executor -> request/result turn timel
|
|
|
25
25
|
TaskChef requires Node.js 18 or newer, Git, Codex desktop, and local access to
|
|
26
26
|
the projects that will receive work.
|
|
27
27
|
|
|
28
|
+
Install [`ccusage`](https://github.com/ccusage/ccusage) separately when you want
|
|
29
|
+
the optional dashboard token and API-equivalent cost estimates. TaskChef calls
|
|
30
|
+
its structured offline Codex report and does not parse Codex rollout files
|
|
31
|
+
itself. Lifecycle reporting and the dashboard continue to work when `ccusage`
|
|
32
|
+
is absent or incompatible.
|
|
33
|
+
|
|
28
34
|
```sh
|
|
29
35
|
codex plugin marketplace add favoyang/codex-plugins
|
|
30
36
|
codex plugin add taskchef@favoyang-plugins
|
|
@@ -50,6 +56,7 @@ The canonical workspace is `~/.agents/taskchef`. TaskChef owns only:
|
|
|
50
56
|
AGENTS.md managed dispatcher instructions plus user additions
|
|
51
57
|
taskchef.json schema-2 Codex project index, dashboard preference, and delegation metadata
|
|
52
58
|
tasks.jsonl one task snapshot per line (schema 9; schema 4-8 migration supported)
|
|
59
|
+
.taskchef-usage.json optional mode-0600 ccusage snapshot and turn-boundary cache
|
|
53
60
|
```
|
|
54
61
|
|
|
55
62
|
Index or inspect Codex projects conversationally:
|
|
@@ -269,6 +276,16 @@ Codex CLI at the canonical ChatGPT or Codex desktop app location under
|
|
|
269
276
|
`/Applications`, to archive the exact thread UUID. The Codex chat leaves active chat lists while the TaskChef record
|
|
270
277
|
and its activity timeline remain unchanged. If the bundled CLI is unavailable,
|
|
271
278
|
the dashboard does not fall back to another `codex` executable from `PATH`.
|
|
279
|
+
|
|
280
|
+
Task details also show whole-task and per-turn token usage when `ccusage` can
|
|
281
|
+
map the linked Codex thread. A completed turn briefly shows “Tokens:
|
|
282
|
+
calculating…” while TaskChef performs bounded deferred reconciliation, because
|
|
283
|
+
the terminal lifecycle callback precedes Codex's final output write. Historical
|
|
284
|
+
tasks may show a trustworthy task total while older turns remain “Tokens
|
|
285
|
+
unavailable” when no cumulative turn boundaries were recorded. Input, cached
|
|
286
|
+
input, output, reasoning, and total counts retain ccusage's categories. Dollar
|
|
287
|
+
figures are labeled API-equivalent estimates; zero-priced unknown models show
|
|
288
|
+
cost unavailable rather than a misleading `$0.00`.
|
|
272
289
|
The header shows the running TaskChef package version reported by the same
|
|
273
290
|
bounded health identity used for compatible-listener checks.
|
|
274
291
|
The canonical port is owned by a dashboard initialized in the TaskChef MCP host
|
|
@@ -304,6 +321,8 @@ or secrets.
|
|
|
304
321
|
|
|
305
322
|

|
|
306
323
|
|
|
324
|
+

|
|
325
|
+
|
|
307
326
|

|
|
308
327
|
|
|
309
328
|
## Common recovery
|
|
Binary file
|
package/docs/spec.md
CHANGED
|
@@ -28,7 +28,7 @@ is dated research, not contract.
|
|
|
28
28
|
| **Last semantic result** | The final result-history entry, exposed through the derived `lastResult` compatibility alias. |
|
|
29
29
|
| **Turn reference** | Required lifecycle identity for one executor prompt. It is the native Codex turn ID when available, otherwise a retained client-generated UUID. |
|
|
30
30
|
| **Current turn ID** | Optional Codex metadata for the reported prompt; null when native turn reading is unavailable. |
|
|
31
|
-
| **Dashboard** | The loopback, read-only UI derived from validated workspace snapshots and bounded native actions. |
|
|
31
|
+
| **Dashboard** | The loopback, read-only UI derived from validated workspace snapshots, optional local usage projections, and bounded native actions. |
|
|
32
32
|
| **Skill** | One packaged agent procedure: `taskchef-bootstrap`, `taskchef-dashboard`, `taskchef-delegate`, `taskchef-executor`, or `taskchef-copilot`. |
|
|
33
33
|
|
|
34
34
|
## Components and ownership
|
|
@@ -56,7 +56,8 @@ is dated research, not contract.
|
|
|
56
56
|
|
|
57
57
|
## Workspace contract
|
|
58
58
|
|
|
59
|
-
TaskChef MUST manage only `AGENTS.md`, `taskchef.json`,
|
|
59
|
+
TaskChef MUST manage only `AGENTS.md`, `taskchef.json`, `tasks.jsonl`, and the
|
|
60
|
+
optional `.taskchef-usage.json` cache
|
|
60
61
|
inside the dispatcher workspace. It MUST preserve unrelated paths.
|
|
61
62
|
|
|
62
63
|
`taskchef.json` MUST have schema version 2, the following required fields, and
|
|
@@ -116,6 +117,48 @@ from the final derived semantic result. Interrupted outcomes MUST be excluded.
|
|
|
116
117
|
These projections MUST NOT be persisted in schema 9 and remain compatibility
|
|
117
118
|
aliases for existing callers.
|
|
118
119
|
|
|
120
|
+
## Optional usage projection
|
|
121
|
+
|
|
122
|
+
TaskChef MAY invoke an installed `ccusage` executable as an optional local
|
|
123
|
+
adapter using `ccusage codex session --json --offline`. TaskChef MUST NOT parse,
|
|
124
|
+
store, or serve raw Codex rollout files, prompts, responses, transcripts, or
|
|
125
|
+
reasoning. Analyzer absence, timeout, malformed output, unknown pricing, or an
|
|
126
|
+
unresolved thread MUST NOT block lifecycle tools or dashboard loading.
|
|
127
|
+
|
|
128
|
+
The mode-0600 `.taskchef-usage.json` cache stores only normalized cumulative
|
|
129
|
+
token boundaries, per-turn deltas, model names, estimated cost, source version,
|
|
130
|
+
and freshness. It is independent of schema-9 task records so legacy logs remain
|
|
131
|
+
readable. Writes MUST use the workspace lock and atomic replacement. Symlinked
|
|
132
|
+
or unsupported cache files MUST be rejected. Writes MUST compact the derived
|
|
133
|
+
cache to recent task projections, recent per-turn results, and the latest
|
|
134
|
+
cumulative boundary. An oversized legacy cache MUST be treated as rebuildable
|
|
135
|
+
derived data so it cannot permanently disable usage reporting.
|
|
136
|
+
|
|
137
|
+
A linked Codex thread MAY map to multiple ccusage session segments. TaskChef
|
|
138
|
+
MUST aggregate only records whose primary durable thread UUID is that exact
|
|
139
|
+
identity; a UUID appearing only as a nested suffix MUST NOT be attributed to
|
|
140
|
+
the parent or child TaskChef task. It MUST retain input, cached-input, output,
|
|
141
|
+
reasoning-output, and total fields without adding cached or reasoning subsets
|
|
142
|
+
into totals a second time.
|
|
143
|
+
|
|
144
|
+
After a terminal report, TaskChef MUST mark the turn `calculating` and perform
|
|
145
|
+
bounded deferred reconciliation without delaying the lifecycle response.
|
|
146
|
+
Per-turn usage MUST be a non-negative delta between adjacent reliable cumulative
|
|
147
|
+
boundaries. A first recorded turn MAY use zero as its baseline. Historical
|
|
148
|
+
turns without boundaries and decreasing or ambiguous snapshots MUST be labeled
|
|
149
|
+
`unavailable`, never zero or estimated. A historical task MAY still show its
|
|
150
|
+
resolvable cumulative total. A boundary is reliable only after two consecutive
|
|
151
|
+
samples agree. Exhausted unstable sampling and a newer turn beginning before
|
|
152
|
+
stabilization MUST leave that turn unavailable and MUST NOT establish a delta
|
|
153
|
+
baseline. Zero is valid for the first turn only when TaskChef observed that turn
|
|
154
|
+
in progress before its terminal report; a first historical terminal turn MUST
|
|
155
|
+
remain unavailable even when its cumulative task total is resolvable.
|
|
156
|
+
|
|
157
|
+
Every available projection MUST identify ccusage, its version when available,
|
|
158
|
+
and freshness. Dollar values MUST be labeled API-equivalent estimates. Positive
|
|
159
|
+
token usage with a zero or missing analyzer cost MUST display cost unavailable,
|
|
160
|
+
not `$0.00`.
|
|
161
|
+
|
|
119
162
|
Task IDs and non-null thread identities MUST be unique. The immutable intent
|
|
120
163
|
fields MUST NOT change after recording.
|
|
121
164
|
|
package/docs/workflows.md
CHANGED
|
@@ -21,6 +21,8 @@ research.
|
|
|
21
21
|
| `src/cli.js` | Administration, normalized cached briefs, inspection, diagnostics, and dashboard startup. |
|
|
22
22
|
| `src/dashboard.js` | Versioned health identity, validated compact snapshots, SSE fan-out, on-demand details, and bounded open actions. |
|
|
23
23
|
| `src/dashboard-manager.js` | Concurrent singleton ensure, exact listener reuse, conflicts, and owned shutdown. |
|
|
24
|
+
| `src/usage.js` | Optional bounded ccusage execution, exact primary-thread mapping, normalized aggregation, and the private usage cache. |
|
|
25
|
+
| `src/usage-tracker.js` | Deferred sampling, cumulative boundaries, historical availability, and per-turn deltas. |
|
|
24
26
|
|
|
25
27
|
The MCP process resolves `TASKCHEF_WORKSPACE` once and never accepts a model
|
|
26
28
|
supplied path. The CLI resolves `--workspace`, then the environment, then the
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "taskchef",
|
|
3
|
-
"version": "7.
|
|
3
|
+
"version": "7.20.0",
|
|
4
4
|
"description": "A non-blocking interactive dispatcher for visible Codex tasks.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Favo Yang",
|
|
@@ -27,6 +27,7 @@
|
|
|
27
27
|
"docs/workflows.md",
|
|
28
28
|
"docs/firstmate-taskchef-comparison.md",
|
|
29
29
|
"docs/images/dashboard-identity.jpg",
|
|
30
|
+
"docs/images/ccusage-token-consumption.png",
|
|
30
31
|
"docs/images/notification-event-snapshots.jpg",
|
|
31
32
|
"docs/images/result-history-dashboard.jpg",
|
|
32
33
|
"docs/images/interrupted-turn-recovery.jpg",
|
package/src/dashboard/app.js
CHANGED
|
@@ -25,6 +25,9 @@ import {
|
|
|
25
25
|
} from "./github-links.js";
|
|
26
26
|
import { formatRelativeTime, RelativeTimeController, parsedTimestamp } from "./time.js";
|
|
27
27
|
|
|
28
|
+
const USAGE_POLL_INTERVAL_MS = 1_500;
|
|
29
|
+
const MAX_USAGE_POLL_ATTEMPTS = 40;
|
|
30
|
+
|
|
28
31
|
const state = {
|
|
29
32
|
archivedThreadIds: new Set(),
|
|
30
33
|
tasks: [],
|
|
@@ -57,6 +60,7 @@ const elements = {
|
|
|
57
60
|
dialogRelatedLinks: document.querySelector("#dialog-related-links"),
|
|
58
61
|
dialogResults: document.querySelector("#dialog-results"),
|
|
59
62
|
dialogTitle: document.querySelector("#dialog-title"),
|
|
63
|
+
dialogUsage: document.querySelector("#dialog-usage"),
|
|
60
64
|
dismissDashboardMessage: document.querySelector("#dismiss-dashboard-message"),
|
|
61
65
|
emptyState: document.querySelector("#empty-state"),
|
|
62
66
|
notifications: document.querySelector("#notifications"),
|
|
@@ -347,6 +351,58 @@ function detailRow(term, value) {
|
|
|
347
351
|
return [dt, dd];
|
|
348
352
|
}
|
|
349
353
|
|
|
354
|
+
const tokenFormatter = new Intl.NumberFormat();
|
|
355
|
+
|
|
356
|
+
function formatEstimatedCost(value) {
|
|
357
|
+
if (typeof value !== "number") return "cost unavailable";
|
|
358
|
+
if (value === 0) return "estimated $0.00";
|
|
359
|
+
return `estimated $${value < 0.01 ? value.toFixed(4) : value.toFixed(2)}`;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
function usageBreakdownText(usage) {
|
|
363
|
+
return [
|
|
364
|
+
`${tokenFormatter.format(usage.inputTokens)} input`,
|
|
365
|
+
`${tokenFormatter.format(usage.cachedInputTokens)} cached input`,
|
|
366
|
+
`${tokenFormatter.format(usage.outputTokens)} output`,
|
|
367
|
+
`${tokenFormatter.format(usage.reasoningOutputTokens)} reasoning`,
|
|
368
|
+
].join(" · ");
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
function usagePresentation(usage, { wholeTask = false } = {}) {
|
|
372
|
+
const container = document.createElement("div");
|
|
373
|
+
container.className = `usage-summary usage-${usage?.status ?? "calculating"}`;
|
|
374
|
+
if (!usage || usage.status === "calculating") {
|
|
375
|
+
const indicator = document.createElement("span");
|
|
376
|
+
indicator.className = "usage-spinner";
|
|
377
|
+
indicator.setAttribute("aria-hidden", "true");
|
|
378
|
+
const text = document.createElement("span");
|
|
379
|
+
text.textContent = "Tokens: calculating…";
|
|
380
|
+
container.replaceChildren(indicator, text);
|
|
381
|
+
return container;
|
|
382
|
+
}
|
|
383
|
+
if (usage.status !== "available") {
|
|
384
|
+
container.textContent = `Tokens unavailable${usage.reason ? ` — ${usage.reason}` : "."}`;
|
|
385
|
+
return container;
|
|
386
|
+
}
|
|
387
|
+
const headline = document.createElement("strong");
|
|
388
|
+
headline.textContent = `${tokenFormatter.format(usage.totalTokens)} tokens · ${formatEstimatedCost(usage.estimatedCostUsd)}`;
|
|
389
|
+
const breakdown = document.createElement("span");
|
|
390
|
+
breakdown.textContent = usageBreakdownText(usage);
|
|
391
|
+
container.append(headline, breakdown);
|
|
392
|
+
const provenance = document.createElement("span");
|
|
393
|
+
const version = usage.provenance?.version ? ` ${usage.provenance.version}` : "";
|
|
394
|
+
const freshness = usage.sourceUpdatedAt ?? usage.sampledAt;
|
|
395
|
+
provenance.textContent = `Source: ccusage${version}${freshness ? ` · updated ${formatRelativeTime(freshness)}` : ""}. Dollar cost is an API-equivalent estimate${wholeTask ? " for the task" : " for this turn"}.`;
|
|
396
|
+
container.append(provenance);
|
|
397
|
+
return container;
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
function usageStillCalculating(task) {
|
|
401
|
+
return task.usage?.status === "calculating"
|
|
402
|
+
|| (task.status !== "working" && Object.values(task.usage?.turns ?? {})
|
|
403
|
+
.some((turn) => turn.status === "calculating"));
|
|
404
|
+
}
|
|
405
|
+
|
|
350
406
|
function turnTimeline(task) {
|
|
351
407
|
if (task.turns.length === 0) {
|
|
352
408
|
const empty = document.createElement("p");
|
|
@@ -387,7 +443,20 @@ function turnTimeline(task) {
|
|
|
387
443
|
const turnMetadata = document.createElement("p");
|
|
388
444
|
turnMetadata.className = "result-history-turn";
|
|
389
445
|
turnMetadata.textContent = `Turn ref ${turn.turnRef ?? "not recorded"}; Codex turn ${turn.turnId ?? "unavailable"}`;
|
|
390
|
-
|
|
446
|
+
const turnUsage = task.usage?.turns?.[turn.turnRef ?? turn.turnId] ?? (
|
|
447
|
+
turn.result === null
|
|
448
|
+
? { status: "calculating" }
|
|
449
|
+
: { status: "unavailable", reason: "No reliable turn boundary is available." }
|
|
450
|
+
);
|
|
451
|
+
item.append(
|
|
452
|
+
header,
|
|
453
|
+
requestLabel,
|
|
454
|
+
request,
|
|
455
|
+
resultLabel,
|
|
456
|
+
result,
|
|
457
|
+
usagePresentation(turnUsage),
|
|
458
|
+
turnMetadata,
|
|
459
|
+
);
|
|
391
460
|
return item;
|
|
392
461
|
});
|
|
393
462
|
}
|
|
@@ -418,6 +487,12 @@ function renderDialog(task) {
|
|
|
418
487
|
(task.relatedGitHubLinks?.length ?? 0) === 0 && !task.relatedGitHubLinksTruncated
|
|
419
488
|
);
|
|
420
489
|
elements.dialogResults.replaceChildren(...turnTimeline(detailedTask));
|
|
490
|
+
elements.dialogUsage.replaceChildren(usagePresentation(
|
|
491
|
+
task.usage?.status === "available" && task.usage.task
|
|
492
|
+
? { status: "available", ...task.usage.task }
|
|
493
|
+
: task.usage ?? { status: task.threadId ? "calculating" : "unavailable" },
|
|
494
|
+
{ wholeTask: true },
|
|
495
|
+
));
|
|
421
496
|
elements.dialogInstruction.textContent = task.instruction;
|
|
422
497
|
elements.copyTaskId.disabled = !task.id;
|
|
423
498
|
setCopyTaskIdLabel("Copy Task ID");
|
|
@@ -464,22 +539,28 @@ async function openDialog(task) {
|
|
|
464
539
|
const requestGeneration = ++detailRequestGeneration;
|
|
465
540
|
renderDialog(task);
|
|
466
541
|
if (!elements.dialog.open) elements.dialog.showModal();
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
542
|
+
const load = async (attempt = 0) => {
|
|
543
|
+
try {
|
|
544
|
+
const response = await fetch(`/api/tasks/${encodeURIComponent(task.id)}`);
|
|
545
|
+
if (!response.ok) throw new Error("Task details are unavailable.");
|
|
546
|
+
const detail = await response.json();
|
|
547
|
+
if (
|
|
548
|
+
requestGeneration === detailRequestGeneration
|
|
549
|
+
&& state.selectedTask?.id === task.id
|
|
550
|
+
&& elements.dialog.open
|
|
551
|
+
) {
|
|
552
|
+
renderDialog(detail.task);
|
|
553
|
+
if (usageStillCalculating(detail.task) && attempt < MAX_USAGE_POLL_ATTEMPTS) {
|
|
554
|
+
setTimeout(() => load(attempt + 1), USAGE_POLL_INTERVAL_MS);
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
} catch {
|
|
558
|
+
if (state.selectedTask?.id === task.id) {
|
|
559
|
+
showMessage("Task activity timeline is temporarily unavailable.");
|
|
560
|
+
}
|
|
481
561
|
}
|
|
482
|
-
}
|
|
562
|
+
};
|
|
563
|
+
await load();
|
|
483
564
|
}
|
|
484
565
|
|
|
485
566
|
function taskCard(task) {
|
package/src/dashboard/index.html
CHANGED
|
@@ -116,6 +116,10 @@
|
|
|
116
116
|
<button id="archive-codex" class="danger-button" type="button" aria-label="Archive this chat in Codex" hidden>Archive chat</button>
|
|
117
117
|
</div>
|
|
118
118
|
<nav id="dialog-related-links" class="github-links" aria-label="Related GitHub links" hidden></nav>
|
|
119
|
+
<section>
|
|
120
|
+
<h3>Usage</h3>
|
|
121
|
+
<div id="dialog-usage" class="usage-panel" aria-live="polite"></div>
|
|
122
|
+
</section>
|
|
119
123
|
<section>
|
|
120
124
|
<h3>Activity timeline</h3>
|
|
121
125
|
<div id="dialog-results" class="result-history"></div>
|
package/src/dashboard/styles.css
CHANGED
|
@@ -165,6 +165,13 @@ dialog section + section { margin-top: 24px; }
|
|
|
165
165
|
.result-history-item h4 { margin: 10px 0 3px; color: var(--muted); font-size: 0.72rem; letter-spacing: 0.04em; text-transform: uppercase; }
|
|
166
166
|
.result-history-turn, .result-history-empty { color: var(--muted); font-size: 0.78rem; overflow-wrap: anywhere; }
|
|
167
167
|
.result-history-empty { margin: 0; }
|
|
168
|
+
.usage-panel { margin-top: 8px; }
|
|
169
|
+
.usage-summary { display: grid; gap: 3px; margin: 10px 0 4px; color: var(--muted); font-size: 0.8rem; }
|
|
170
|
+
.usage-summary strong { color: var(--text); font-size: 0.9rem; }
|
|
171
|
+
.usage-calculating { display: flex; align-items: center; gap: 7px; }
|
|
172
|
+
.usage-spinner { width: 12px; height: 12px; border: 2px solid var(--border); border-top-color: var(--accent); border-radius: 50%; animation: usage-spin 0.8s linear infinite; }
|
|
173
|
+
@keyframes usage-spin { to { transform: rotate(360deg); } }
|
|
174
|
+
@media (prefers-reduced-motion: reduce) { .usage-spinner { animation: none; border-top-color: var(--border); background: var(--accent); } }
|
|
168
175
|
pre { max-height: 280px; margin: 0; padding: 14px; overflow: auto; border-radius: 7px; background: var(--surface-muted); white-space: pre-wrap; overflow-wrap: anywhere; font: 0.86rem/1.55 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
|
|
169
176
|
.metadata { display: grid; grid-template-columns: minmax(100px, 150px) 1fr; margin: 0; font-size: 0.88rem; }
|
|
170
177
|
.metadata dt, .metadata dd { padding: 7px 0; border-bottom: 1px solid var(--border); overflow-wrap: anywhere; }
|
package/src/dashboard.js
CHANGED
|
@@ -22,6 +22,7 @@ import {
|
|
|
22
22
|
} from "./workspace.js";
|
|
23
23
|
import { DASHBOARD_SERVER_VERSION, TASKCHEF_VERSION } from "./version.js";
|
|
24
24
|
import { taskGitHubProjection } from "./dashboard/github-links.js";
|
|
25
|
+
import { createUsageTracker } from "./usage-tracker.js";
|
|
25
26
|
|
|
26
27
|
const TASKS_FILE_NAME = "tasks.jsonl";
|
|
27
28
|
const STATIC_ROOT = fileURLToPath(new URL("./dashboard/", import.meta.url));
|
|
@@ -455,6 +456,7 @@ export async function createDashboardServer({
|
|
|
455
456
|
launcher = "standalone",
|
|
456
457
|
taskchefVersion = TASKCHEF_VERSION,
|
|
457
458
|
serverVersion = DASHBOARD_SERVER_VERSION,
|
|
459
|
+
usageTracker = null,
|
|
458
460
|
} = {}) {
|
|
459
461
|
if (!LOOPBACK_HOSTS.has(host)) {
|
|
460
462
|
throw new Error("dashboard host must be a loopback address");
|
|
@@ -470,6 +472,7 @@ export async function createDashboardServer({
|
|
|
470
472
|
}
|
|
471
473
|
const monitor = new DashboardMonitor(workspace, monitorOptions);
|
|
472
474
|
await monitor.start();
|
|
475
|
+
const taskUsageTracker = usageTracker ?? createUsageTracker({ workspace: monitor.workspace });
|
|
473
476
|
const identity = Object.freeze({
|
|
474
477
|
schemaVersion: 1,
|
|
475
478
|
service: "taskchef-dashboard",
|
|
@@ -574,7 +577,16 @@ export async function createDashboardServer({
|
|
|
574
577
|
response.writeHead(200, securityHeaders("application/json; charset=utf-8"));
|
|
575
578
|
response.end();
|
|
576
579
|
} else {
|
|
577
|
-
|
|
580
|
+
const usage = await taskUsageTracker.get(task).catch(() => ({
|
|
581
|
+
status: "unavailable",
|
|
582
|
+
reason: "Task usage is temporarily unavailable.",
|
|
583
|
+
task: null,
|
|
584
|
+
turns: {},
|
|
585
|
+
}));
|
|
586
|
+
sendJson(response, 200, {
|
|
587
|
+
schemaVersion: 1,
|
|
588
|
+
task: { ...taskDetailProjection(task), usage },
|
|
589
|
+
});
|
|
578
590
|
}
|
|
579
591
|
return;
|
|
580
592
|
}
|
package/src/mcp.js
CHANGED
|
@@ -13,6 +13,7 @@ import { parseTaskChefMarker } from "./delegation.js";
|
|
|
13
13
|
import { createDashboardManager } from "./dashboard-manager.js";
|
|
14
14
|
import { resolveWorkspacePath } from "./workspace-path.js";
|
|
15
15
|
import { DASHBOARD_SERVER_VERSION, TASKCHEF_VERSION } from "./version.js";
|
|
16
|
+
import { createUsageTracker } from "./usage-tracker.js";
|
|
16
17
|
|
|
17
18
|
const projectSchema = z.object({
|
|
18
19
|
name: z.string(),
|
|
@@ -145,6 +146,7 @@ export function createTaskChefMcpServer({
|
|
|
145
146
|
dashboardManager = createDashboardManager({ workspace }),
|
|
146
147
|
readConfiguration = readConfig,
|
|
147
148
|
logDashboardDiagnostic,
|
|
149
|
+
usageTracker = createUsageTracker({ workspace }),
|
|
148
150
|
} = {}) {
|
|
149
151
|
const server = new McpServer(
|
|
150
152
|
{ name: "taskchef", version: TASKCHEF_VERSION },
|
|
@@ -287,6 +289,7 @@ export function createTaskChefMcpServer({
|
|
|
287
289
|
},
|
|
288
290
|
async (input) => {
|
|
289
291
|
const task = await reportState(workspace, input);
|
|
292
|
+
void usageTracker.observe(task).catch(() => {});
|
|
290
293
|
return toolResult("task", task, `Recorded ${task.status} state for TaskChef task ${task.id}.`);
|
|
291
294
|
},
|
|
292
295
|
);
|
|
@@ -314,6 +317,7 @@ export function createTaskChefMcpServer({
|
|
|
314
317
|
},
|
|
315
318
|
async (input) => {
|
|
316
319
|
const task = await reportResult(workspace, input);
|
|
320
|
+
void usageTracker.observe(task).catch(() => {});
|
|
317
321
|
return toolResult("task", task, `Recorded ${task.status} result for TaskChef task ${task.id}.`);
|
|
318
322
|
},
|
|
319
323
|
);
|
|
@@ -0,0 +1,425 @@
|
|
|
1
|
+
import { realpath } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
|
|
4
|
+
import { acquireWorkspaceLock } from "./workspace.js";
|
|
5
|
+
import {
|
|
6
|
+
readCcusageThreadUsage,
|
|
7
|
+
readUsageStore,
|
|
8
|
+
usageDelta,
|
|
9
|
+
writeUsageStore,
|
|
10
|
+
} from "./usage.js";
|
|
11
|
+
|
|
12
|
+
const TERMINAL_STATUSES = new Set(["needs_input", "completed", "failed"]);
|
|
13
|
+
const MAX_TRACKED_TURNS = 250;
|
|
14
|
+
|
|
15
|
+
function hasTerminalLatestTurn(task) {
|
|
16
|
+
return task.latestTurn !== null
|
|
17
|
+
&& task.latestTurn !== undefined
|
|
18
|
+
&& task.latestTurn.result !== null
|
|
19
|
+
&& TERMINAL_STATUSES.has(task.latestTurn.result.status);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function lifecycleGeneration(task) {
|
|
23
|
+
return {
|
|
24
|
+
turnCount: task.turns.length,
|
|
25
|
+
terminal: hasTerminalLatestTurn(task),
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function generationIsOlder(task, existing) {
|
|
30
|
+
if (!existing) return false;
|
|
31
|
+
const incoming = lifecycleGeneration(task);
|
|
32
|
+
if (incoming.turnCount !== existing.generationTurnCount) {
|
|
33
|
+
return incoming.turnCount < existing.generationTurnCount;
|
|
34
|
+
}
|
|
35
|
+
return !incoming.terminal && existing.generationTerminal;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function publicReason(error) {
|
|
39
|
+
const message = String(error?.message ?? "");
|
|
40
|
+
if (/not installed/i.test(message)) return "ccusage is not installed.";
|
|
41
|
+
if (/could not resolve/i.test(message)) return "No matching Codex usage session was found.";
|
|
42
|
+
if (/timed out/i.test(message)) return "ccusage did not finish in time.";
|
|
43
|
+
return "Codex usage is unavailable from ccusage.";
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function calculatingTask(task, existing = null, now = new Date().toISOString()) {
|
|
47
|
+
const latestTurnRef = task.latestTurn?.turnRef ?? null;
|
|
48
|
+
const preserveAvailable = existing?.generationTurnRef === latestTurnRef
|
|
49
|
+
&& existing?.status === "available"
|
|
50
|
+
&& existing?.turns?.[latestTurnRef]?.status === "available";
|
|
51
|
+
const recentTurns = task.turns.slice(-MAX_TRACKED_TURNS);
|
|
52
|
+
const turns = Object.fromEntries(recentTurns.flatMap((turn) => (
|
|
53
|
+
existing?.turns?.[turn.turnRef]
|
|
54
|
+
? [[turn.turnRef, existing.turns[turn.turnRef]]]
|
|
55
|
+
: []
|
|
56
|
+
)));
|
|
57
|
+
const generation = lifecycleGeneration(task);
|
|
58
|
+
for (const turn of recentTurns) {
|
|
59
|
+
if (turn.result === null) {
|
|
60
|
+
turns[turn.turnRef] = { status: "calculating", updatedAt: now };
|
|
61
|
+
} else if (turn.result.status === "interrupted") {
|
|
62
|
+
turns[turn.turnRef] = {
|
|
63
|
+
status: "unavailable",
|
|
64
|
+
reason: "The turn ended without a terminal usage boundary.",
|
|
65
|
+
updatedAt: now,
|
|
66
|
+
};
|
|
67
|
+
} else if (turns[turn.turnRef]?.status === "calculating"
|
|
68
|
+
&& turn.turnRef !== task.latestTurn?.turnRef) {
|
|
69
|
+
turns[turn.turnRef] = {
|
|
70
|
+
status: "unavailable",
|
|
71
|
+
reason: "A newer turn started before a stable usage boundary was recorded.",
|
|
72
|
+
updatedAt: now,
|
|
73
|
+
};
|
|
74
|
+
} else if (!turns[turn.turnRef]) {
|
|
75
|
+
turns[turn.turnRef] = {
|
|
76
|
+
status: "unavailable",
|
|
77
|
+
reason: "No reliable cumulative boundary was recorded for this historical turn.",
|
|
78
|
+
updatedAt: now,
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
if (task.latestTurn?.result
|
|
83
|
+
&& TERMINAL_STATUSES.has(task.latestTurn.result.status)
|
|
84
|
+
&& !preserveAvailable) {
|
|
85
|
+
turns[task.latestTurn.turnRef] = { status: "calculating", updatedAt: now };
|
|
86
|
+
}
|
|
87
|
+
return {
|
|
88
|
+
threadId: task.threadId,
|
|
89
|
+
generationTurnRef: latestTurnRef,
|
|
90
|
+
generationTurnCount: generation.turnCount,
|
|
91
|
+
generationTerminal: generation.terminal,
|
|
92
|
+
zeroBaselineTurnRef: existing?.zeroBaselineTurnRef ?? (
|
|
93
|
+
task.turns.length === 1 && task.latestTurn?.result === null
|
|
94
|
+
? task.latestTurn.turnRef
|
|
95
|
+
: null
|
|
96
|
+
),
|
|
97
|
+
status: preserveAvailable ? "available" : "calculating",
|
|
98
|
+
updatedAt: preserveAvailable ? existing.updatedAt : now,
|
|
99
|
+
retryAfter: null,
|
|
100
|
+
task: existing?.task ?? null,
|
|
101
|
+
turns,
|
|
102
|
+
boundaries: existing?.boundaries ?? {},
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
async function updateStore(workspace, taskId, transform) {
|
|
107
|
+
const release = await acquireWorkspaceLock(workspace);
|
|
108
|
+
try {
|
|
109
|
+
const store = await readUsageStore(workspace);
|
|
110
|
+
const next = await transform(store.tasks[taskId] ?? null);
|
|
111
|
+
store.tasks[taskId] = next;
|
|
112
|
+
await writeUsageStore(workspace, store);
|
|
113
|
+
return next;
|
|
114
|
+
} finally {
|
|
115
|
+
await release();
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function snapshotFingerprint(snapshot) {
|
|
120
|
+
return JSON.stringify([
|
|
121
|
+
snapshot.inputTokens,
|
|
122
|
+
snapshot.cachedInputTokens,
|
|
123
|
+
snapshot.outputTokens,
|
|
124
|
+
snapshot.reasoningOutputTokens,
|
|
125
|
+
snapshot.totalTokens,
|
|
126
|
+
snapshot.estimatedCostUsd,
|
|
127
|
+
snapshot.sourceUpdatedAt,
|
|
128
|
+
]);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function snapshotSupersedes(current, incoming) {
|
|
132
|
+
if (!current) return true;
|
|
133
|
+
const currentSampledAt = Date.parse(current.sampledAt ?? 0);
|
|
134
|
+
const incomingSampledAt = Date.parse(incoming.sampledAt ?? 0);
|
|
135
|
+
if (!Number.isFinite(incomingSampledAt) || incomingSampledAt < currentSampledAt) return false;
|
|
136
|
+
return [
|
|
137
|
+
"inputTokens",
|
|
138
|
+
"cachedInputTokens",
|
|
139
|
+
"outputTokens",
|
|
140
|
+
"reasoningOutputTokens",
|
|
141
|
+
"totalTokens",
|
|
142
|
+
].every((field) => incoming[field] >= current[field]);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function candidateHasAdvanced(task, existing, snapshot) {
|
|
146
|
+
const latestIndex = task.turns.findIndex((turn) => turn.turnRef === task.latestTurn?.turnRef);
|
|
147
|
+
if (latestIndex <= 0) return true;
|
|
148
|
+
const previousTurn = task.turns[latestIndex - 1];
|
|
149
|
+
const previousBoundary = existing?.boundaries?.[previousTurn.turnRef];
|
|
150
|
+
if (!previousBoundary) return true;
|
|
151
|
+
const delta = usageDelta(snapshot, previousBoundary);
|
|
152
|
+
return delta !== null && delta.totalTokens > 0;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function reconcileRecord(task, existing, snapshot, { boundaryReliable = true } = {}) {
|
|
156
|
+
const now = snapshot.sampledAt;
|
|
157
|
+
const boundaries = { ...(existing?.boundaries ?? {}) };
|
|
158
|
+
const recentTurns = task.turns.slice(-MAX_TRACKED_TURNS);
|
|
159
|
+
const turns = Object.fromEntries(recentTurns.flatMap((turn) => (
|
|
160
|
+
existing?.turns?.[turn.turnRef]
|
|
161
|
+
? [[turn.turnRef, existing.turns[turn.turnRef]]]
|
|
162
|
+
: []
|
|
163
|
+
)));
|
|
164
|
+
const terminalTurns = recentTurns.filter((turn) => (
|
|
165
|
+
turn.result !== null && TERMINAL_STATUSES.has(turn.result.status)
|
|
166
|
+
));
|
|
167
|
+
const generation = lifecycleGeneration(task);
|
|
168
|
+
for (const turn of terminalTurns) {
|
|
169
|
+
if (!turns[turn.turnRef]) {
|
|
170
|
+
turns[turn.turnRef] = {
|
|
171
|
+
status: "unavailable",
|
|
172
|
+
reason: "No reliable cumulative boundary was recorded for this historical turn.",
|
|
173
|
+
updatedAt: now,
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const latest = terminalTurns.at(-1);
|
|
179
|
+
if (latest && latest.turnRef === task.latestTurn?.turnRef && boundaryReliable) {
|
|
180
|
+
const index = task.turns.findIndex((turn) => turn.turnRef === latest.turnRef);
|
|
181
|
+
const previousTurn = index > 0 ? task.turns[index - 1] : null;
|
|
182
|
+
const previousBoundary = previousTurn ? boundaries[previousTurn.turnRef] ?? null : null;
|
|
183
|
+
const delta = previousTurn === null
|
|
184
|
+
? (existing?.zeroBaselineTurnRef === latest.turnRef ? usageDelta(snapshot, null) : null)
|
|
185
|
+
: (previousBoundary !== null ? usageDelta(snapshot, previousBoundary) : null);
|
|
186
|
+
const advanced = previousTurn === null || previousBoundary === null || delta?.totalTokens > 0;
|
|
187
|
+
if (advanced) boundaries[latest.turnRef] = snapshot;
|
|
188
|
+
turns[latest.turnRef] = delta === null || !advanced
|
|
189
|
+
? {
|
|
190
|
+
status: "unavailable",
|
|
191
|
+
reason: !advanced
|
|
192
|
+
? "Cumulative usage did not advance beyond the preceding turn."
|
|
193
|
+
: (previousTurn === null
|
|
194
|
+
? "No live zero-token boundary was recorded for this historical turn."
|
|
195
|
+
: "The preceding turn has no reliable cumulative boundary."),
|
|
196
|
+
updatedAt: now,
|
|
197
|
+
}
|
|
198
|
+
: {
|
|
199
|
+
status: "available",
|
|
200
|
+
...delta,
|
|
201
|
+
provenance: snapshot.provenance,
|
|
202
|
+
sampledAt: snapshot.sampledAt,
|
|
203
|
+
sourceUpdatedAt: snapshot.sourceUpdatedAt,
|
|
204
|
+
updatedAt: now,
|
|
205
|
+
};
|
|
206
|
+
} else if (latest && latest.turnRef === task.latestTurn?.turnRef) {
|
|
207
|
+
turns[latest.turnRef] = {
|
|
208
|
+
status: "unavailable",
|
|
209
|
+
reason: "Usage did not stabilize before reconciliation finished.",
|
|
210
|
+
updatedAt: now,
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
return {
|
|
215
|
+
threadId: task.threadId,
|
|
216
|
+
generationTurnRef: task.latestTurn?.turnRef ?? null,
|
|
217
|
+
generationTurnCount: generation.turnCount,
|
|
218
|
+
generationTerminal: generation.terminal,
|
|
219
|
+
zeroBaselineTurnRef: existing?.zeroBaselineTurnRef ?? null,
|
|
220
|
+
status: "available",
|
|
221
|
+
updatedAt: now,
|
|
222
|
+
retryAfter: null,
|
|
223
|
+
task: snapshot,
|
|
224
|
+
turns,
|
|
225
|
+
boundaries,
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
export function createUsageTracker({
|
|
230
|
+
workspace,
|
|
231
|
+
readThreadUsage = readCcusageThreadUsage,
|
|
232
|
+
retryDelaysMs = [2_000, 3_000, 4_000, 1_000],
|
|
233
|
+
retryCooldownMs = 60_000,
|
|
234
|
+
setTimer = setTimeout,
|
|
235
|
+
} = {}) {
|
|
236
|
+
const jobs = new Map();
|
|
237
|
+
const observationChains = new Map();
|
|
238
|
+
let canonicalWorkspace = null;
|
|
239
|
+
|
|
240
|
+
const root = async () => {
|
|
241
|
+
canonicalWorkspace ??= await realpath(path.resolve(workspace));
|
|
242
|
+
return canonicalWorkspace;
|
|
243
|
+
};
|
|
244
|
+
|
|
245
|
+
const markCalculating = async (task) => updateStore(await root(), task.id, (existing) => (
|
|
246
|
+
generationIsOlder(task, existing) ? existing : calculatingTask(task, existing)
|
|
247
|
+
));
|
|
248
|
+
|
|
249
|
+
const reconcile = async (task, {
|
|
250
|
+
finalAttempt = false,
|
|
251
|
+
isCurrent = () => true,
|
|
252
|
+
job,
|
|
253
|
+
} = {}) => {
|
|
254
|
+
let snapshot;
|
|
255
|
+
try {
|
|
256
|
+
snapshot = await readThreadUsage(task.threadId);
|
|
257
|
+
} catch (error) {
|
|
258
|
+
if (!finalAttempt) throw error;
|
|
259
|
+
if (!isCurrent()) return null;
|
|
260
|
+
return updateStore(await root(), task.id, (existing) => {
|
|
261
|
+
if (existing?.generationTurnRef !== task.latestTurn?.turnRef) return existing;
|
|
262
|
+
if (existing?.status === "available"
|
|
263
|
+
&& existing?.turns?.[task.latestTurn?.turnRef]?.status === "available") return existing;
|
|
264
|
+
const calculating = calculatingTask(task, existing);
|
|
265
|
+
return {
|
|
266
|
+
...calculating,
|
|
267
|
+
status: "unavailable",
|
|
268
|
+
reason: publicReason(error),
|
|
269
|
+
updatedAt: new Date().toISOString(),
|
|
270
|
+
retryAfter: new Date(Date.now() + retryCooldownMs).toISOString(),
|
|
271
|
+
turns: Object.fromEntries(Object.entries(calculating.turns).map(
|
|
272
|
+
([turnRef, usage]) => [turnRef, usage.status === "calculating"
|
|
273
|
+
? { status: "unavailable", reason: publicReason(error), updatedAt: new Date().toISOString() }
|
|
274
|
+
: usage],
|
|
275
|
+
)),
|
|
276
|
+
};
|
|
277
|
+
});
|
|
278
|
+
}
|
|
279
|
+
if (!isCurrent()) return null;
|
|
280
|
+
const fingerprint = snapshotFingerprint(snapshot);
|
|
281
|
+
const stable = job.previousFingerprint === fingerprint;
|
|
282
|
+
job.previousFingerprint = fingerprint;
|
|
283
|
+
if (!stable && !finalAttempt) return null;
|
|
284
|
+
if (stable && !finalAttempt) {
|
|
285
|
+
const store = await readUsageStore(await root());
|
|
286
|
+
if (!candidateHasAdvanced(task, store.tasks[task.id], snapshot)) return null;
|
|
287
|
+
}
|
|
288
|
+
return updateStore(await root(), task.id, (existing) => {
|
|
289
|
+
const latestTurnRef = task.latestTurn?.turnRef;
|
|
290
|
+
if (existing?.generationTurnRef !== latestTurnRef) return existing;
|
|
291
|
+
const supersedes = snapshotSupersedes(existing?.task, snapshot);
|
|
292
|
+
if (existing?.boundaries?.[latestTurnRef]
|
|
293
|
+
|| existing?.turns?.[latestTurnRef]?.status === "available") {
|
|
294
|
+
return supersedes
|
|
295
|
+
? { ...existing, status: "available", updatedAt: snapshot.sampledAt, task: snapshot }
|
|
296
|
+
: existing;
|
|
297
|
+
}
|
|
298
|
+
if (!supersedes) return existing;
|
|
299
|
+
return reconcileRecord(task, existing, snapshot, { boundaryReliable: stable });
|
|
300
|
+
});
|
|
301
|
+
};
|
|
302
|
+
|
|
303
|
+
const schedule = (task, { immediate = false } = {}) => {
|
|
304
|
+
if (!task.threadId || jobs.has(task.id)) return;
|
|
305
|
+
const job = {
|
|
306
|
+
cancelled: false,
|
|
307
|
+
previousFingerprint: null,
|
|
308
|
+
turnRef: task.latestTurn?.turnRef ?? null,
|
|
309
|
+
};
|
|
310
|
+
let attempt = 0;
|
|
311
|
+
const run = async () => {
|
|
312
|
+
if (job.cancelled || jobs.get(task.id) !== job) return;
|
|
313
|
+
let complete = false;
|
|
314
|
+
try {
|
|
315
|
+
const result = await reconcile(task, {
|
|
316
|
+
finalAttempt: attempt >= retryDelaysMs.length,
|
|
317
|
+
isCurrent: () => !job.cancelled && jobs.get(task.id) === job,
|
|
318
|
+
job,
|
|
319
|
+
});
|
|
320
|
+
complete = result !== null;
|
|
321
|
+
} catch {
|
|
322
|
+
// Retry bounded transient analyzer failures.
|
|
323
|
+
}
|
|
324
|
+
if (job.cancelled || jobs.get(task.id) !== job) return;
|
|
325
|
+
if (complete || attempt >= retryDelaysMs.length) {
|
|
326
|
+
if (jobs.get(task.id) === job) jobs.delete(task.id);
|
|
327
|
+
return;
|
|
328
|
+
}
|
|
329
|
+
const delay = retryDelaysMs[attempt] ?? 0;
|
|
330
|
+
attempt += 1;
|
|
331
|
+
const timer = setTimer(run, delay);
|
|
332
|
+
timer?.unref?.();
|
|
333
|
+
};
|
|
334
|
+
jobs.set(task.id, job);
|
|
335
|
+
const timer = setTimer(run, immediate ? 0 : retryDelaysMs[attempt++]);
|
|
336
|
+
timer?.unref?.();
|
|
337
|
+
};
|
|
338
|
+
|
|
339
|
+
return {
|
|
340
|
+
observe(task) {
|
|
341
|
+
const previous = observationChains.get(task.id) ?? Promise.resolve();
|
|
342
|
+
const observation = previous.catch(() => {}).then(async () => {
|
|
343
|
+
if (!task.threadId) return null;
|
|
344
|
+
const active = jobs.get(task.id);
|
|
345
|
+
const latestTurnRef = task.latestTurn?.turnRef ?? null;
|
|
346
|
+
if (active && active.turnRef !== latestTurnRef) {
|
|
347
|
+
active.cancelled = true;
|
|
348
|
+
jobs.delete(task.id);
|
|
349
|
+
}
|
|
350
|
+
const usage = await markCalculating(task);
|
|
351
|
+
if (hasTerminalLatestTurn(task)) schedule(task);
|
|
352
|
+
return usage;
|
|
353
|
+
});
|
|
354
|
+
observationChains.set(task.id, observation);
|
|
355
|
+
void observation.finally(() => {
|
|
356
|
+
if (observationChains.get(task.id) === observation) observationChains.delete(task.id);
|
|
357
|
+
}).catch(() => {});
|
|
358
|
+
return observation;
|
|
359
|
+
},
|
|
360
|
+
async get(task) {
|
|
361
|
+
const store = await readUsageStore(await root());
|
|
362
|
+
const usage = store.tasks[task.id] ?? null;
|
|
363
|
+
if (!task.threadId) return {
|
|
364
|
+
status: "unavailable",
|
|
365
|
+
reason: "This task has no linked Codex thread.",
|
|
366
|
+
task: null,
|
|
367
|
+
turns: {},
|
|
368
|
+
};
|
|
369
|
+
if (!task.latestTurn) return {
|
|
370
|
+
status: "unavailable",
|
|
371
|
+
reason: "No TaskChef turn has started yet.",
|
|
372
|
+
task: null,
|
|
373
|
+
turns: {},
|
|
374
|
+
};
|
|
375
|
+
const active = jobs.get(task.id);
|
|
376
|
+
const latestTurnRef = task.latestTurn?.turnRef ?? null;
|
|
377
|
+
if (active && active.turnRef !== latestTurnRef) {
|
|
378
|
+
active.cancelled = true;
|
|
379
|
+
jobs.delete(task.id);
|
|
380
|
+
}
|
|
381
|
+
if (!usage || usage.threadId !== task.threadId) {
|
|
382
|
+
const calculating = calculatingTask(task);
|
|
383
|
+
void markCalculating(task)
|
|
384
|
+
.then(() => {
|
|
385
|
+
if (hasTerminalLatestTurn(task)) schedule(task, { immediate: true });
|
|
386
|
+
})
|
|
387
|
+
.catch(() => {});
|
|
388
|
+
return calculating;
|
|
389
|
+
}
|
|
390
|
+
if (usage.generationTurnRef !== latestTurnRef) {
|
|
391
|
+
const calculating = calculatingTask(task, usage);
|
|
392
|
+
void markCalculating(task)
|
|
393
|
+
.then(() => {
|
|
394
|
+
if (hasTerminalLatestTurn(task)) schedule(task, { immediate: true });
|
|
395
|
+
})
|
|
396
|
+
.catch(() => {});
|
|
397
|
+
return calculating;
|
|
398
|
+
}
|
|
399
|
+
const latestTurnUsage = task.latestTurn
|
|
400
|
+
? usage.turns?.[task.latestTurn.turnRef]
|
|
401
|
+
: null;
|
|
402
|
+
if (!latestTurnUsage) {
|
|
403
|
+
const calculating = calculatingTask(task, usage);
|
|
404
|
+
void markCalculating(task)
|
|
405
|
+
.then(() => {
|
|
406
|
+
if (hasTerminalLatestTurn(task)) schedule(task, { immediate: true });
|
|
407
|
+
})
|
|
408
|
+
.catch(() => {});
|
|
409
|
+
return calculating;
|
|
410
|
+
}
|
|
411
|
+
if (hasTerminalLatestTurn(task) && usage.status !== "available") {
|
|
412
|
+
if (usage.status === "unavailable"
|
|
413
|
+
&& usage.retryAfter
|
|
414
|
+
&& Date.parse(usage.retryAfter) > Date.now()) return usage;
|
|
415
|
+
const calculating = calculatingTask(task, usage);
|
|
416
|
+
void markCalculating(task)
|
|
417
|
+
.then(() => schedule(task, { immediate: true }))
|
|
418
|
+
.catch(() => {});
|
|
419
|
+
return calculating;
|
|
420
|
+
}
|
|
421
|
+
return usage;
|
|
422
|
+
},
|
|
423
|
+
schedule,
|
|
424
|
+
};
|
|
425
|
+
}
|
package/src/usage.js
ADDED
|
@@ -0,0 +1,388 @@
|
|
|
1
|
+
import { execFile as execFileCallback } from "node:child_process";
|
|
2
|
+
import { lstat, mkdir, readFile, rename, unlink, writeFile } from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { promisify } from "node:util";
|
|
5
|
+
import { randomUUID } from "node:crypto";
|
|
6
|
+
|
|
7
|
+
const execFile = promisify(execFileCallback);
|
|
8
|
+
const USAGE_FILE_NAME = ".taskchef-usage.json";
|
|
9
|
+
const UUID_PATTERN = /[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}/gi;
|
|
10
|
+
const TOKEN_FIELDS = [
|
|
11
|
+
"inputTokens",
|
|
12
|
+
"cachedInputTokens",
|
|
13
|
+
"outputTokens",
|
|
14
|
+
"reasoningOutputTokens",
|
|
15
|
+
"totalTokens",
|
|
16
|
+
];
|
|
17
|
+
const USAGE_STATUSES = new Set(["calculating", "available", "unavailable"]);
|
|
18
|
+
const MAX_USAGE_FILE_BYTES = 16 * 1024 * 1024;
|
|
19
|
+
const USAGE_WRITE_BUDGET_BYTES = 8 * 1024 * 1024;
|
|
20
|
+
const MAX_PERSISTED_TASKS = 1_000;
|
|
21
|
+
const MAX_PERSISTED_TURNS = 250;
|
|
22
|
+
|
|
23
|
+
function nonNegativeNumber(value, name) {
|
|
24
|
+
if (typeof value !== "number" || !Number.isFinite(value) || value < 0) {
|
|
25
|
+
throw new Error(`${name} must be a non-negative number`);
|
|
26
|
+
}
|
|
27
|
+
return value;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function matchingSession(session, threadId) {
|
|
31
|
+
const identity = `${session.sessionId ?? ""} ${session.sessionFile ?? ""}`;
|
|
32
|
+
const [primaryThreadId] = identity.match(UUID_PATTERN) ?? [];
|
|
33
|
+
return primaryThreadId?.toLowerCase() === threadId;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function normalizeModelUsage(models, name) {
|
|
37
|
+
if (!models || typeof models !== "object" || Array.isArray(models)) {
|
|
38
|
+
throw new Error(`${name}.models must be an object`);
|
|
39
|
+
}
|
|
40
|
+
return Object.fromEntries(Object.entries(models).map(([model, usage]) => {
|
|
41
|
+
if (model.length === 0 || model.length > 256) throw new Error(`${name}.models has an invalid model name`);
|
|
42
|
+
return [model, {
|
|
43
|
+
inputTokens: nonNegativeNumber(usage.inputTokens, `${name}.models.${model}.inputTokens`),
|
|
44
|
+
cachedInputTokens: nonNegativeNumber(usage.cacheReadTokens, `${name}.models.${model}.cacheReadTokens`),
|
|
45
|
+
outputTokens: nonNegativeNumber(usage.outputTokens, `${name}.models.${model}.outputTokens`),
|
|
46
|
+
reasoningOutputTokens: nonNegativeNumber(
|
|
47
|
+
usage.reasoningOutputTokens,
|
|
48
|
+
`${name}.models.${model}.reasoningOutputTokens`,
|
|
49
|
+
),
|
|
50
|
+
totalTokens: nonNegativeNumber(usage.totalTokens, `${name}.models.${model}.totalTokens`),
|
|
51
|
+
}];
|
|
52
|
+
}));
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function addModelUsage(target, source) {
|
|
56
|
+
for (const [model, usage] of Object.entries(source)) {
|
|
57
|
+
if (!Object.hasOwn(target, model)) {
|
|
58
|
+
target[model] = Object.fromEntries(TOKEN_FIELDS.map((field) => [field, 0]));
|
|
59
|
+
}
|
|
60
|
+
for (const field of TOKEN_FIELDS) target[model][field] += usage[field];
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function timestampOrNull(value, name) {
|
|
65
|
+
if (value === null) return null;
|
|
66
|
+
if (typeof value !== "string" || Number.isNaN(Date.parse(value))) {
|
|
67
|
+
throw new Error(`${name} must be an ISO timestamp or null`);
|
|
68
|
+
}
|
|
69
|
+
return value;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function normalizeProvenance(value, name, { includeSessionCount = false } = {}) {
|
|
73
|
+
if (!value || value.provider !== "ccusage") {
|
|
74
|
+
throw new Error(`${name}.provenance is invalid`);
|
|
75
|
+
}
|
|
76
|
+
return {
|
|
77
|
+
provider: "ccusage",
|
|
78
|
+
version: typeof value.version === "string" ? value.version.slice(0, 64) : null,
|
|
79
|
+
...(includeSessionCount ? {
|
|
80
|
+
sessionCount: nonNegativeNumber(value.sessionCount, `${name}.provenance.sessionCount`),
|
|
81
|
+
} : {}),
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function normalizeStoredSnapshot(value, name) {
|
|
86
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
87
|
+
throw new Error(`${name} must be an object`);
|
|
88
|
+
}
|
|
89
|
+
const snapshot = Object.fromEntries(TOKEN_FIELDS.map((field) => [
|
|
90
|
+
field,
|
|
91
|
+
nonNegativeNumber(value[field], `${name}.${field}`),
|
|
92
|
+
]));
|
|
93
|
+
if (value.estimatedCostUsd !== null) {
|
|
94
|
+
snapshot.estimatedCostUsd = nonNegativeNumber(value.estimatedCostUsd, `${name}.estimatedCostUsd`);
|
|
95
|
+
} else {
|
|
96
|
+
snapshot.estimatedCostUsd = null;
|
|
97
|
+
}
|
|
98
|
+
snapshot.costStatus = value.costStatus === "estimated" ? "estimated" : "unavailable";
|
|
99
|
+
const models = normalizeModelUsage(Object.fromEntries(Object.entries(value.models ?? {}).map(
|
|
100
|
+
([model, usage]) => [model, {
|
|
101
|
+
...usage,
|
|
102
|
+
cacheReadTokens: usage.cachedInputTokens,
|
|
103
|
+
}],
|
|
104
|
+
)), name);
|
|
105
|
+
if (Object.keys(models).length > 64 || Object.keys(models).some((model) => model.length > 256)) {
|
|
106
|
+
throw new Error(`${name}.models exceeds the usage cache limit`);
|
|
107
|
+
}
|
|
108
|
+
snapshot.models = models;
|
|
109
|
+
snapshot.provenance = normalizeProvenance(value.provenance, name, { includeSessionCount: true });
|
|
110
|
+
snapshot.sampledAt = timestampOrNull(value.sampledAt, `${name}.sampledAt`);
|
|
111
|
+
snapshot.sourceUpdatedAt = timestampOrNull(value.sourceUpdatedAt, `${name}.sourceUpdatedAt`);
|
|
112
|
+
return snapshot;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function normalizeStoredTurn(value, name) {
|
|
116
|
+
if (!value || typeof value !== "object" || !USAGE_STATUSES.has(value.status)) {
|
|
117
|
+
throw new Error(`${name} has an invalid status`);
|
|
118
|
+
}
|
|
119
|
+
const updatedAt = timestampOrNull(value.updatedAt, `${name}.updatedAt`);
|
|
120
|
+
if (value.status === "available") {
|
|
121
|
+
const normalized = Object.fromEntries(TOKEN_FIELDS.map((field) => [
|
|
122
|
+
field,
|
|
123
|
+
nonNegativeNumber(value[field], `${name}.${field}`),
|
|
124
|
+
]));
|
|
125
|
+
normalized.estimatedCostUsd = value.estimatedCostUsd === null
|
|
126
|
+
? null
|
|
127
|
+
: nonNegativeNumber(value.estimatedCostUsd, `${name}.estimatedCostUsd`);
|
|
128
|
+
return {
|
|
129
|
+
status: "available",
|
|
130
|
+
...normalized,
|
|
131
|
+
costStatus: normalized.estimatedCostUsd === null ? "unavailable" : "estimated",
|
|
132
|
+
provenance: normalizeProvenance(value.provenance, name),
|
|
133
|
+
sampledAt: timestampOrNull(value.sampledAt, `${name}.sampledAt`),
|
|
134
|
+
sourceUpdatedAt: timestampOrNull(value.sourceUpdatedAt, `${name}.sourceUpdatedAt`),
|
|
135
|
+
updatedAt,
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
return {
|
|
139
|
+
status: value.status,
|
|
140
|
+
...(value.status === "unavailable" ? {
|
|
141
|
+
reason: typeof value.reason === "string" ? value.reason.slice(0, 256) : "Usage is unavailable.",
|
|
142
|
+
} : {}),
|
|
143
|
+
updatedAt,
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function normalizeStoredRecord(value, name) {
|
|
148
|
+
if (!value || typeof value !== "object" || !USAGE_STATUSES.has(value.status)) {
|
|
149
|
+
throw new Error(`${name} is invalid`);
|
|
150
|
+
}
|
|
151
|
+
const threadId = String(value.threadId ?? "").toLowerCase();
|
|
152
|
+
if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/.test(threadId)) {
|
|
153
|
+
throw new Error(`${name}.threadId is invalid`);
|
|
154
|
+
}
|
|
155
|
+
const generationTurnRef = value.generationTurnRef ?? null;
|
|
156
|
+
if (generationTurnRef !== null
|
|
157
|
+
&& (typeof generationTurnRef !== "string" || generationTurnRef.length === 0 || generationTurnRef.length > 256)) {
|
|
158
|
+
throw new Error(`${name}.generationTurnRef is invalid`);
|
|
159
|
+
}
|
|
160
|
+
const zeroBaselineTurnRef = value.zeroBaselineTurnRef ?? null;
|
|
161
|
+
if (zeroBaselineTurnRef !== null
|
|
162
|
+
&& (typeof zeroBaselineTurnRef !== "string" || zeroBaselineTurnRef.length === 0 || zeroBaselineTurnRef.length > 256)) {
|
|
163
|
+
throw new Error(`${name}.zeroBaselineTurnRef is invalid`);
|
|
164
|
+
}
|
|
165
|
+
const generationTurnCount = nonNegativeNumber(
|
|
166
|
+
value.generationTurnCount ?? 0,
|
|
167
|
+
`${name}.generationTurnCount`,
|
|
168
|
+
);
|
|
169
|
+
const generationTerminal = value.generationTerminal === true;
|
|
170
|
+
const normalizeMap = (entries, normalizer, mapName) => {
|
|
171
|
+
if (!entries || typeof entries !== "object" || Array.isArray(entries)) {
|
|
172
|
+
throw new Error(`${mapName} must be an object`);
|
|
173
|
+
}
|
|
174
|
+
const pairs = Object.entries(entries);
|
|
175
|
+
if (pairs.length > 10_000) throw new Error(`${mapName} exceeds the usage cache limit`);
|
|
176
|
+
return Object.fromEntries(pairs.map(([key, item]) => [key, normalizer(item, `${mapName}.${key}`)]));
|
|
177
|
+
};
|
|
178
|
+
return {
|
|
179
|
+
threadId,
|
|
180
|
+
generationTurnRef,
|
|
181
|
+
generationTurnCount,
|
|
182
|
+
generationTerminal,
|
|
183
|
+
zeroBaselineTurnRef,
|
|
184
|
+
status: value.status,
|
|
185
|
+
updatedAt: timestampOrNull(value.updatedAt, `${name}.updatedAt`),
|
|
186
|
+
retryAfter: timestampOrNull(value.retryAfter ?? null, `${name}.retryAfter`),
|
|
187
|
+
task: value.task === null ? null : normalizeStoredSnapshot(value.task, `${name}.task`),
|
|
188
|
+
turns: normalizeMap(value.turns, normalizeStoredTurn, `${name}.turns`),
|
|
189
|
+
boundaries: normalizeMap(value.boundaries, normalizeStoredSnapshot, `${name}.boundaries`),
|
|
190
|
+
...(value.status === "unavailable" ? {
|
|
191
|
+
reason: typeof value.reason === "string" ? value.reason.slice(0, 256) : "Usage is unavailable.",
|
|
192
|
+
} : {}),
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
export function aggregateCcusageSessions(payload, threadId, {
|
|
197
|
+
sampledAt = new Date().toISOString(),
|
|
198
|
+
version = null,
|
|
199
|
+
} = {}) {
|
|
200
|
+
if (!payload || typeof payload !== "object" || !Array.isArray(payload.sessions)) {
|
|
201
|
+
throw new Error("ccusage output must contain a sessions array");
|
|
202
|
+
}
|
|
203
|
+
const normalizedThreadId = String(threadId).toLowerCase();
|
|
204
|
+
const sessions = payload.sessions.filter((session) => matchingSession(session, normalizedThreadId));
|
|
205
|
+
if (sessions.length === 0) throw new Error("ccusage could not resolve this Codex thread");
|
|
206
|
+
|
|
207
|
+
const usage = Object.fromEntries(TOKEN_FIELDS.map((field) => [field, 0]));
|
|
208
|
+
const models = Object.create(null);
|
|
209
|
+
let estimatedCostUsd = 0;
|
|
210
|
+
let hasUnpricedUsage = false;
|
|
211
|
+
let sourceUpdatedAt = null;
|
|
212
|
+
for (const [index, session] of sessions.entries()) {
|
|
213
|
+
const name = `ccusage.sessions[${index}]`;
|
|
214
|
+
usage.inputTokens += nonNegativeNumber(session.inputTokens, `${name}.inputTokens`);
|
|
215
|
+
usage.cachedInputTokens += nonNegativeNumber(session.cacheReadTokens, `${name}.cacheReadTokens`);
|
|
216
|
+
usage.outputTokens += nonNegativeNumber(session.outputTokens, `${name}.outputTokens`);
|
|
217
|
+
usage.reasoningOutputTokens += nonNegativeNumber(
|
|
218
|
+
session.reasoningOutputTokens,
|
|
219
|
+
`${name}.reasoningOutputTokens`,
|
|
220
|
+
);
|
|
221
|
+
const sessionTotalTokens = nonNegativeNumber(session.totalTokens, `${name}.totalTokens`);
|
|
222
|
+
usage.totalTokens += sessionTotalTokens;
|
|
223
|
+
const sessionCost = session.costUSD === undefined || session.costUSD === null
|
|
224
|
+
? null
|
|
225
|
+
: nonNegativeNumber(session.costUSD, `${name}.costUSD`);
|
|
226
|
+
if (sessionCost !== null) estimatedCostUsd += sessionCost;
|
|
227
|
+
if (sessionTotalTokens > 0 && (sessionCost === null || sessionCost === 0)) hasUnpricedUsage = true;
|
|
228
|
+
addModelUsage(models, normalizeModelUsage(session.models, name));
|
|
229
|
+
if (typeof session.lastActivity === "string" && !Number.isNaN(Date.parse(session.lastActivity))) {
|
|
230
|
+
if (sourceUpdatedAt === null || Date.parse(session.lastActivity) > Date.parse(sourceUpdatedAt)) {
|
|
231
|
+
sourceUpdatedAt = session.lastActivity;
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
return {
|
|
237
|
+
...usage,
|
|
238
|
+
estimatedCostUsd: hasUnpricedUsage ? null : estimatedCostUsd,
|
|
239
|
+
costStatus: hasUnpricedUsage ? "unavailable" : "estimated",
|
|
240
|
+
models,
|
|
241
|
+
provenance: {
|
|
242
|
+
provider: "ccusage",
|
|
243
|
+
version,
|
|
244
|
+
sessionCount: sessions.length,
|
|
245
|
+
},
|
|
246
|
+
sampledAt,
|
|
247
|
+
sourceUpdatedAt,
|
|
248
|
+
};
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
export async function readCcusageThreadUsage(threadId, {
|
|
252
|
+
command = "ccusage",
|
|
253
|
+
run = execFile,
|
|
254
|
+
sampledAt = new Date().toISOString(),
|
|
255
|
+
timeoutMs = 8_000,
|
|
256
|
+
} = {}) {
|
|
257
|
+
let version = null;
|
|
258
|
+
try {
|
|
259
|
+
const result = await run(command, ["--version"], {
|
|
260
|
+
timeout: Math.min(timeoutMs, 2_000),
|
|
261
|
+
maxBuffer: 64 * 1024,
|
|
262
|
+
});
|
|
263
|
+
version = String(result.stdout).trim().replace(/^ccusage\s+/i, "") || null;
|
|
264
|
+
} catch {
|
|
265
|
+
// Usage remains useful if an older compatible ccusage cannot print its version.
|
|
266
|
+
}
|
|
267
|
+
let result;
|
|
268
|
+
try {
|
|
269
|
+
result = await run(command, ["codex", "session", "--json", "--offline"], {
|
|
270
|
+
timeout: timeoutMs,
|
|
271
|
+
maxBuffer: 32 * 1024 * 1024,
|
|
272
|
+
env: { ...process.env, NO_COLOR: "1" },
|
|
273
|
+
});
|
|
274
|
+
} catch (error) {
|
|
275
|
+
if (error?.code === "ENOENT") throw new Error("ccusage is not installed");
|
|
276
|
+
if (error?.killed || error?.code === "ETIMEDOUT") throw new Error("ccusage timed out");
|
|
277
|
+
throw new Error("ccusage could not read Codex usage");
|
|
278
|
+
}
|
|
279
|
+
let payload;
|
|
280
|
+
try {
|
|
281
|
+
payload = JSON.parse(result.stdout);
|
|
282
|
+
} catch {
|
|
283
|
+
throw new Error("ccusage returned malformed JSON");
|
|
284
|
+
}
|
|
285
|
+
return aggregateCcusageSessions(payload, threadId, { sampledAt, version });
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
function usageFile(workspace) {
|
|
289
|
+
return path.join(workspace, USAGE_FILE_NAME);
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
export async function readUsageStore(workspace) {
|
|
293
|
+
const filePath = usageFile(workspace);
|
|
294
|
+
const details = await lstat(filePath).catch((error) => {
|
|
295
|
+
if (error.code === "ENOENT") return null;
|
|
296
|
+
throw error;
|
|
297
|
+
});
|
|
298
|
+
if (details === null) return { schemaVersion: 1, tasks: {} };
|
|
299
|
+
if (details.isSymbolicLink() || !details.isFile()) {
|
|
300
|
+
throw new Error("TaskChef usage cache must be a regular file");
|
|
301
|
+
}
|
|
302
|
+
if (details.size > MAX_USAGE_FILE_BYTES) {
|
|
303
|
+
return { schemaVersion: 1, tasks: {} };
|
|
304
|
+
}
|
|
305
|
+
const value = JSON.parse(await readFile(filePath, "utf8"));
|
|
306
|
+
if (value?.schemaVersion !== 1 || !value.tasks || typeof value.tasks !== "object") {
|
|
307
|
+
throw new Error("TaskChef usage cache has an unsupported schema");
|
|
308
|
+
}
|
|
309
|
+
const tasks = Object.entries(value.tasks);
|
|
310
|
+
if (tasks.length > 2_000) return { schemaVersion: 1, tasks: {} };
|
|
311
|
+
return {
|
|
312
|
+
schemaVersion: 1,
|
|
313
|
+
tasks: Object.fromEntries(tasks.map(([taskId, record]) => [
|
|
314
|
+
taskId,
|
|
315
|
+
normalizeStoredRecord(record, `usage task ${taskId}`),
|
|
316
|
+
])),
|
|
317
|
+
};
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
function compactUsageRecord(record) {
|
|
321
|
+
const turnEntries = Object.entries(record.turns ?? {}).slice(-MAX_PERSISTED_TURNS);
|
|
322
|
+
const boundaryEntries = Object.entries(record.boundaries ?? {}).slice(-1);
|
|
323
|
+
return {
|
|
324
|
+
...record,
|
|
325
|
+
turns: Object.fromEntries(turnEntries),
|
|
326
|
+
boundaries: Object.fromEntries(boundaryEntries),
|
|
327
|
+
};
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
export function compactUsageStore(store) {
|
|
331
|
+
const entries = Object.entries(store.tasks ?? {})
|
|
332
|
+
.sort(([, left], [, right]) => Date.parse(right.updatedAt ?? 0) - Date.parse(left.updatedAt ?? 0))
|
|
333
|
+
.slice(0, MAX_PERSISTED_TASKS);
|
|
334
|
+
const tasks = {};
|
|
335
|
+
let approximateBytes = 64;
|
|
336
|
+
for (const [taskId, record] of entries) {
|
|
337
|
+
const compacted = compactUsageRecord(record);
|
|
338
|
+
const entryBytes = Buffer.byteLength(JSON.stringify([taskId, compacted]), "utf8");
|
|
339
|
+
if (Object.keys(tasks).length > 0 && approximateBytes + entryBytes > USAGE_WRITE_BUDGET_BYTES) break;
|
|
340
|
+
tasks[taskId] = compacted;
|
|
341
|
+
approximateBytes += entryBytes;
|
|
342
|
+
}
|
|
343
|
+
return { schemaVersion: 1, tasks };
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
export async function writeUsageStore(workspace, store) {
|
|
347
|
+
await mkdir(workspace, { recursive: true });
|
|
348
|
+
const filePath = usageFile(workspace);
|
|
349
|
+
const temporaryPath = `${filePath}.${process.pid}.${randomUUID()}.tmp`;
|
|
350
|
+
const serialized = `${JSON.stringify(compactUsageStore(store), null, 2)}\n`;
|
|
351
|
+
if (Buffer.byteLength(serialized, "utf8") > MAX_USAGE_FILE_BYTES) {
|
|
352
|
+
throw new Error("TaskChef usage cache cannot be compacted below the size limit");
|
|
353
|
+
}
|
|
354
|
+
await writeFile(temporaryPath, serialized, {
|
|
355
|
+
encoding: "utf8",
|
|
356
|
+
flag: "wx",
|
|
357
|
+
mode: 0o600,
|
|
358
|
+
});
|
|
359
|
+
try {
|
|
360
|
+
await rename(temporaryPath, filePath);
|
|
361
|
+
} catch (error) {
|
|
362
|
+
await unlink(temporaryPath).catch(() => {});
|
|
363
|
+
throw error;
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
export function usageDelta(current, previous = null) {
|
|
368
|
+
const baseline = previous ?? Object.fromEntries(TOKEN_FIELDS.map((field) => [field, 0]));
|
|
369
|
+
const delta = {};
|
|
370
|
+
for (const field of TOKEN_FIELDS) {
|
|
371
|
+
const value = current[field] - baseline[field];
|
|
372
|
+
if (!Number.isFinite(value) || value < 0) return null;
|
|
373
|
+
delta[field] = value;
|
|
374
|
+
}
|
|
375
|
+
let estimatedCostUsd = current.estimatedCostUsd === null
|
|
376
|
+
|| (previous !== null && previous.estimatedCostUsd === null)
|
|
377
|
+
? null
|
|
378
|
+
: current.estimatedCostUsd - (previous?.estimatedCostUsd ?? 0);
|
|
379
|
+
if (estimatedCostUsd !== null && (!Number.isFinite(estimatedCostUsd) || estimatedCostUsd < 0)) {
|
|
380
|
+
estimatedCostUsd = null;
|
|
381
|
+
}
|
|
382
|
+
if (estimatedCostUsd === 0 && delta.totalTokens > 0) estimatedCostUsd = null;
|
|
383
|
+
return {
|
|
384
|
+
...delta,
|
|
385
|
+
estimatedCostUsd,
|
|
386
|
+
costStatus: estimatedCostUsd === null ? "unavailable" : "estimated",
|
|
387
|
+
};
|
|
388
|
+
}
|