@bermudi/pi-delegate 0.1.17 → 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/dispatch.ts CHANGED
@@ -1,14 +1,6 @@
1
1
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
2
  import type { AgentToolUpdateCallback } from "@earendil-works/pi-agent-core";
3
- import {
4
- ticketRegistry,
5
- generateTicketId,
6
- deliverTicketResults,
7
- sweepTickets,
8
- resolveFinalTicketStatus,
9
- settleTicket,
10
- notifyWaiters,
11
- } from "./tickets.ts";
3
+ import { getDefaultDelegateRuntime, type DelegateRuntime } from "./runtime.ts";
12
4
  import {
13
5
  getConcurrencyLimit,
14
6
  getMaxAsyncTickets,
@@ -18,7 +10,7 @@ import {
18
10
  import type { DelegateConfig } from "./config.ts";
19
11
  import { getCurrentLeafId } from "./leaf.ts";
20
12
  import { getModelKey, mapConcurrentByModel } from "./concurrency.ts";
21
- import { aggregateTaskResults, sumUsage } from "./usage.ts";
13
+ import { aggregateTaskResults, emptyUsage, sumUsage } from "./usage.ts";
22
14
  import { runResolvedTask, updateProgressFromRun } from "./lifecycle.ts";
23
15
  import {
24
16
  fmtDuration,
@@ -36,8 +28,13 @@ import {
36
28
  type SharedWriteConflict,
37
29
  } from "./shared-write-safety.ts";
38
30
  import type { CallSpan } from "./telemetry.ts";
39
- import { prepareIsolatedBatch } from "./isolated-workspace.ts";
31
+ import {
32
+ prepareIsolatedBatch,
33
+ type IsolatedReconcileOptions,
34
+ type PreparedIsolatedBatch,
35
+ } from "./isolated-workspace.ts";
40
36
  import { sanitizeTerminalLine } from "./utils.ts";
37
+ import { checkScratchWorkspaceSupport } from "./workspace.ts";
41
38
  import { quarantinedTasks } from "./session-quarantine.ts";
42
39
  import type {
43
40
  AgentConfig,
@@ -135,6 +132,7 @@ export function makeFireUpdater(
135
132
  resolved: ResolvedTask[],
136
133
  parentModelId: string | undefined,
137
134
  dispatchWarning?: string,
135
+ serializedNotice?: string,
138
136
  ): () => void {
139
137
  return () =>
140
138
  onUpdate?.({
@@ -150,6 +148,7 @@ export function makeFireUpdater(
150
148
  progress: [...progress],
151
149
  parentModel: parentModelId,
152
150
  dispatchWarning,
151
+ serializedNotice,
153
152
  },
154
153
  });
155
154
  }
@@ -165,6 +164,10 @@ export interface AsyncDispatchInput {
165
164
  callSpan?: CallSpan;
166
165
  dispatchConfig: DelegateConfig;
167
166
  dispatchWarning?: string;
167
+ /** Same-call shared-writer groups admission serialized into task order. */
168
+ serializedGroups?: SharedWriteConflict[];
169
+ serializedNotice?: string;
170
+ runtime?: DelegateRuntime;
168
171
  }
169
172
 
170
173
  /** Inputs needed by the sync (blocking) dispatch path. */
@@ -179,6 +182,10 @@ export interface SyncDispatchInput {
179
182
  callSpan?: CallSpan;
180
183
  dispatchConfig: DelegateConfig;
181
184
  dispatchWarning?: string;
185
+ /** Same-call shared-writer groups admission serialized into task order. */
186
+ serializedGroups?: SharedWriteConflict[];
187
+ serializedNotice?: string;
188
+ runtime?: DelegateRuntime;
182
189
  }
183
190
 
184
191
  /** Inputs for the normal task-validation, resolution, and dispatch path. */
@@ -192,6 +199,7 @@ export interface DelegateDispatchInput {
192
199
  signal: AbortSignal | undefined;
193
200
  onUpdate: AgentToolUpdateCallback<DelegateDetails> | undefined;
194
201
  callSpan?: CallSpan;
202
+ runtime?: DelegateRuntime;
195
203
  }
196
204
 
197
205
  function taskReference(task: DispatchableTask, index: number): string {
@@ -207,12 +215,42 @@ function asAdmissionWriter(task: ResolvedTask): ResolvedTask {
207
215
  : task;
208
216
  }
209
217
 
210
- function sharedWriteRejection(
218
+ const SCRATCH_VIABILITY_TIMEOUT_MS = 5_000;
219
+
220
+ /** Verify scratch viability for the given task cwds before a rejection message
221
+ * recommends it. Resolves with the clause text to append: a recommendation
222
+ * when every cwd passes the same read-only pre-flight scratch setup runs,
223
+ * otherwise a short unavailability note carrying the blocking reason.
224
+ * Uncertainty (timeout, unexpected error) fails closed — no recommendation. */
225
+ async function scratchRecommendation(cwds: readonly string[]): Promise<string> {
226
+ if (!cwds.length) return "";
227
+ try {
228
+ await Promise.all(
229
+ cwds.map((cwd) =>
230
+ checkScratchWorkspaceSupport(
231
+ cwd,
232
+ AbortSignal.timeout(SCRATCH_VIABILITY_TIMEOUT_MS),
233
+ ),
234
+ ),
235
+ );
236
+ return ', or use workspace: "scratch" when changes may be discarded';
237
+ } catch (error) {
238
+ const reason = error instanceof Error ? error.message : String(error);
239
+ return ` workspace: "scratch" is unavailable in this checkout (${reason})`;
240
+ }
241
+ }
242
+
243
+ /** Compose the shared-write rejection after the admission lock has released:
244
+ * the scratch clause is verified against scratch's own pre-flight, so the
245
+ * message never vouches for a mode this checkout cannot run (linked Git
246
+ * worktrees, nested repositories, non-Linux hosts). */
247
+ async function sharedWriteRejection(
211
248
  tasks: DispatchableTask[],
212
249
  parentModelId: string | undefined,
213
250
  conflicts: SharedWriteConflict[],
214
- references: readonly string[] = tasks.map(taskReference),
215
- ): DelegateToolResult {
251
+ references: readonly string[],
252
+ cwds: readonly string[],
253
+ ): Promise<DelegateToolResult> {
216
254
  const scopes = conflicts
217
255
  .map(({ scope, taskIndexes }) => {
218
256
  const refs = taskIndexes
@@ -221,6 +259,7 @@ function sharedWriteRejection(
221
259
  return `${refs} share ${scope.kind === "git" ? "Git root" : "directory"} '${scope.root}'.`;
222
260
  })
223
261
  .join(" ");
262
+ const scratch = await scratchRecommendation(cwds);
224
263
  return {
225
264
  content: [
226
265
  {
@@ -228,7 +267,7 @@ function sharedWriteRejection(
228
267
  text:
229
268
  `Rejected before dispatch; no tasks were started. ${scopes} ` +
230
269
  "Each listed task has mutating or unclassified tool capability, so concurrent shared execution could silently overwrite work. " +
231
- 'Run them sequentially, use workspace: "isolated" for Git-backed ordered reconciliation, or use workspace: "scratch" when changes may be discarded. ' +
270
+ `Run them sequentially, use workspace: "isolated" for Git-backed ordered reconciliation${scratch}. ` +
232
271
  "External processes remain outside this check.",
233
272
  },
234
273
  ],
@@ -236,26 +275,64 @@ function sharedWriteRejection(
236
275
  };
237
276
  }
238
277
 
239
- function sharedWriteSafetyFailure(
278
+ async function sharedWriteSafetyFailure(
240
279
  tasks: DispatchableTask[],
241
280
  parentModelId: string | undefined,
242
281
  error: unknown,
243
- ): DelegateToolResult {
282
+ cwds: readonly string[],
283
+ ): Promise<DelegateToolResult> {
244
284
  const detail =
245
285
  error instanceof Error ? error.message : "Unknown workspace error.";
286
+ const scratch = await scratchRecommendation(cwds);
246
287
  return {
247
288
  content: [
248
289
  {
249
290
  type: "text",
250
291
  text:
251
292
  `Rejected before dispatch; no tasks were started because shared-write safety could not be verified. ${detail} ` +
252
- 'Fix the task directory or Git metadata, run tasks sequentially, use workspace: "isolated" for Git-backed ordered reconciliation, or use workspace: "scratch" when changes may be discarded.',
293
+ `Fix the task directory or Git metadata, run tasks sequentially, use workspace: "isolated" for Git-backed ordered reconciliation${scratch}.`,
253
294
  },
254
295
  ],
255
296
  details: { tasks, results: [], progress: [], parentModel: parentModelId },
256
297
  };
257
298
  }
258
299
 
300
+ /** Build the predecessor gate that runs serialized task chains one at a time,
301
+ * in task order. Each chained successor awaits its predecessor's completion
302
+ * before acquiring a global slot; `complete` must be called in every task's
303
+ * `finally` so throw, abort, and queued-abort paths all unblock successors. */
304
+ function buildSerializationGate(
305
+ groups: readonly SharedWriteConflict[] | undefined,
306
+ ):
307
+ | {
308
+ beforeAcquire: (index: number) => Promise<void>;
309
+ complete: (index: number) => void;
310
+ }
311
+ | undefined {
312
+ if (!groups?.length) return undefined;
313
+ const predecessor = new Map<number, number>();
314
+ for (const { taskIndexes } of groups) {
315
+ for (let position = 1; position < taskIndexes.length; position++) {
316
+ predecessor.set(taskIndexes[position]!, taskIndexes[position - 1]!);
317
+ }
318
+ }
319
+ if (predecessor.size === 0) return undefined;
320
+ const settled = new Map<number, Promise<void>>();
321
+ const resolvers = new Map<number, () => void>();
322
+ for (const index of new Set(predecessor.values())) {
323
+ let resolve!: () => void;
324
+ settled.set(index, new Promise<void>((r) => (resolve = r)));
325
+ resolvers.set(index, resolve);
326
+ }
327
+ return {
328
+ beforeAcquire: async (index) => {
329
+ const predecessorIndex = predecessor.get(index);
330
+ if (predecessorIndex !== undefined) await settled.get(predecessorIndex);
331
+ },
332
+ complete: (index) => resolvers.get(index)?.(),
333
+ };
334
+ }
335
+
259
336
  /** Validate, resolve, and dispatch a non-short-circuit delegate operation. */
260
337
  export async function dispatchDelegate(
261
338
  input: DelegateDispatchInput,
@@ -276,10 +353,11 @@ export async function dispatchDelegate(
276
353
  signal,
277
354
  onUpdate,
278
355
  callSpan,
356
+ runtime = getDefaultDelegateRuntime(),
279
357
  } = input;
280
358
  const tasks = params.tasks ?? [];
281
359
 
282
- const validationError = validateTasks(tasks, agents, parentModelId);
360
+ const validationError = validateTasks(tasks, agents, parentModelId, runtime);
283
361
  if (validationError) {
284
362
  callSpan?.finish({
285
363
  status: "failed",
@@ -296,6 +374,7 @@ export async function dispatchDelegate(
296
374
  agents,
297
375
  parentDefaults,
298
376
  dispatchConfig,
377
+ runtime,
299
378
  );
300
379
  if (resolveResult.error !== undefined) {
301
380
  callSpan?.finish({
@@ -310,36 +389,28 @@ export async function dispatchDelegate(
310
389
  };
311
390
  }
312
391
  const resolved = resolveResult.tasks;
313
- if (params.async && resolved.some((task) => task.workspace === "isolated")) {
314
- callSpan?.finish({
315
- status: "failed",
316
- totalTokens: 0,
317
- totalCost: 0,
318
- wallMs: Date.now() - callSpan.startedAt,
319
- });
320
- return {
321
- content: [
322
- {
323
- type: "text",
324
- text: 'Invalid delegate call: workspace "isolated" is synchronous; remove async.',
325
- },
326
- ],
327
- details: {
328
- tasks,
329
- results: [],
330
- progress: [],
331
- parentModel: parentModelId,
332
- },
333
- };
334
- }
335
392
 
336
- const dispatchWarning = dispatchConfig.allowUnsafeSharedWrites
393
+ // Mutated inside the admission lock: unsafe mode's standing warning, and the
394
+ // serialization notice built when same-call shared writers are ordered into
395
+ // task order instead of rejected. Mutually exclusive by construction —
396
+ // unsafe mode skips admission entirely.
397
+ let dispatchWarning = dispatchConfig.allowUnsafeSharedWrites
337
398
  ? UNSAFE_SHARED_WRITES_WARNING
338
399
  : undefined;
400
+ let serializedNotice: string | undefined;
401
+ let serializedGroups: SharedWriteConflict[] | undefined;
339
402
  const signalWasAbortedBeforeAdmission = signal?.aborted === true;
340
403
  let syncReservation: symbol | undefined;
341
404
  let admissionResult:
342
- DelegateToolResult | { progress: TaskProgress[]; fire: () => void };
405
+ | DelegateToolResult
406
+ | { progress: TaskProgress[]; fire: () => void }
407
+ | {
408
+ rejected: {
409
+ conflicts: SharedWriteConflict[];
410
+ references: string[];
411
+ cwds: string[];
412
+ };
413
+ };
343
414
 
344
415
  try {
345
416
  admissionResult = await withSharedWriteAdmissionLock(async () => {
@@ -360,7 +431,7 @@ export async function dispatchDelegate(
360
431
  taskReference(tasks[index]!, index),
361
432
  );
362
433
 
363
- for (const ticket of ticketRegistry.values()) {
434
+ for (const ticket of runtime.tickets.values()) {
364
435
  if (
365
436
  ticket.status !== "running" &&
366
437
  ticket.status !== "cancelling" &&
@@ -391,29 +462,66 @@ export async function dispatchDelegate(
391
462
  }
392
463
 
393
464
  const incomingCount = resolved.length;
394
- const conflicts = (
395
- await findSharedWriteConflicts(
396
- [...incomingForSafety, ...activeResolved],
397
- signal,
398
- )
399
- ).filter(({ taskIndexes }) => {
400
- if (!taskIndexes.some((index) => index < incomingCount)) return false;
401
- // Multiple isolated tasks in this call intentionally share one
402
- // baseline/root. Any shared task or active dispatch in the same
403
- // conflict group still rejects the call.
404
- return !taskIndexes.every(
405
- (index) =>
406
- index < incomingCount &&
407
- resolved[index]?.workspace === "isolated",
408
- );
409
- });
410
- if (conflicts.length) {
411
- return sharedWriteRejection(
412
- tasks,
413
- parentModelId,
414
- conflicts,
415
- references,
416
- );
465
+ const rejectConflicts: SharedWriteConflict[] = [];
466
+ const serializedConflicts: SharedWriteConflict[] = [];
467
+ const rejectedCwds = new Set<string>();
468
+ for (const conflict of await findSharedWriteConflicts(
469
+ [...incomingForSafety, ...activeResolved],
470
+ signal,
471
+ )) {
472
+ const { taskIndexes } = conflict;
473
+ if (!taskIndexes.some((index) => index < incomingCount)) continue;
474
+ if (taskIndexes.every((index) => index < incomingCount)) {
475
+ const workspaces = new Set(
476
+ taskIndexes.map((index) => resolved[index]?.workspace),
477
+ );
478
+ if (workspaces.has("isolated")) {
479
+ if (workspaces.size === 1) {
480
+ // Multiple isolated tasks in this call intentionally share
481
+ // one baseline/root; no shared writer is involved.
482
+ continue;
483
+ }
484
+ // Mixed isolated/shared in this call: a shared writer cannot be
485
+ // ordered against the isolated reservation window (baseline
486
+ // snapshot through reconciliation).
487
+ rejectConflicts.push(conflict);
488
+ } else {
489
+ // Same-call shared writers: serialize in task order instead of
490
+ // rejecting. Concurrent dispatch is unordered anyway, so
491
+ // serial-in-dispatch-order refines the same contract.
492
+ serializedConflicts.push(conflict);
493
+ }
494
+ } else {
495
+ // An active writer (async ticket, running sync dispatch, or
496
+ // quarantined task) is involved: queueing behind unknown-duration
497
+ // work cannot be ordered safely here.
498
+ rejectConflicts.push(conflict);
499
+ }
500
+ for (const index of taskIndexes) {
501
+ if (index < incomingCount) rejectedCwds.add(resolved[index]!.cwd);
502
+ }
503
+ }
504
+ if (rejectConflicts.length) {
505
+ return {
506
+ rejected: {
507
+ conflicts: rejectConflicts,
508
+ references,
509
+ cwds: [...rejectedCwds],
510
+ },
511
+ };
512
+ }
513
+ if (serializedConflicts.length) {
514
+ serializedGroups = serializedConflicts;
515
+ serializedNotice = `Serialized: ${serializedConflicts
516
+ .map(
517
+ ({ scope, taskIndexes }) =>
518
+ `${taskIndexes
519
+ .map((index) => references[index] ?? `Task ${index + 1}`)
520
+ .join(", ")} share ${
521
+ scope.kind === "git" ? "Git root" : "directory"
522
+ } '${scope.root}' — running one at a time in task order.`,
523
+ )
524
+ .join(" ")}`;
417
525
  }
418
526
  }
419
527
 
@@ -425,6 +533,7 @@ export async function dispatchDelegate(
425
533
  resolved,
426
534
  parentModelId,
427
535
  dispatchWarning,
536
+ serializedNotice,
428
537
  );
429
538
  fire();
430
539
 
@@ -439,6 +548,9 @@ export async function dispatchDelegate(
439
548
  callSpan,
440
549
  dispatchConfig,
441
550
  dispatchWarning,
551
+ serializedGroups,
552
+ serializedNotice,
553
+ runtime,
442
554
  });
443
555
  }
444
556
 
@@ -483,18 +595,29 @@ export async function dispatchDelegate(
483
595
  totalCost: 0,
484
596
  wallMs: Date.now() - callSpan.startedAt,
485
597
  });
486
- return sharedWriteSafetyFailure(tasks, parentModelId, error);
598
+ return await sharedWriteSafetyFailure(tasks, parentModelId, error, [
599
+ ...new Set(resolved.map((task) => task.cwd)),
600
+ ]);
601
+ }
602
+
603
+ if ("rejected" in admissionResult) {
604
+ callSpan?.finish({
605
+ status: "failed",
606
+ totalTokens: 0,
607
+ totalCost: 0,
608
+ wallMs: Date.now() - (callSpan?.startedAt ?? Date.now()),
609
+ });
610
+ const { conflicts, references, cwds } = admissionResult.rejected;
611
+ return await sharedWriteRejection(
612
+ tasks,
613
+ parentModelId,
614
+ conflicts,
615
+ references,
616
+ cwds,
617
+ );
487
618
  }
488
619
 
489
620
  if ("content" in admissionResult) {
490
- if (admissionResult.content[0]?.text.includes("Rejected before dispatch")) {
491
- callSpan?.finish({
492
- status: "failed",
493
- totalTokens: 0,
494
- totalCost: 0,
495
- wallMs: Date.now() - callSpan.startedAt,
496
- });
497
- }
498
621
  return admissionResult;
499
622
  }
500
623
 
@@ -510,6 +633,9 @@ export async function dispatchDelegate(
510
633
  callSpan,
511
634
  dispatchConfig,
512
635
  dispatchWarning,
636
+ serializedGroups,
637
+ serializedNotice,
638
+ runtime,
513
639
  });
514
640
  } finally {
515
641
  if (syncReservation) activeSyncDispatches.delete(syncReservation);
@@ -519,8 +645,12 @@ export async function dispatchDelegate(
519
645
  /** Deliver a settled ticket and, when leaf affinity downgraded delivery to a
520
646
  * non-waking `nextTurn` message, tell the human — otherwise the completion is
521
647
  * silent apart from the footer clearing. */
522
- function finishTicketDelivery(pi: ExtensionAPI, ticket: AsyncTicket): void {
523
- if (deliverTicketResults(pi, ticket) === "deferred") {
648
+ function finishTicketDelivery(
649
+ pi: ExtensionAPI,
650
+ ticket: AsyncTicket,
651
+ runtime: DelegateRuntime,
652
+ ): void {
653
+ if (runtime.tickets.deliverTicketResults(pi, ticket) === "deferred") {
524
654
  notifyCrossLeafDelivery(ticket);
525
655
  }
526
656
  }
@@ -541,6 +671,88 @@ function settleAsyncCall(
541
671
  callSpan.finish({ status, totalTokens, totalCost, wallMs });
542
672
  }
543
673
 
674
+ function taskCompletedSuccessfully(result: TaskResult): boolean {
675
+ return (
676
+ !result.error &&
677
+ result.integration?.status !== "retained" &&
678
+ result.integration?.status !== "conflict" &&
679
+ result.integration?.status !== "apply_failed"
680
+ );
681
+ }
682
+
683
+ function completeUnexpectedResults(
684
+ resolved: readonly ResolvedTask[],
685
+ progress: TaskProgress[],
686
+ current: readonly (TaskResult | undefined)[],
687
+ error: unknown,
688
+ ): TaskResult[] {
689
+ const reason = error instanceof Error ? error.message : String(error);
690
+ return resolved.map((task, index) => {
691
+ const result = current[index];
692
+ if (result) return result;
693
+ const p = progress[index]!;
694
+ p.status = "failed";
695
+ p.error = reason;
696
+ return {
697
+ id: task.id,
698
+ agent: task.agentName,
699
+ resumedFrom: task.resumeFromDisplay,
700
+ output: "",
701
+ error: reason,
702
+ durationMs: p.durationMs,
703
+ tokens: 0,
704
+ usage: emptyUsage(),
705
+ workspace: task.workspace,
706
+ touchedFiles: [],
707
+ attributedFiles: [],
708
+ };
709
+ });
710
+ }
711
+
712
+ async function reconcileIsolatedResults(
713
+ batch: PreparedIsolatedBatch,
714
+ resolved: readonly ResolvedTask[],
715
+ results: TaskResult[],
716
+ options?: IsolatedReconcileOptions,
717
+ ): Promise<TaskResult[]> {
718
+ try {
719
+ return await batch.reconcile(results, options);
720
+ } catch (error) {
721
+ const detail = error instanceof Error ? error.message : String(error);
722
+ console.error("[delegate] isolated reconciliation failed", error);
723
+ for (let index = 0; index < results.length; index++) {
724
+ if (resolved[index]?.workspace !== "isolated") continue;
725
+ const existing = results[index]?.integration;
726
+ results[index] = {
727
+ ...results[index]!,
728
+ integration: {
729
+ status: "apply_failed",
730
+ proposedFiles: existing?.proposedFiles ?? [],
731
+ appliedFiles: [],
732
+ conflicts: [
733
+ ...(existing?.conflicts ?? []),
734
+ { path: "(batch)", reason: detail },
735
+ ],
736
+ ...(existing?.baselineRef
737
+ ? { baselineRef: existing.baselineRef }
738
+ : {}),
739
+ ...(existing?.proposalRef
740
+ ? { proposalRef: existing.proposalRef }
741
+ : {}),
742
+ ...(existing?.patchPath ? { patchPath: existing.patchPath } : {}),
743
+ ...(existing?.worktreePath
744
+ ? { worktreePath: existing.worktreePath }
745
+ : {}),
746
+ ...(existing?.cleanupIssue
747
+ ? { cleanupIssue: existing.cleanupIssue }
748
+ : {}),
749
+ },
750
+ };
751
+ }
752
+ return results;
753
+ }
754
+ }
755
+
544
756
  /** Fire-and-forget background execution. Registers an `AsyncTicket`, kicks off
545
757
  * the concurrent run, and returns the ticket acknowledgment immediately.
546
758
  * Results are delivered via `deliverTicketResults` when all tasks settle. */
@@ -555,10 +767,13 @@ export function dispatchAsync(input: AsyncDispatchInput): DelegateToolResult {
555
767
  callSpan,
556
768
  dispatchConfig,
557
769
  dispatchWarning,
770
+ serializedGroups,
771
+ serializedNotice,
772
+ runtime = getDefaultDelegateRuntime(),
558
773
  } = input;
559
774
 
560
- sweepTickets();
561
- const runningCount = [...ticketRegistry.values()].filter(
775
+ runtime.tickets.sweepTickets();
776
+ const runningCount = [...runtime.tickets.values()].filter(
562
777
  (t) => t.status === "running" || t.status === "cancelling",
563
778
  ).length;
564
779
  const maxAsyncTickets = getMaxAsyncTickets(dispatchConfig);
@@ -580,7 +795,7 @@ export function dispatchAsync(input: AsyncDispatchInput): DelegateToolResult {
580
795
  };
581
796
  }
582
797
 
583
- const ticketId = generateTicketId();
798
+ const ticketId = runtime.tickets.generateTicketId();
584
799
  const controller = new AbortController();
585
800
  const ticket: AsyncTicket = {
586
801
  id: ticketId,
@@ -602,17 +817,18 @@ export function dispatchAsync(input: AsyncDispatchInput): DelegateToolResult {
602
817
  telemetryConfig: callSpan?.telemetryConfig,
603
818
  workersSettled: false,
604
819
  dispatchWarning,
820
+ serializedNotice,
605
821
  // Capture the dispatch-scoped snapshot so async workers and later poll/wait
606
822
  // formatting use the same retry/stall/output/provider settings that were
607
823
  // in effect when the ticket was spawned.
608
824
  config: dispatchConfig,
609
825
  };
610
- ticketRegistry.set(ticketId, ticket);
826
+ runtime.tickets.set(ticketId, ticket);
611
827
  callSpan?.spawn();
612
828
  // Footer visibility for the new background work (see status.ts). Uses the
613
829
  // ctx cached from the dispatch path in extension.ts — DelegateToolCtx is
614
830
  // the intentionally narrowed surface and does not carry `ui`.
615
- syncDelegateStatus();
831
+ syncDelegateStatus(undefined, runtime);
616
832
 
617
833
  // Capture values for the closure — do NOT use `signal` from execute()
618
834
  // The parent turn's signal dies when execute() returns.
@@ -630,16 +846,17 @@ export function dispatchAsync(input: AsyncDispatchInput): DelegateToolResult {
630
846
  callSpan?.telemetryConfig ?? getTelemetryConfig(dispatchConfig),
631
847
  async: true,
632
848
  config: dispatchConfig,
849
+ runtime,
633
850
  onProgress: (p, u) => {
634
851
  updateProgressFromRun(p, u);
635
- notifyWaiters(ticket);
852
+ runtime.tickets.notifyWaiters(ticket);
636
853
  // Live subagent counts in the footer. Deduped by text, so only
637
854
  // running/pending count transitions trigger a render.
638
- syncDelegateStatus();
855
+ syncDelegateStatus(undefined, runtime);
639
856
  },
640
857
  onStatusChange: () => {
641
- notifyWaiters(ticket);
642
- syncDelegateStatus();
858
+ runtime.tickets.notifyWaiters(ticket);
859
+ syncDelegateStatus(undefined, runtime);
643
860
  },
644
861
  };
645
862
 
@@ -652,27 +869,92 @@ export function dispatchAsync(input: AsyncDispatchInput): DelegateToolResult {
652
869
  // In particular, result delivery is allowed to fail without re-entering the
653
870
  // worker completion path; the terminal ticket remains available to poll.
654
871
  const finishLiveSettlement = (t: AsyncTicket): void => {
655
- syncDelegateStatus();
872
+ syncDelegateStatus(undefined, runtime);
656
873
  settleAsyncCall(t, callSpan);
657
- finishTicketDelivery(pi, t);
874
+ finishTicketDelivery(pi, t, runtime);
658
875
  };
659
876
 
660
- const completion = mapConcurrentByModel(
661
- resolved,
662
- (t) => getModelKey(t.model),
663
- (modelKey) => getConcurrencyLimit(modelKey, dispatchConfig),
664
- async (t, i) => {
665
- const result = await runResolvedTask(asyncEnv, t, ticket.progress[i]!, i);
666
- ticket.results[i] = result;
667
- return result;
668
- },
669
- ticketSignal,
670
- )
671
- .then(() => {
672
- // mapConcurrentByModel is the worker-settled barrier. Publish that before
673
- // terminal formatting/delivery so the final spill projection is safely
674
- // memoized; shutdown may already have formatted an intentionally uncached
675
- // partial snapshot while this flag was false.
877
+ const completion = (async () => {
878
+ try {
879
+ let executionResolved = resolved;
880
+ let isolatedBatch: PreparedIsolatedBatch | undefined;
881
+ try {
882
+ isolatedBatch = await prepareIsolatedBatch(resolved, ticketSignal);
883
+ if (isolatedBatch) executionResolved = isolatedBatch.resolved;
884
+ } catch (error) {
885
+ const detail = error instanceof Error ? error.message : String(error);
886
+ throw new Error(
887
+ `Isolated workspace setup failed; no subagents were started. ${detail}`,
888
+ { cause: error },
889
+ );
890
+ }
891
+
892
+ let results: TaskResult[];
893
+ const gate = buildSerializationGate(serializedGroups);
894
+ try {
895
+ results = await mapConcurrentByModel(
896
+ executionResolved,
897
+ (t) => getModelKey(t.model),
898
+ (modelKey) => getConcurrencyLimit(modelKey, dispatchConfig),
899
+ async (t, i) => {
900
+ try {
901
+ const result = await runResolvedTask(
902
+ asyncEnv,
903
+ t,
904
+ ticket.progress[i]!,
905
+ i,
906
+ );
907
+ ticket.results[i] = result;
908
+ return result;
909
+ } finally {
910
+ gate?.complete(i);
911
+ }
912
+ },
913
+ ticketSignal,
914
+ gate?.beforeAcquire,
915
+ );
916
+ } catch (error) {
917
+ results = completeUnexpectedResults(
918
+ resolved,
919
+ ticket.progress,
920
+ ticket.results,
921
+ error,
922
+ );
923
+ if (isolatedBatch) {
924
+ results = await reconcileIsolatedResults(
925
+ isolatedBatch,
926
+ resolved,
927
+ results,
928
+ {
929
+ shouldApplySource: () => false,
930
+ retainedReason:
931
+ "Batch execution failed before source application; completed proposals were retained for recovery.",
932
+ },
933
+ );
934
+ }
935
+ ticket.results = [...results];
936
+ throw error;
937
+ }
938
+ if (isolatedBatch) {
939
+ results = await reconcileIsolatedResults(
940
+ isolatedBatch,
941
+ resolved,
942
+ results,
943
+ {
944
+ shouldApplySource: () =>
945
+ ticket.status === "running" && !ticketSignal.aborted,
946
+ signal: ticketSignal,
947
+ retainedReason:
948
+ "The async ticket was cancelled before source application; the proposal was retained for recovery.",
949
+ },
950
+ );
951
+ ticket.results = [...results];
952
+ }
953
+
954
+ // Worker execution and isolated reconciliation are complete. Publish that
955
+ // before terminal formatting/delivery so the final spill projection is
956
+ // safely memoized; shutdown may already have formatted an intentionally
957
+ // uncached partial snapshot while this flag was false.
676
958
  ticket.workersSettled = true;
677
959
  // Shutdown marks the ticket terminal before cooperative worker aborts
678
960
  // have finished. Still write one final aggregate after every result has
@@ -682,43 +964,36 @@ export function dispatchAsync(input: AsyncDispatchInput): DelegateToolResult {
682
964
  settleAsyncCall(ticket, callSpan);
683
965
  return;
684
966
  }
685
- // All tasks settled determine final ticket status.
686
- // Use progress (set by runResolvedTask) for settled-ness so the
687
- // status reflects work completion, not just result-array density.
688
- // A partial ticket (not all settled, e.g. aborted mid-flight) must
689
- // NOT be marked "done" — that would mask incomplete work as
690
- // complete. resolveFinalTicketStatus returns "failed" for that
691
- // case and for any case with a failed task.
692
- // A "cancelling" ticket that outlived its workers settles as
693
- // "cancelled": the per-task results record what actually happened;
694
- // the ticket state reports that the batch was aborted by the caller.
695
- settleTicket(ticket, {
967
+ // Use progress (set by runResolvedTask) for settled-ness so a partial
968
+ // ticket can never be marked done. A cancelling ticket reports cancelled
969
+ // even when some workers completed before the abort.
970
+ runtime.tickets.settleTicket(ticket, {
696
971
  status:
697
972
  ticket.status === "running"
698
- ? resolveFinalTicketStatus(ticket)
973
+ ? runtime.tickets.resolveFinalTicketStatus(ticket)
699
974
  : "cancelled",
700
975
  });
701
976
  finishLiveSettlement(ticket);
702
- })
703
- .catch((err) => {
704
- // mapConcurrentByModel waits for all sibling workers before rejecting.
977
+ } catch (err) {
978
+ // Preparation and reconciliation join the same terminal path as workers:
979
+ // any unexpected rejection must leave the ticket pollable and settled.
705
980
  ticket.workersSettled = true;
706
- // Defense-in-depth — should not happen if individual tasks catch properly.
707
- // Even an unexpected worker rejection must leave the shutdown aggregate
708
- // with every result that did settle, without touching the stale UI.
709
981
  if (ticket.status === "cancelled") {
710
982
  settleAsyncCall(ticket, callSpan);
711
983
  return;
712
984
  }
713
- settleTicket(ticket, {
714
- status: ticket.status === "cancelling" ? "cancelled" : "failed",
715
- error: err instanceof Error ? err.message : String(err),
985
+ const cancelling = ticket.status === "cancelling";
986
+ runtime.tickets.settleTicket(ticket, {
987
+ status: cancelling ? "cancelled" : "failed",
988
+ ...(cancelling
989
+ ? {}
990
+ : { error: err instanceof Error ? err.message : String(err) }),
716
991
  });
717
992
  finishLiveSettlement(ticket);
718
- })
719
- .finally(() => {
993
+ } finally {
720
994
  ticket.workersSettled = true;
721
- });
995
+ }
996
+ })();
722
997
  ticket.completion = completion;
723
998
 
724
999
  return {
@@ -728,6 +1003,7 @@ export function dispatchAsync(input: AsyncDispatchInput): DelegateToolResult {
728
1003
  text: [
729
1004
  `Async ticket: ${ticketId}`,
730
1005
  `${resolved.length} task(s) dispatched · ${runningCount + 1}/${maxAsyncTickets} async slots in use`,
1006
+ ...(serializedNotice ? [serializedNotice] : []),
731
1007
  ...(dispatchWarning ? [`WARNING: ${dispatchWarning}`] : []),
732
1008
  "",
733
1009
  "Work is detached. Stop this turn to let final results auto-deliver.",
@@ -745,6 +1021,7 @@ export function dispatchAsync(input: AsyncDispatchInput): DelegateToolResult {
745
1021
  status: ticket.status,
746
1022
  elapsedMs: Date.now() - ticket.created,
747
1023
  dispatchWarning,
1024
+ serializedNotice,
748
1025
  },
749
1026
  };
750
1027
  }
@@ -765,6 +1042,9 @@ export async function dispatchSync(
765
1042
  callSpan,
766
1043
  dispatchConfig,
767
1044
  dispatchWarning,
1045
+ serializedGroups,
1046
+ serializedNotice,
1047
+ runtime = getDefaultDelegateRuntime(),
768
1048
  } = input;
769
1049
 
770
1050
  const startedAt = Date.now();
@@ -807,6 +1087,7 @@ export async function dispatchSync(
807
1087
  callSpan?.telemetryConfig ?? getTelemetryConfig(dispatchConfig),
808
1088
  async: false,
809
1089
  config: dispatchConfig,
1090
+ runtime,
810
1091
  onProgress: (p, u) => {
811
1092
  updateProgressFromRun(p, u);
812
1093
  fire();
@@ -814,32 +1095,51 @@ export async function dispatchSync(
814
1095
  onStatusChange: () => fire(),
815
1096
  };
816
1097
 
817
- let results = await mapConcurrentByModel(
818
- executionResolved,
819
- (t) => getModelKey(t.model),
820
- (modelKey) => getConcurrencyLimit(modelKey, dispatchConfig),
821
- async (t, i) => runResolvedTask(syncEnv, t, progress[i]!, i),
822
- signal,
1098
+ const partialResults: (TaskResult | undefined)[] = new Array(
1099
+ executionResolved.length,
823
1100
  );
824
- if (isolatedBatch) {
825
- try {
826
- results = await isolatedBatch.reconcile(results);
827
- } catch (error) {
828
- const detail = error instanceof Error ? error.message : String(error);
829
- console.error("[delegate] isolated reconciliation failed", error);
830
- for (let index = 0; index < results.length; index++) {
831
- if (resolved[index]?.workspace !== "isolated") continue;
832
- results[index] = {
833
- ...results[index]!,
834
- integration: {
835
- status: "apply_failed",
836
- proposedFiles: [],
837
- appliedFiles: [],
838
- conflicts: [{ path: "(batch)", reason: detail }],
839
- },
840
- };
841
- }
842
- }
1101
+ let results: TaskResult[];
1102
+ let isolatedReconciled = false;
1103
+ const gate = buildSerializationGate(serializedGroups);
1104
+ try {
1105
+ results = await mapConcurrentByModel(
1106
+ executionResolved,
1107
+ (t) => getModelKey(t.model),
1108
+ (modelKey) => getConcurrencyLimit(modelKey, dispatchConfig),
1109
+ async (t, i) => {
1110
+ try {
1111
+ const result = await runResolvedTask(syncEnv, t, progress[i]!, i);
1112
+ partialResults[i] = result;
1113
+ return result;
1114
+ } finally {
1115
+ gate?.complete(i);
1116
+ }
1117
+ },
1118
+ signal,
1119
+ gate?.beforeAcquire,
1120
+ );
1121
+ } catch (error) {
1122
+ if (!isolatedBatch) throw error;
1123
+ results = completeUnexpectedResults(
1124
+ resolved,
1125
+ progress,
1126
+ partialResults,
1127
+ error,
1128
+ );
1129
+ results = await reconcileIsolatedResults(isolatedBatch, resolved, results, {
1130
+ shouldApplySource: () => false,
1131
+ retainedReason:
1132
+ "Batch execution failed before source application; completed proposals were retained for recovery.",
1133
+ });
1134
+ isolatedReconciled = true;
1135
+ }
1136
+ if (isolatedBatch && !isolatedReconciled) {
1137
+ results = await reconcileIsolatedResults(isolatedBatch, resolved, results, {
1138
+ shouldApplySource: () => !signal?.aborted,
1139
+ signal,
1140
+ retainedReason:
1141
+ "The parent cancelled before source application; the proposal was retained for recovery.",
1142
+ });
843
1143
  }
844
1144
 
845
1145
  // ── Format for LLM ────────────────────────────────────────────
@@ -847,10 +1147,11 @@ export async function dispatchSync(
847
1147
  const elapsedTotal = Date.now() - startedAt;
848
1148
 
849
1149
  const parts: string[] = [];
850
- const succeeded = finalResults.filter((r) => !r.error).length;
1150
+ const succeeded = finalResults.filter(taskCompletedSuccessfully).length;
851
1151
  parts.push(
852
1152
  `${succeeded}/${finalResults.length} tasks completed successfully · ${fmtDuration(elapsedTotal)} wall time\n`,
853
1153
  );
1154
+ if (serializedNotice) parts.push(serializedNotice);
854
1155
  if (dispatchWarning) parts.push(`WARNING: ${dispatchWarning}`);
855
1156
  for (let i = 0; i < finalResults.length; i++) {
856
1157
  const r = finalResults[i]!;
@@ -863,14 +1164,9 @@ export async function dispatchSync(
863
1164
  );
864
1165
  if (overlapWarning) parts.push("", overlapWarning);
865
1166
 
866
- const status = finalResults.some(
867
- (r) =>
868
- r.error ||
869
- r.integration?.status === "conflict" ||
870
- r.integration?.status === "apply_failed",
871
- )
872
- ? "failed"
873
- : "success";
1167
+ const status = finalResults.every(taskCompletedSuccessfully)
1168
+ ? "success"
1169
+ : "failed";
874
1170
  const totalTokens = finalResults.reduce((sum, r) => sum + r.tokens, 0);
875
1171
  const totalCost = finalResults.reduce(
876
1172
  (sum, r) => sum + r.usage.cost.total,
@@ -893,6 +1189,7 @@ export async function dispatchSync(
893
1189
  elapsedMs: elapsedTotal,
894
1190
  overlapWarning: overlapWarning || undefined,
895
1191
  dispatchWarning,
1192
+ serializedNotice,
896
1193
  },
897
1194
  // Aggregate subagent spend so Pi folds it into the parent's
898
1195
  // session/footer totals. Sync dispatch only — async results arrive via a