taskchef 7.19.0 → 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/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(),
@@ -88,6 +89,7 @@ const preparationSchema = z.object({
88
89
 
89
90
  const dashboardSchema = z.object({
90
91
  action: z.enum(["started", "reused"]),
92
+ launcher: z.literal("mcp"),
91
93
  url: z.string().url(),
92
94
  workspace: z.string(),
93
95
  taskchefVersion: z.string(),
@@ -144,6 +146,7 @@ export function createTaskChefMcpServer({
144
146
  dashboardManager = createDashboardManager({ workspace }),
145
147
  readConfiguration = readConfig,
146
148
  logDashboardDiagnostic,
149
+ usageTracker = createUsageTracker({ workspace }),
147
150
  } = {}) {
148
151
  const server = new McpServer(
149
152
  { name: "taskchef", version: TASKCHEF_VERSION },
@@ -163,7 +166,7 @@ export function createTaskChefMcpServer({
163
166
  return closePromise;
164
167
  };
165
168
  server.server.onclose = () => {
166
- void dashboardManager.close();
169
+ void dashboardManager.close().catch(() => {});
167
170
  };
168
171
 
169
172
  server.registerTool(
@@ -171,7 +174,7 @@ export function createTaskChefMcpServer({
171
174
  {
172
175
  title: "Ensure TaskChef dashboard",
173
176
  description:
174
- "Best-effort ensure the canonical TaskChef dashboard is available on 127.0.0.1:3210. Starts one dashboard inside this MCP process or reuses only an exact compatible TaskChef dashboard for the same canonical workspace; unknown listeners are never terminated or replaced.",
177
+ "Best-effort ensure the canonical TaskChef dashboard is available on 127.0.0.1:3210. Starts one dashboard inside this MCP process or reuses only an exact compatible MCP-launched TaskChef dashboard for the same canonical workspace; standalone and unknown listeners are never terminated or replaced.",
175
178
  inputSchema: {},
176
179
  outputSchema: { dashboard: dashboardSchema },
177
180
  annotations: {
@@ -286,6 +289,7 @@ export function createTaskChefMcpServer({
286
289
  },
287
290
  async (input) => {
288
291
  const task = await reportState(workspace, input);
292
+ void usageTracker.observe(task).catch(() => {});
289
293
  return toolResult("task", task, `Recorded ${task.status} state for TaskChef task ${task.id}.`);
290
294
  },
291
295
  );
@@ -313,6 +317,7 @@ export function createTaskChefMcpServer({
313
317
  },
314
318
  async (input) => {
315
319
  const task = await reportResult(workspace, input);
320
+ void usageTracker.observe(task).catch(() => {});
316
321
  return toolResult("task", task, `Recorded ${task.status} result for TaskChef task ${task.id}.`);
317
322
  },
318
323
  );
@@ -325,8 +330,16 @@ export function createTaskChefMcpServer({
325
330
  ...(logDashboardDiagnostic ? { log: logDashboardDiagnostic } : {}),
326
331
  });
327
332
  server.connect = async (...args) => {
328
- await originalConnect(...args);
329
- void autostartDashboard();
333
+ await autostartDashboard();
334
+ try {
335
+ await originalConnect(...args);
336
+ } catch (error) {
337
+ await Promise.allSettled([
338
+ Promise.resolve().then(() => dashboardManager.close()),
339
+ Promise.resolve().then(() => originalClose()),
340
+ ]);
341
+ throw error;
342
+ }
330
343
  };
331
344
 
332
345
  return server;
@@ -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
+ }