@bermudi/pi-delegate 0.1.2 → 0.1.3

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/concurrency.ts CHANGED
@@ -115,7 +115,17 @@ async function mapConcurrent<T, R>(
115
115
  results[i] = await fn(items[i]!, i);
116
116
  }
117
117
  };
118
- await Promise.all(Array.from({ length: limit }, () => worker()));
118
+ // Promise.all would reject as soon as one task throws while sibling workers
119
+ // are still unwinding. Wait for every worker first so callers can safely use
120
+ // this promise as the batch-settled barrier (notably shutdown telemetry).
121
+ const outcomes = await Promise.allSettled(
122
+ Array.from({ length: limit }, () => worker()),
123
+ );
124
+ const rejection = outcomes.find(
125
+ (outcome): outcome is PromiseRejectedResult =>
126
+ outcome.status === "rejected",
127
+ );
128
+ if (rejection) throw rejection.reason;
119
129
  return results;
120
130
  }
121
131
 
@@ -155,8 +165,10 @@ export async function mapConcurrentByModel<T, R>(
155
165
  group.indices.push(i);
156
166
  }
157
167
 
158
- // Run all groups in parallel, each with its own concurrency limit + global cap
159
- await Promise.all(
168
+ // Run all groups in parallel, each with its own concurrency limit + global cap.
169
+ // As above, wait for every group before surfacing an unexpected rejection so
170
+ // a late sibling cannot mutate a ticket after its completion barrier resolves.
171
+ const outcomes = await Promise.allSettled(
160
172
  [...groups.entries()].map(([, group]) => {
161
173
  const groupItems = group.indices.map((i) => items[i]!);
162
174
  return mapConcurrent(
@@ -185,5 +197,10 @@ export async function mapConcurrentByModel<T, R>(
185
197
  );
186
198
  }),
187
199
  );
200
+ const rejection = outcomes.find(
201
+ (outcome): outcome is PromiseRejectedResult =>
202
+ outcome.status === "rejected",
203
+ );
204
+ if (rejection) throw rejection.reason;
188
205
  return results;
189
206
  }
package/config.ts CHANGED
@@ -8,6 +8,52 @@ import {
8
8
  OUTPUT_SPILL_THRESHOLD_CHARS,
9
9
  } from "./constants.ts";
10
10
 
11
+ export interface TelemetryConfig {
12
+ /** Whether to record delegate calls to the local SQLite store. Default true. */
13
+ enabled?: boolean;
14
+ /** Path to the SQLite database. Defaults to `~/.pi/agent/delegate-usage.db`. */
15
+ dbPath?: string;
16
+ }
17
+
18
+ const DEFAULT_TELEMETRY_CONFIG: TelemetryConfig = {
19
+ enabled: true,
20
+ };
21
+
22
+ function isRecord(value: unknown): value is Record<string, unknown> {
23
+ return value !== null && typeof value === "object" && !Array.isArray(value);
24
+ }
25
+
26
+ /**
27
+ * Validate the user-editable telemetry block at its boundary. An explicitly
28
+ * malformed block disables telemetry rather than silently turning it on with
29
+ * the default database path. Missing telemetry is different: it means the
30
+ * user did not configure the feature, so the default remains enabled.
31
+ */
32
+ export function normalizeTelemetryConfig(raw: unknown): TelemetryConfig {
33
+ if (raw === undefined) return { ...DEFAULT_TELEMETRY_CONFIG };
34
+ if (!isRecord(raw)) return { enabled: false };
35
+
36
+ const hasEnabled = Object.prototype.hasOwnProperty.call(raw, "enabled");
37
+ const hasDbPath = Object.prototype.hasOwnProperty.call(raw, "dbPath");
38
+ const enabled = raw.enabled;
39
+ const dbPath = raw.dbPath;
40
+
41
+ if (hasEnabled && typeof enabled !== "boolean") {
42
+ return { enabled: false };
43
+ }
44
+ if (hasDbPath && (typeof dbPath !== "string" || dbPath.trim().length === 0)) {
45
+ return { enabled: false };
46
+ }
47
+
48
+ const normalizedEnabled =
49
+ typeof enabled === "boolean" ? enabled : DEFAULT_TELEMETRY_CONFIG.enabled;
50
+ const normalizedDbPath = typeof dbPath === "string" ? dbPath : undefined;
51
+ return {
52
+ enabled: normalizedEnabled,
53
+ ...(normalizedDbPath === undefined ? {} : { dbPath: normalizedDbPath }),
54
+ };
55
+ }
56
+
11
57
  export interface DelegateConfig {
12
58
  agent: {
13
59
  /** Global default model for all agent types. */
@@ -41,6 +87,8 @@ export interface DelegateConfig {
41
87
  providerExtensions?: {
42
88
  [provider: string]: readonly string[];
43
89
  };
90
+ /** Local SQLite telemetry for usage/health analytics. See `telemetry.ts`. */
91
+ telemetry?: TelemetryConfig;
44
92
  /** LLM-facing output bounding: over-threshold final output is spilled to a
45
93
  * temp file with a tail kept in-context. See `spill.ts`. */
46
94
  output?: {
@@ -108,6 +156,9 @@ const DEFAULT_DELEGATE_CONFIG: DelegateConfig = {
108
156
  wholeTaskBaseDelayMs: 1_000,
109
157
  },
110
158
  providerExtensions: DEFAULT_PROVIDER_EXTENSIONS,
159
+ telemetry: {
160
+ enabled: true,
161
+ },
111
162
  output: {
112
163
  spillThresholdChars: OUTPUT_SPILL_THRESHOLD_CHARS,
113
164
  spillTailChars: OUTPUT_SPILL_TAIL_CHARS,
@@ -119,6 +170,7 @@ let __delegateConfig: DelegateConfig = {
119
170
  ...DEFAULT_DELEGATE_CONFIG,
120
171
  agent: { ...DEFAULT_DELEGATE_CONFIG.agent },
121
172
  concurrency: { ...DEFAULT_DELEGATE_CONFIG.concurrency },
173
+ telemetry: { ...DEFAULT_DELEGATE_CONFIG.telemetry },
122
174
  };
123
175
  let stallTimeoutOverrideForTesting: number | undefined;
124
176
 
@@ -140,6 +192,7 @@ export function loadDelegateConfig(): DelegateConfig {
140
192
  },
141
193
  retry: { ...DEFAULT_DELEGATE_CONFIG.retry, ...(parsed.retry ?? {}) },
142
194
  providerExtensions: resolveProviderExtensions(parsed.providerExtensions),
195
+ telemetry: normalizeTelemetryConfig(parsed.telemetry),
143
196
  output: { ...DEFAULT_DELEGATE_CONFIG.output, ...(parsed.output ?? {}) },
144
197
  } as DelegateConfig;
145
198
  } catch {
@@ -203,6 +256,7 @@ export function _setDelegateConfigForTesting(
203
256
  ...(config.retry ?? {}),
204
257
  },
205
258
  providerExtensions: resolveProviderExtensions(config.providerExtensions),
259
+ telemetry: normalizeTelemetryConfig(config.telemetry),
206
260
  output: {
207
261
  ...DEFAULT_DELEGATE_CONFIG.output,
208
262
  ...(config.output ?? {}),
@@ -356,3 +410,10 @@ export function resolveModelSpec(options: {
356
410
  (v): v is string => typeof v === "string" && v.length > 0,
357
411
  );
358
412
  }
413
+
414
+ /** Get the configured telemetry settings. */
415
+ export function getTelemetryConfig(
416
+ config: DelegateConfig = __delegateConfig,
417
+ ): TelemetryConfig {
418
+ return normalizeTelemetryConfig(config.telemetry);
419
+ }
package/delegate.ts CHANGED
@@ -92,6 +92,7 @@ export type { ActiveTicketSummary } from "./status.ts";
92
92
  export { getHostDeps, invalidateHostDepsCache } from "./host.ts";
93
93
  export type { HostDeps, HostDepsOptions } from "./host.ts";
94
94
  export {
95
+ aggregateTaskResults,
95
96
  emptyUsage,
96
97
  snapshotSessionUsage,
97
98
  usageDelta,
package/dispatch.ts CHANGED
@@ -12,7 +12,7 @@ import {
12
12
  import { getConcurrencyLimit, getMaxAsyncTickets } from "./config.ts";
13
13
  import { getCurrentLeafId } from "./leaf.ts";
14
14
  import { getModelKey, mapConcurrentByModel } from "./concurrency.ts";
15
- import { sumUsage } from "./usage.ts";
15
+ import { aggregateTaskResults, sumUsage } from "./usage.ts";
16
16
  import { runResolvedTask, updateProgressFromRun } from "./lifecycle.ts";
17
17
  import {
18
18
  fmtDuration,
@@ -24,6 +24,7 @@ import {
24
24
  import { validateDelegateOperation } from "./schema.ts";
25
25
  import { notifyCrossLeafDelivery, syncDelegateStatus } from "./status.ts";
26
26
  import { validateTasks, resolveTasks } from "./task-resolution.ts";
27
+ import type { CallSpan } from "./telemetry.ts";
27
28
  import type {
28
29
  AgentConfig,
29
30
  AsyncTicket,
@@ -113,6 +114,7 @@ export interface AsyncDispatchInput {
113
114
  resolved: ResolvedTask[];
114
115
  progress: TaskProgress[];
115
116
  parentModelId: string | undefined;
117
+ callSpan?: CallSpan;
116
118
  }
117
119
 
118
120
  /** Inputs needed by the sync (blocking) dispatch path. */
@@ -124,6 +126,7 @@ export interface SyncDispatchInput {
124
126
  parentModelId: string | undefined;
125
127
  signal: AbortSignal | undefined;
126
128
  fire: () => void;
129
+ callSpan?: CallSpan;
127
130
  }
128
131
 
129
132
  /** Inputs for the normal task-validation, resolution, and dispatch path. */
@@ -136,6 +139,7 @@ export interface DelegateDispatchInput {
136
139
  parentDefaults: ParentAgentDefaults;
137
140
  signal: AbortSignal | undefined;
138
141
  onUpdate: AgentToolUpdateCallback<DelegateDetails> | undefined;
142
+ callSpan?: CallSpan;
139
143
  }
140
144
 
141
145
  /** Validate, resolve, and dispatch a non-short-circuit delegate operation. */
@@ -151,11 +155,20 @@ export async function dispatchDelegate(
151
155
  parentDefaults,
152
156
  signal,
153
157
  onUpdate,
158
+ callSpan,
154
159
  } = input;
155
160
  const tasks = params.tasks ?? [];
156
161
 
157
162
  const validationError = validateTasks(tasks, agents, parentModelId);
158
- if (validationError) return validationError;
163
+ if (validationError) {
164
+ callSpan?.finish({
165
+ status: "failed",
166
+ totalTokens: 0,
167
+ totalCost: 0,
168
+ wallMs: Date.now() - callSpan.startedAt,
169
+ });
170
+ return validationError;
171
+ }
159
172
 
160
173
  const resolved = resolveTasks(tasks, ctx, agents, parentDefaults);
161
174
  const progress = initProgress(resolved);
@@ -176,6 +189,7 @@ export async function dispatchDelegate(
176
189
  resolved,
177
190
  progress,
178
191
  parentModelId,
192
+ callSpan,
179
193
  });
180
194
  }
181
195
 
@@ -187,6 +201,7 @@ export async function dispatchDelegate(
187
201
  parentModelId,
188
202
  signal,
189
203
  fire,
204
+ callSpan,
190
205
  });
191
206
  }
192
207
 
@@ -199,17 +214,39 @@ function finishTicketDelivery(pi: ExtensionAPI, ticket: AsyncTicket): void {
199
214
  }
200
215
  }
201
216
 
217
+ function settleAsyncCall(
218
+ ticket: AsyncTicket,
219
+ callSpan: CallSpan | undefined,
220
+ ): void {
221
+ if (!callSpan) return;
222
+ const { totalTokens, totalCost } = aggregateTaskResults(ticket.results);
223
+ const wallMs = (ticket.completedAt ?? Date.now()) - callSpan.startedAt;
224
+ const status =
225
+ ticket.status === "done"
226
+ ? "done"
227
+ : ticket.status === "cancelled"
228
+ ? "cancelled"
229
+ : "failed";
230
+ callSpan.finish({ status, totalTokens, totalCost, wallMs });
231
+ }
232
+
202
233
  /** Fire-and-forget background execution. Registers an `AsyncTicket`, kicks off
203
234
  * the concurrent run, and returns the ticket acknowledgment immediately.
204
235
  * Results are delivered via `deliverTicketResults` when all tasks settle. */
205
236
  export function dispatchAsync(input: AsyncDispatchInput): DelegateToolResult {
206
- const { pi, ctx, tasks, resolved, progress, parentModelId } = input;
237
+ const { pi, ctx, tasks, resolved, progress, parentModelId, callSpan } = input;
207
238
 
208
239
  sweepTickets();
209
240
  const runningCount = [...ticketRegistry.values()].filter(
210
241
  (t) => t.status === "running" || t.status === "cancelling",
211
242
  ).length;
212
243
  if (runningCount >= getMaxAsyncTickets()) {
244
+ callSpan?.finish({
245
+ status: "failed",
246
+ totalTokens: 0,
247
+ totalCost: 0,
248
+ wallMs: Date.now() - (callSpan?.startedAt ?? Date.now()),
249
+ });
213
250
  return {
214
251
  content: [
215
252
  {
@@ -236,8 +273,13 @@ export function dispatchAsync(input: AsyncDispatchInput): DelegateToolResult {
236
273
  // Leaf affinity for delivery: a ticket that outlives a /tree navigation
237
274
  // must not wake the agent on the branch the user moved to (issue #30).
238
275
  spawnLeafId: getCurrentLeafId(),
276
+ callId: callSpan?.id,
277
+ callStartedAt: callSpan?.startedAt,
278
+ callRecord: callSpan ? { ...callSpan.baseRecord() } : undefined,
279
+ telemetryGeneration: callSpan?.generation,
239
280
  };
240
281
  ticketRegistry.set(ticketId, ticket);
282
+ callSpan?.spawn();
241
283
  // Footer visibility for the new background work (see status.ts). Uses the
242
284
  // ctx cached from the dispatch path in extension.ts — DelegateToolCtx is
243
285
  // the intentionally narrowed surface and does not carry `ui`.
@@ -254,6 +296,9 @@ export function dispatchAsync(input: AsyncDispatchInput): DelegateToolResult {
254
296
  parentSessionManager: ctx.sessionManager,
255
297
  ticketId,
256
298
  delegateStartedAt: ticket.created,
299
+ telemetryCallId: callSpan?.id,
300
+ telemetryGeneration: callSpan?.generation,
301
+ async: true,
257
302
  onProgress: (p, u) => {
258
303
  updateProgressFromRun(p, u);
259
304
  notifyWaiters(ticket);
@@ -271,7 +316,7 @@ export function dispatchAsync(input: AsyncDispatchInput): DelegateToolResult {
271
316
  // Worker must store the TaskResult back into ticket.results, since
272
317
  // formatCompletedTicket/handlePoll read from there. Without the write,
273
318
  // completed async tasks would be reported as PENDING.
274
- mapConcurrentByModel(
319
+ const completion = mapConcurrentByModel(
275
320
  resolved,
276
321
  (t) => getModelKey(t.model),
277
322
  getConcurrencyLimit,
@@ -283,12 +328,14 @@ export function dispatchAsync(input: AsyncDispatchInput): DelegateToolResult {
283
328
  ticketSignal,
284
329
  )
285
330
  .then(() => {
286
- // A ticket that is already terminally "cancelled" at this point was
287
- // finalized by cancelTicketForShutdown (user cancels pass through
288
- // "cancelling" first): the extension runtime is being torn down, the
289
- // captured `pi` is stale or about to be, and a follow-up message has
290
- // no live session to land in. Skip delivery entirely.
291
- if (ticket.status === "cancelled") return;
331
+ // Shutdown marks the ticket terminal before cooperative worker aborts
332
+ // have finished. Still write one final aggregate after every result has
333
+ // landed; the immediate shutdown snapshot may have missed late usage.
334
+ // The runtime is being torn down, so never attempt UI delivery here.
335
+ if (ticket.status === "cancelled") {
336
+ settleAsyncCall(ticket, callSpan);
337
+ return;
338
+ }
292
339
  // All tasks settled — determine final ticket status.
293
340
  // Use progress (set by runResolvedTask) for settled-ness so the
294
341
  // status reflects work completion, not just result-array density.
@@ -309,12 +356,17 @@ export function dispatchAsync(input: AsyncDispatchInput): DelegateToolResult {
309
356
  syncTicketBusyIndex(ticket);
310
357
  }
311
358
  syncDelegateStatus();
359
+ settleAsyncCall(ticket, callSpan);
312
360
  finishTicketDelivery(pi, ticket);
313
361
  })
314
362
  .catch((err) => {
315
363
  // Defense-in-depth — should not happen if individual tasks catch properly.
316
- // Same shutdown guard as the .then path above.
317
- if (ticket.status === "cancelled") return;
364
+ // Even an unexpected worker rejection must leave the shutdown aggregate
365
+ // with every result that did settle, without touching the stale UI.
366
+ if (ticket.status === "cancelled") {
367
+ settleAsyncCall(ticket, callSpan);
368
+ return;
369
+ }
318
370
  if (ticket.status === "cancelling") {
319
371
  ticket.status = "cancelled";
320
372
  } else if (ticket.status === "running") {
@@ -324,8 +376,10 @@ export function dispatchAsync(input: AsyncDispatchInput): DelegateToolResult {
324
376
  ticket.completedAt = Date.now();
325
377
  syncTicketBusyIndex(ticket);
326
378
  syncDelegateStatus();
379
+ settleAsyncCall(ticket, callSpan);
327
380
  finishTicketDelivery(pi, ticket);
328
381
  });
382
+ ticket.completion = completion;
329
383
 
330
384
  return {
331
385
  content: [
@@ -335,8 +389,8 @@ export function dispatchAsync(input: AsyncDispatchInput): DelegateToolResult {
335
389
  `Async ticket: ${ticketId}`,
336
390
  `${resolved.length} task(s) dispatched · ${runningCount + 1}/${getMaxAsyncTickets()} async slots in use`,
337
391
  "",
338
- "Completed task results are available via poll. Final results delivered automatically when all tasks complete.",
339
- `Check progress: delegate({ ticketAction: "poll", ticket: "${ticketId}" }) — avoid polling in a tight loop`,
392
+ "Work is detached. Stop this turn to let final results auto-deliver.",
393
+ `If this turn must block for the result, call once: delegate({ ticketAction: "wait", ticket: "${ticketId}" }) — omit timeoutMs and do not poll`,
340
394
  `Cancel if needed: delegate({ ticketAction: "cancel", ticket: "${ticketId}", force: true }) — first call without force is a preview`,
341
395
  ].join("\n"),
342
396
  },
@@ -357,7 +411,16 @@ export function dispatchAsync(input: AsyncDispatchInput): DelegateToolResult {
357
411
  export async function dispatchSync(
358
412
  input: SyncDispatchInput,
359
413
  ): Promise<DelegateToolResult> {
360
- const { ctx, tasks, resolved, progress, parentModelId, signal, fire } = input;
414
+ const {
415
+ ctx,
416
+ tasks,
417
+ resolved,
418
+ progress,
419
+ parentModelId,
420
+ signal,
421
+ fire,
422
+ callSpan,
423
+ } = input;
361
424
 
362
425
  const startedAt = Date.now();
363
426
  const syncEnv: TaskRunEnv = {
@@ -366,6 +429,9 @@ export async function dispatchSync(
366
429
  parentSessionManager: ctx.sessionManager,
367
430
  ticketId: undefined,
368
431
  delegateStartedAt: startedAt,
432
+ telemetryCallId: callSpan?.id,
433
+ telemetryGeneration: callSpan?.generation,
434
+ async: false,
369
435
  onProgress: (p, u) => {
370
436
  updateProgressFromRun(p, u);
371
437
  fire();
@@ -401,6 +467,19 @@ export async function dispatchSync(
401
467
  );
402
468
  if (overlapWarning) parts.push("", overlapWarning);
403
469
 
470
+ const status = finalResults.some((r) => r.error) ? "failed" : "success";
471
+ const totalTokens = finalResults.reduce((sum, r) => sum + r.tokens, 0);
472
+ const totalCost = finalResults.reduce(
473
+ (sum, r) => sum + r.usage.cost.total,
474
+ 0,
475
+ );
476
+ callSpan?.finish({
477
+ status,
478
+ totalTokens,
479
+ totalCost,
480
+ wallMs: Date.now() - callSpan.startedAt,
481
+ });
482
+
404
483
  return {
405
484
  content: [{ type: "text", text: parts.join("\n\n") }],
406
485
  details: {