@bermudi/pi-delegate 0.1.18 → 0.1.19
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 +14 -13
- package/agents.ts +1 -1
- package/concurrency.ts +7 -0
- package/delegate.ts +6 -0
- package/dispatch.ts +460 -163
- package/extension.ts +35 -26
- package/format.ts +4 -1
- package/host.ts +1 -1
- package/isolated-workspace.ts +154 -8
- package/lifecycle.ts +31 -20
- package/manual.ts +11 -7
- package/package.json +2 -1
- package/parent-context.ts +1 -1
- package/pool.ts +492 -428
- package/render-branches.ts +2 -1
- package/render-result.ts +6 -0
- package/runtime.ts +36 -0
- package/schema.ts +22 -13
- package/status.ts +24 -11
- package/task-resolution.ts +12 -9
- package/test-harness.ts +81 -0
- package/ticket-format.ts +4 -3
- package/tickets.ts +672 -576
- package/types.ts +19 -0
- package/workspace.ts +58 -27
package/tickets.ts
CHANGED
|
@@ -37,89 +37,18 @@ import type {
|
|
|
37
37
|
|
|
38
38
|
const PENDING_RESULT_ERROR = "PENDING — result not available";
|
|
39
39
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
function removeTicketBusySessions(ticketId: string): void {
|
|
50
|
-
const sessions = busySessionsByTicket.get(ticketId);
|
|
51
|
-
if (!sessions) return;
|
|
52
|
-
for (const sessionId of sessions) {
|
|
53
|
-
const ticketIds = busyTicketIdsBySession.get(sessionId);
|
|
54
|
-
ticketIds?.delete(ticketId);
|
|
55
|
-
if (ticketIds?.size === 0) busyTicketIdsBySession.delete(sessionId);
|
|
56
|
-
}
|
|
57
|
-
busySessionsByTicket.delete(ticketId);
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
/** Add/remove a ticket's session IDs from the O(1) busy index. */
|
|
61
|
-
export function syncTicketBusyIndex(ticket: AsyncTicket): void {
|
|
62
|
-
removeTicketBusySessions(ticket.id);
|
|
63
|
-
if (ticket.status !== "running" && ticket.status !== "cancelling") return;
|
|
64
|
-
const sids = new Set<string>();
|
|
65
|
-
for (const sid of sessionIdsFor(ticket)) {
|
|
66
|
-
const ticketIds = busyTicketIdsBySession.get(sid) ?? new Set<string>();
|
|
67
|
-
ticketIds.add(ticket.id);
|
|
68
|
-
busyTicketIdsBySession.set(sid, ticketIds);
|
|
69
|
-
sids.add(sid);
|
|
70
|
-
}
|
|
71
|
-
if (sids.size) busySessionsByTicket.set(ticket.id, sids);
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
class TicketRegistry extends Map<string, AsyncTicket> {
|
|
75
|
-
set(key: string, value: AsyncTicket): this {
|
|
76
|
-
super.set(key, value);
|
|
77
|
-
syncTicketBusyIndex(value);
|
|
78
|
-
return this;
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
delete(key: string): boolean {
|
|
82
|
-
const ok = super.delete(key);
|
|
83
|
-
if (ok) removeTicketBusySessions(key);
|
|
84
|
-
return ok;
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
clear(): void {
|
|
88
|
-
super.clear();
|
|
89
|
-
busyTicketIdsBySession.clear();
|
|
90
|
-
busySessionsByTicket.clear();
|
|
91
|
-
}
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
export const ticketRegistry = new TicketRegistry();
|
|
95
|
-
|
|
96
|
-
/** Generate a short human-copyable identifier for an async ticket. */
|
|
97
|
-
export function generateTicketId(): string {
|
|
98
|
-
// Retry on the extremely unlikely collision rather than allowing Map.set()
|
|
99
|
-
// in dispatch to replace a still-retained ticket.
|
|
100
|
-
let id: string;
|
|
101
|
-
do {
|
|
102
|
-
id = Math.random().toString(36).slice(2, 10);
|
|
103
|
-
} while (!id || ticketRegistry.has(id));
|
|
104
|
-
return id;
|
|
105
|
-
}
|
|
40
|
+
/** Prefix for a result whose spawn leaf is no longer the active one. The model
|
|
41
|
+
* would otherwise read a foreign branch's work as current-turn context. */
|
|
42
|
+
const CROSS_LEAF_NOTICE =
|
|
43
|
+
"NOTE: this async delegate ticket was spawned on a different branch of the " +
|
|
44
|
+
"session tree; the conversation has since navigated elsewhere (/tree). These " +
|
|
45
|
+
"results may not relate to the current line of work — verify relevance before " +
|
|
46
|
+
"acting on them.";
|
|
106
47
|
|
|
107
|
-
/**
|
|
108
|
-
*
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
for (const [id, ticket] of ticketRegistry) {
|
|
112
|
-
// TTL cleanup for completed/failed/cancelled
|
|
113
|
-
if (
|
|
114
|
-
ticket.status !== "running" &&
|
|
115
|
-
ticket.status !== "cancelling" &&
|
|
116
|
-
ticket.completedAt &&
|
|
117
|
-
now - ticket.completedAt > ASYNC_TICKET_TTL_MS
|
|
118
|
-
) {
|
|
119
|
-
ticketRegistry.delete(id);
|
|
120
|
-
}
|
|
121
|
-
}
|
|
122
|
-
}
|
|
48
|
+
/** How a completed ticket was handed back. `deferred` means the result was
|
|
49
|
+
* queued without waking the agent because the session navigated away from
|
|
50
|
+
* the spawn leaf; callers surface that to the human (see status.ts). */
|
|
51
|
+
export type TicketDelivery = "none" | "waiters" | "steer" | "deferred";
|
|
123
52
|
|
|
124
53
|
/** Options for `settleTicket`. */
|
|
125
54
|
export interface SettleTicketOptions {
|
|
@@ -129,197 +58,12 @@ export interface SettleTicketOptions {
|
|
|
129
58
|
error?: string;
|
|
130
59
|
}
|
|
131
60
|
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
* shutdown paths cannot drift apart. Result delivery is deliberately outside
|
|
137
|
-
* this transition: a host `sendMessage()` failure must not undo or re-enter
|
|
138
|
-
* worker settlement. Settling is idempotent: `completedAt` is the settle
|
|
139
|
-
* marker, and a second settle attempt is a loud no-op.
|
|
140
|
-
*/
|
|
141
|
-
export function settleTicket(
|
|
142
|
-
ticket: AsyncTicket,
|
|
143
|
-
opts: SettleTicketOptions,
|
|
144
|
-
): void {
|
|
145
|
-
if (ticket.completedAt) {
|
|
146
|
-
console.error(
|
|
147
|
-
`[delegate] ticket '${ticket.id}' already settled as '${ticket.status}'; ignoring settle as '${opts.status}'`,
|
|
148
|
-
);
|
|
149
|
-
return;
|
|
150
|
-
}
|
|
151
|
-
ticket.status = opts.status;
|
|
152
|
-
if (opts.error !== undefined) ticket.error = opts.error;
|
|
153
|
-
ticket.completedAt = Date.now();
|
|
154
|
-
syncTicketBusyIndex(ticket);
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
/**
|
|
158
|
-
* Finalize an active ticket during host shutdown and resolve any blocking
|
|
159
|
-
* waiters. This intentionally does not send a follow-up: the host is exiting.
|
|
160
|
-
*/
|
|
161
|
-
export function cancelTicketForShutdown(ticket: AsyncTicket): void {
|
|
162
|
-
if (ticket.status !== "running" && ticket.status !== "cancelling") return;
|
|
163
|
-
ticket.controller.abort();
|
|
164
|
-
settleTicket(ticket, { status: "cancelled" });
|
|
165
|
-
settleTicketWaiters(ticket);
|
|
166
|
-
if (ticket.callRecord) {
|
|
167
|
-
const completedAt = ticket.completedAt ?? Date.now();
|
|
168
|
-
const { totalTokens, totalCost } = aggregateTaskResults(ticket.results);
|
|
169
|
-
recordCall(
|
|
170
|
-
{
|
|
171
|
-
...ticket.callRecord,
|
|
172
|
-
status: "cancelled",
|
|
173
|
-
wall_ms: completedAt - (ticket.callStartedAt ?? ticket.created),
|
|
174
|
-
total_tokens: totalTokens,
|
|
175
|
-
total_cost: totalCost,
|
|
176
|
-
},
|
|
177
|
-
ticket.telemetryGeneration,
|
|
178
|
-
ticket.telemetryConfig,
|
|
179
|
-
);
|
|
180
|
-
}
|
|
181
|
-
}
|
|
182
|
-
|
|
183
|
-
/** Request cooperative cancellation of a live ticket: abort the workers and
|
|
184
|
-
* move to "cancelling" so they settle and report what actually ran. Unlike
|
|
185
|
-
* `cancelTicketForShutdown` this leaves the ticket deliverable — the runtime
|
|
186
|
-
* is still alive, so the final "cancelled" result still reaches the user. */
|
|
187
|
-
export function requestTicketCancel(ticket: AsyncTicket): void {
|
|
188
|
-
if (ticket.status !== "running") return;
|
|
189
|
-
ticket.controller.abort();
|
|
190
|
-
ticket.status = "cancelling";
|
|
191
|
-
syncTicketBusyIndex(ticket);
|
|
192
|
-
}
|
|
193
|
-
|
|
194
|
-
/** Check if any running async ticket holds a given sessionId.
|
|
195
|
-
* Backed by an O(1) map updated when tickets start/complete. */
|
|
196
|
-
export function isSessionBusy(sessionId: string): string | null {
|
|
197
|
-
return busyTicketIdsBySession.get(sessionId)?.values().next().value ?? null;
|
|
198
|
-
}
|
|
199
|
-
|
|
200
|
-
/**
|
|
201
|
-
* Determine the final status for a ticket whose task batch has settled.
|
|
202
|
-
*
|
|
203
|
-
* A ticket is "done" only when every task settled successfully. A
|
|
204
|
-
* partially-settled ticket (e.g. aborted mid-flight, leaving some progress
|
|
205
|
-
* rows still "running"/"pending") is "failed" — never "done" — so incomplete
|
|
206
|
-
* work is never masked as complete. Any ticket with at least one failed task
|
|
207
|
-
* is also "failed".
|
|
208
|
-
*
|
|
209
|
-
* Callers are expected to have already handled "cancelled" / "cancelling"
|
|
210
|
-
* (set by handleCancel / cancelTicketForShutdown) before invoking this;
|
|
211
|
-
* dispatchAsync's completion path routes both through `settleTicket`, which
|
|
212
|
-
* only calls this for a still-"running" ticket.
|
|
213
|
-
*/
|
|
214
|
-
export function resolveFinalTicketStatus(
|
|
215
|
-
ticket: AsyncTicket,
|
|
216
|
-
): "done" | "failed" {
|
|
217
|
-
const anyFailed = ticket.results.some((r) => r && "error" in r && r.error);
|
|
218
|
-
const allSettled = ticket.progress.every(
|
|
219
|
-
(p) => p.status === "done" || p.status === "failed",
|
|
220
|
-
);
|
|
221
|
-
if (allSettled && !anyFailed) return "done";
|
|
222
|
-
return "failed";
|
|
223
|
-
}
|
|
224
|
-
|
|
225
|
-
/** Format a completed ticket for LLM consumption. Reuses sync result formatting. */
|
|
226
|
-
export function formatCompletedTicket(
|
|
227
|
-
ticket: AsyncTicket,
|
|
228
|
-
): AgentToolResult<DelegateDetails> {
|
|
229
|
-
// Shutdown can make a ticket terminal while its workers are still unwinding.
|
|
230
|
-
// Do not freeze that partial projection: late TaskResults must appear in a
|
|
231
|
-
// later poll once the worker-settled barrier has resolved. Tickets created
|
|
232
|
-
// before this marker existed (including simple fixtures) are already safe to
|
|
233
|
-
// memoize because only live async dispatches explicitly set it false.
|
|
234
|
-
const canMemoize = ticket.workersSettled !== false;
|
|
235
|
-
if (canMemoize && ticket.formattedResult) return ticket.formattedResult;
|
|
236
|
-
|
|
237
|
-
const parts: string[] = [];
|
|
238
|
-
const succeeded = ticket.results.filter(
|
|
239
|
-
(r) => r && !("error" in r && r.error),
|
|
240
|
-
).length;
|
|
241
|
-
const elapsedTotal = (ticket.completedAt ?? Date.now()) - ticket.created;
|
|
242
|
-
// Surface the overall ticket status so a failed/cancelled batch is not
|
|
243
|
-
// mistaken for success. "done" tickets keep the original header; others
|
|
244
|
-
// get an explicit status tag up front.
|
|
245
|
-
const statusTag =
|
|
246
|
-
ticket.status === "done" ? "" : `${ticket.status.toUpperCase()} · `;
|
|
247
|
-
const completionLabel =
|
|
248
|
-
ticket.status === "cancelled"
|
|
249
|
-
? "tasks completed before abort"
|
|
250
|
-
: "tasks completed";
|
|
251
|
-
parts.push(
|
|
252
|
-
`${statusTag}${succeeded}/${ticket.results.length} ${completionLabel} · ${fmtDuration(elapsedTotal)} wall time\n`,
|
|
253
|
-
);
|
|
254
|
-
if (ticket.dispatchWarning) {
|
|
255
|
-
parts.push(`WARNING: ${ticket.dispatchWarning}`);
|
|
256
|
-
}
|
|
257
|
-
|
|
258
|
-
const pendingLabelFor = (index: number): string => {
|
|
259
|
-
if (ticket.status !== "cancelled") {
|
|
260
|
-
return "PENDING — result not available";
|
|
261
|
-
}
|
|
262
|
-
return ticket.progress[index]?.status === "pending"
|
|
263
|
-
? "CANCELLED — task not started"
|
|
264
|
-
: "CANCELLED — task aborted mid-run, partial effects possible";
|
|
265
|
-
};
|
|
266
|
-
|
|
267
|
-
for (let i = 0; i < ticket.results.length; i++) {
|
|
268
|
-
const r = ticket.results[i];
|
|
269
|
-
const t = ticket.resolved[i]!;
|
|
270
|
-
if (!r) {
|
|
271
|
-
parts.push(
|
|
272
|
-
`=== ${t.agentName}${resumeMarker(ticket.progress[i]!)}${formatTaskId(t.id)}: ${trunc(t.prompt || "", 80)} ===`,
|
|
273
|
-
);
|
|
274
|
-
parts.push(`[${pendingLabelFor(i)}]`);
|
|
275
|
-
continue;
|
|
276
|
-
}
|
|
277
|
-
parts.push(...formatCompletedTask(t, r, ticket.config));
|
|
278
|
-
}
|
|
279
|
-
|
|
280
|
-
const completedResults = ticket.results.filter(
|
|
281
|
-
(r): r is TaskResult => r !== undefined && "touchedFiles" in r,
|
|
282
|
-
);
|
|
283
|
-
const overlapWarning = formatTouchedOverlapWarning(
|
|
284
|
-
findTouchedOverlaps(completedResults),
|
|
285
|
-
);
|
|
286
|
-
if (overlapWarning) parts.push("", overlapWarning);
|
|
287
|
-
|
|
288
|
-
if (ticket.status === "cancelled") {
|
|
289
|
-
parts.push(
|
|
290
|
-
"",
|
|
291
|
-
"WARNING: Cancellation stopped the remaining work. Files already written or shell commands already executed by the subagents were NOT rolled back. Review the touched files and session files above before deciding whether to retry.",
|
|
292
|
-
);
|
|
293
|
-
}
|
|
294
|
-
|
|
295
|
-
const formatted: AgentToolResult<DelegateDetails> = {
|
|
296
|
-
content: [{ type: "text", text: parts.join("\n\n") }],
|
|
297
|
-
details: {
|
|
298
|
-
tasks: ticket.tasks,
|
|
299
|
-
results: [...ticket.results].map(
|
|
300
|
-
(r, index) =>
|
|
301
|
-
r ?? {
|
|
302
|
-
...pendingResultPlaceholder(ticket.resolved[index]),
|
|
303
|
-
error: pendingLabelFor(index),
|
|
304
|
-
},
|
|
305
|
-
),
|
|
306
|
-
progress: [...ticket.progress],
|
|
307
|
-
parentModel: ticket.parentModelId,
|
|
308
|
-
// Thread ticketId so renderResult can show the running-ticket banner and
|
|
309
|
-
// the human sees which ticket they polled, even in the rich tree path.
|
|
310
|
-
ticketId: ticket.id,
|
|
311
|
-
status: ticket.status,
|
|
312
|
-
elapsedMs: elapsedTotal,
|
|
313
|
-
overlapWarning: overlapWarning || undefined,
|
|
314
|
-
dispatchWarning: ticket.dispatchWarning,
|
|
315
|
-
},
|
|
316
|
-
};
|
|
317
|
-
if (canMemoize) ticket.formattedResult = formatted;
|
|
318
|
-
return formatted;
|
|
61
|
+
function sessionIdsFor(ticket: AsyncTicket): string[] {
|
|
62
|
+
return ticket.resolved
|
|
63
|
+
.map((t) => t.sessionId)
|
|
64
|
+
.filter((s): s is string => typeof s === "string" && s.length > 0);
|
|
319
65
|
}
|
|
320
66
|
|
|
321
|
-
// ── Waiter helpers ─────────────────────────────────────────────────────────
|
|
322
|
-
|
|
323
67
|
function pendingResultPlaceholder(task: ResolvedTask | undefined): TaskResult {
|
|
324
68
|
return {
|
|
325
69
|
id: task?.id,
|
|
@@ -359,6 +103,7 @@ function buildWaitDetails(ticket: AsyncTicket): DelegateDetails {
|
|
|
359
103
|
elapsedMs: (ticket.completedAt ?? Date.now()) - ticket.created,
|
|
360
104
|
overlapWarning: overlapWarning || undefined,
|
|
361
105
|
dispatchWarning: ticket.dispatchWarning,
|
|
106
|
+
serializedNotice: ticket.serializedNotice,
|
|
362
107
|
};
|
|
363
108
|
}
|
|
364
109
|
|
|
@@ -399,32 +144,6 @@ function buildWaitRunningUpdate(
|
|
|
399
144
|
};
|
|
400
145
|
}
|
|
401
146
|
|
|
402
|
-
function buildWaitTimeoutResult(
|
|
403
|
-
ticket: AsyncTicket,
|
|
404
|
-
timeoutMs: number,
|
|
405
|
-
): AgentToolResult<DelegateDetails> {
|
|
406
|
-
// A timeout must not be an information cliff. Reuse the same rich snapshot
|
|
407
|
-
// as poll so the caller can see activity and consume any completed outputs
|
|
408
|
-
// without making a second tool call.
|
|
409
|
-
const snapshot = handlePoll({ ticket: ticket.id }, {} as ExtensionContext);
|
|
410
|
-
const snapshotText = snapshot.content
|
|
411
|
-
.filter((item) => item.type === "text")
|
|
412
|
-
.map((item) => item.text)
|
|
413
|
-
.join("\n");
|
|
414
|
-
const base = `Ticket ${ticket.id} still ${ticket.status} after ${fmtDuration(timeoutMs)} · wait timed out (ticket continues in background)`;
|
|
415
|
-
const guidance =
|
|
416
|
-
"If you need the final result in this turn, call wait once with timeoutMs omitted; do not poll after a timeout. Otherwise stop calling ticket controls and let the final result auto-deliver.";
|
|
417
|
-
return {
|
|
418
|
-
content: [
|
|
419
|
-
{
|
|
420
|
-
type: "text",
|
|
421
|
-
text: `${base}\n\n${snapshotText}\n\n${guidance}`,
|
|
422
|
-
},
|
|
423
|
-
],
|
|
424
|
-
details: snapshot.details,
|
|
425
|
-
};
|
|
426
|
-
}
|
|
427
|
-
|
|
428
147
|
function buildWaitAbortResult(
|
|
429
148
|
ticket: AsyncTicket,
|
|
430
149
|
): AgentToolResult<DelegateDetails> {
|
|
@@ -454,342 +173,719 @@ function abortWaiter(w: TicketWaiter, ticket: AsyncTicket): void {
|
|
|
454
173
|
settleWaiter(w, buildWaitAbortResult(ticket));
|
|
455
174
|
}
|
|
456
175
|
|
|
457
|
-
function
|
|
458
|
-
|
|
459
|
-
ticket
|
|
460
|
-
|
|
461
|
-
): void {
|
|
462
|
-
// If the ticket became terminal (e.g. completed or cancelled just before
|
|
463
|
-
// this timer fired), return that snapshot. Workers may still be unwinding, so
|
|
464
|
-
// waiting for deliverTicketResults here could strand the caller indefinitely.
|
|
465
|
-
sweepTickets();
|
|
466
|
-
if (ticket.status !== "running" && ticket.status !== "cancelling") {
|
|
467
|
-
settleWaiter(w, formatCompletedTicket(ticket));
|
|
468
|
-
return;
|
|
469
|
-
}
|
|
470
|
-
settleWaiter(w, buildWaitTimeoutResult(ticket, timeoutMs));
|
|
176
|
+
function cleanWaiters(ticket: AsyncTicket): void {
|
|
177
|
+
if (!ticket.waiters) return;
|
|
178
|
+
const active = ticket.waiters.filter((w) => !w.settled && !w.signal?.aborted);
|
|
179
|
+
ticket.waiters = active.length ? active : undefined;
|
|
471
180
|
}
|
|
472
181
|
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
const deadline = Date.now() + timeoutMs;
|
|
481
|
-
w.clearDeadline = scheduleDeadline(deadline, () =>
|
|
482
|
-
timeoutWaiter(w, ticket, timeoutMs),
|
|
182
|
+
function resultSucceeded(result: TaskResult | undefined): boolean {
|
|
183
|
+
return Boolean(
|
|
184
|
+
result &&
|
|
185
|
+
!result.error &&
|
|
186
|
+
result.integration?.status !== "retained" &&
|
|
187
|
+
result.integration?.status !== "conflict" &&
|
|
188
|
+
result.integration?.status !== "apply_failed",
|
|
483
189
|
);
|
|
484
190
|
}
|
|
485
191
|
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
192
|
+
/**
|
|
193
|
+
* Determine the final status for a ticket whose task batch has settled.
|
|
194
|
+
*
|
|
195
|
+
* A ticket is "done" only when every task settled successfully. A
|
|
196
|
+
* partially-settled ticket (e.g. aborted mid-flight, leaving some progress
|
|
197
|
+
* rows still "running"/"pending") is "failed" — never "done" — so incomplete
|
|
198
|
+
* work is never masked as complete. Any ticket with at least one failed task
|
|
199
|
+
* is also "failed".
|
|
200
|
+
*
|
|
201
|
+
* Callers are expected to have already handled "cancelled" / "cancelling"
|
|
202
|
+
* (set by handleCancel / cancelTicketForShutdown) before invoking this;
|
|
203
|
+
* dispatchAsync's completion path routes both through `settleTicket`, which
|
|
204
|
+
* only calls this for a still-"running" ticket.
|
|
205
|
+
*/
|
|
206
|
+
function resolveFinalTicketStatusImpl(ticket: AsyncTicket): "done" | "failed" {
|
|
207
|
+
const anyFailed = ticket.results.some((result) =>
|
|
208
|
+
result ? !resultSucceeded(result) : false,
|
|
209
|
+
);
|
|
210
|
+
const allSettled = ticket.progress.every(
|
|
211
|
+
(p) => p.status === "done" || p.status === "failed",
|
|
212
|
+
);
|
|
213
|
+
if (allSettled && !anyFailed) return "done";
|
|
214
|
+
return "failed";
|
|
490
215
|
}
|
|
491
216
|
|
|
492
|
-
/**
|
|
493
|
-
*
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
217
|
+
/** Encapsulated async ticket registry and busy index. Each instance owns its
|
|
218
|
+
* own tickets, waiters, and O(1) busy maps so injected runtimes never touch
|
|
219
|
+
* the default module-level ticket registry. */
|
|
220
|
+
export class TicketRegistry extends Map<string, AsyncTicket> {
|
|
221
|
+
private busyTicketIdsBySession = new Map<string, Set<string>>();
|
|
222
|
+
private busySessionsByTicket = new Map<string, Set<string>>();
|
|
223
|
+
|
|
224
|
+
private removeTicketBusySessions(ticketId: string): void {
|
|
225
|
+
const sessions = this.busySessionsByTicket.get(ticketId);
|
|
226
|
+
if (!sessions) return;
|
|
227
|
+
for (const sessionId of sessions) {
|
|
228
|
+
const ticketIds = this.busyTicketIdsBySession.get(sessionId);
|
|
229
|
+
ticketIds?.delete(ticketId);
|
|
230
|
+
if (ticketIds?.size === 0) this.busyTicketIdsBySession.delete(sessionId);
|
|
231
|
+
}
|
|
232
|
+
this.busySessionsByTicket.delete(ticketId);
|
|
503
233
|
}
|
|
504
|
-
cleanWaiters(ticket);
|
|
505
|
-
return hadActive;
|
|
506
|
-
}
|
|
507
234
|
|
|
508
|
-
/**
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
abortWaiter(w, ticket);
|
|
520
|
-
continue;
|
|
235
|
+
/** Add/remove a ticket's session IDs from the O(1) busy index. */
|
|
236
|
+
private syncTicketBusyIndex(ticket: AsyncTicket): void {
|
|
237
|
+
this.removeTicketBusySessions(ticket.id);
|
|
238
|
+
if (ticket.status !== "running" && ticket.status !== "cancelling") return;
|
|
239
|
+
const sids = new Set<string>();
|
|
240
|
+
for (const sid of sessionIdsFor(ticket)) {
|
|
241
|
+
const ticketIds =
|
|
242
|
+
this.busyTicketIdsBySession.get(sid) ?? new Set<string>();
|
|
243
|
+
ticketIds.add(ticket.id);
|
|
244
|
+
this.busyTicketIdsBySession.set(sid, ticketIds);
|
|
245
|
+
sids.add(sid);
|
|
521
246
|
}
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
247
|
+
if (sids.size) this.busySessionsByTicket.set(ticket.id, sids);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
override set(key: string, value: AsyncTicket): this {
|
|
251
|
+
super.set(key, value);
|
|
252
|
+
this.syncTicketBusyIndex(value);
|
|
253
|
+
return this;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
override delete(key: string): boolean {
|
|
257
|
+
const ok = super.delete(key);
|
|
258
|
+
if (ok) this.removeTicketBusySessions(key);
|
|
259
|
+
return ok;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
override clear(): void {
|
|
263
|
+
super.clear();
|
|
264
|
+
this.busyTicketIdsBySession.clear();
|
|
265
|
+
this.busySessionsByTicket.clear();
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/** Generate a short human-copyable identifier for an async ticket. */
|
|
269
|
+
generateTicketId(): string {
|
|
270
|
+
// Retry on the extremely unlikely collision rather than allowing Map.set()
|
|
271
|
+
// in dispatch to replace a still-retained ticket.
|
|
272
|
+
let id: string;
|
|
273
|
+
do {
|
|
274
|
+
id = Math.random().toString(36).slice(2, 10);
|
|
275
|
+
} while (!id || this.has(id));
|
|
276
|
+
return id;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/** Remove completed tickets after their retention TTL. Running tickets have no
|
|
280
|
+
* wall-clock deadline: per-task stall detection owns failed-agent handling. */
|
|
281
|
+
sweepTickets(): void {
|
|
282
|
+
const now = Date.now();
|
|
283
|
+
for (const [id, ticket] of this) {
|
|
284
|
+
// TTL cleanup for completed/failed/cancelled
|
|
285
|
+
if (
|
|
286
|
+
ticket.status !== "running" &&
|
|
287
|
+
ticket.status !== "cancelling" &&
|
|
288
|
+
ticket.completedAt &&
|
|
289
|
+
now - ticket.completedAt > ASYNC_TICKET_TTL_MS
|
|
290
|
+
) {
|
|
291
|
+
this.delete(id);
|
|
535
292
|
}
|
|
536
293
|
}
|
|
537
294
|
}
|
|
538
|
-
ticket.waiters = active.length ? active : undefined;
|
|
539
|
-
}
|
|
540
295
|
|
|
541
|
-
/**
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
296
|
+
/**
|
|
297
|
+
* Transition an active ticket to a terminal state. Single owner of the
|
|
298
|
+
* terminal transition — status, completion timestamp, error, and the
|
|
299
|
+
* busy-session index — so the normal-completion, unexpected-error, and
|
|
300
|
+
* shutdown paths cannot drift apart. Result delivery is deliberately outside
|
|
301
|
+
* this transition: a host `sendMessage()` failure must not undo or re-enter
|
|
302
|
+
* worker settlement. Settling is idempotent: `completedAt` is the settle
|
|
303
|
+
* marker, and a second settle attempt is a loud no-op.
|
|
304
|
+
*/
|
|
305
|
+
settleTicket(ticket: AsyncTicket, opts: SettleTicketOptions): void {
|
|
306
|
+
if (ticket.completedAt) {
|
|
307
|
+
console.error(
|
|
308
|
+
`[delegate] ticket '${ticket.id}' already settled as '${ticket.status}'; ignoring settle as '${opts.status}'`,
|
|
309
|
+
);
|
|
310
|
+
return;
|
|
311
|
+
}
|
|
312
|
+
ticket.status = opts.status;
|
|
313
|
+
if (opts.error !== undefined) ticket.error = opts.error;
|
|
314
|
+
ticket.completedAt = Date.now();
|
|
315
|
+
this.syncTicketBusyIndex(ticket);
|
|
316
|
+
}
|
|
553
317
|
|
|
554
|
-
/**
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
if (settleTicketWaiters(ticket)) return "waiters";
|
|
574
|
-
|
|
575
|
-
const formatted = formatCompletedTicket(ticket);
|
|
576
|
-
const text = formatted.content
|
|
577
|
-
.filter(
|
|
578
|
-
(c): c is { type: "text"; text: string } =>
|
|
579
|
-
c.type === "text" && typeof c.text === "string",
|
|
580
|
-
)
|
|
581
|
-
.map((c) => c.text)
|
|
582
|
-
.join("\n");
|
|
583
|
-
|
|
584
|
-
const crossLeaf = isCrossLeafTicket(ticket);
|
|
585
|
-
|
|
586
|
-
try {
|
|
587
|
-
pi.sendMessage(
|
|
588
|
-
{
|
|
589
|
-
customType: "async_delegate_result",
|
|
590
|
-
content: crossLeaf ? `${CROSS_LEAF_NOTICE}\n\n${text}` : text,
|
|
591
|
-
display: true,
|
|
592
|
-
details: {
|
|
593
|
-
...formatted.details,
|
|
594
|
-
ticketId: ticket.id,
|
|
595
|
-
status: ticket.status,
|
|
596
|
-
crossLeafDelivery: crossLeaf,
|
|
318
|
+
/**
|
|
319
|
+
* Finalize an active ticket during host shutdown and resolve any blocking
|
|
320
|
+
* waiters. This intentionally does not send a follow-up: the host is exiting.
|
|
321
|
+
*/
|
|
322
|
+
cancelTicketForShutdown(ticket: AsyncTicket): void {
|
|
323
|
+
if (ticket.status !== "running" && ticket.status !== "cancelling") return;
|
|
324
|
+
ticket.controller.abort();
|
|
325
|
+
this.settleTicket(ticket, { status: "cancelled" });
|
|
326
|
+
this.settleTicketWaiters(ticket);
|
|
327
|
+
if (ticket.callRecord) {
|
|
328
|
+
const completedAt = ticket.completedAt ?? Date.now();
|
|
329
|
+
const { totalTokens, totalCost } = aggregateTaskResults(ticket.results);
|
|
330
|
+
recordCall(
|
|
331
|
+
{
|
|
332
|
+
...ticket.callRecord,
|
|
333
|
+
status: "cancelled",
|
|
334
|
+
wall_ms: completedAt - (ticket.callStartedAt ?? ticket.created),
|
|
335
|
+
total_tokens: totalTokens,
|
|
336
|
+
total_cost: totalCost,
|
|
597
337
|
},
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
338
|
+
ticket.telemetryGeneration,
|
|
339
|
+
ticket.telemetryConfig,
|
|
340
|
+
);
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/** Request cooperative cancellation of a live ticket: abort the workers and
|
|
345
|
+
* move to "cancelling" so they settle and report what actually ran. Unlike
|
|
346
|
+
* `cancelTicketForShutdown` this leaves the ticket deliverable — the runtime
|
|
347
|
+
* is still alive, so the final "cancelled" result still reaches the user. */
|
|
348
|
+
requestTicketCancel(ticket: AsyncTicket): void {
|
|
349
|
+
if (ticket.status !== "running") return;
|
|
350
|
+
ticket.controller.abort();
|
|
351
|
+
ticket.status = "cancelling";
|
|
352
|
+
this.syncTicketBusyIndex(ticket);
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/** Check if any running async ticket holds a given sessionId.
|
|
356
|
+
* Backed by an O(1) map updated when tickets start/complete. */
|
|
357
|
+
isSessionBusy(sessionId: string): string | null {
|
|
358
|
+
return (
|
|
359
|
+
this.busyTicketIdsBySession.get(sessionId)?.values().next().value ?? null
|
|
609
360
|
);
|
|
610
|
-
return "none";
|
|
611
361
|
}
|
|
612
|
-
return crossLeaf ? "deferred" : "steer";
|
|
613
|
-
}
|
|
614
362
|
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
params: { ticket?: string },
|
|
618
|
-
ctx: ExtensionContext,
|
|
619
|
-
): AgentToolResult<DelegateDetails> {
|
|
620
|
-
sweepTickets();
|
|
621
|
-
const parentModelId = ctx.model?.id;
|
|
622
|
-
|
|
623
|
-
// Only use top-level ticket param — per-task prompt is NOT a ticket ID
|
|
624
|
-
const ticketId = params.ticket;
|
|
625
|
-
if (!ticketId) {
|
|
626
|
-
const tickets = [...ticketRegistry.values()];
|
|
627
|
-
return tickets.length
|
|
628
|
-
? rosterTicketPollResult(tickets, parentModelId)
|
|
629
|
-
: emptyTicketPollResult(parentModelId);
|
|
363
|
+
resolveFinalTicketStatus(ticket: AsyncTicket): "done" | "failed" {
|
|
364
|
+
return resolveFinalTicketStatusImpl(ticket);
|
|
630
365
|
}
|
|
631
366
|
|
|
632
|
-
|
|
633
|
-
|
|
367
|
+
/** Resolve active waiters for a terminal ticket. Returns whether any waiter was
|
|
368
|
+
* resolved, so callers can avoid also delivering a duplicate follow-up. */
|
|
369
|
+
private settleTicketWaiters(ticket: AsyncTicket): boolean {
|
|
370
|
+
if (!ticket.waiters?.length) return false;
|
|
371
|
+
|
|
372
|
+
const formatted = this.formatCompletedTicket(ticket);
|
|
373
|
+
let hadActive = false;
|
|
374
|
+
for (const w of ticket.waiters) {
|
|
375
|
+
if (w.settled || w.signal?.aborted) continue;
|
|
376
|
+
settleWaiter(w, formatted);
|
|
377
|
+
hadActive = true;
|
|
378
|
+
}
|
|
379
|
+
cleanWaiters(ticket);
|
|
380
|
+
return hadActive;
|
|
381
|
+
}
|
|
634
382
|
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
383
|
+
/** Format a completed ticket for LLM consumption. Reuses sync result formatting. */
|
|
384
|
+
formatCompletedTicket(ticket: AsyncTicket): AgentToolResult<DelegateDetails> {
|
|
385
|
+
// Shutdown can make a ticket terminal while its workers are still unwinding.
|
|
386
|
+
// Do not freeze that partial projection: late TaskResults must appear in a
|
|
387
|
+
// later poll once the worker-settled barrier has resolved. Tickets created
|
|
388
|
+
// before this marker existed (including simple fixtures) are already safe to
|
|
389
|
+
// memoize because only live async dispatches explicitly set it false.
|
|
390
|
+
const canMemoize = ticket.workersSettled !== false;
|
|
391
|
+
if (canMemoize && ticket.formattedResult) return ticket.formattedResult;
|
|
392
|
+
|
|
393
|
+
const parts: string[] = [];
|
|
394
|
+
const succeeded = ticket.results.filter(resultSucceeded).length;
|
|
395
|
+
const elapsedTotal = (ticket.completedAt ?? Date.now()) - ticket.created;
|
|
396
|
+
// Surface the overall ticket status so a failed/cancelled batch is not
|
|
397
|
+
// mistaken for success. "done" tickets keep the original header; others
|
|
398
|
+
// get an explicit status tag up front.
|
|
399
|
+
const statusTag =
|
|
400
|
+
ticket.status === "done" ? "" : `${ticket.status.toUpperCase()} · `;
|
|
401
|
+
const completionLabel =
|
|
402
|
+
ticket.status === "cancelled"
|
|
403
|
+
? "tasks completed before abort"
|
|
404
|
+
: "tasks completed";
|
|
405
|
+
parts.push(
|
|
406
|
+
`${statusTag}${succeeded}/${ticket.results.length} ${completionLabel} · ${fmtDuration(elapsedTotal)} wall time\n`,
|
|
407
|
+
);
|
|
408
|
+
if (ticket.serializedNotice) {
|
|
409
|
+
parts.push(ticket.serializedNotice);
|
|
410
|
+
}
|
|
411
|
+
if (ticket.dispatchWarning) {
|
|
412
|
+
parts.push(`WARNING: ${ticket.dispatchWarning}`);
|
|
413
|
+
}
|
|
414
|
+
if (ticket.error) parts.push(`[BATCH FAILED: ${ticket.error}]`);
|
|
415
|
+
|
|
416
|
+
const pendingLabelFor = (index: number): string => {
|
|
417
|
+
if (ticket.status !== "cancelled") {
|
|
418
|
+
return "PENDING — result not available";
|
|
419
|
+
}
|
|
420
|
+
return ticket.progress[index]?.status === "pending"
|
|
421
|
+
? "CANCELLED — task not started"
|
|
422
|
+
: "CANCELLED — task aborted mid-run, partial effects possible";
|
|
423
|
+
};
|
|
424
|
+
|
|
425
|
+
for (let i = 0; i < ticket.results.length; i++) {
|
|
426
|
+
const r = ticket.results[i];
|
|
427
|
+
const t = ticket.resolved[i]!;
|
|
428
|
+
if (!r) {
|
|
429
|
+
parts.push(
|
|
430
|
+
`=== ${t.agentName}${resumeMarker(ticket.progress[i]!)}${formatTaskId(t.id)}: ${trunc(t.prompt || "", 80)} ===`,
|
|
431
|
+
);
|
|
432
|
+
parts.push(`[${pendingLabelFor(i)}]`);
|
|
433
|
+
continue;
|
|
434
|
+
}
|
|
435
|
+
parts.push(...formatCompletedTask(t, r, ticket.config));
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
const completedResults = ticket.results.filter(
|
|
439
|
+
(r): r is TaskResult => r !== undefined && "touchedFiles" in r,
|
|
440
|
+
);
|
|
441
|
+
const overlapWarning = formatTouchedOverlapWarning(
|
|
442
|
+
findTouchedOverlaps(completedResults),
|
|
443
|
+
);
|
|
444
|
+
if (overlapWarning) parts.push("", overlapWarning);
|
|
445
|
+
|
|
446
|
+
if (ticket.status === "cancelled") {
|
|
447
|
+
parts.push(
|
|
448
|
+
"",
|
|
449
|
+
"WARNING: Cancellation stopped the remaining work. Files already written or shell commands already executed by the subagents were NOT rolled back. Review the touched files and session files above before deciding whether to retry.",
|
|
450
|
+
);
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
const formatted: AgentToolResult<DelegateDetails> = {
|
|
454
|
+
content: [{ type: "text", text: parts.join("\n\n") }],
|
|
639
455
|
details: {
|
|
640
456
|
tasks: ticket.tasks,
|
|
641
|
-
results:
|
|
642
|
-
(r,
|
|
457
|
+
results: [...ticket.results].map(
|
|
458
|
+
(r, index) =>
|
|
459
|
+
r ?? {
|
|
460
|
+
...pendingResultPlaceholder(ticket.resolved[index]),
|
|
461
|
+
error: pendingLabelFor(index),
|
|
462
|
+
},
|
|
643
463
|
),
|
|
644
464
|
progress: [...ticket.progress],
|
|
645
465
|
parentModel: ticket.parentModelId,
|
|
646
|
-
// Thread ticketId so
|
|
647
|
-
//
|
|
466
|
+
// Thread ticketId so renderResult can show the running-ticket banner and
|
|
467
|
+
// the human sees which ticket they polled, even in the rich tree path.
|
|
648
468
|
ticketId: ticket.id,
|
|
649
469
|
status: ticket.status,
|
|
650
|
-
elapsedMs:
|
|
651
|
-
overlapWarning:
|
|
470
|
+
elapsedMs: elapsedTotal,
|
|
471
|
+
overlapWarning: overlapWarning || undefined,
|
|
652
472
|
dispatchWarning: ticket.dispatchWarning,
|
|
473
|
+
serializedNotice: ticket.serializedNotice,
|
|
653
474
|
},
|
|
654
475
|
};
|
|
476
|
+
if (canMemoize) ticket.formattedResult = formatted;
|
|
477
|
+
return formatted;
|
|
655
478
|
}
|
|
656
479
|
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
480
|
+
/** Forward current progress to all active blocking waiters. Called whenever an
|
|
481
|
+
* async task reports a progress or status change. */
|
|
482
|
+
notifyWaiters(ticket: AsyncTicket): void {
|
|
483
|
+
if (!ticket.waiters?.length) return;
|
|
484
|
+
// Progress frames make sense while the ticket is running or cancelling.
|
|
485
|
+
// Terminal tickets (done/failed/cancelled) are resolved by deliverTicketResults.
|
|
486
|
+
if (ticket.status !== "running" && ticket.status !== "cancelling") return;
|
|
487
|
+
const active: TicketWaiter[] = [];
|
|
488
|
+
for (const w of ticket.waiters) {
|
|
489
|
+
if (w.settled) continue;
|
|
490
|
+
if (w.signal?.aborted) {
|
|
491
|
+
abortWaiter(w, ticket);
|
|
492
|
+
continue;
|
|
493
|
+
}
|
|
494
|
+
active.push(w);
|
|
495
|
+
if (w.onUpdate) {
|
|
496
|
+
try {
|
|
497
|
+
w.onUpdate(buildWaitRunningUpdate(ticket));
|
|
498
|
+
} catch (error) {
|
|
499
|
+
// Progress delivery is an observer boundary. A host callback must not
|
|
500
|
+
// be able to fail the worker that reported the update or reject the
|
|
501
|
+
// wait promise; keep the waiter attached for the terminal result and
|
|
502
|
+
// leave the failure visible for diagnosis.
|
|
503
|
+
console.error(
|
|
504
|
+
`[delegate] wait progress callback for ticket '${ticket.id}' threw; continuing`,
|
|
505
|
+
error,
|
|
506
|
+
);
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
ticket.waiters = active.length ? active : undefined;
|
|
682
511
|
}
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
512
|
+
|
|
513
|
+
/** Push results into parent session via sendMessage when background ticket completes.
|
|
514
|
+
* If there are active blocking waiters, resolve them directly and suppress the
|
|
515
|
+
* automatic follow-up so completion is delivered exactly once.
|
|
516
|
+
*
|
|
517
|
+
* Delivery mode depends on leaf affinity (see leaf.ts). Same leaf: `steer` +
|
|
518
|
+
* `triggerTurn`, the agent picks the result up immediately. Different leaf:
|
|
519
|
+
* `nextTurn`, which explicitly "does not interrupt or trigger anything" — the
|
|
520
|
+
* result waits for the human's next prompt instead of waking the agent on a
|
|
521
|
+
* branch the task was never part of. The ticket stays pollable either way. */
|
|
522
|
+
deliverTicketResults(pi: ExtensionAPI, ticket: AsyncTicket): TicketDelivery {
|
|
523
|
+
if (!ticket.completedAt) return "none";
|
|
524
|
+
|
|
525
|
+
// Resolve active blocking waiters directly. Stale/aborted waiters are
|
|
526
|
+
// cleaned but not resolved here (their abort handlers already returned).
|
|
527
|
+
// A waiter is a tool call on the live leaf by construction, so leaf
|
|
528
|
+
// affinity does not apply to it.
|
|
529
|
+
if (this.settleTicketWaiters(ticket)) return "waiters";
|
|
530
|
+
|
|
531
|
+
const formatted = this.formatCompletedTicket(ticket);
|
|
532
|
+
const text = formatted.content
|
|
533
|
+
.filter(
|
|
534
|
+
(c): c is { type: "text"; text: string } =>
|
|
535
|
+
c.type === "text" && typeof c.text === "string",
|
|
536
|
+
)
|
|
537
|
+
.map((c) => c.text)
|
|
538
|
+
.join("\n");
|
|
539
|
+
|
|
540
|
+
const crossLeaf = isCrossLeafTicket(ticket);
|
|
541
|
+
|
|
542
|
+
try {
|
|
543
|
+
pi.sendMessage(
|
|
686
544
|
{
|
|
687
|
-
|
|
688
|
-
|
|
545
|
+
customType: "async_delegate_result",
|
|
546
|
+
content: crossLeaf ? `${CROSS_LEAF_NOTICE}\n\n${text}` : text,
|
|
547
|
+
display: true,
|
|
548
|
+
details: {
|
|
549
|
+
...formatted.details,
|
|
550
|
+
ticketId: ticket.id,
|
|
551
|
+
status: ticket.status,
|
|
552
|
+
crossLeafDelivery: crossLeaf,
|
|
553
|
+
},
|
|
689
554
|
},
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
555
|
+
crossLeaf
|
|
556
|
+
? { deliverAs: "nextTurn" }
|
|
557
|
+
: { deliverAs: "steer", triggerTurn: true },
|
|
558
|
+
);
|
|
559
|
+
} catch (error) {
|
|
560
|
+
// Delivery is an observer boundary. The terminal ticket remains pollable
|
|
561
|
+
// even when the host cannot accept an unsolicited follow-up.
|
|
562
|
+
console.error(
|
|
563
|
+
`[delegate] failed to deliver results for ticket '${ticket.id}'; it remains pollable`,
|
|
564
|
+
error,
|
|
565
|
+
);
|
|
566
|
+
return "none";
|
|
567
|
+
}
|
|
568
|
+
return crossLeaf ? "deferred" : "steer";
|
|
693
569
|
}
|
|
694
|
-
|
|
570
|
+
|
|
571
|
+
/** Return a snapshot of one async ticket or the complete ticket roster. */
|
|
572
|
+
handlePoll(
|
|
573
|
+
params: { ticket?: string },
|
|
574
|
+
ctx: ExtensionContext,
|
|
575
|
+
): AgentToolResult<DelegateDetails> {
|
|
576
|
+
this.sweepTickets();
|
|
577
|
+
const parentModelId = ctx.model?.id;
|
|
578
|
+
|
|
579
|
+
// Only use top-level ticket param — per-task prompt is NOT a ticket ID
|
|
580
|
+
const ticketId = params.ticket;
|
|
581
|
+
if (!ticketId) {
|
|
582
|
+
const tickets = [...this.values()];
|
|
583
|
+
return tickets.length
|
|
584
|
+
? rosterTicketPollResult(tickets, parentModelId)
|
|
585
|
+
: emptyTicketPollResult(parentModelId);
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
const ticket = this.get(ticketId);
|
|
589
|
+
if (!ticket) return missingTicketPollResult(ticketId, parentModelId);
|
|
590
|
+
|
|
591
|
+
if (ticket.status === "running" || ticket.status === "cancelling") {
|
|
592
|
+
const snapshot = formatLiveTicketPoll(ticket);
|
|
593
|
+
return {
|
|
594
|
+
content: [{ type: "text", text: snapshot.text }],
|
|
595
|
+
details: {
|
|
596
|
+
tasks: ticket.tasks,
|
|
597
|
+
results: snapshot.completedResults.map(
|
|
598
|
+
(r, i) => r ?? pendingResultPlaceholder(ticket.resolved[i]),
|
|
599
|
+
),
|
|
600
|
+
progress: [...ticket.progress],
|
|
601
|
+
parentModel: ticket.parentModelId,
|
|
602
|
+
// Thread ticketId so the rich renderResult path shows the ticket banner
|
|
603
|
+
// (friction #2). The LLM-facing content still names the ticket id too.
|
|
604
|
+
ticketId: ticket.id,
|
|
605
|
+
status: ticket.status,
|
|
606
|
+
elapsedMs: Date.now() - ticket.created,
|
|
607
|
+
overlapWarning: snapshot.overlapWarning || undefined,
|
|
608
|
+
dispatchWarning: ticket.dispatchWarning,
|
|
609
|
+
serializedNotice: ticket.serializedNotice,
|
|
610
|
+
},
|
|
611
|
+
};
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
return this.formatCompletedTicket(ticket);
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
/** Preview or request cancellation of a running async ticket. */
|
|
618
|
+
handleCancel(params: {
|
|
619
|
+
ticket?: string;
|
|
620
|
+
force?: boolean;
|
|
621
|
+
}): AgentToolResult<DelegateDetails> {
|
|
622
|
+
this.sweepTickets();
|
|
623
|
+
const ticketId = params.ticket;
|
|
624
|
+
|
|
625
|
+
if (!ticketId) {
|
|
626
|
+
return {
|
|
627
|
+
content: [
|
|
628
|
+
{ type: "text", text: "ticketAction='cancel' requires a ticket ID." },
|
|
629
|
+
],
|
|
630
|
+
details: { tasks: [], results: [], progress: [] },
|
|
631
|
+
};
|
|
632
|
+
}
|
|
633
|
+
const ticket = this.get(ticketId);
|
|
634
|
+
if (!ticket) {
|
|
635
|
+
return {
|
|
636
|
+
content: [{ type: "text", text: `Ticket '${ticketId}' not found.` }],
|
|
637
|
+
details: { tasks: [], results: [], progress: [] },
|
|
638
|
+
};
|
|
639
|
+
}
|
|
640
|
+
if (ticket.status !== "running") {
|
|
641
|
+
return {
|
|
642
|
+
content: [
|
|
643
|
+
{
|
|
644
|
+
type: "text",
|
|
645
|
+
text: `Ticket '${ticketId}' is already ${ticket.status}.`,
|
|
646
|
+
},
|
|
647
|
+
],
|
|
648
|
+
details: { tasks: [], results: [], progress: [] },
|
|
649
|
+
};
|
|
650
|
+
}
|
|
651
|
+
if (!params.force) {
|
|
652
|
+
const details = buildWaitDetails(ticket);
|
|
653
|
+
const text = appendDispatchWarnings(formatCancelPreview(ticket), details);
|
|
654
|
+
return {
|
|
655
|
+
content: [{ type: "text", text }],
|
|
656
|
+
details,
|
|
657
|
+
};
|
|
658
|
+
}
|
|
659
|
+
this.requestTicketCancel(ticket);
|
|
695
660
|
const details = buildWaitDetails(ticket);
|
|
696
|
-
const
|
|
661
|
+
const base = `Ticket '${ticketId}' is cancelling; workers are settling. Poll for final status.`;
|
|
662
|
+
const text = appendDispatchWarnings(base, details);
|
|
697
663
|
return {
|
|
698
664
|
content: [{ type: "text", text }],
|
|
699
665
|
details,
|
|
700
666
|
};
|
|
701
667
|
}
|
|
702
|
-
requestTicketCancel(ticket);
|
|
703
|
-
const details = buildWaitDetails(ticket);
|
|
704
|
-
const base = `Ticket '${ticketId}' is cancelling; workers are settling. Poll for final status.`;
|
|
705
|
-
const text = appendDispatchWarnings(base, details);
|
|
706
|
-
return {
|
|
707
|
-
content: [{ type: "text", text }],
|
|
708
|
-
details,
|
|
709
|
-
};
|
|
710
|
-
}
|
|
711
668
|
|
|
712
|
-
/** Block until a ticket reaches a terminal state or `timeoutMs` expires.
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
): Promise<AgentToolResult<DelegateDetails>> {
|
|
722
|
-
|
|
723
|
-
|
|
669
|
+
/** Block until a ticket reaches a terminal state or `timeoutMs` expires.
|
|
670
|
+
* Progress is streamed through `onUpdate` without consuming model turns.
|
|
671
|
+
* Parent-tool abort or timeout detaches the waiter and leaves the ticket
|
|
672
|
+
* running; cancellation remains explicit (`ticketAction: "cancel"`). */
|
|
673
|
+
handleWait(
|
|
674
|
+
params: { ticket?: string; timeoutMs?: number },
|
|
675
|
+
signal: AbortSignal | undefined,
|
|
676
|
+
onUpdate: AgentToolUpdateCallback<DelegateDetails> | undefined,
|
|
677
|
+
ctx: ExtensionContext,
|
|
678
|
+
): Promise<AgentToolResult<DelegateDetails>> {
|
|
679
|
+
this.sweepTickets();
|
|
680
|
+
const parentModelId = ctx.model?.id;
|
|
681
|
+
|
|
682
|
+
const ticketId = params.ticket;
|
|
683
|
+
if (!ticketId) {
|
|
684
|
+
return Promise.resolve({
|
|
685
|
+
content: [
|
|
686
|
+
{ type: "text", text: "ticketAction='wait' requires a ticket ID." },
|
|
687
|
+
],
|
|
688
|
+
details: {
|
|
689
|
+
tasks: [],
|
|
690
|
+
results: [],
|
|
691
|
+
progress: [],
|
|
692
|
+
parentModel: parentModelId,
|
|
693
|
+
},
|
|
694
|
+
});
|
|
695
|
+
}
|
|
724
696
|
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
});
|
|
738
|
-
}
|
|
697
|
+
const ticket = this.get(ticketId);
|
|
698
|
+
if (!ticket) {
|
|
699
|
+
return Promise.resolve({
|
|
700
|
+
content: [{ type: "text", text: `Ticket '${ticketId}' not found.` }],
|
|
701
|
+
details: {
|
|
702
|
+
tasks: [],
|
|
703
|
+
results: [],
|
|
704
|
+
progress: [],
|
|
705
|
+
parentModel: parentModelId,
|
|
706
|
+
},
|
|
707
|
+
});
|
|
708
|
+
}
|
|
739
709
|
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
710
|
+
if (ticket.status !== "running" && ticket.status !== "cancelling") {
|
|
711
|
+
return Promise.resolve(this.formatCompletedTicket(ticket));
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
// Already aborted parent signal → detach immediately.
|
|
715
|
+
if (signal?.aborted) {
|
|
716
|
+
return Promise.resolve(buildWaitAbortResult(ticket));
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
return new Promise((resolve, reject) => {
|
|
720
|
+
const waiter: TicketWaiter = {
|
|
721
|
+
signal,
|
|
722
|
+
onUpdate,
|
|
723
|
+
resolve,
|
|
724
|
+
reject,
|
|
725
|
+
settled: false,
|
|
726
|
+
};
|
|
727
|
+
|
|
728
|
+
if (signal) {
|
|
729
|
+
const onAbort = () => {
|
|
730
|
+
abortWaiter(waiter, ticket);
|
|
731
|
+
};
|
|
732
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
733
|
+
waiter.removeAbortListener = () => {
|
|
734
|
+
signal.removeEventListener("abort", onAbort);
|
|
735
|
+
};
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
if (
|
|
739
|
+
params.timeoutMs !== undefined &&
|
|
740
|
+
params.timeoutMs >= 0 &&
|
|
741
|
+
Number.isFinite(params.timeoutMs)
|
|
742
|
+
) {
|
|
743
|
+
this.scheduleWaitTimeout(waiter, ticket, params.timeoutMs);
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
ticket.waiters = ticket.waiters ?? [];
|
|
747
|
+
ticket.waiters.push(waiter);
|
|
748
|
+
|
|
749
|
+
// Immediate progress frame so the TUI shows the current state.
|
|
750
|
+
this.notifyWaiters(ticket);
|
|
750
751
|
});
|
|
751
752
|
}
|
|
752
753
|
|
|
753
|
-
|
|
754
|
-
|
|
754
|
+
/** Schedule a waiter timeout in clamp-safe chunks, so the host timer clamp
|
|
755
|
+
* cannot turn a multi-week (or longer) wait into an immediate timeout. */
|
|
756
|
+
private scheduleWaitTimeout(
|
|
757
|
+
w: TicketWaiter,
|
|
758
|
+
ticket: AsyncTicket,
|
|
759
|
+
timeoutMs: number,
|
|
760
|
+
): void {
|
|
761
|
+
const deadline = Date.now() + timeoutMs;
|
|
762
|
+
w.clearDeadline = scheduleDeadline(deadline, () =>
|
|
763
|
+
this.timeoutWaiter(w, ticket, timeoutMs),
|
|
764
|
+
);
|
|
755
765
|
}
|
|
756
766
|
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
767
|
+
/** Fire a wait timeout: if the ticket has already become terminal, hand that
|
|
768
|
+
* snapshot to the waiter; otherwise return a rich timeout poll result. */
|
|
769
|
+
private timeoutWaiter(
|
|
770
|
+
w: TicketWaiter,
|
|
771
|
+
ticket: AsyncTicket,
|
|
772
|
+
timeoutMs: number,
|
|
773
|
+
): void {
|
|
774
|
+
// If the ticket became terminal (e.g. completed or cancelled just before
|
|
775
|
+
// this timer fired), return that snapshot. Workers may still be unwinding, so
|
|
776
|
+
// waiting for deliverTicketResults here could strand the caller indefinitely.
|
|
777
|
+
this.sweepTickets();
|
|
778
|
+
if (ticket.status !== "running" && ticket.status !== "cancelling") {
|
|
779
|
+
settleWaiter(w, this.formatCompletedTicket(ticket));
|
|
780
|
+
return;
|
|
781
|
+
}
|
|
782
|
+
settleWaiter(w, this.buildWaitTimeoutResult(ticket, timeoutMs));
|
|
760
783
|
}
|
|
761
784
|
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
785
|
+
/** Reuse the same rich snapshot as poll so the caller can see activity and
|
|
786
|
+
* consume any completed outputs without making a second tool call. */
|
|
787
|
+
private buildWaitTimeoutResult(
|
|
788
|
+
ticket: AsyncTicket,
|
|
789
|
+
timeoutMs: number,
|
|
790
|
+
): AgentToolResult<DelegateDetails> {
|
|
791
|
+
const snapshot = this.handlePoll(
|
|
792
|
+
{ ticket: ticket.id },
|
|
793
|
+
{} as ExtensionContext,
|
|
794
|
+
);
|
|
795
|
+
const snapshotText = snapshot.content
|
|
796
|
+
.filter((item) => item.type === "text")
|
|
797
|
+
.map((item) => item.text)
|
|
798
|
+
.join("\n");
|
|
799
|
+
const base = `Ticket ${ticket.id} still ${ticket.status} after ${fmtDuration(timeoutMs)} · wait timed out (ticket continues in background)`;
|
|
800
|
+
const guidance =
|
|
801
|
+
"If you need the final result in this turn, call wait once with timeoutMs omitted; do not poll after a timeout. Otherwise stop calling ticket controls and let the final result auto-deliver.";
|
|
802
|
+
return {
|
|
803
|
+
content: [
|
|
804
|
+
{
|
|
805
|
+
type: "text",
|
|
806
|
+
text: `${base}\n\n${snapshotText}\n\n${guidance}`,
|
|
807
|
+
},
|
|
808
|
+
],
|
|
809
|
+
details: snapshot.details,
|
|
769
810
|
};
|
|
811
|
+
}
|
|
812
|
+
}
|
|
770
813
|
|
|
771
|
-
|
|
772
|
-
const onAbort = () => {
|
|
773
|
-
abortWaiter(waiter, ticket);
|
|
774
|
-
};
|
|
775
|
-
signal.addEventListener("abort", onAbort, { once: true });
|
|
776
|
-
waiter.removeAbortListener = () => {
|
|
777
|
-
signal.removeEventListener("abort", onAbort);
|
|
778
|
-
};
|
|
779
|
-
}
|
|
814
|
+
// ── Default registry and compatibility wrappers ────────────────────────────
|
|
780
815
|
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
816
|
+
/** Default {@link TicketRegistry} used by module-level wrappers and the default
|
|
817
|
+
* {@link getDefaultDelegateRuntime}. */
|
|
818
|
+
export const ticketRegistry = new TicketRegistry();
|
|
819
|
+
|
|
820
|
+
export function generateTicketId(): string {
|
|
821
|
+
return ticketRegistry.generateTicketId();
|
|
822
|
+
}
|
|
788
823
|
|
|
789
|
-
|
|
790
|
-
|
|
824
|
+
export function sweepTickets(): void {
|
|
825
|
+
ticketRegistry.sweepTickets();
|
|
826
|
+
}
|
|
791
827
|
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
828
|
+
export function settleTicket(
|
|
829
|
+
ticket: AsyncTicket,
|
|
830
|
+
opts: SettleTicketOptions,
|
|
831
|
+
): void {
|
|
832
|
+
ticketRegistry.settleTicket(ticket, opts);
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
export function cancelTicketForShutdown(ticket: AsyncTicket): void {
|
|
836
|
+
ticketRegistry.cancelTicketForShutdown(ticket);
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
export function requestTicketCancel(ticket: AsyncTicket): void {
|
|
840
|
+
ticketRegistry.requestTicketCancel(ticket);
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
export function isSessionBusy(sessionId: string): string | null {
|
|
844
|
+
return ticketRegistry.isSessionBusy(sessionId);
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
export function resolveFinalTicketStatus(
|
|
848
|
+
ticket: AsyncTicket,
|
|
849
|
+
): "done" | "failed" {
|
|
850
|
+
return ticketRegistry.resolveFinalTicketStatus(ticket);
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
export function formatCompletedTicket(
|
|
854
|
+
ticket: AsyncTicket,
|
|
855
|
+
): AgentToolResult<DelegateDetails> {
|
|
856
|
+
return ticketRegistry.formatCompletedTicket(ticket);
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
export function notifyWaiters(ticket: AsyncTicket): void {
|
|
860
|
+
ticketRegistry.notifyWaiters(ticket);
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
export function deliverTicketResults(
|
|
864
|
+
pi: ExtensionAPI,
|
|
865
|
+
ticket: AsyncTicket,
|
|
866
|
+
): TicketDelivery {
|
|
867
|
+
return ticketRegistry.deliverTicketResults(pi, ticket);
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
export function handlePoll(
|
|
871
|
+
params: { ticket?: string },
|
|
872
|
+
ctx: ExtensionContext,
|
|
873
|
+
): AgentToolResult<DelegateDetails> {
|
|
874
|
+
return ticketRegistry.handlePoll(params, ctx);
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
export function handleCancel(params: {
|
|
878
|
+
ticket?: string;
|
|
879
|
+
force?: boolean;
|
|
880
|
+
}): AgentToolResult<DelegateDetails> {
|
|
881
|
+
return ticketRegistry.handleCancel(params);
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
export function handleWait(
|
|
885
|
+
params: { ticket?: string; timeoutMs?: number },
|
|
886
|
+
signal: AbortSignal | undefined,
|
|
887
|
+
onUpdate: AgentToolUpdateCallback<DelegateDetails> | undefined,
|
|
888
|
+
ctx: ExtensionContext,
|
|
889
|
+
): Promise<AgentToolResult<DelegateDetails>> {
|
|
890
|
+
return ticketRegistry.handleWait(params, signal, onUpdate, ctx);
|
|
795
891
|
}
|