@bermudi/pi-delegate 0.1.1 → 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/dispatch.ts CHANGED
@@ -10,13 +10,21 @@ import {
10
10
  notifyWaiters,
11
11
  } from "./tickets.ts";
12
12
  import { getConcurrencyLimit, getMaxAsyncTickets } from "./config.ts";
13
+ import { getCurrentLeafId } from "./leaf.ts";
13
14
  import { getModelKey, mapConcurrentByModel } from "./concurrency.ts";
14
- import { sumUsage } from "./usage.ts";
15
+ import { aggregateTaskResults, sumUsage } from "./usage.ts";
15
16
  import { runResolvedTask, updateProgressFromRun } from "./lifecycle.ts";
16
- import { fmtDuration, formatCompletedTask, trunc } from "./format.ts";
17
+ import {
18
+ fmtDuration,
19
+ formatCompletedTask,
20
+ trunc,
21
+ findTouchedOverlaps,
22
+ formatTouchedOverlapWarning,
23
+ } from "./format.ts";
17
24
  import { validateDelegateOperation } from "./schema.ts";
18
- import { syncDelegateStatus } from "./status.ts";
25
+ import { notifyCrossLeafDelivery, syncDelegateStatus } from "./status.ts";
19
26
  import { validateTasks, resolveTasks } from "./task-resolution.ts";
