@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/tickets.ts CHANGED
@@ -37,89 +37,18 @@ import type {
37
37
 
38
38
  const PENDING_RESULT_ERROR = "PENDING — result not available";
39
39
 
40
- const busyTicketIdsBySession = new Map<string, Set<string>>();
41
- const busySessionsByTicket = new Map<string, Set<string>>();
42
-
43
- function sessionIdsFor(ticket: AsyncTicket): string[] {
44
- return ticket.resolved
45
- .map((t) => t.sessionId)
46
- .filter((s): s is string => typeof s === "string" && s.length > 0);
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
- /** Remove completed tickets after their retention TTL. Running tickets have no
108
- * wall-clock deadline: per-task stall detection owns failed-agent handling. */
109
- export function sweepTickets(): void {
110
- const now = Date.now();
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
- * Transition an active ticket to a terminal state. Single owner of the
134
- * terminal transition — status, completion timestamp, error, and the
135
- * busy-session index so the normal-completion, unexpected-error, and
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 timeoutWaiter(
458
- w: TicketWaiter,
459
- ticket: AsyncTicket,
460
- timeoutMs: number,
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
- /** Schedule a waiter timeout in clamp-safe chunks, so the host timer clamp
474
- * cannot turn a multi-week (or longer) wait into an immediate timeout. */
475
- function scheduleWaitTimeout(
476
- w: TicketWaiter,
477
- ticket: AsyncTicket,
478
- timeoutMs: number,
479
- ): void {
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
- function cleanWaiters(ticket: AsyncTicket): void {
487
- if (!ticket.waiters) return;
488
- const active = ticket.waiters.filter((w) => !w.settled && !w.signal?.aborted);
489
- ticket.waiters = active.length ? active : undefined;
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
- /** Resolve active waiters for a terminal ticket. Returns whether any waiter was
493
- * resolved, so callers can avoid also delivering a duplicate follow-up. */
494
- function settleTicketWaiters(ticket: AsyncTicket): boolean {
495
- if (!ticket.waiters?.length) return false;
496
-
497
- const formatted = formatCompletedTicket(ticket);
498
- let hadActive = false;
499
- for (const w of ticket.waiters) {
500
- if (w.settled || w.signal?.aborted) continue;
501
- settleWaiter(w, formatted);
502
- hadActive = true;
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
- /** Forward current progress to all active blocking waiters. Called whenever an
509
- * async task reports a progress or status change. */
510
- export function notifyWaiters(ticket: AsyncTicket): void {
511
- if (!ticket.waiters?.length) return;
512
- // Progress frames make sense while the ticket is running or cancelling.
513
- // Terminal tickets (done/failed/cancelled) are resolved by deliverTicketResults.
514
- if (ticket.status !== "running" && ticket.status !== "cancelling") return;
515
- const active: TicketWaiter[] = [];
516
- for (const w of ticket.waiters) {
517
- if (w.settled) continue;
518
- if (w.signal?.aborted) {
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
- active.push(w);
523
- if (w.onUpdate) {
524
- try {
525
- w.onUpdate(buildWaitRunningUpdate(ticket));
526
- } catch (error) {
527
- // Progress delivery is an observer boundary. A host callback must not
528
- // be able to fail the worker that reported the update or reject the
529
- // wait promise; keep the waiter attached for the terminal result and
530
- // leave the failure visible for diagnosis.
531
- console.error(
532
- `[delegate] wait progress callback for ticket '${ticket.id}' threw; continuing`,
533
- error,
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
- /** 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
-
549
- /** How a completed ticket was handed back. `deferred` means the result was
550
- * queued without waking the agent because the session navigated away from
551
- * the spawn leaf; callers surface that to the human (see status.ts). */
552
- export type TicketDelivery = "none" | "waiters" | "steer" | "deferred";
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
- /** Push results into parent session via sendMessage when background ticket completes.
555
- * If there are active blocking waiters, resolve them directly and suppress the
556
- * automatic follow-up so completion is delivered exactly once.
557
- *
558
- * Delivery mode depends on leaf affinity (see leaf.ts). Same leaf: `steer` +
559
- * `triggerTurn`, the agent picks the result up immediately. Different leaf:
560
- * `nextTurn`, which explicitly "does not interrupt or trigger anything" — the
561
- * result waits for the human's next prompt instead of waking the agent on a
562
- * branch the task was never part of. The ticket stays pollable either way. */
563
- export function deliverTicketResults(
564
- pi: ExtensionAPI,
565
- ticket: AsyncTicket,
566
- ): TicketDelivery {
567
- if (!ticket.completedAt) return "none";
568
-
569
- // Resolve active blocking waiters directly. Stale/aborted waiters are
570
- // cleaned but not resolved here (their abort handlers already returned).
571
- // A waiter is a tool call on the live leaf by construction, so leaf
572
- // affinity does not apply to it.
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
- crossLeaf
600
- ? { deliverAs: "nextTurn" }
601
- : { deliverAs: "steer", triggerTurn: true },
602
- );
603
- } catch (error) {
604
- // Delivery is an observer boundary. The terminal ticket remains pollable
605
- // even when the host cannot accept an unsolicited follow-up.
606
- console.error(
607
- `[delegate] failed to deliver results for ticket '${ticket.id}'; it remains pollable`,
608
- error,
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
- /** Return a snapshot of one async ticket or the complete ticket roster. */
616
- export function handlePoll(
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
- const ticket = ticketRegistry.get(ticketId);
633
- if (!ticket) return missingTicketPollResult(ticketId, parentModelId);
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
- if (ticket.status === "running" || ticket.status === "cancelling") {
636
- const snapshot = formatLiveTicketPoll(ticket);
637
- return {
638
- content: [{ type: "text", text: snapshot.text }],
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: snapshot.completedResults.map(
642
- (r, i) => r ?? pendingResultPlaceholder(ticket.resolved[i]),
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 the rich renderResult path shows the ticket banner
647
- // (friction #2). The LLM-facing content still names the ticket id too.
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: Date.now() - ticket.created,
651
- overlapWarning: snapshot.overlapWarning || undefined,
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
- return formatCompletedTicket(ticket);
658
- }
659
-
660
- /** Preview or request cancellation of a running async ticket. */
661
- export function handleCancel(params: {
662
- ticket?: string;
663
- force?: boolean;
664
- }): AgentToolResult<DelegateDetails> {
665
- sweepTickets();
666
- const ticketId = params.ticket;
667
-
668
- if (!ticketId) {
669
- return {
670
- content: [
671
- { type: "text", text: "ticketAction='cancel' requires a ticket ID." },
672
- ],
673
- details: { tasks: [], results: [], progress: [] },
674
- };
675
- }
676
- const ticket = ticketRegistry.get(ticketId);
677
- if (!ticket) {
678
- return {
679
- content: [{ type: "text", text: `Ticket '${ticketId}' not found.` }],
680
- details: { tasks: [], results: [], progress: [] },
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
- if (ticket.status !== "running") {
684
- return {
685
- content: [
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
- type: "text",
688
- text: `Ticket '${ticketId}' is already ${ticket.status}.`,
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
- details: { tasks: [], results: [], progress: [] },
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
- if (!params.force) {
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 text = appendDispatchWarnings(formatCancelPreview(ticket), details);
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
- * Progress is streamed through `onUpdate` without consuming model turns.
714
- * Parent-tool abort or timeout detaches the waiter and leaves the ticket
715
- * running; cancellation remains explicit (`ticketAction: "cancel"`). */
716
- export function handleWait(
717
- params: { ticket?: string; timeoutMs?: number },
718
- signal: AbortSignal | undefined,
719
- onUpdate: AgentToolUpdateCallback<DelegateDetails> | undefined,
720
- ctx: ExtensionContext,
721
- ): Promise<AgentToolResult<DelegateDetails>> {
722
- sweepTickets();
723
- const parentModelId = ctx.model?.id;
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
- const ticketId = params.ticket;
726
- if (!ticketId) {
727
- return Promise.resolve({
728
- content: [
729
- { type: "text", text: "ticketAction='wait' requires a ticket ID." },
730
- ],
731
- details: {
732
- tasks: [],
733
- results: [],
734
- progress: [],
735
- parentModel: parentModelId,
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
- const ticket = ticketRegistry.get(ticketId);
741
- if (!ticket) {
742
- return Promise.resolve({
743
- content: [{ type: "text", text: `Ticket '${ticketId}' not found.` }],
744
- details: {
745
- tasks: [],
746
- results: [],
747
- progress: [],
748
- parentModel: parentModelId,
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
- if (ticket.status !== "running" && ticket.status !== "cancelling") {
754
- return Promise.resolve(formatCompletedTicket(ticket));
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
- // Already aborted parent signal detach immediately.
758
- if (signal?.aborted) {
759
- return Promise.resolve(buildWaitAbortResult(ticket));
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
- return new Promise((resolve, reject) => {
763
- const waiter: TicketWaiter = {
764
- signal,
765
- onUpdate,
766
- resolve,
767
- reject,
768
- settled: false,
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
- if (signal) {
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
- if (
782
- params.timeoutMs !== undefined &&
783
- params.timeoutMs >= 0 &&
784
- Number.isFinite(params.timeoutMs)
785
- ) {
786
- scheduleWaitTimeout(waiter, ticket, params.timeoutMs);
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
- ticket.waiters = ticket.waiters ?? [];
790
- ticket.waiters.push(waiter);
824
+ export function sweepTickets(): void {
825
+ ticketRegistry.sweepTickets();
826
+ }
791
827
 
792
- // Immediate progress frame so the TUI shows the current state.
793
- notifyWaiters(ticket);
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
  }