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