27
+ import type { CallSpan } from "./telemetry.ts";
20
28
  import type {
21
29
  AgentConfig,
22
30
  AsyncTicket,
@@ -57,9 +65,10 @@ export function validateDelegateOperationResult(
57
65
  /** Build the initial per-task progress rows from resolved tasks. */
58
66
  export function initProgress(resolved: ResolvedTask[]): TaskProgress[] {
59
67
  return resolved.map((t, i) => ({
68
+ id: t.id,
60
69
  index: i,
61
70
  agent: t.agentName,
62
- task: trunc(t.prompt || t.action || "", 50),
71
+ task: trunc(t.prompt || t.sessionAction || "", 50),
63
72
  status: "pending" as const,
64
73
  durationMs: 0,
65
74
  tokens: 0,
@@ -105,6 +114,7 @@ export interface AsyncDispatchInput {
105
114
  resolved: ResolvedTask[];
106
115
  progress: TaskProgress[];
107
116
  parentModelId: string | undefined;
117
+ callSpan?: CallSpan;
108
118
  }
109
119
 
110
120
  /** Inputs needed by the sync (blocking) dispatch path. */
@@ -116,6 +126,7 @@ export interface SyncDispatchInput {
116
126
  parentModelId: string | undefined;
117
127
  signal: AbortSignal | undefined;
118
128
  fire: () => void;
129
+ callSpan?: CallSpan;
119
130
  }
120
131
 
121
132
  /** Inputs for the normal task-validation, resolution, and dispatch path. */
@@ -128,6 +139,7 @@ export interface DelegateDispatchInput {
128
139
  parentDefaults: ParentAgentDefaults;
129
140
  signal: AbortSignal | undefined;
130
141
  onUpdate: AgentToolUpdateCallback<DelegateDetails> | undefined;
142
+ callSpan?: CallSpan;
131
143
  }
132
144
 
133
145
  /** Validate, resolve, and dispatch a non-short-circuit delegate operation. */
@@ -143,11 +155,20 @@ export async function dispatchDelegate(
143
155
  parentDefaults,
144
156
  signal,
145
157
  onUpdate,
158
+ callSpan,
146
159
  } = input;
147
160
  const tasks = params.tasks ?? [];
148
161
 
149
162
  const validationError = validateTasks(tasks, agents, parentModelId);
150
- 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
+ }
151
172
 
152
173
  const resolved = resolveTasks(tasks, ctx, agents, parentDefaults);
153
174
  const progress = initProgress(resolved);
@@ -168,6 +189,7 @@ export async function dispatchDelegate(
168
189
  resolved,
169
190
  progress,
170
191
  parentModelId,
192
+ callSpan,
171
193
  });
172
194
  }
173
195
 
@@ -179,20 +201,52 @@ export async function dispatchDelegate(
179
201
  parentModelId,
180
202
  signal,
181
203
  fire,
204
+ callSpan,
182
205
  });
183
206
  }
184
207
 
208
+ /** Deliver a settled ticket and, when leaf affinity downgraded delivery to a
209
+ * non-waking `nextTurn` message, tell the human — otherwise the completion is
210
+ * silent apart from the footer clearing. */
211
+ function finishTicketDelivery(pi: ExtensionAPI, ticket: AsyncTicket): void {
212
+ if (deliverTicketResults(pi, ticket) === "deferred") {
213
+ notifyCrossLeafDelivery(ticket);
214
+ }
215
+ }
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
+
185
233
  /** Fire-and-forget background execution. Registers an `AsyncTicket`, kicks off
186
234
  * the concurrent run, and returns the ticket acknowledgment immediately.
187
235
  * Results are delivered via `deliverTicketResults` when all tasks settle. */
188
236
  export function dispatchAsync(input: AsyncDispatchInput): DelegateToolResult {
189
- const { pi, ctx, tasks, resolved, progress, parentModelId } = input;
237
+ const { pi, ctx, tasks, resolved, progress, parentModelId, callSpan } = input;
190
238
 
191
239
  sweepTickets();
192
240
  const runningCount = [...ticketRegistry.values()].filter(
193
241
  (t) => t.status === "running" || t.status === "cancelling",
194
242
  ).length;
195
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
+ });
196
250
  return {
197
251
  content: [
198
252
  {
@@ -216,8 +270,16 @@ export function dispatchAsync(input: AsyncDispatchInput): DelegateToolResult {
216
270
  progress: [...progress],
217
271
  controller,
218
272
  parentModelId,
273
+ // Leaf affinity for delivery: a ticket that outlives a /tree navigation
274
+ // must not wake the agent on the branch the user moved to (issue #30).
275
+ spawnLeafId: getCurrentLeafId(),
276
+ callId: callSpan?.id,
277
+ callStartedAt: callSpan?.startedAt,
278
+ callRecord: callSpan ? { ...callSpan.baseRecord() } : undefined,
279
+ telemetryGeneration: callSpan?.generation,
219
280
  };
220
281
  ticketRegistry.set(ticketId, ticket);
282
+ callSpan?.spawn();
221
283
  // Footer visibility for the new background work (see status.ts). Uses the
222
284
  // ctx cached from the dispatch path in extension.ts — DelegateToolCtx is
223
285
  // the intentionally narrowed surface and does not carry `ui`.
@@ -234,6 +296,9 @@ export function dispatchAsync(input: AsyncDispatchInput): DelegateToolResult {
234
296
  parentSessionManager: ctx.sessionManager,
235
297
  ticketId,
236
298
  delegateStartedAt: ticket.created,
299
+ telemetryCallId: callSpan?.id,
300
+ telemetryGeneration: callSpan?.generation,
301
+ async: true,
237
302
  onProgress: (p, u) => {
238
303
  updateProgressFromRun(p, u);
239
304
  notifyWaiters(ticket);
@@ -251,7 +316,7 @@ export function dispatchAsync(input: AsyncDispatchInput): DelegateToolResult {
251
316
  // Worker must store the TaskResult back into ticket.results, since
252
317
  // formatCompletedTicket/handlePoll read from there. Without the write,
253
318
  // completed async tasks would be reported as PENDING.
254
- mapConcurrentByModel(
319
+ const completion = mapConcurrentByModel(
255
320
  resolved,
256
321
  (t) => getModelKey(t.model),
257
322
  getConcurrencyLimit,
@@ -263,12 +328,14 @@ export function dispatchAsync(input: AsyncDispatchInput): DelegateToolResult {
263
328
  ticketSignal,
264
329
  )
265
330
  .then(() => {
266
- // A ticket that is already terminally "cancelled" at this point was
267
- // finalized by cancelTicketForShutdown (user cancels pass through
268
- // "cancelling" first): the extension runtime is being torn down, the
269
- // captured `pi` is stale or about to be, and a follow-up message has
270
- // no live session to land in. Skip delivery entirely.
271
- 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
+ }
272
339
  // All tasks settled — determine final ticket status.
273
340
  // Use progress (set by runResolvedTask) for settled-ness so the
274
341
  // status reflects work completion, not just result-array density.
@@ -289,12 +356,17 @@ export function dispatchAsync(input: AsyncDispatchInput): DelegateToolResult {
289
356
  syncTicketBusyIndex(ticket);
290
357
  }
291
358
  syncDelegateStatus();
292
- deliverTicketResults(pi, ticket);
359
+ settleAsyncCall(ticket, callSpan);
360
+ finishTicketDelivery(pi, ticket);
293
361
  })
294
362
  .catch((err) => {
295
363
  // Defense-in-depth — should not happen if individual tasks catch properly.
296
- // Same shutdown guard as the .then path above.
297
- 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
+ }
298
370
  if (ticket.status === "cancelling") {
299
371
  ticket.status = "cancelled";
300
372
  } else if (ticket.status === "running") {
@@ -304,8 +376,10 @@ export function dispatchAsync(input: AsyncDispatchInput): DelegateToolResult {
304
376
  ticket.completedAt = Date.now();
305
377
  syncTicketBusyIndex(ticket);
306
378
  syncDelegateStatus();
307
- deliverTicketResults(pi, ticket);
379
+ settleAsyncCall(ticket, callSpan);
380
+ finishTicketDelivery(pi, ticket);
308
381
  });
382
+ ticket.completion = completion;
309
383
 
310
384
  return {
311
385
  content: [
@@ -315,9 +389,9 @@ export function dispatchAsync(input: AsyncDispatchInput): DelegateToolResult {
315
389
  `Async ticket: ${ticketId}`,
316
390
  `${resolved.length} task(s) dispatched · ${runningCount + 1}/${getMaxAsyncTickets()} async slots in use`,
317
391
  "",
318
- "Completed task results are available via poll. Final results delivered automatically when all tasks complete.",
319
- `Check progress: delegate({ action: "poll", ticket: "${ticketId}" }) — avoid polling in a tight loop`,
320
- `Cancel if needed: delegate({ action: "cancel", ticket: "${ticketId}", force: true }) — first call without force is a preview`,
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`,
394
+ `Cancel if needed: delegate({ ticketAction: "cancel", ticket: "${ticketId}", force: true }) — first call without force is a preview`,
321
395
  ].join("\n"),
322
396
  },
323
397
  ],
@@ -337,7 +411,16 @@ export function dispatchAsync(input: AsyncDispatchInput): DelegateToolResult {
337
411
  export async function dispatchSync(
338
412
  input: SyncDispatchInput,
339
413
  ): Promise<DelegateToolResult> {
340
- 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;
341
424
 
342
425
  const startedAt = Date.now();
343
426
  const syncEnv: TaskRunEnv = {
@@ -346,6 +429,9 @@ export async function dispatchSync(
346
429
  parentSessionManager: ctx.sessionManager,
347
430
  ticketId: undefined,
348
431
  delegateStartedAt: startedAt,
432
+ telemetryCallId: callSpan?.id,
433
+ telemetryGeneration: callSpan?.generation,
434
+ async: false,
349
435
  onProgress: (p, u) => {
350
436
  updateProgressFromRun(p, u);
351
437
  fire();
@@ -376,6 +462,24 @@ export async function dispatchSync(
376
462
  parts.push(...formatCompletedTask(t, r));
377
463
  }
378
464
 
465
+ const overlapWarning = formatTouchedOverlapWarning(
466
+ findTouchedOverlaps(finalResults),
467
+ );
468
+ if (overlapWarning) parts.push("", overlapWarning);
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
+
379
483
  return {
380
484
  content: [{ type: "text", text: parts.join("\n\n") }],
381
485
  details: {
@@ -383,6 +487,7 @@ export async function dispatchSync(
383
487
  results: finalResults,
384
488
  progress,
385
489
  parentModel: parentModelId,
490
+ overlapWarning: overlapWarning || undefined,
386
491
  },
387
492
  // Aggregate subagent spend so Pi folds it into the parent's
388
493
  // session/footer totals. Sync dispatch only — async results arrive via a