@bermudi/pi-delegate 0.1.10 → 0.1.12

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
@@ -6,10 +6,16 @@ import {
6
6
  deliverTicketResults,
7
7
  sweepTickets,
8
8
  resolveFinalTicketStatus,
9
- syncTicketBusyIndex,
9
+ settleTicket,
10
10
  notifyWaiters,
11
11
  } from "./tickets.ts";
12
- import { getConcurrencyLimit, getMaxAsyncTickets } from "./config.ts";
12
+ import {
13
+ getConcurrencyLimit,
14
+ getMaxAsyncTickets,
15
+ getDelegateConfigSnapshot,
16
+ getTelemetryConfig,
17
+ } from "./config.ts";
18
+ import type { DelegateConfig } from "./config.ts";
13
19
  import { getCurrentLeafId } from "./leaf.ts";
14
20
  import { getModelKey, mapConcurrentByModel } from "./concurrency.ts";
15
21
  import { aggregateTaskResults, sumUsage } from "./usage.ts";
@@ -24,8 +30,13 @@ import {
24
30
  import { validateDelegateOperation } from "./schema.ts";
25
31
  import { notifyCrossLeafDelivery, syncDelegateStatus } from "./status.ts";
26
32
  import { validateTasks, resolveTasks } from "./task-resolution.ts";
27
- import { clearDelegateSettingsCache } from "./settings.ts";
33
+ import {
34
+ findSharedWriteConflicts,
35
+ isSharedWriter,
36
+ type SharedWriteConflict,
37
+ } from "./shared-write-safety.ts";
28
38
  import type { CallSpan } from "./telemetry.ts";
39
+ import { prepareIsolatedBatch } from "./isolated-workspace.ts";
29
40
  import type {
30
41
  AgentConfig,
31
42
  AsyncTicket,
@@ -41,6 +52,36 @@ import type {
41
52
  TaskRunEnv,
42
53
  } from "./types.ts";
43
54
 
55
+ const UNSAFE_SHARED_WRITES_WARNING =
56
+ "UNSAFE SHARED WRITES ENABLED: shared-write admission is bypassed. Delegate provides no isolation or rollback.";
57
+
58
+ interface ActiveSyncDispatch {
59
+ tasks: TaskDef[];
60
+ resolved: ResolvedTask[];
61
+ }
62
+
63
+ const activeSyncDispatches = new Map<symbol, ActiveSyncDispatch>();
64
+ let sharedWriteAdmissionTail: Promise<void> = Promise.resolve();
65
+
66
+ /** Serialize the preflight snapshot and publication step. The lock is held only
67
+ * during admission, never while subagents run. This prevents two concurrent
68
+ * calls from both inspecting an empty active set and then starting together. */
69
+ async function withSharedWriteAdmissionLock<T>(
70
+ operation: () => Promise<T>,
71
+ ): Promise<T> {
72
+ const previous = sharedWriteAdmissionTail;
73
+ let release!: () => void;
74
+ sharedWriteAdmissionTail = new Promise<void>((resolve) => {
75
+ release = resolve;
76
+ });
77
+ await previous;
78
+ try {
79
+ return await operation();
80
+ } finally {
81
+ release();
82
+ }
83
+ }
84
+
44
85
  /** Return the structured result for an invalid top-level operation, or null when
45
86
  * the call may proceed to a ticket control/help/dispatch path. */
46
87
  export function validateDelegateOperationResult(
@@ -89,6 +130,7 @@ export function makeFireUpdater(
89
130
  progress: TaskProgress[],
90
131
  resolved: ResolvedTask[],
91
132
  parentModelId: string | undefined,
133
+ dispatchWarning?: string,
92
134
  ): () => void {
93
135
  return () =>
94
136
  onUpdate?.({
@@ -103,6 +145,7 @@ export function makeFireUpdater(
103
145
  results: [],
104
146
  progress: [...progress],
105
147
  parentModel: parentModelId,
148
+ dispatchWarning,
106
149
  },
107
150
  });
108
151
  }
@@ -116,6 +159,8 @@ export interface AsyncDispatchInput {
116
159
  progress: TaskProgress[];
117
160
  parentModelId: string | undefined;
118
161
  callSpan?: CallSpan;
162
+ dispatchConfig: DelegateConfig;
163
+ dispatchWarning?: string;
119
164
  }
120
165
 
121
166
  /** Inputs needed by the sync (blocking) dispatch path. */
@@ -128,6 +173,8 @@ export interface SyncDispatchInput {
128
173
  signal: AbortSignal | undefined;
129
174
  fire: () => void;
130
175
  callSpan?: CallSpan;
176
+ dispatchConfig: DelegateConfig;
177
+ dispatchWarning?: string;
131
178
  }
132
179
 
133
180
  /** Inputs for the normal task-validation, resolution, and dispatch path. */
@@ -143,13 +190,78 @@ export interface DelegateDispatchInput {
143
190
  callSpan?: CallSpan;
144
191
  }
145
192
 
193
+ function taskReference(task: TaskDef, index: number): string {
194
+ return `Task ${index + 1}${task.id ? `#${task.id}` : ""}`;
195
+ }
196
+
197
+ /** Isolated workers do not share their worktrees with each other, but their
198
+ * source root must remain reserved against shared writers until ordered apply
199
+ * finishes. Represent them as shared only inside the admission index. */
200
+ function asAdmissionWriter(task: ResolvedTask): ResolvedTask {
201
+ return task.workspace === "isolated"
202
+ ? { ...task, workspace: "shared" }
203
+ : task;
204
+ }
205
+
206
+ function sharedWriteRejection(
207
+ tasks: TaskDef[],
208
+ parentModelId: string | undefined,
209
+ conflicts: SharedWriteConflict[],
210
+ references: readonly string[] = tasks.map(taskReference),
211
+ ): DelegateToolResult {
212
+ const scopes = conflicts
213
+ .map(({ scope, taskIndexes }) => {
214
+ const refs = taskIndexes
215
+ .map((index) => references[index] ?? `Active writer ${index + 1}`)
216
+ .join(", ");
217
+ return `${refs} share ${scope.kind === "git" ? "Git root" : "directory"} '${scope.root}'.`;
218
+ })
219
+ .join(" ");
220
+ return {
221
+ content: [
222
+ {
223
+ type: "text",
224
+ text:
225
+ `Rejected before dispatch; no tasks were started. ${scopes} ` +
226
+ "Each listed task has mutating or unclassified tool capability, so concurrent shared execution could silently overwrite work. " +
227
+ 'Run them sequentially, use workspace: "isolated" for Git-backed ordered reconciliation, or use workspace: "scratch" when changes may be discarded. ' +
228
+ "External processes remain outside this check.",
229
+ },
230
+ ],
231
+ details: { tasks, results: [], progress: [], parentModel: parentModelId },
232
+ };
233
+ }
234
+
235
+ function sharedWriteSafetyFailure(
236
+ tasks: TaskDef[],
237
+ parentModelId: string | undefined,
238
+ error: unknown,
239
+ ): DelegateToolResult {
240
+ const detail =
241
+ error instanceof Error ? error.message : "Unknown workspace error.";
242
+ return {
243
+ content: [
244
+ {
245
+ type: "text",
246
+ text:
247
+ `Rejected before dispatch; no tasks were started because shared-write safety could not be verified. ${detail} ` +
248
+ '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.',
249
+ },
250
+ ],
251
+ details: { tasks, results: [], progress: [], parentModel: parentModelId },
252
+ };
253
+ }
254
+
146
255
  /** Validate, resolve, and dispatch a non-short-circuit delegate operation. */
147
256
  export async function dispatchDelegate(
148
257
  input: DelegateDispatchInput,
149
258
  ): Promise<DelegateToolResult> {
150
- // Settings are user-editable. Clear once at the dispatch boundary so every
151
- // task in this batch observes one consistent settings snapshot.
152
- clearDelegateSettingsCache();
259
+ // The extension entry point already reloaded delegate.json and reconfigured
260
+ // the global concurrency cap. Capture a dispatch-scoped snapshot here so the
261
+ // retry/stall/output/provider settings stay immutable for every task in this
262
+ // batch, even if a later dispatch mutates the global singleton while async
263
+ // work is still in flight.
264
+ const dispatchConfig = getDelegateConfigSnapshot();
153
265
  const {
154
266
  pi,
155
267
  params,
@@ -174,39 +286,211 @@ export async function dispatchDelegate(
174
286
  return validationError;
175
287
  }
176
288
 
177
- const resolved = resolveTasks(tasks, ctx, agents, parentDefaults);
178
- const progress = initProgress(resolved);
179
- const fire = makeFireUpdater(
180
- onUpdate,
289
+ const resolved = resolveTasks(
181
290
  tasks,
182
- progress,
183
- resolved,
184
- parentModelId,
291
+ ctx,
292
+ agents,
293
+ parentDefaults,
294
+ dispatchConfig,
185
295
  );
186
- fire();
296
+ if (params.async && resolved.some((task) => task.workspace === "isolated")) {
297
+ callSpan?.finish({
298
+ status: "failed",
299
+ totalTokens: 0,
300
+ totalCost: 0,
301
+ wallMs: Date.now() - callSpan.startedAt,
302
+ });
303
+ return {
304
+ content: [
305
+ {
306
+ type: "text",
307
+ text: 'Invalid delegate call: workspace "isolated" is synchronous; remove async.',
308
+ },
309
+ ],
310
+ details: {
311
+ tasks,
312
+ results: [],
313
+ progress: [],
314
+ parentModel: parentModelId,
315
+ },
316
+ };
317
+ }
318
+
319
+ const dispatchWarning = dispatchConfig.allowUnsafeSharedWrites
320
+ ? UNSAFE_SHARED_WRITES_WARNING
321
+ : undefined;
322
+ const signalWasAbortedBeforeAdmission = signal?.aborted === true;
323
+ let syncReservation: symbol | undefined;
324
+ let admissionResult:
325
+ DelegateToolResult | { progress: TaskProgress[]; fire: () => void };
326
+
327
+ try {
328
+ admissionResult = await withSharedWriteAdmissionLock(async () => {
329
+ // The call may have been cancelled while queued behind another
330
+ // preflight. Async dispatch uses its own controller after publication, so
331
+ // this check must happen before progress, reservations, or ticket
332
+ // creation rather than relying on the parent signal downstream.
333
+ if (!signalWasAbortedBeforeAdmission && signal?.aborted) {
334
+ throw new Error("Delegate call aborted while waiting for admission.");
335
+ }
336
+ if (
337
+ !dispatchConfig.allowUnsafeSharedWrites &&
338
+ resolved.some((task) => isSharedWriter(asAdmissionWriter(task)))
339
+ ) {
340
+ const incomingForSafety = resolved.map(asAdmissionWriter);
341
+ const activeResolved: ResolvedTask[] = [];
342
+ const references = resolved.map((_, index) =>
343
+ taskReference(tasks[index]!, index),
344
+ );
345
+
346
+ for (const ticket of ticketRegistry.values()) {
347
+ if (
348
+ ticket.status !== "running" &&
349
+ ticket.status !== "cancelling" &&
350
+ ticket.workersSettled !== false
351
+ ) {
352
+ continue;
353
+ }
354
+ for (let index = 0; index < ticket.resolved.length; index++) {
355
+ activeResolved.push(ticket.resolved[index]!);
356
+ references.push(
357
+ `async ticket '${ticket.id}' ${taskReference(ticket.tasks[index]!, index).toLowerCase()}`,
358
+ );
359
+ }
360
+ }
361
+ for (const active of activeSyncDispatches.values()) {
362
+ for (let index = 0; index < active.resolved.length; index++) {
363
+ activeResolved.push(active.resolved[index]!);
364
+ references.push(
365
+ `active sync ${taskReference(active.tasks[index]!, index).toLowerCase()}`,
366
+ );
367
+ }
368
+ }
187
369
 
188
- if (params.async) {
189
- return dispatchAsync({
190
- pi,
370
+ const incomingCount = resolved.length;
371
+ const conflicts = (
372
+ await findSharedWriteConflicts(
373
+ [...incomingForSafety, ...activeResolved],
374
+ signal,
375
+ )
376
+ ).filter(({ taskIndexes }) => {
377
+ if (!taskIndexes.some((index) => index < incomingCount)) return false;
378
+ // Multiple isolated tasks in this call intentionally share one
379
+ // baseline/root. Any shared task or active dispatch in the same
380
+ // conflict group still rejects the call.
381
+ return !taskIndexes.every(
382
+ (index) =>
383
+ index < incomingCount &&
384
+ resolved[index]?.workspace === "isolated",
385
+ );
386
+ });
387
+ if (conflicts.length) {
388
+ return sharedWriteRejection(
389
+ tasks,
390
+ parentModelId,
391
+ conflicts,
392
+ references,
393
+ );
394
+ }
395
+ }
396
+
397
+ const progress = initProgress(resolved);
398
+ const fire = makeFireUpdater(
399
+ onUpdate,
400
+ tasks,
401
+ progress,
402
+ resolved,
403
+ parentModelId,
404
+ dispatchWarning,
405
+ );
406
+ fire();
407
+
408
+ if (params.async) {
409
+ return dispatchAsync({
410
+ pi,
411
+ ctx,
412
+ tasks,
413
+ resolved,
414
+ progress,
415
+ parentModelId,
416
+ callSpan,
417
+ dispatchConfig,
418
+ dispatchWarning,
419
+ });
420
+ }
421
+
422
+ syncReservation = Symbol("shared-write-dispatch");
423
+ activeSyncDispatches.set(syncReservation, {
424
+ tasks,
425
+ resolved: resolved.map(asAdmissionWriter),
426
+ });
427
+ return { progress, fire };
428
+ });
429
+ } catch (error) {
430
+ // A parent abort mid-preflight rejects the git rev-parse execFile call;
431
+ // gitRepositoryRoot wraps every execFile error (abort-shaped ones
432
+ // included) in GitScopeError, so the error alone cannot distinguish a
433
+ // cancellation from a verification failure. Check the signal first —
434
+ // reporting Git-metadata advice for a plain abort would misdirect.
435
+ if (signal?.aborted) {
436
+ callSpan?.finish({
437
+ status: "cancelled",
438
+ totalTokens: 0,
439
+ totalCost: 0,
440
+ wallMs: Date.now() - callSpan.startedAt,
441
+ });
442
+ return {
443
+ content: [
444
+ {
445
+ type: "text",
446
+ text: "Aborted before dispatch; no tasks were started.",
447
+ },
448
+ ],
449
+ details: {
450
+ tasks,
451
+ results: [],
452
+ progress: [],
453
+ parentModel: parentModelId,
454
+ },
455
+ };
456
+ }
457
+ callSpan?.finish({
458
+ status: "failed",
459
+ totalTokens: 0,
460
+ totalCost: 0,
461
+ wallMs: Date.now() - callSpan.startedAt,
462
+ });
463
+ return sharedWriteSafetyFailure(tasks, parentModelId, error);
464
+ }
465
+
466
+ if ("content" in admissionResult) {
467
+ if (admissionResult.content[0]?.text.includes("Rejected before dispatch")) {
468
+ callSpan?.finish({
469
+ status: "failed",
470
+ totalTokens: 0,
471
+ totalCost: 0,
472
+ wallMs: Date.now() - callSpan.startedAt,
473
+ });
474
+ }
475
+ return admissionResult;
476
+ }
477
+
478
+ try {
479
+ return await dispatchSync({
191
480
  ctx,
192
481
  tasks,
193
482
  resolved,
194
- progress,
483
+ progress: admissionResult.progress,
195
484
  parentModelId,
485
+ signal,
486
+ fire: admissionResult.fire,
196
487
  callSpan,
488
+ dispatchConfig,
489
+ dispatchWarning,
197
490
  });
491
+ } finally {
492
+ if (syncReservation) activeSyncDispatches.delete(syncReservation);
198
493
  }
199
-
200
- return dispatchSync({
201
- ctx,
202
- tasks,
203
- resolved,
204
- progress,
205
- parentModelId,
206
- signal,
207
- fire,
208
- callSpan,
209
- });
210
494
  }
211
495
 
212
496
  /** Deliver a settled ticket and, when leaf affinity downgraded delivery to a
@@ -238,13 +522,24 @@ function settleAsyncCall(
238
522
  * the concurrent run, and returns the ticket acknowledgment immediately.
239
523
  * Results are delivered via `deliverTicketResults` when all tasks settle. */
240
524
  export function dispatchAsync(input: AsyncDispatchInput): DelegateToolResult {
241
- const { pi, ctx, tasks, resolved, progress, parentModelId, callSpan } = input;
525
+ const {
526
+ pi,
527
+ ctx,
528
+ tasks,
529
+ resolved,
530
+ progress,
531
+ parentModelId,
532
+ callSpan,
533
+ dispatchConfig,
534
+ dispatchWarning,
535
+ } = input;
242
536
 
243
537
  sweepTickets();
244
538
  const runningCount = [...ticketRegistry.values()].filter(
245
539
  (t) => t.status === "running" || t.status === "cancelling",
246
540
  ).length;
247
- if (runningCount >= getMaxAsyncTickets()) {
541
+ const maxAsyncTickets = getMaxAsyncTickets(dispatchConfig);
542
+ if (runningCount >= maxAsyncTickets) {
248
543
  callSpan?.finish({
249
544
  status: "failed",
250
545
  totalTokens: 0,
@@ -255,7 +550,7 @@ export function dispatchAsync(input: AsyncDispatchInput): DelegateToolResult {
255
550
  content: [
256
551
  {
257
552
  type: "text",
258
- text: `Too many async tickets running or cancelling (${runningCount}/${getMaxAsyncTickets()}). Poll existing tickets or cancel one first.`,
553
+ text: `Too many async tickets running or cancelling (${runningCount}/${maxAsyncTickets}). Poll existing tickets or cancel one first.`,
259
554
  },
260
555
  ],
261
556
  details: { tasks, results: [], progress: [], parentModel: parentModelId },
@@ -281,6 +576,13 @@ export function dispatchAsync(input: AsyncDispatchInput): DelegateToolResult {
281
576
  callStartedAt: callSpan?.startedAt,
282
577
  callRecord: callSpan ? { ...callSpan.baseRecord() } : undefined,
283
578
  telemetryGeneration: callSpan?.generation,
579
+ telemetryConfig: callSpan?.telemetryConfig,
580
+ workersSettled: false,
581
+ dispatchWarning,
582
+ // Capture the dispatch-scoped snapshot so async workers and later poll/wait
583
+ // formatting use the same retry/stall/output/provider settings that were
584
+ // in effect when the ticket was spawned.
585
+ config: dispatchConfig,
284
586
  };
285
587
  ticketRegistry.set(ticketId, ticket);
286
588
  callSpan?.spawn();
@@ -301,7 +603,10 @@ export function dispatchAsync(input: AsyncDispatchInput): DelegateToolResult {
301
603
  delegateStartedAt: ticket.created,
302
604
  telemetryCallId: callSpan?.id,
303
605
  telemetryGeneration: callSpan?.generation,
606
+ telemetryConfig:
607
+ callSpan?.telemetryConfig ?? getTelemetryConfig(dispatchConfig),
304
608
  async: true,
609
+ config: dispatchConfig,
305
610
  onProgress: (p, u) => {
306
611
  updateProgressFromRun(p, u);
307
612
  notifyWaiters(ticket);
@@ -319,10 +624,20 @@ export function dispatchAsync(input: AsyncDispatchInput): DelegateToolResult {
319
624
  // Worker must store the TaskResult back into ticket.results, since
320
625
  // formatCompletedTicket/handlePoll read from there. Without the write,
321
626
  // completed async tasks would be reported as PENDING.
627
+ //
628
+ // Worker settlement is complete before these live-runtime observers run.
629
+ // In particular, result delivery is allowed to fail without re-entering the
630
+ // worker completion path; the terminal ticket remains available to poll.
631
+ const finishLiveSettlement = (t: AsyncTicket): void => {
632
+ syncDelegateStatus();
633
+ settleAsyncCall(t, callSpan);
634
+ finishTicketDelivery(pi, t);
635
+ };
636
+
322
637
  const completion = mapConcurrentByModel(
323
638
  resolved,
324
639
  (t) => getModelKey(t.model),
325
- getConcurrencyLimit,
640
+ (modelKey) => getConcurrencyLimit(modelKey, dispatchConfig),
326
641
  async (t, i) => {
327
642
  const result = await runResolvedTask(asyncEnv, t, ticket.progress[i]!, i);
328
643
  ticket.results[i] = result;
@@ -346,21 +661,16 @@ export function dispatchAsync(input: AsyncDispatchInput): DelegateToolResult {
346
661
  // NOT be marked "done" — that would mask incomplete work as
347
662
  // complete. resolveFinalTicketStatus returns "failed" for that
348
663
  // case and for any case with a failed task.
349
- if (ticket.status === "running") {
350
- ticket.status = resolveFinalTicketStatus(ticket);
351
- ticket.completedAt = Date.now();
352
- syncTicketBusyIndex(ticket);
353
- } else if (ticket.status === "cancelling") {
354
- // Cancellation was requested while tasks were still settling. The
355
- // per-task results record what actually happened; the ticket state
356
- // reports that the batch was aborted by the caller.
357
- ticket.status = "cancelled";
358
- ticket.completedAt = Date.now();
359
- syncTicketBusyIndex(ticket);
360
- }
361
- syncDelegateStatus();
362
- settleAsyncCall(ticket, callSpan);
363
- finishTicketDelivery(pi, ticket);
664
+ // A "cancelling" ticket that outlived its workers settles as
665
+ // "cancelled": the per-task results record what actually happened;
666
+ // the ticket state reports that the batch was aborted by the caller.
667
+ settleTicket(ticket, {
668
+ status:
669
+ ticket.status === "running"
670
+ ? resolveFinalTicketStatus(ticket)
671
+ : "cancelled",
672
+ });
673
+ finishLiveSettlement(ticket);
364
674
  })
365
675
  .catch((err) => {
366
676
  // Defense-in-depth — should not happen if individual tasks catch properly.
@@ -370,17 +680,14 @@ export function dispatchAsync(input: AsyncDispatchInput): DelegateToolResult {
370
680
  settleAsyncCall(ticket, callSpan);
371
681
  return;
372
682
  }
373
- if (ticket.status === "cancelling") {
374
- ticket.status = "cancelled";
375
- } else if (ticket.status === "running") {
376
- ticket.status = "failed";
377
- }
378
- ticket.error = err instanceof Error ? err.message : String(err);
379
- ticket.completedAt = Date.now();
380
- syncTicketBusyIndex(ticket);
381
- syncDelegateStatus();
382
- settleAsyncCall(ticket, callSpan);
383
- finishTicketDelivery(pi, ticket);
683
+ settleTicket(ticket, {
684
+ status: ticket.status === "cancelling" ? "cancelled" : "failed",
685
+ error: err instanceof Error ? err.message : String(err),
686
+ });
687
+ finishLiveSettlement(ticket);
688
+ })
689
+ .finally(() => {
690
+ ticket.workersSettled = true;
384
691
  });
385
692
  ticket.completion = completion;
386
693
 
@@ -390,7 +697,8 @@ export function dispatchAsync(input: AsyncDispatchInput): DelegateToolResult {
390
697
  type: "text",
391
698
  text: [
392
699
  `Async ticket: ${ticketId}`,
393
- `${resolved.length} task(s) dispatched · ${runningCount + 1}/${getMaxAsyncTickets()} async slots in use`,
700
+ `${resolved.length} task(s) dispatched · ${runningCount + 1}/${maxAsyncTickets} async slots in use`,
701
+ ...(dispatchWarning ? [`WARNING: ${dispatchWarning}`] : []),
394
702
  "",
395
703
  "Work is detached. Stop this turn to let final results auto-deliver.",
396
704
  `If this turn must block for the result, call once: delegate({ ticketAction: "wait", ticket: "${ticketId}" }) — omit timeoutMs and do not poll`,
@@ -405,6 +713,7 @@ export function dispatchAsync(input: AsyncDispatchInput): DelegateToolResult {
405
713
  parentModel: parentModelId,
406
714
  ticketId,
407
715
  status: ticket.status,
716
+ dispatchWarning,
408
717
  },
409
718
  };
410
719
  }
@@ -423,9 +732,39 @@ export async function dispatchSync(
423
732
  signal,
424
733
  fire,
425
734
  callSpan,
735
+ dispatchConfig,
736
+ dispatchWarning,
426
737
  } = input;
427
738
 
428
739
  const startedAt = Date.now();
740
+ let executionResolved = resolved;
741
+ let isolatedBatch: Awaited<ReturnType<typeof prepareIsolatedBatch>>;
742
+ try {
743
+ isolatedBatch = await prepareIsolatedBatch(resolved, signal);
744
+ if (isolatedBatch) executionResolved = isolatedBatch.resolved;
745
+ } catch (error) {
746
+ const detail = error instanceof Error ? error.message : String(error);
747
+ callSpan?.finish({
748
+ status: "failed",
749
+ totalTokens: 0,
750
+ totalCost: 0,
751
+ wallMs: Date.now() - startedAt,
752
+ });
753
+ return {
754
+ content: [
755
+ {
756
+ type: "text",
757
+ text: `Isolated workspace setup failed; no subagents were started. ${detail}`,
758
+ },
759
+ ],
760
+ details: {
761
+ tasks,
762
+ results: [],
763
+ progress: [],
764
+ parentModel: parentModelId,
765
+ },
766
+ };
767
+ }
429
768
  const syncEnv: TaskRunEnv = {
430
769
  signal,
431
770
  modelRegistry: ctx.modelRegistry,
@@ -433,7 +772,10 @@ export async function dispatchSync(
433
772
  delegateStartedAt: startedAt,
434
773
  telemetryCallId: callSpan?.id,
435
774
  telemetryGeneration: callSpan?.generation,
775
+ telemetryConfig:
776
+ callSpan?.telemetryConfig ?? getTelemetryConfig(dispatchConfig),
436
777
  async: false,
778
+ config: dispatchConfig,
437
779
  onProgress: (p, u) => {
438
780
  updateProgressFromRun(p, u);
439
781
  fire();
@@ -441,13 +783,33 @@ export async function dispatchSync(
441
783
  onStatusChange: () => fire(),
442
784
  };
443
785
 
444
- const results = await mapConcurrentByModel(
445
- resolved,
786
+ let results = await mapConcurrentByModel(
787
+ executionResolved,
446
788
  (t) => getModelKey(t.model),
447
- getConcurrencyLimit,
789
+ (modelKey) => getConcurrencyLimit(modelKey, dispatchConfig),
448
790
  async (t, i) => runResolvedTask(syncEnv, t, progress[i]!, i),
449
791
  signal,
450
792
  );
793
+ if (isolatedBatch) {
794
+ try {
795
+ results = await isolatedBatch.reconcile(results);
796
+ } catch (error) {
797
+ const detail = error instanceof Error ? error.message : String(error);
798
+ console.error("[delegate] isolated reconciliation failed", error);
799
+ for (let index = 0; index < results.length; index++) {
800
+ if (resolved[index]?.workspace !== "isolated") continue;
801
+ results[index] = {
802
+ ...results[index]!,
803
+ integration: {
804
+ status: "apply_failed",
805
+ proposedFiles: [],
806
+ appliedFiles: [],
807
+ conflicts: [{ path: "(batch)", reason: detail }],
808
+ },
809
+ };
810
+ }
811
+ }
812
+ }
451
813
 
452
814
  // ── Format for LLM ────────────────────────────────────────────
453
815
  const finalResults: TaskResult[] = results;
@@ -458,10 +820,11 @@ export async function dispatchSync(
458
820
  parts.push(
459
821
  `${succeeded}/${finalResults.length} tasks completed successfully · ${fmtDuration(elapsedTotal)} wall time\n`,
460
822
  );
823
+ if (dispatchWarning) parts.push(`WARNING: ${dispatchWarning}`);
461
824
  for (let i = 0; i < finalResults.length; i++) {
462
825
  const r = finalResults[i]!;
463
826
  const t = resolved[i]!;
464
- parts.push(...formatCompletedTask(t, r));
827
+ parts.push(...formatCompletedTask(t, r, dispatchConfig));
465
828
  }
466
829
 
467
830
  const overlapWarning = formatTouchedOverlapWarning(
@@ -469,7 +832,14 @@ export async function dispatchSync(
469
832
  );
470
833
  if (overlapWarning) parts.push("", overlapWarning);
471
834
 
472
- const status = finalResults.some((r) => r.error) ? "failed" : "success";
835
+ const status = finalResults.some(
836
+ (r) =>
837
+ r.error ||
838
+ r.integration?.status === "conflict" ||
839
+ r.integration?.status === "apply_failed",
840
+ )
841
+ ? "failed"
842
+ : "success";
473
843
  const totalTokens = finalResults.reduce((sum, r) => sum + r.tokens, 0);
474
844
  const totalCost = finalResults.reduce(
475
845
  (sum, r) => sum + r.usage.cost.total,
@@ -490,6 +860,7 @@ export async function dispatchSync(
490
860
  progress,
491
861
  parentModel: parentModelId,
492
862
  overlapWarning: overlapWarning || undefined,
863
+ dispatchWarning,
493
864
  },
494
865
  // Aggregate subagent spend so Pi folds it into the parent's
495
866
  // session/footer totals. Sync dispatch only — async results arrive via a