@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/dispatch.ts CHANGED
@@ -1,14 +1,7 @@
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";
4
+ import { PauseController } from "./pause.ts";
12
5
  import {
13
6
  getConcurrencyLimit,
14
7
  getMaxAsyncTickets,
@@ -18,7 +11,7 @@ import {
18
11
  import type { DelegateConfig } from "./config.ts";
19
12
  import { getCurrentLeafId } from "./leaf.ts";
20
13
  import { getModelKey, mapConcurrentByModel } from "./concurrency.ts";
21
- import { aggregateTaskResults, sumUsage } from "./usage.ts";
14
+ import { aggregateTaskResults, emptyUsage, sumUsage } from "./usage.ts";
22
15
  import { runResolvedTask, updateProgressFromRun } from "./lifecycle.ts";
23
16
  import {
24
17
  fmtDuration,
@@ -36,8 +29,13 @@ import {
36
29
  type SharedWriteConflict,
37
30
  } from "./shared-write-safety.ts";
38
31
  import type { CallSpan } from "./telemetry.ts";
39
- import { prepareIsolatedBatch } from "./isolated-workspace.ts";
32
+ import {
33
+ prepareIsolatedBatch,
34
+ type IsolatedReconcileOptions,
35
+ type PreparedIsolatedBatch,
36
+ } from "./isolated-workspace.ts";
40
37
  import { sanitizeTerminalLine } from "./utils.ts";
38
+ import { checkScratchWorkspaceSupport } from "./workspace.ts";
41
39
  import { quarantinedTasks } from "./session-quarantine.ts";
42
40
  import type {
43
41
  AgentConfig,
@@ -135,6 +133,7 @@ export function makeFireUpdater(
135
133
  resolved: ResolvedTask[],
136
134
  parentModelId: string | undefined,
137
135
  dispatchWarning?: string,
136
+ serializedNotice?: string,
138
137
  ): () => void {
139
138
  return () =>
140
139
  onUpdate?.({
@@ -150,6 +149,7 @@ export function makeFireUpdater(
150
149
  progress: [...progress],
151
150
  parentModel: parentModelId,
152
151
  dispatchWarning,
152
+ serializedNotice,
153
153
  },
154
154
  });
155
155
  }
@@ -165,6 +165,10 @@ export interface AsyncDispatchInput {
165
165
  callSpan?: CallSpan;
166
166
  dispatchConfig: DelegateConfig;
167
167
  dispatchWarning?: string;
168
+ /** Same-call shared-writer groups admission serialized into task order. */
169
+ serializedGroups?: SharedWriteConflict[];
170
+ serializedNotice?: string;
171
+ runtime?: DelegateRuntime;
168
172
  }
169
173
 
170
174
  /** Inputs needed by the sync (blocking) dispatch path. */
@@ -179,6 +183,10 @@ export interface SyncDispatchInput {
179
183
  callSpan?: CallSpan;
180
184
  dispatchConfig: DelegateConfig;
181
185
  dispatchWarning?: string;
186
+ /** Same-call shared-writer groups admission serialized into task order. */
187
+ serializedGroups?: SharedWriteConflict[];
188
+ serializedNotice?: string;
189
+ runtime?: DelegateRuntime;
182
190
  }
183
191
 
184
192
  /** Inputs for the normal task-validation, resolution, and dispatch path. */
@@ -192,6 +200,7 @@ export interface DelegateDispatchInput {
192
200
  signal: AbortSignal | undefined;
193
201
  onUpdate: AgentToolUpdateCallback<DelegateDetails> | undefined;
194
202
  callSpan?: CallSpan;
203
+ runtime?: DelegateRuntime;
195
204
  }
196
205
 
197
206
  function taskReference(task: DispatchableTask, index: number): string {
@@ -207,12 +216,42 @@ function asAdmissionWriter(task: ResolvedTask): ResolvedTask {
207
216
  : task;
208
217
  }
209
218
 
210
- function sharedWriteRejection(
219
+ const SCRATCH_VIABILITY_TIMEOUT_MS = 5_000;
220
+
221
+ /** Verify scratch viability for the given task cwds before a rejection message
222
+ * recommends it. Resolves with the clause text to append: a recommendation
223
+ * when every cwd passes the same read-only pre-flight scratch setup runs,
224
+ * otherwise a short unavailability note carrying the blocking reason.
225
+ * Uncertainty (timeout, unexpected error) fails closed — no recommendation. */
226
+ async function scratchRecommendation(cwds: readonly string[]): Promise<string> {
227
+ if (!cwds.length) return "";
228
+ try {
229
+ await Promise.all(
230
+ cwds.map((cwd) =>
231
+ checkScratchWorkspaceSupport(
232
+ cwd,
233
+ AbortSignal.timeout(SCRATCH_VIABILITY_TIMEOUT_MS),
234
+ ),
235
+ ),
236
+ );
237
+ return ', or use workspace: "scratch" when changes may be discarded';
238
+ } catch (error) {
239
+ const reason = error instanceof Error ? error.message : String(error);
240
+ return ` workspace: "scratch" is unavailable in this checkout (${reason})`;
241
+ }
242
+ }
243
+
244
+ /** Compose the shared-write rejection after the admission lock has released:
245
+ * the scratch clause is verified against scratch's own pre-flight, so the
246
+ * message never vouches for a mode this checkout cannot run (linked Git
247
+ * worktrees, nested repositories, non-Linux hosts). */
248
+ async function sharedWriteRejection(
211
249
  tasks: DispatchableTask[],
212
250
  parentModelId: string | undefined,
213
251
  conflicts: SharedWriteConflict[],
214
- references: readonly string[] = tasks.map(taskReference),
215
- ): DelegateToolResult {
252
+ references: readonly string[],
253
+ cwds: readonly string[],
254
+ ): Promise<DelegateToolResult> {
216
255
  const scopes = conflicts
217
256
  .map(({ scope, taskIndexes }) => {
218
257
  const refs = taskIndexes
@@ -221,6 +260,7 @@ function sharedWriteRejection(
221
260
  return `${refs} share ${scope.kind === "git" ? "Git root" : "directory"} '${scope.root}'.`;
222
261
  })
223
262
  .join(" ");
263
+ const scratch = await scratchRecommendation(cwds);
224
264
  return {
225
265
  content: [
226
266
  {
@@ -228,7 +268,7 @@ function sharedWriteRejection(
228
268
  text:
229
269
  `Rejected before dispatch; no tasks were started. ${scopes} ` +
230
270
  "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. ' +
271
+ `Run them sequentially, use workspace: "isolated" for Git-backed ordered reconciliation${scratch}. ` +
232
272
  "External processes remain outside this check.",
233
273
  },
234
274
  ],
@@ -236,26 +276,69 @@ function sharedWriteRejection(
236
276
  };
237
277
  }
238
278
 
239
- function sharedWriteSafetyFailure(
279
+ async function sharedWriteSafetyFailure(
240
280
  tasks: DispatchableTask[],
241
281
  parentModelId: string | undefined,
242
282
  error: unknown,
243
- ): DelegateToolResult {
283
+ cwds: readonly string[],
284
+ ): Promise<DelegateToolResult> {
244
285
  const detail =
245
286
  error instanceof Error ? error.message : "Unknown workspace error.";
287
+ const scratch = await scratchRecommendation(cwds);
246
288
  return {
247
289
  content: [
248
290
  {
249
291
  type: "text",
250
292
  text:
251
293
  `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.',
294
+ `Fix the task directory or Git metadata, run tasks sequentially, use workspace: "isolated" for Git-backed ordered reconciliation${scratch}.`,
253
295
  },
254
296
  ],
255
297
  details: { tasks, results: [], progress: [], parentModel: parentModelId },
256
298
  };
257
299
  }
258
300
 
301
+ /** Build the predecessor gate that runs serialized task chains one at a time,
302
+ * in task order. Each chained successor awaits its predecessor's completion
303
+ * before acquiring a global slot; `complete` must be called in every task's
304
+ * `finally` so throw, abort, and queued-abort paths all unblock successors. */
305
+ function buildSerializationGate(
306
+ groups: readonly SharedWriteConflict[] | undefined,
307
+ progress: TaskProgress[],
308
+ ):
309
+ | {
310
+ beforeAcquire: (index: number) => Promise<void>;
311
+ complete: (index: number) => void;
312
+ }
313
+ | undefined {
314
+ if (!groups?.length) return undefined;
315
+ const predecessor = new Map<number, number>();
316
+ for (const { taskIndexes } of groups) {
317
+ for (let position = 1; position < taskIndexes.length; position++) {
318
+ predecessor.set(taskIndexes[position]!, taskIndexes[position - 1]!);
319
+ const row = progress[taskIndexes[position]!];
320
+ if (row) row.waitingFor = taskIndexes[position - 1]!;
321
+ }
322
+ }
323
+ if (predecessor.size === 0) return undefined;
324
+ const settled = new Map<number, Promise<void>>();
325
+ const resolvers = new Map<number, () => void>();
326
+ for (const index of new Set(predecessor.values())) {
327
+ let resolve!: () => void;
328
+ settled.set(index, new Promise<void>((r) => (resolve = r)));
329
+ resolvers.set(index, resolve);
330
+ }
331
+ return {
332
+ beforeAcquire: async (index) => {
333
+ const predecessorIndex = predecessor.get(index);
334
+ if (predecessorIndex !== undefined) await settled.get(predecessorIndex);
335
+ const row = progress[index];
336
+ if (row) row.waitingFor = undefined;
337
+ },
338
+ complete: (index) => resolvers.get(index)?.(),
339
+ };
340
+ }
341
+
259
342
  /** Validate, resolve, and dispatch a non-short-circuit delegate operation. */
260
343
  export async function dispatchDelegate(
261
344
  input: DelegateDispatchInput,
@@ -276,10 +359,11 @@ export async function dispatchDelegate(
276
359
  signal,
277
360
  onUpdate,
278
361
  callSpan,
362
+ runtime = getDefaultDelegateRuntime(),
279
363
  } = input;
280
364
  const tasks = params.tasks ?? [];
281
365
 
282
- const validationError = validateTasks(tasks, agents, parentModelId);
366
+ const validationError = validateTasks(tasks, agents, parentModelId, runtime);
283
367
  if (validationError) {
284
368
  callSpan?.finish({
285
369
  status: "failed",
@@ -296,6 +380,7 @@ export async function dispatchDelegate(
296
380
  agents,
297
381
  parentDefaults,
298
382
  dispatchConfig,
383
+ runtime,
299
384
  );
300
385
  if (resolveResult.error !== undefined) {
301
386
  callSpan?.finish({
@@ -310,36 +395,28 @@ export async function dispatchDelegate(
310
395
  };
311
396
  }
312
397
  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
398
 
336
- const dispatchWarning = dispatchConfig.allowUnsafeSharedWrites
399
+ // Mutated inside the admission lock: unsafe mode's standing warning, and the
400
+ // serialization notice built when same-call shared writers are ordered into
401
+ // task order instead of rejected. Mutually exclusive by construction —
402
+ // unsafe mode skips admission entirely.
403
+ let dispatchWarning = dispatchConfig.allowUnsafeSharedWrites
337
404
  ? UNSAFE_SHARED_WRITES_WARNING
338
405
  : undefined;
406
+ let serializedNotice: string | undefined;
407
+ let serializedGroups: SharedWriteConflict[] | undefined;
339
408
  const signalWasAbortedBeforeAdmission = signal?.aborted === true;
340
409
  let syncReservation: symbol | undefined;
341
410
  let admissionResult:
342
- DelegateToolResult | { progress: TaskProgress[]; fire: () => void };
411
+ | DelegateToolResult
412
+ | { progress: TaskProgress[]; fire: () => void }
413
+ | {
414
+ rejected: {
415
+ conflicts: SharedWriteConflict[];
416
+ references: string[];
417
+ cwds: string[];
418
+ };
419
+ };
343
420
 
344
421
  try {
345
422
  admissionResult = await withSharedWriteAdmissionLock(async () => {
@@ -360,7 +437,7 @@ export async function dispatchDelegate(
360
437
  taskReference(tasks[index]!, index),
361
438
  );
362
439
 
363
- for (const ticket of ticketRegistry.values()) {
440
+ for (const ticket of runtime.tickets.values()) {
364
441
  if (
365
442
  ticket.status !== "running" &&
366
443
  ticket.status !== "cancelling" &&
@@ -391,29 +468,66 @@ export async function dispatchDelegate(
391
468
  }
392
469
 
393
470
  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
- );
471
+ const rejectConflicts: SharedWriteConflict[] = [];
472
+ const serializedConflicts: SharedWriteConflict[] = [];
473
+ const rejectedCwds = new Set<string>();
474
+ for (const conflict of await findSharedWriteConflicts(
475
+ [...incomingForSafety, ...activeResolved],
476
+ signal,
477
+ )) {
478
+ const { taskIndexes } = conflict;
479
+ if (!taskIndexes.some((index) => index < incomingCount)) continue;
480
+ if (taskIndexes.every((index) => index < incomingCount)) {
481
+ const workspaces = new Set(
482
+ taskIndexes.map((index) => resolved[index]?.workspace),
483
+ );
484
+ if (workspaces.has("isolated")) {
485
+ if (workspaces.size === 1) {
486
+ // Multiple isolated tasks in this call intentionally share
487
+ // one baseline/root; no shared writer is involved.
488
+ continue;
489
+ }
490
+ // Mixed isolated/shared in this call: a shared writer cannot be
491
+ // ordered against the isolated reservation window (baseline
492
+ // snapshot through reconciliation).
493
+ rejectConflicts.push(conflict);
494
+ } else {
495
+ // Same-call shared writers: serialize in task order instead of
496
+ // rejecting. Concurrent dispatch is unordered anyway, so
497
+ // serial-in-dispatch-order refines the same contract.
498
+ serializedConflicts.push(conflict);
499
+ }
500
+ } else {
501
+ // An active writer (async ticket, running sync dispatch, or
502
+ // quarantined task) is involved: queueing behind unknown-duration
503
+ // work cannot be ordered safely here.
504
+ rejectConflicts.push(conflict);
505
+ }
506
+ for (const index of taskIndexes) {
507
+ if (index < incomingCount) rejectedCwds.add(resolved[index]!.cwd);
508
+ }
509
+ }
510
+ if (rejectConflicts.length) {
511
+ return {
512
+ rejected: {
513
+ conflicts: rejectConflicts,
514
+ references,
515
+ cwds: [...rejectedCwds],
516
+ },
517
+ };
518
+ }
519
+ if (serializedConflicts.length) {
520
+ serializedGroups = serializedConflicts;
521
+ serializedNotice = `Serialized: ${serializedConflicts
522
+ .map(
523
+ ({ scope, taskIndexes }) =>
524
+ `${taskIndexes
525
+ .map((index) => references[index] ?? `Task ${index + 1}`)
526
+ .join(", ")} share ${
527
+ scope.kind === "git" ? "Git root" : "directory"
528
+ } '${scope.root}' — running one at a time in task order.`,
529
+ )
530
+ .join(" ")}`;
417
531
  }
418
532
  }
419
533
 
@@ -425,6 +539,7 @@ export async function dispatchDelegate(
425
539
  resolved,
426
540
  parentModelId,
427
541
  dispatchWarning,
542
+ serializedNotice,
428
543
  );
429
544
  fire();
430
545
 
@@ -439,6 +554,9 @@ export async function dispatchDelegate(
439
554
  callSpan,
440
555
  dispatchConfig,
441
556
  dispatchWarning,
557
+ serializedGroups,
558
+ serializedNotice,
559
+ runtime,
442
560
  });
443
561
  }
444
562
 
@@ -483,18 +601,29 @@ export async function dispatchDelegate(
483
601
  totalCost: 0,
484
602
  wallMs: Date.now() - callSpan.startedAt,
485
603
  });
486
- return sharedWriteSafetyFailure(tasks, parentModelId, error);
604
+ return await sharedWriteSafetyFailure(tasks, parentModelId, error, [
605
+ ...new Set(resolved.map((task) => task.cwd)),
606
+ ]);
607
+ }
608
+
609
+ if ("rejected" in admissionResult) {
610
+ callSpan?.finish({
611
+ status: "failed",
612
+ totalTokens: 0,
613
+ totalCost: 0,
614
+ wallMs: Date.now() - (callSpan?.startedAt ?? Date.now()),
615
+ });
616
+ const { conflicts, references, cwds } = admissionResult.rejected;
617
+ return await sharedWriteRejection(
618
+ tasks,
619
+ parentModelId,
620
+ conflicts,
621
+ references,
622
+ cwds,
623
+ );
487
624
  }
488
625
 
489
626
  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
627
  return admissionResult;
499
628
  }
500
629
 
@@ -510,6 +639,9 @@ export async function dispatchDelegate(
510
639
  callSpan,
511
640
  dispatchConfig,
512
641
  dispatchWarning,
642
+ serializedGroups,
643
+ serializedNotice,
644
+ runtime,
513
645
  });
514
646
  } finally {
515
647
  if (syncReservation) activeSyncDispatches.delete(syncReservation);
@@ -519,8 +651,12 @@ export async function dispatchDelegate(
519
651
  /** Deliver a settled ticket and, when leaf affinity downgraded delivery to a
520
652
  * non-waking `nextTurn` message, tell the human — otherwise the completion is
521
653
  * silent apart from the footer clearing. */
522
- function finishTicketDelivery(pi: ExtensionAPI, ticket: AsyncTicket): void {
523
- if (deliverTicketResults(pi, ticket) === "deferred") {
654
+ function finishTicketDelivery(
655
+ pi: ExtensionAPI,
656
+ ticket: AsyncTicket,
657
+ runtime: DelegateRuntime,
658
+ ): void {
659
+ if (runtime.tickets.deliverTicketResults(pi, ticket) === "deferred") {
524
660
  notifyCrossLeafDelivery(ticket);
525
661
  }
526
662
  }
@@ -541,6 +677,88 @@ function settleAsyncCall(
541
677
  callSpan.finish({ status, totalTokens, totalCost, wallMs });
542
678
  }
543
679
 
680
+ function taskCompletedSuccessfully(result: TaskResult): boolean {
681
+ return (
682
+ !result.error &&
683
+ result.integration?.status !== "retained" &&
684
+ result.integration?.status !== "conflict" &&
685
+ result.integration?.status !== "apply_failed"
686
+ );
687
+ }
688
+
689
+ function completeUnexpectedResults(
690
+ resolved: readonly ResolvedTask[],
691
+ progress: TaskProgress[],
692
+ current: readonly (TaskResult | undefined)[],
693
+ error: unknown,
694
+ ): TaskResult[] {
695
+ const reason = error instanceof Error ? error.message : String(error);
696
+ return resolved.map((task, index) => {
697
+ const result = current[index];
698
+ if (result) return result;
699
+ const p = progress[index]!;
700
+ p.status = "failed";
701
+ p.error = reason;
702
+ return {
703
+ id: task.id,
704
+ agent: task.agentName,
705
+ resumedFrom: task.resumeFromDisplay,
706
+ output: "",
707
+ error: reason,
708
+ durationMs: p.durationMs,
709
+ tokens: 0,
710
+ usage: emptyUsage(),
711
+ workspace: task.workspace,
712
+ touchedFiles: [],
713
+ attributedFiles: [],
714
+ };
715
+ });
716
+ }
717
+
718
+ async function reconcileIsolatedResults(
719
+ batch: PreparedIsolatedBatch,
720
+ resolved: readonly ResolvedTask[],
721
+ results: TaskResult[],
722
+ options?: IsolatedReconcileOptions,
723
+ ): Promise<TaskResult[]> {
724
+ try {
725
+ return await batch.reconcile(results, options);
726
+ } catch (error) {
727
+ const detail = error instanceof Error ? error.message : String(error);
728
+ console.error("[delegate] isolated reconciliation failed", error);
729
+ for (let index = 0; index < results.length; index++) {
730
+ if (resolved[index]?.workspace !== "isolated") continue;
731
+ const existing = results[index]?.integration;
732
+ results[index] = {
733
+ ...results[index]!,
734
+ integration: {
735
+ status: "apply_failed",
736
+ proposedFiles: existing?.proposedFiles ?? [],
737
+ appliedFiles: [],
738
+ conflicts: [
739
+ ...(existing?.conflicts ?? []),
740
+ { path: "(batch)", reason: detail },
741
+ ],
742
+ ...(existing?.baselineRef
743
+ ? { baselineRef: existing.baselineRef }
744
+ : {}),
745
+ ...(existing?.proposalRef
746
+ ? { proposalRef: existing.proposalRef }
747
+ : {}),
748
+ ...(existing?.patchPath ? { patchPath: existing.patchPath } : {}),
749
+ ...(existing?.worktreePath
750
+ ? { worktreePath: existing.worktreePath }
751
+ : {}),
752
+ ...(existing?.cleanupIssue
753
+ ? { cleanupIssue: existing.cleanupIssue }
754
+ : {}),
755
+ },
756
+ };
757
+ }
758
+ return results;
759
+ }
760
+ }
761
+
544
762
  /** Fire-and-forget background execution. Registers an `AsyncTicket`, kicks off
545
763
  * the concurrent run, and returns the ticket acknowledgment immediately.
546
764
  * Results are delivered via `deliverTicketResults` when all tasks settle. */
@@ -555,10 +773,13 @@ export function dispatchAsync(input: AsyncDispatchInput): DelegateToolResult {
555
773
  callSpan,
556
774
  dispatchConfig,
557
775
  dispatchWarning,
776
+ serializedGroups,
777
+ serializedNotice,
778
+ runtime = getDefaultDelegateRuntime(),
558
779
  } = input;
559
780
 
560
- sweepTickets();
561
- const runningCount = [...ticketRegistry.values()].filter(
781
+ runtime.tickets.sweepTickets();
782
+ const runningCount = [...runtime.tickets.values()].filter(
562
783
  (t) => t.status === "running" || t.status === "cancelling",
563
784
  ).length;
564
785
  const maxAsyncTickets = getMaxAsyncTickets(dispatchConfig);
@@ -580,7 +801,7 @@ export function dispatchAsync(input: AsyncDispatchInput): DelegateToolResult {
580
801
  };
581
802
  }
582
803
 
583
- const ticketId = generateTicketId();
804
+ const ticketId = runtime.tickets.generateTicketId();
584
805
  const controller = new AbortController();
585
806
  const ticket: AsyncTicket = {
586
807
  id: ticketId,
@@ -602,17 +823,33 @@ export function dispatchAsync(input: AsyncDispatchInput): DelegateToolResult {
602
823
  telemetryConfig: callSpan?.telemetryConfig,
603
824
  workersSettled: false,
604
825
  dispatchWarning,
826
+ serializedNotice,
605
827
  // Capture the dispatch-scoped snapshot so async workers and later poll/wait
606
828
  // formatting use the same retry/stall/output/provider settings that were
607
829
  // in effect when the ticket was spawned.
608
830
  config: dispatchConfig,
609
831
  };
610
- ticketRegistry.set(ticketId, ticket);
832
+ runtime.tickets.set(ticketId, ticket);
833
+ let lastPauseState = "running";
834
+ const pause = new PauseController(() => {
835
+ if (pause.state !== lastPauseState) {
836
+ lastPauseState = pause.state;
837
+ if (ticket.status === "running")
838
+ console.info(`[delegate] ticket '${ticketId}' ${lastPauseState}`);
839
+ }
840
+ for (const p of ticket.progress) p.paused = pause.isParked(p.index);
841
+ runtime.tickets.notifyWaiters(ticket);
842
+ syncDelegateStatus(undefined, runtime);
843
+ });
844
+ ticket.pause = pause;
845
+ // Preparation/source integration are operations too: a pause request must
846
+ // not claim quiet while either is mutating a workspace.
847
+ pause.enter(-1);
611
848
  callSpan?.spawn();
612
849
  // Footer visibility for the new background work (see status.ts). Uses the
613
850
  // ctx cached from the dispatch path in extension.ts — DelegateToolCtx is
614
851
  // the intentionally narrowed surface and does not carry `ui`.
615
- syncDelegateStatus();
852
+ syncDelegateStatus(undefined, runtime);
616
853
 
617
854
  // Capture values for the closure — do NOT use `signal` from execute()
618
855
  // The parent turn's signal dies when execute() returns.
@@ -620,6 +857,7 @@ export function dispatchAsync(input: AsyncDispatchInput): DelegateToolResult {
620
857
  const modelRegistry = ctx.modelRegistry;
621
858
 
622
859
  const asyncEnv: TaskRunEnv = {
860
+ pause,
623
861
  signal: ticketSignal,
624
862
  modelRegistry,
625
863
  ticketId,
@@ -630,16 +868,17 @@ export function dispatchAsync(input: AsyncDispatchInput): DelegateToolResult {
630
868
  callSpan?.telemetryConfig ?? getTelemetryConfig(dispatchConfig),
631
869
  async: true,
632
870
  config: dispatchConfig,
871
+ runtime,
633
872
  onProgress: (p, u) => {
634
873
  updateProgressFromRun(p, u);
635
- notifyWaiters(ticket);
874
+ runtime.tickets.notifyWaiters(ticket);
636
875
  // Live subagent counts in the footer. Deduped by text, so only
637
876
  // running/pending count transitions trigger a render.
638
- syncDelegateStatus();
877
+ syncDelegateStatus(undefined, runtime);
639
878
  },
640
879
  onStatusChange: () => {
641
- notifyWaiters(ticket);
642
- syncDelegateStatus();
880
+ runtime.tickets.notifyWaiters(ticket);
881
+ syncDelegateStatus(undefined, runtime);
643
882
  },
644
883
  };
645
884
 
@@ -652,27 +891,118 @@ export function dispatchAsync(input: AsyncDispatchInput): DelegateToolResult {
652
891
  // In particular, result delivery is allowed to fail without re-entering the
653
892
  // worker completion path; the terminal ticket remains available to poll.
654
893
  const finishLiveSettlement = (t: AsyncTicket): void => {
655
- syncDelegateStatus();
894
+ syncDelegateStatus(undefined, runtime);
656
895
  settleAsyncCall(t, callSpan);
657
- finishTicketDelivery(pi, t);
896
+ finishTicketDelivery(pi, t, runtime);
658
897
  };
659
898
 
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.
899
+ const completion = (async () => {
900
+ try {
901
+ let executionResolved = resolved;
902
+ let isolatedBatch: PreparedIsolatedBatch | undefined;
903
+ try {
904
+ await pause.checkpoint(-1, ticketSignal);
905
+ isolatedBatch = await prepareIsolatedBatch(resolved, ticketSignal);
906
+ if (isolatedBatch) executionResolved = isolatedBatch.resolved;
907
+ } catch (error) {
908
+ const detail = error instanceof Error ? error.message : String(error);
909
+ throw new Error(
910
+ `Isolated workspace setup failed; no subagents were started. ${detail}`,
911
+ { cause: error },
912
+ );
913
+ } finally {
914
+ pause.leave(-1);
915
+ }
916
+
917
+ let results: TaskResult[];
918
+ const gate = buildSerializationGate(serializedGroups, ticket.progress);
919
+ try {
920
+ results = await mapConcurrentByModel(
921
+ executionResolved,
922
+ (t) => getModelKey(t.model),
923
+ (modelKey) => getConcurrencyLimit(modelKey, dispatchConfig),
924
+ async (t, i) => {
925
+ pause.enter(i);
926
+ try {
927
+ await pause.checkpoint(i, ticketSignal);
928
+ const result = await runResolvedTask(
929
+ asyncEnv,
930
+ t,
931
+ ticket.progress[i]!,
932
+ i,
933
+ );
934
+ const group = serializedGroups?.findIndex((group) =>
935
+ group.taskIndexes.includes(i),
936
+ );
937
+ if (group !== undefined && group >= 0)
938
+ result.serializedGroup = group;
939
+ ticket.results[i] = result;
940
+ return result;
941
+ } finally {
942
+ pause.leave(i);
943
+ gate?.complete(i);
944
+ }
945
+ },
946
+ ticketSignal,
947
+ async (i) => {
948
+ await gate?.beforeAcquire(i);
949
+ await pause.checkpoint(i, ticketSignal);
950
+ },
951
+ );
952
+ } catch (error) {
953
+ results = completeUnexpectedResults(
954
+ resolved,
955
+ ticket.progress,
956
+ ticket.results,
957
+ error,
958
+ );
959
+ if (isolatedBatch) {
960
+ pause.enter(-1);
961
+ try {
962
+ await pause.checkpoint(-1, ticketSignal);
963
+ results = await reconcileIsolatedResults(
964
+ isolatedBatch,
965
+ resolved,
966
+ results,
967
+ {
968
+ shouldApplySource: () => false,
969
+ retainedReason:
970
+ "Batch execution failed before source application; completed proposals were retained for recovery.",
971
+ },
972
+ );
973
+ } finally {
974
+ pause.leave(-1);
975
+ }
976
+ }
977
+ ticket.results = [...results];
978
+ throw error;
979
+ }
980
+ if (isolatedBatch) {
981
+ pause.enter(-1);
982
+ try {
983
+ await pause.checkpoint(-1, ticketSignal);
984
+ results = await reconcileIsolatedResults(
985
+ isolatedBatch,
986
+ resolved,
987
+ results,
988
+ {
989
+ shouldApplySource: () =>
990
+ ticket.status === "running" && !ticketSignal.aborted,
991
+ signal: ticketSignal,
992
+ retainedReason:
993
+ "The async ticket was cancelled before source application; the proposal was retained for recovery.",
994
+ },
995
+ );
996
+ } finally {
997
+ pause.leave(-1);
998
+ }
999
+ ticket.results = [...results];
1000
+ }
1001
+
1002
+ // Worker execution and isolated reconciliation are complete. Publish that
1003
+ // before terminal formatting/delivery so the final spill projection is
1004
+ // safely memoized; shutdown may already have formatted an intentionally
1005
+ // uncached partial snapshot while this flag was false.
676
1006
  ticket.workersSettled = true;
677
1007
  // Shutdown marks the ticket terminal before cooperative worker aborts
678
1008
  // have finished. Still write one final aggregate after every result has
@@ -682,43 +1012,36 @@ export function dispatchAsync(input: AsyncDispatchInput): DelegateToolResult {
682
1012
  settleAsyncCall(ticket, callSpan);
683
1013
  return;
684
1014
  }
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, {
1015
+ // Use progress (set by runResolvedTask) for settled-ness so a partial
1016
+ // ticket can never be marked done. A cancelling ticket reports cancelled
1017
+ // even when some workers completed before the abort.
1018
+ runtime.tickets.settleTicket(ticket, {
696
1019
  status:
697
1020
  ticket.status === "running"
698
- ? resolveFinalTicketStatus(ticket)
1021
+ ? runtime.tickets.resolveFinalTicketStatus(ticket)
699
1022
  : "cancelled",
700
1023
  });
701
1024
  finishLiveSettlement(ticket);
702
- })
703
- .catch((err) => {
704
- // mapConcurrentByModel waits for all sibling workers before rejecting.
1025
+ } catch (err) {
1026
+ // Preparation and reconciliation join the same terminal path as workers:
1027
+ // any unexpected rejection must leave the ticket pollable and settled.
705
1028
  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
1029
  if (ticket.status === "cancelled") {
710
1030
  settleAsyncCall(ticket, callSpan);
711
1031
  return;
712
1032
  }
713
- settleTicket(ticket, {
714
- status: ticket.status === "cancelling" ? "cancelled" : "failed",
715
- error: err instanceof Error ? err.message : String(err),
1033
+ const cancelling = ticket.status === "cancelling";
1034
+ runtime.tickets.settleTicket(ticket, {
1035
+ status: cancelling ? "cancelled" : "failed",
1036
+ ...(cancelling
1037
+ ? {}
1038
+ : { error: err instanceof Error ? err.message : String(err) }),
716
1039
  });
717
1040
  finishLiveSettlement(ticket);
718
- })
719
- .finally(() => {
1041
+ } finally {
720
1042
  ticket.workersSettled = true;
721
- });
1043
+ }
1044
+ })();
722
1045
  ticket.completion = completion;
723
1046
 
724
1047
  return {
@@ -728,6 +1051,7 @@ export function dispatchAsync(input: AsyncDispatchInput): DelegateToolResult {
728
1051
  text: [
729
1052
  `Async ticket: ${ticketId}`,
730
1053
  `${resolved.length} task(s) dispatched · ${runningCount + 1}/${maxAsyncTickets} async slots in use`,
1054
+ ...(serializedNotice ? [serializedNotice] : []),
731
1055
  ...(dispatchWarning ? [`WARNING: ${dispatchWarning}`] : []),
732
1056
  "",
733
1057
  "Work is detached. Stop this turn to let final results auto-deliver.",
@@ -745,6 +1069,7 @@ export function dispatchAsync(input: AsyncDispatchInput): DelegateToolResult {
745
1069
  status: ticket.status,
746
1070
  elapsedMs: Date.now() - ticket.created,
747
1071
  dispatchWarning,
1072
+ serializedNotice,
748
1073
  },
749
1074
  };
750
1075
  }
@@ -765,6 +1090,9 @@ export async function dispatchSync(
765
1090
  callSpan,
766
1091
  dispatchConfig,
767
1092
  dispatchWarning,
1093
+ serializedGroups,
1094
+ serializedNotice,
1095
+ runtime = getDefaultDelegateRuntime(),
768
1096
  } = input;
769
1097
 
770
1098
  const startedAt = Date.now();
@@ -807,6 +1135,7 @@ export async function dispatchSync(
807
1135
  callSpan?.telemetryConfig ?? getTelemetryConfig(dispatchConfig),
808
1136
  async: false,
809
1137
  config: dispatchConfig,
1138
+ runtime,
810
1139
  onProgress: (p, u) => {
811
1140
  updateProgressFromRun(p, u);
812
1141
  fire();
@@ -814,32 +1143,55 @@ export async function dispatchSync(
814
1143
  onStatusChange: () => fire(),
815
1144
  };
816
1145
 
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,
1146
+ const partialResults: (TaskResult | undefined)[] = new Array(
1147
+ executionResolved.length,
823
1148
  );
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
- }
1149
+ let results: TaskResult[];
1150
+ let isolatedReconciled = false;
1151
+ const gate = buildSerializationGate(serializedGroups, progress);
1152
+ try {
1153
+ results = await mapConcurrentByModel(
1154
+ executionResolved,
1155
+ (t) => getModelKey(t.model),
1156
+ (modelKey) => getConcurrencyLimit(modelKey, dispatchConfig),
1157
+ async (t, i) => {
1158
+ try {
1159
+ const result = await runResolvedTask(syncEnv, t, progress[i]!, i);
1160
+ const group = serializedGroups?.findIndex((group) =>
1161
+ group.taskIndexes.includes(i),
1162
+ );
1163
+ if (group !== undefined && group >= 0) result.serializedGroup = group;
1164
+ partialResults[i] = result;
1165
+ return result;
1166
+ } finally {
1167
+ gate?.complete(i);
1168
+ }
1169
+ },
1170
+ signal,
1171
+ gate?.beforeAcquire,
1172
+ );
1173
+ } catch (error) {
1174
+ if (!isolatedBatch) throw error;
1175
+ results = completeUnexpectedResults(
1176
+ resolved,
1177
+ progress,
1178
+ partialResults,
1179
+ error,
1180
+ );
1181
+ results = await reconcileIsolatedResults(isolatedBatch, resolved, results, {
1182
+ shouldApplySource: () => false,
1183
+ retainedReason:
1184
+ "Batch execution failed before source application; completed proposals were retained for recovery.",
1185
+ });
1186
+ isolatedReconciled = true;
1187
+ }
1188
+ if (isolatedBatch && !isolatedReconciled) {
1189
+ results = await reconcileIsolatedResults(isolatedBatch, resolved, results, {
1190
+ shouldApplySource: () => !signal?.aborted,
1191
+ signal,
1192
+ retainedReason:
1193
+ "The parent cancelled before source application; the proposal was retained for recovery.",
1194
+ });
843
1195
  }
844
1196
 
845
1197
  // ── Format for LLM ────────────────────────────────────────────
@@ -847,10 +1199,11 @@ export async function dispatchSync(
847
1199
  const elapsedTotal = Date.now() - startedAt;
848
1200
 
849
1201
  const parts: string[] = [];
850
- const succeeded = finalResults.filter((r) => !r.error).length;
1202
+ const succeeded = finalResults.filter(taskCompletedSuccessfully).length;
851
1203
  parts.push(
852
1204
  `${succeeded}/${finalResults.length} tasks completed successfully · ${fmtDuration(elapsedTotal)} wall time\n`,
853
1205
  );
1206
+ if (serializedNotice) parts.push(serializedNotice);
854
1207
  if (dispatchWarning) parts.push(`WARNING: ${dispatchWarning}`);
855
1208
  for (let i = 0; i < finalResults.length; i++) {
856
1209
  const r = finalResults[i]!;
@@ -863,14 +1216,9 @@ export async function dispatchSync(
863
1216
  );
864
1217
  if (overlapWarning) parts.push("", overlapWarning);
865
1218
 
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";
1219
+ const status = finalResults.every(taskCompletedSuccessfully)
1220
+ ? "success"
1221
+ : "failed";
874
1222
  const totalTokens = finalResults.reduce((sum, r) => sum + r.tokens, 0);
875
1223
  const totalCost = finalResults.reduce(
876
1224
  (sum, r) => sum + r.usage.cost.total,
@@ -893,6 +1241,7 @@ export async function dispatchSync(
893
1241
  elapsedMs: elapsedTotal,
894
1242
  overlapWarning: overlapWarning || undefined,
895
1243
  dispatchWarning,
1244
+ serializedNotice,
896
1245
  },
897
1246
  // Aggregate subagent spend so Pi folds it into the parent's
898
1247
  // session/footer totals. Sync dispatch only — async results arrive via a