@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/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,
@@ -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((p) => p.status === "running").length;
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 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));
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
- /** 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),
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
- 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;
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
- /** 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;
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
- /** 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;
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
- 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
- );
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
- /** 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";
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
- /** 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,
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
- 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,
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
- /** 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);
371
+ resolveFinalTicketStatus(ticket: AsyncTicket): "done" | "failed" {
372
+ return resolveFinalTicketStatusImpl(ticket);
630
373
  }
631
374
 
632
- const ticket = ticketRegistry.get(ticketId);
633
- if (!ticket) return missingTicketPollResult(ticketId, parentModelId);
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
- if (ticket.status === "running" || ticket.status === "cancelling") {
636
- const snapshot = formatLiveTicketPoll(ticket);
637
- return {
638
- content: [{ type: "text", text: snapshot.text }],
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: snapshot.completedResults.map(
642
- (r, i) => r ?? pendingResultPlaceholder(ticket.resolved[i]),
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 the rich renderResult path shows the ticket banner
647
- // (friction #2). The LLM-facing content still names the ticket id too.
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: Date.now() - ticket.created,
651
- overlapWarning: snapshot.overlapWarning || undefined,
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
- 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;
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
- if (!ticketId) {
669
- return {
670
- content: [
671
- { type: "text", text: "ticketAction='cancel' requires a ticket ID." },
672
- ],
673
- details: { tasks: [], results: [], progress: [] },
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
- 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
- };
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
- if (ticket.status !== "running") {
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
- if (!params.force) {
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 text = appendDispatchWarnings(formatCancelPreview(ticket), details);
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
- * 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;
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
- 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
- }
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
- 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
- },
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
- if (ticket.status !== "running" && ticket.status !== "cancelling") {
754
- return Promise.resolve(formatCompletedTicket(ticket));
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
- // Already aborted parent signal detach immediately.
758
- if (signal?.aborted) {
759
- return Promise.resolve(buildWaitAbortResult(ticket));
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
- return new Promise((resolve, reject) => {
763
- const waiter: TicketWaiter = {
764
- signal,
765
- onUpdate,
766
- resolve,
767
- reject,
768
- settled: false,
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
- 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
- }
859
+ // ── Default registry and compatibility wrappers ────────────────────────────
780
860
 
781
- if (
782
- params.timeoutMs !== undefined &&
783
- params.timeoutMs >= 0 &&
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
- ticket.waiters = ticket.waiters ?? [];
790
- ticket.waiters.push(waiter);
865
+ export function generateTicketId(): string {
866
+ return ticketRegistry.generateTicketId();
867
+ }
791
868
 
792
- // Immediate progress frame so the TUI shows the current state.
793
- notifyWaiters(ticket);
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
  }