@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/README.md +3 -2
- package/agents.ts +163 -17
- package/concurrency.ts +90 -10
- package/config.ts +61 -0
- package/delegate.ts +14 -0
- package/dispatch.ts +126 -21
- package/extension.ts +309 -62
- package/file-tracking.ts +27 -5
- package/format.ts +46 -7
- package/leaf.ts +48 -0
- package/lifecycle.ts +129 -35
- package/manual.ts +38 -10
- package/package.json +4 -1
- package/patches/@marcfargas%2Fpi-test-harness@0.6.1.patch +13 -0
- package/render-branches.ts +31 -13
- package/render-result.ts +12 -0
- package/runner.ts +237 -42
- package/schema.ts +204 -47
- package/status.ts +68 -2
- package/task-resolution.ts +101 -65
- package/telemetry.ts +738 -0
- package/tickets.ts +196 -61
- package/tools.ts +16 -15
- package/types.ts +71 -13
- package/usage.ts +19 -0
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 {
|
|
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.
|
|
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)
|
|
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
|
-
//
|
|
267
|
-
//
|
|
268
|
-
//
|
|
269
|
-
//
|
|
270
|
-
|
|
271
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
297
|
-
|
|
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
|
-
|
|
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
|
-
"
|
|
319
|
-
`
|
|
320
|
-
`Cancel if needed: delegate({
|
|
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 {
|
|
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
|