@bermudi/pi-delegate 0.1.2 → 0.1.4
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/README.md +27 -1
- package/concurrency.ts +20 -3
- package/config.ts +62 -1
- package/delegate.ts +4 -0
- package/dispatch.ts +94 -15
- package/extension.ts +292 -61
- package/format.ts +5 -1
- package/host-compat.ts +1 -0
- package/lifecycle.ts +235 -25
- package/manual.ts +3 -2
- package/package.json +1 -1
- package/schema.ts +16 -2
- package/task-resolution.ts +6 -0
- package/telemetry.ts +743 -0
- package/tickets.ts +34 -10
- package/types.ts +22 -0
- package/usage.ts +19 -0
- package/workspace.ts +672 -0
package/telemetry.ts
ADDED
|
@@ -0,0 +1,743 @@
|
|
|
1
|
+
import { createRequire } from "node:module";
|
|
2
|
+
import { fileURLToPath } from "node:url";
|
|
3
|
+
import * as fs from "node:fs";
|
|
4
|
+
import * as os from "node:os";
|
|
5
|
+
import * as path from "node:path";
|
|
6
|
+
import * as crypto from "node:crypto";
|
|
7
|
+
import * as piCodingAgent from "@earendil-works/pi-coding-agent";
|
|
8
|
+
import type { DatabaseSync, StatementSync } from "node:sqlite";
|
|
9
|
+
import { getTelemetryConfig } from "./config.ts";
|
|
10
|
+
import type { ResolvedTask, TaskProgress, TaskResult } from "./types.ts";
|
|
11
|
+
|
|
12
|
+
export interface CallRecord {
|
|
13
|
+
id: string;
|
|
14
|
+
ts: number;
|
|
15
|
+
version: string | undefined;
|
|
16
|
+
pi_version: string | undefined;
|
|
17
|
+
mode: string;
|
|
18
|
+
parent_model: string | undefined;
|
|
19
|
+
task_count: number;
|
|
20
|
+
wall_ms: number;
|
|
21
|
+
status: string;
|
|
22
|
+
total_tokens: number;
|
|
23
|
+
total_cost: number;
|
|
24
|
+
parent_session_file: string | undefined;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface TaskRecord {
|
|
28
|
+
id: string;
|
|
29
|
+
call_id: string;
|
|
30
|
+
ts: number;
|
|
31
|
+
version: string | undefined;
|
|
32
|
+
pi_version: string | undefined;
|
|
33
|
+
idx: number;
|
|
34
|
+
agent: string;
|
|
35
|
+
model: string | undefined;
|
|
36
|
+
thinking: string | undefined;
|
|
37
|
+
tools: string;
|
|
38
|
+
outcome: string;
|
|
39
|
+
failure_kind: string | undefined;
|
|
40
|
+
duration_ms: number;
|
|
41
|
+
tokens: number;
|
|
42
|
+
cost: number;
|
|
43
|
+
tool_uses: number;
|
|
44
|
+
retries: number;
|
|
45
|
+
prompt_chars: number;
|
|
46
|
+
output_chars: number;
|
|
47
|
+
session_file: string | undefined;
|
|
48
|
+
async: number;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface TelemetryRecorder {
|
|
52
|
+
recordCall(record: CallRecord): void;
|
|
53
|
+
recordTask(record: TaskRecord): void;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
let DatabaseSyncCtor: typeof DatabaseSync | undefined;
|
|
57
|
+
|
|
58
|
+
try {
|
|
59
|
+
const req = createRequire(import.meta.url);
|
|
60
|
+
const sqlite = req("node:sqlite") as { DatabaseSync: typeof DatabaseSync };
|
|
61
|
+
DatabaseSyncCtor = sqlite.DatabaseSync;
|
|
62
|
+
} catch {
|
|
63
|
+
DatabaseSyncCtor = undefined;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
interface TelemetryBackend {
|
|
67
|
+
recordCall(record: CallRecord): void;
|
|
68
|
+
recordTask(record: TaskRecord): void;
|
|
69
|
+
close(): void;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
let backend: TelemetryBackend | undefined;
|
|
73
|
+
let backendGeneration: number | undefined;
|
|
74
|
+
let backendFailed = false;
|
|
75
|
+
/** Monotonic runtime identity. A late worker from a previous runtime may not
|
|
76
|
+
* write into the next runtime's backend after a bounded shutdown drain. */
|
|
77
|
+
let telemetryGeneration = 0;
|
|
78
|
+
/** A closed runtime must not lazily reopen SQLite for stale work that is still
|
|
79
|
+
* unwinding. The next extension registration explicitly reopens the lifecycle. */
|
|
80
|
+
let telemetryClosed = false;
|
|
81
|
+
let testingRecorder: TelemetryRecorder | undefined;
|
|
82
|
+
|
|
83
|
+
const TELEMETRY_SCHEMA_VERSION = 1;
|
|
84
|
+
const SQLITE_BUSY_TIMEOUT_MS = 5_000;
|
|
85
|
+
|
|
86
|
+
function defaultDbPath(): string {
|
|
87
|
+
return path.join(os.homedir(), ".pi", "agent", "delegate-usage.db");
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function findPackageJson(startFile: string): string | undefined {
|
|
91
|
+
const candidates = [
|
|
92
|
+
path.join(path.dirname(startFile), "package.json"),
|
|
93
|
+
path.join(path.dirname(startFile), "..", "package.json"),
|
|
94
|
+
path.join(process.cwd(), "package.json"),
|
|
95
|
+
];
|
|
96
|
+
for (const candidate of candidates) {
|
|
97
|
+
if (fs.existsSync(candidate)) return candidate;
|
|
98
|
+
}
|
|
99
|
+
return undefined;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function readPackageVersion(packageJsonPath: string): string | undefined {
|
|
103
|
+
try {
|
|
104
|
+
const raw = fs.readFileSync(packageJsonPath, "utf-8");
|
|
105
|
+
const parsed = JSON.parse(raw) as unknown;
|
|
106
|
+
return typeof parsed === "object" &&
|
|
107
|
+
parsed !== null &&
|
|
108
|
+
"version" in parsed &&
|
|
109
|
+
typeof (parsed as { version: unknown }).version === "string"
|
|
110
|
+
? (parsed as { version: string }).version
|
|
111
|
+
: undefined;
|
|
112
|
+
} catch {
|
|
113
|
+
return undefined;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function resolveDelegatePackageJson(): string | undefined {
|
|
118
|
+
try {
|
|
119
|
+
const thisFile = fileURLToPath(import.meta.url);
|
|
120
|
+
return findPackageJson(thisFile);
|
|
121
|
+
} catch {
|
|
122
|
+
return findPackageJson(process.cwd());
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
let delegateVersion: string | undefined;
|
|
127
|
+
let piVersion: string | undefined;
|
|
128
|
+
|
|
129
|
+
function getDelegateVersion(): string | undefined {
|
|
130
|
+
if (delegateVersion === undefined) {
|
|
131
|
+
const packageJson = resolveDelegatePackageJson();
|
|
132
|
+
delegateVersion = packageJson ? readPackageVersion(packageJson) : undefined;
|
|
133
|
+
}
|
|
134
|
+
return delegateVersion;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function getPiVersion(): string | undefined {
|
|
138
|
+
if (piVersion === undefined) {
|
|
139
|
+
const candidate = (piCodingAgent as Record<string, unknown>).VERSION;
|
|
140
|
+
piVersion =
|
|
141
|
+
typeof candidate === "string" && candidate.length > 0
|
|
142
|
+
? candidate
|
|
143
|
+
: undefined;
|
|
144
|
+
}
|
|
145
|
+
return piVersion;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
type TelemetryColumn = readonly [name: string, definition: string];
|
|
149
|
+
type TelemetryTable = {
|
|
150
|
+
name: "calls" | "tasks";
|
|
151
|
+
columns: readonly TelemetryColumn[];
|
|
152
|
+
createSql: string;
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
const TELEMETRY_TABLES: readonly TelemetryTable[] = [
|
|
156
|
+
{
|
|
157
|
+
name: "calls",
|
|
158
|
+
columns: [
|
|
159
|
+
["id", "TEXT PRIMARY KEY"],
|
|
160
|
+
["ts", "INTEGER"],
|
|
161
|
+
["version", "TEXT"],
|
|
162
|
+
["pi_version", "TEXT"],
|
|
163
|
+
["mode", "TEXT"],
|
|
164
|
+
["parent_model", "TEXT"],
|
|
165
|
+
["task_count", "INTEGER"],
|
|
166
|
+
["wall_ms", "INTEGER"],
|
|
167
|
+
["status", "TEXT"],
|
|
168
|
+
["total_tokens", "INTEGER"],
|
|
169
|
+
["total_cost", "REAL"],
|
|
170
|
+
["parent_session_file", "TEXT"],
|
|
171
|
+
],
|
|
172
|
+
createSql: `
|
|
173
|
+
CREATE TABLE IF NOT EXISTS calls(
|
|
174
|
+
id TEXT PRIMARY KEY,
|
|
175
|
+
ts INTEGER,
|
|
176
|
+
version TEXT,
|
|
177
|
+
pi_version TEXT,
|
|
178
|
+
mode TEXT,
|
|
179
|
+
parent_model TEXT,
|
|
180
|
+
task_count INTEGER,
|
|
181
|
+
wall_ms INTEGER,
|
|
182
|
+
status TEXT,
|
|
183
|
+
total_tokens INTEGER,
|
|
184
|
+
total_cost REAL,
|
|
185
|
+
parent_session_file TEXT
|
|
186
|
+
);
|
|
187
|
+
`,
|
|
188
|
+
},
|
|
189
|
+
{
|
|
190
|
+
name: "tasks",
|
|
191
|
+
columns: [
|
|
192
|
+
["id", "TEXT PRIMARY KEY"],
|
|
193
|
+
["call_id", "TEXT"],
|
|
194
|
+
["ts", "INTEGER"],
|
|
195
|
+
["version", "TEXT"],
|
|
196
|
+
["pi_version", "TEXT"],
|
|
197
|
+
["idx", "INTEGER"],
|
|
198
|
+
["agent", "TEXT"],
|
|
199
|
+
["model", "TEXT"],
|
|
200
|
+
["thinking", "TEXT"],
|
|
201
|
+
["tools", "TEXT"],
|
|
202
|
+
["outcome", "TEXT"],
|
|
203
|
+
["failure_kind", "TEXT"],
|
|
204
|
+
["duration_ms", "INTEGER"],
|
|
205
|
+
["tokens", "INTEGER"],
|
|
206
|
+
["cost", "REAL"],
|
|
207
|
+
["tool_uses", "INTEGER"],
|
|
208
|
+
["retries", "INTEGER"],
|
|
209
|
+
["prompt_chars", "INTEGER"],
|
|
210
|
+
["output_chars", "INTEGER"],
|
|
211
|
+
["session_file", "TEXT"],
|
|
212
|
+
["async", "INTEGER"],
|
|
213
|
+
],
|
|
214
|
+
createSql: `
|
|
215
|
+
CREATE TABLE IF NOT EXISTS tasks(
|
|
216
|
+
id TEXT PRIMARY KEY,
|
|
217
|
+
call_id TEXT,
|
|
218
|
+
ts INTEGER,
|
|
219
|
+
version TEXT,
|
|
220
|
+
pi_version TEXT,
|
|
221
|
+
idx INTEGER,
|
|
222
|
+
agent TEXT,
|
|
223
|
+
model TEXT,
|
|
224
|
+
thinking TEXT,
|
|
225
|
+
tools TEXT,
|
|
226
|
+
outcome TEXT,
|
|
227
|
+
failure_kind TEXT,
|
|
228
|
+
duration_ms INTEGER,
|
|
229
|
+
tokens INTEGER,
|
|
230
|
+
cost REAL,
|
|
231
|
+
tool_uses INTEGER,
|
|
232
|
+
retries INTEGER,
|
|
233
|
+
prompt_chars INTEGER,
|
|
234
|
+
output_chars INTEGER,
|
|
235
|
+
session_file TEXT,
|
|
236
|
+
async INTEGER
|
|
237
|
+
);
|
|
238
|
+
`,
|
|
239
|
+
},
|
|
240
|
+
];
|
|
241
|
+
|
|
242
|
+
function existingTableType(
|
|
243
|
+
db: DatabaseSync,
|
|
244
|
+
tableName: TelemetryTable["name"],
|
|
245
|
+
): string | undefined {
|
|
246
|
+
const row = db
|
|
247
|
+
.prepare("SELECT type FROM sqlite_master WHERE name = ?")
|
|
248
|
+
.get(tableName) as { type?: unknown } | undefined;
|
|
249
|
+
return typeof row?.type === "string" ? row.type : undefined;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function existingTableColumns(
|
|
253
|
+
db: DatabaseSync,
|
|
254
|
+
tableName: TelemetryTable["name"],
|
|
255
|
+
): Set<string> {
|
|
256
|
+
const rows = db
|
|
257
|
+
.prepare(`PRAGMA table_info(${tableName})`)
|
|
258
|
+
.all() as unknown as Array<{ name?: unknown }>;
|
|
259
|
+
return new Set(
|
|
260
|
+
rows.flatMap((row) => (typeof row.name === "string" ? [row.name] : [])),
|
|
261
|
+
);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* Create or repair the small telemetry schema as one transaction. The version
|
|
266
|
+
* marker is deliberately written last: an interrupted migration must leave a
|
|
267
|
+
* version-0 database that can be retried, not a version-1 database whose
|
|
268
|
+
* tables are only half present. Existing version-1 databases still pass
|
|
269
|
+
* through the same ensure/repair path so a process killed during an older
|
|
270
|
+
* migration can recover missing tables or columns.
|
|
271
|
+
*/
|
|
272
|
+
function initSchema(db: DatabaseSync): void {
|
|
273
|
+
let transactionStarted = false;
|
|
274
|
+
try {
|
|
275
|
+
db.exec("BEGIN IMMEDIATE");
|
|
276
|
+
transactionStarted = true;
|
|
277
|
+
|
|
278
|
+
const row = db.prepare("PRAGMA user_version").get() as
|
|
279
|
+
{ user_version?: number } | undefined;
|
|
280
|
+
const currentVersion = row?.user_version ?? 0;
|
|
281
|
+
if (currentVersion > TELEMETRY_SCHEMA_VERSION) {
|
|
282
|
+
throw new Error(
|
|
283
|
+
`Unsupported telemetry schema version ${currentVersion}; expected at most ${TELEMETRY_SCHEMA_VERSION}`,
|
|
284
|
+
);
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
for (const table of TELEMETRY_TABLES) {
|
|
288
|
+
const type = existingTableType(db, table.name);
|
|
289
|
+
if (type !== undefined && type !== "table") {
|
|
290
|
+
throw new Error(
|
|
291
|
+
`Telemetry object ${table.name} is ${type}, not a table`,
|
|
292
|
+
);
|
|
293
|
+
}
|
|
294
|
+
db.exec(table.createSql);
|
|
295
|
+
|
|
296
|
+
const columns = existingTableColumns(db, table.name);
|
|
297
|
+
for (const [name, definition] of table.columns) {
|
|
298
|
+
if (columns.has(name)) continue;
|
|
299
|
+
if (name === "id") {
|
|
300
|
+
throw new Error(
|
|
301
|
+
`Telemetry table ${table.name} is missing its id column`,
|
|
302
|
+
);
|
|
303
|
+
}
|
|
304
|
+
db.exec(`ALTER TABLE ${table.name} ADD COLUMN ${name} ${definition}`);
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
// Set the marker only after every table/column operation succeeded.
|
|
309
|
+
db.exec(`PRAGMA user_version = ${TELEMETRY_SCHEMA_VERSION}`);
|
|
310
|
+
db.exec("COMMIT");
|
|
311
|
+
transactionStarted = false;
|
|
312
|
+
} catch (error) {
|
|
313
|
+
if (transactionStarted) {
|
|
314
|
+
try {
|
|
315
|
+
db.exec("ROLLBACK");
|
|
316
|
+
} catch (rollbackError) {
|
|
317
|
+
console.error(
|
|
318
|
+
"[delegate] telemetry schema rollback failed",
|
|
319
|
+
rollbackError,
|
|
320
|
+
);
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
throw error;
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
class SqliteTelemetryBackend implements TelemetryBackend {
|
|
328
|
+
private db: DatabaseSync;
|
|
329
|
+
private insertCall: StatementSync;
|
|
330
|
+
private insertTask: StatementSync;
|
|
331
|
+
private readonly onFailure: (operation: string, error: unknown) => void;
|
|
332
|
+
|
|
333
|
+
constructor(
|
|
334
|
+
db: DatabaseSync,
|
|
335
|
+
onFailure: (operation: string, error: unknown) => void,
|
|
336
|
+
) {
|
|
337
|
+
this.db = db;
|
|
338
|
+
this.onFailure = onFailure;
|
|
339
|
+
this.insertCall = db.prepare(
|
|
340
|
+
`INSERT OR REPLACE INTO calls(
|
|
341
|
+
id, ts, version, pi_version, mode, parent_model, task_count,
|
|
342
|
+
wall_ms, status, total_tokens, total_cost, parent_session_file
|
|
343
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
344
|
+
);
|
|
345
|
+
this.insertTask = db.prepare(
|
|
346
|
+
`INSERT OR REPLACE INTO tasks(
|
|
347
|
+
id, call_id, ts, version, pi_version, idx, agent, model, thinking,
|
|
348
|
+
tools, outcome, failure_kind, duration_ms, tokens, cost, tool_uses,
|
|
349
|
+
retries, prompt_chars, output_chars, session_file, async
|
|
350
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
351
|
+
);
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
recordCall(record: CallRecord): void {
|
|
355
|
+
try {
|
|
356
|
+
this.insertCall.run(
|
|
357
|
+
record.id,
|
|
358
|
+
record.ts,
|
|
359
|
+
record.version ?? null,
|
|
360
|
+
record.pi_version ?? null,
|
|
361
|
+
record.mode,
|
|
362
|
+
record.parent_model ?? null,
|
|
363
|
+
record.task_count,
|
|
364
|
+
record.wall_ms,
|
|
365
|
+
record.status,
|
|
366
|
+
record.total_tokens,
|
|
367
|
+
record.total_cost,
|
|
368
|
+
record.parent_session_file ?? null,
|
|
369
|
+
);
|
|
370
|
+
} catch (error) {
|
|
371
|
+
this.onFailure("recordCall", error);
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
recordTask(record: TaskRecord): void {
|
|
376
|
+
try {
|
|
377
|
+
this.insertTask.run(
|
|
378
|
+
record.id,
|
|
379
|
+
record.call_id,
|
|
380
|
+
record.ts,
|
|
381
|
+
record.version ?? null,
|
|
382
|
+
record.pi_version ?? null,
|
|
383
|
+
record.idx,
|
|
384
|
+
record.agent,
|
|
385
|
+
record.model ?? null,
|
|
386
|
+
record.thinking ?? null,
|
|
387
|
+
record.tools,
|
|
388
|
+
record.outcome,
|
|
389
|
+
record.failure_kind ?? null,
|
|
390
|
+
record.duration_ms,
|
|
391
|
+
record.tokens,
|
|
392
|
+
record.cost,
|
|
393
|
+
record.tool_uses,
|
|
394
|
+
record.retries,
|
|
395
|
+
record.prompt_chars,
|
|
396
|
+
record.output_chars,
|
|
397
|
+
record.session_file ?? null,
|
|
398
|
+
record.async,
|
|
399
|
+
);
|
|
400
|
+
} catch (error) {
|
|
401
|
+
this.onFailure("recordTask", error);
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
close(): void {
|
|
406
|
+
try {
|
|
407
|
+
this.db.close();
|
|
408
|
+
} catch (error) {
|
|
409
|
+
console.error("[delegate] telemetry database close failed", error);
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
class RecorderBackend implements TelemetryBackend {
|
|
415
|
+
private readonly recorder: TelemetryRecorder;
|
|
416
|
+
private readonly onFailure: (operation: string, error: unknown) => void;
|
|
417
|
+
|
|
418
|
+
constructor(
|
|
419
|
+
recorder: TelemetryRecorder,
|
|
420
|
+
onFailure: (operation: string, error: unknown) => void,
|
|
421
|
+
) {
|
|
422
|
+
this.recorder = recorder;
|
|
423
|
+
this.onFailure = onFailure;
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
recordCall(record: CallRecord): void {
|
|
427
|
+
try {
|
|
428
|
+
this.recorder.recordCall(record);
|
|
429
|
+
} catch (error) {
|
|
430
|
+
this.onFailure("recordCall", error);
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
recordTask(record: TaskRecord): void {
|
|
435
|
+
try {
|
|
436
|
+
this.recorder.recordTask(record);
|
|
437
|
+
} catch (error) {
|
|
438
|
+
this.onFailure("recordTask", error);
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
close(): void {}
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
function disableBackend(operation: string, error: unknown): void {
|
|
446
|
+
if (backendFailed) return;
|
|
447
|
+
backendFailed = true;
|
|
448
|
+
const failedBackend = backend;
|
|
449
|
+
backend = undefined;
|
|
450
|
+
try {
|
|
451
|
+
failedBackend?.close();
|
|
452
|
+
} catch (closeError) {
|
|
453
|
+
console.error("[delegate] telemetry backend close failed", closeError);
|
|
454
|
+
}
|
|
455
|
+
console.error(
|
|
456
|
+
`[delegate] telemetry ${operation} failed; disabling telemetry`,
|
|
457
|
+
error,
|
|
458
|
+
);
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
function openBackend(): TelemetryBackend | undefined {
|
|
462
|
+
const config = getTelemetryConfig();
|
|
463
|
+
if (config.enabled === false) return undefined;
|
|
464
|
+
if (backendFailed || telemetryClosed) return undefined;
|
|
465
|
+
if (testingRecorder) {
|
|
466
|
+
return new RecorderBackend(testingRecorder, disableBackend);
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
if (!DatabaseSyncCtor) {
|
|
470
|
+
backendFailed = true;
|
|
471
|
+
return undefined;
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
const dbPath = config.dbPath ?? defaultDbPath();
|
|
475
|
+
let db: DatabaseSync | undefined;
|
|
476
|
+
try {
|
|
477
|
+
fs.mkdirSync(path.dirname(dbPath), { recursive: true });
|
|
478
|
+
db = new DatabaseSyncCtor(dbPath, {
|
|
479
|
+
timeout: SQLITE_BUSY_TIMEOUT_MS,
|
|
480
|
+
});
|
|
481
|
+
// Set the timeout before any operation that can contend with another
|
|
482
|
+
// process. The constructor option covers the initial open; this pragma
|
|
483
|
+
// also makes the configured value observable and explicit.
|
|
484
|
+
db.exec(`PRAGMA busy_timeout = ${SQLITE_BUSY_TIMEOUT_MS};`);
|
|
485
|
+
db.exec("PRAGMA journal_mode = WAL;");
|
|
486
|
+
initSchema(db);
|
|
487
|
+
return new SqliteTelemetryBackend(db, disableBackend);
|
|
488
|
+
} catch (error) {
|
|
489
|
+
try {
|
|
490
|
+
db?.close();
|
|
491
|
+
} catch (closeError) {
|
|
492
|
+
console.error("[delegate] telemetry database close failed", closeError);
|
|
493
|
+
}
|
|
494
|
+
backendFailed = true;
|
|
495
|
+
console.error("[delegate] telemetry database failed to open", error);
|
|
496
|
+
return undefined;
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
function getBackend(generation?: number): TelemetryBackend | undefined {
|
|
501
|
+
if (telemetryClosed) return undefined;
|
|
502
|
+
if (generation !== undefined && generation !== telemetryGeneration) {
|
|
503
|
+
return undefined;
|
|
504
|
+
}
|
|
505
|
+
if (backend) {
|
|
506
|
+
if (backendGeneration !== telemetryGeneration) {
|
|
507
|
+
// A previous runtime left its handle open while its bounded shutdown
|
|
508
|
+
// drain timed out. Dispose that stale handle before opening this one.
|
|
509
|
+
const stale = backend;
|
|
510
|
+
backend = undefined;
|
|
511
|
+
backendGeneration = undefined;
|
|
512
|
+
stale.close();
|
|
513
|
+
} else if (generation !== undefined && backendGeneration !== generation) {
|
|
514
|
+
return undefined;
|
|
515
|
+
} else {
|
|
516
|
+
return backend;
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
backend = openBackend();
|
|
520
|
+
if (backend) backendGeneration = telemetryGeneration;
|
|
521
|
+
return backend;
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
export interface CallSpanInput {
|
|
525
|
+
parentModel?: string;
|
|
526
|
+
mode: string;
|
|
527
|
+
taskCount: number;
|
|
528
|
+
parentSessionFile?: string;
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
export interface CallSpanFinish {
|
|
532
|
+
status: string;
|
|
533
|
+
totalTokens: number;
|
|
534
|
+
totalCost: number;
|
|
535
|
+
wallMs: number;
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
export interface CallSpan {
|
|
539
|
+
readonly id: string;
|
|
540
|
+
readonly startedAt: number;
|
|
541
|
+
/** Runtime generation captured at call creation; stale calls cannot write. */
|
|
542
|
+
readonly generation: number;
|
|
543
|
+
/** Snapshot of the call row as it would be written at spawn. */
|
|
544
|
+
baseRecord(): CallRecord;
|
|
545
|
+
spawn(): void;
|
|
546
|
+
finish(finish: CallSpanFinish): void;
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
class CallSpanImpl implements CallSpan {
|
|
550
|
+
readonly id: string;
|
|
551
|
+
readonly startedAt: number;
|
|
552
|
+
readonly generation = telemetryGeneration;
|
|
553
|
+
private readonly input: CallSpanInput;
|
|
554
|
+
|
|
555
|
+
constructor(input: CallSpanInput) {
|
|
556
|
+
this.id = crypto.randomUUID();
|
|
557
|
+
this.startedAt = Date.now();
|
|
558
|
+
this.input = input;
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
baseRecord(): CallRecord {
|
|
562
|
+
return {
|
|
563
|
+
id: this.id,
|
|
564
|
+
ts: this.startedAt,
|
|
565
|
+
version: getDelegateVersion(),
|
|
566
|
+
pi_version: getPiVersion(),
|
|
567
|
+
mode: this.input.mode,
|
|
568
|
+
parent_model: this.input.parentModel,
|
|
569
|
+
task_count: this.input.taskCount,
|
|
570
|
+
wall_ms: 0,
|
|
571
|
+
status: "running",
|
|
572
|
+
total_tokens: 0,
|
|
573
|
+
total_cost: 0,
|
|
574
|
+
parent_session_file: this.input.parentSessionFile,
|
|
575
|
+
};
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
spawn(): void {
|
|
579
|
+
const b = getBackend(this.generation);
|
|
580
|
+
if (!b) return;
|
|
581
|
+
b.recordCall(this.baseRecord());
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
finish(finish: CallSpanFinish): void {
|
|
585
|
+
const b = getBackend(this.generation);
|
|
586
|
+
if (!b) return;
|
|
587
|
+
const record = this.baseRecord();
|
|
588
|
+
record.wall_ms = finish.wallMs;
|
|
589
|
+
record.status = finish.status;
|
|
590
|
+
record.total_tokens = finish.totalTokens;
|
|
591
|
+
record.total_cost = finish.totalCost;
|
|
592
|
+
b.recordCall(record);
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
export function beginCall(input: CallSpanInput): CallSpan {
|
|
597
|
+
return new CallSpanImpl(input);
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
export function recordCall(record: CallRecord, generation?: number): void {
|
|
601
|
+
const b = getBackend(generation);
|
|
602
|
+
if (!b) return;
|
|
603
|
+
b.recordCall(record);
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
export interface TaskSpanInput {
|
|
607
|
+
/** Stable identity for correction writes of the same logical task row. */
|
|
608
|
+
id?: string;
|
|
609
|
+
callId: string;
|
|
610
|
+
/** Runtime generation captured by the dispatch that owns this task. */
|
|
611
|
+
generation?: number;
|
|
612
|
+
async: boolean;
|
|
613
|
+
taskIndex: number;
|
|
614
|
+
task: ResolvedTask;
|
|
615
|
+
progress: TaskProgress;
|
|
616
|
+
result: TaskResult;
|
|
617
|
+
retries: number;
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
function outcomeFromResult(result: TaskResult): string {
|
|
621
|
+
if (result.error) {
|
|
622
|
+
return result.error === "Aborted" ? "cancelled" : "failed";
|
|
623
|
+
}
|
|
624
|
+
return "success";
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
export function recordTask(input: TaskSpanInput): string | undefined {
|
|
628
|
+
const b = getBackend(input.generation);
|
|
629
|
+
if (!b) return undefined;
|
|
630
|
+
|
|
631
|
+
const { callId, async, taskIndex, task, progress, result, retries } = input;
|
|
632
|
+
const record: TaskRecord = {
|
|
633
|
+
// Correction writes must reuse the provisional row's primary key. A fresh
|
|
634
|
+
// UUID here would make INSERT OR REPLACE append a second task row.
|
|
635
|
+
id: input.id ?? crypto.randomUUID(),
|
|
636
|
+
call_id: callId,
|
|
637
|
+
ts: Date.now(),
|
|
638
|
+
version: getDelegateVersion(),
|
|
639
|
+
pi_version: getPiVersion(),
|
|
640
|
+
idx: taskIndex,
|
|
641
|
+
agent: task.agentName,
|
|
642
|
+
model: task.model?.id,
|
|
643
|
+
thinking: task.thinking,
|
|
644
|
+
tools: JSON.stringify(task.tools),
|
|
645
|
+
outcome: outcomeFromResult(result),
|
|
646
|
+
failure_kind: result.failureKind,
|
|
647
|
+
duration_ms: result.durationMs,
|
|
648
|
+
tokens: result.tokens,
|
|
649
|
+
cost: result.usage.cost.total,
|
|
650
|
+
tool_uses: progress.toolUses,
|
|
651
|
+
retries,
|
|
652
|
+
prompt_chars: task.prompt?.length ?? 0,
|
|
653
|
+
output_chars: result.output?.length ?? 0,
|
|
654
|
+
session_file: result.sessionFile,
|
|
655
|
+
async: async ? 1 : 0,
|
|
656
|
+
};
|
|
657
|
+
b.recordTask(record);
|
|
658
|
+
return record.id;
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
/** Prevent this runtime's late workers from writing after a bounded shutdown
|
|
662
|
+
* drain. Invalidate the generation and close the old handle immediately: a
|
|
663
|
+
* later runtime must never inherit a backend that stale workers can reach.
|
|
664
|
+
* Returns false when a newer runtime already owns the lifecycle. */
|
|
665
|
+
export function sealTelemetryWrites(expectedGeneration?: number): boolean {
|
|
666
|
+
if (
|
|
667
|
+
expectedGeneration !== undefined &&
|
|
668
|
+
expectedGeneration !== telemetryGeneration
|
|
669
|
+
) {
|
|
670
|
+
return false;
|
|
671
|
+
}
|
|
672
|
+
telemetryGeneration++;
|
|
673
|
+
telemetryClosed = true;
|
|
674
|
+
const current = backend;
|
|
675
|
+
backend = undefined;
|
|
676
|
+
backendGeneration = undefined;
|
|
677
|
+
current?.close();
|
|
678
|
+
return true;
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
/** Close the current telemetry backend and prevent stale work from reopening
|
|
682
|
+
* it after the parent runtime starts shutting down. `expectedGeneration`
|
|
683
|
+
* prevents an old shutdown handler from closing a newer runtime's handle. */
|
|
684
|
+
export function closeTelemetry(expectedGeneration?: number): void {
|
|
685
|
+
if (
|
|
686
|
+
expectedGeneration !== undefined &&
|
|
687
|
+
expectedGeneration !== telemetryGeneration
|
|
688
|
+
) {
|
|
689
|
+
return;
|
|
690
|
+
}
|
|
691
|
+
telemetryClosed = true;
|
|
692
|
+
const current = backend;
|
|
693
|
+
backend = undefined;
|
|
694
|
+
backendGeneration = undefined;
|
|
695
|
+
current?.close();
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
/** Current runtime identity for lifecycle owners such as session_shutdown. */
|
|
699
|
+
export function getTelemetryGeneration(): number {
|
|
700
|
+
return telemetryGeneration;
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
/** Mark the start of a fresh extension runtime after a reload. */
|
|
704
|
+
export function prepareTelemetryForSession(): void {
|
|
705
|
+
if (!telemetryClosed) return;
|
|
706
|
+
|
|
707
|
+
// A timed-out old runtime may have left its handle open. Close it before
|
|
708
|
+
// advancing the generation so the old shutdown handler cannot close the new
|
|
709
|
+
// runtime's backend later.
|
|
710
|
+
const stale = backend;
|
|
711
|
+
backend = undefined;
|
|
712
|
+
backendGeneration = undefined;
|
|
713
|
+
stale?.close();
|
|
714
|
+
|
|
715
|
+
telemetryGeneration++;
|
|
716
|
+
telemetryClosed = false;
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
export function _setTelemetryForTesting(
|
|
720
|
+
recorder: TelemetryRecorder | undefined,
|
|
721
|
+
): void {
|
|
722
|
+
if (backend) {
|
|
723
|
+
backend.close();
|
|
724
|
+
backend = undefined;
|
|
725
|
+
backendGeneration = undefined;
|
|
726
|
+
}
|
|
727
|
+
testingRecorder = recorder;
|
|
728
|
+
backendFailed = false;
|
|
729
|
+
telemetryGeneration++;
|
|
730
|
+
telemetryClosed = false;
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
export function _resetTelemetryForTesting(): void {
|
|
734
|
+
testingRecorder = undefined;
|
|
735
|
+
if (backend) {
|
|
736
|
+
backend.close();
|
|
737
|
+
backend = undefined;
|
|
738
|
+
backendGeneration = undefined;
|
|
739
|
+
}
|
|
740
|
+
backendFailed = false;
|
|
741
|
+
telemetryGeneration++;
|
|
742
|
+
telemetryClosed = false;
|
|
743
|
+
}
|