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/.codex-plugin/plugin.json +1 -1
- package/README.md +37 -10
- package/docs/images/ccusage-token-consumption.png +0 -0
- package/docs/spec.md +62 -13
- package/docs/workflows.md +13 -10
- 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-manager.js +10 -3
- package/src/dashboard.js +18 -1
- package/src/mcp.js +17 -4
- package/src/usage-tracker.js +425 -0
- package/src/usage.js +388 -0
- package/src/version.js +1 -1
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
|
|
329
|
-
|
|
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
|
+
}
|