@bermudi/pi-delegate 0.1.11 → 0.1.13

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/delegate.ts CHANGED
@@ -40,9 +40,14 @@ export {
40
40
  getMaxConcurrent,
41
41
  getStallTimeoutMs,
42
42
  resolveModelSpec,
43
+ getAgentOverrides,
44
+ getAgentOverridesByParentModel,
45
+ reloadDelegateConfig,
46
+ getDelegateConfigSnapshot,
43
47
  getOutputSpillThreshold,
44
48
  getOutputSpillTail,
45
49
  } from "./config.ts";
50
+ export type { AgentOverride } from "./config.ts";
46
51
  export {
47
52
  checkout,
48
53
  commit,
@@ -142,10 +147,12 @@ export {
142
147
  } from "./model.ts";
143
148
  export {
144
149
  readDelegateSettingsFile,
150
+ findLegacyDelegateSettings,
151
+ warnLegacyDelegateSettingsMoved,
145
152
  loadDelegateSettings,
146
153
  clearDelegateSettingsCache,
147
154
  } from "./settings.ts";
148
- export type { AgentOverride } from "./settings.ts";
155
+ export type { DelegateSettings } from "./settings.ts";
149
156
  export { resolveCwd, extractOutput, extractUsage } from "./utils.ts";
150
157
  export {
151
158
  decideSpill,
package/dispatch.ts CHANGED
@@ -9,7 +9,13 @@ import {
9
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
+ );
187
345
 
188
- if (params.async) {
189
- return dispatchAsync({
190
- pi,
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
+ }
369
+
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);
@@ -332,7 +637,7 @@ export function dispatchAsync(input: AsyncDispatchInput): DelegateToolResult {
332
637
  const completion = mapConcurrentByModel(
333
638
  resolved,
334
639
  (t) => getModelKey(t.model),
335
- getConcurrencyLimit,
640
+ (modelKey) => getConcurrencyLimit(modelKey, dispatchConfig),
336
641
  async (t, i) => {
337
642
  const result = await runResolvedTask(asyncEnv, t, ticket.progress[i]!, i);
338
643
  ticket.results[i] = result;
@@ -380,6 +685,9 @@ export function dispatchAsync(input: AsyncDispatchInput): DelegateToolResult {
380
685
  error: err instanceof Error ? err.message : String(err),
381
686
  });
382
687
  finishLiveSettlement(ticket);
688
+ })
689
+ .finally(() => {
690
+ ticket.workersSettled = true;
383
691
  });
384
692
  ticket.completion = completion;
385
693
 
@@ -389,7 +697,8 @@ export function dispatchAsync(input: AsyncDispatchInput): DelegateToolResult {
389
697
  type: "text",
390
698
  text: [
391
699
  `Async ticket: ${ticketId}`,
392
- `${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}`] : []),
393
702
  "",
394
703
  "Work is detached. Stop this turn to let final results auto-deliver.",
395
704
  `If this turn must block for the result, call once: delegate({ ticketAction: "wait", ticket: "${ticketId}" }) — omit timeoutMs and do not poll`,
@@ -404,6 +713,7 @@ export function dispatchAsync(input: AsyncDispatchInput): DelegateToolResult {
404
713
  parentModel: parentModelId,
405
714
  ticketId,
406
715
  status: ticket.status,
716
+ dispatchWarning,
407
717
  },
408
718
  };
409
719
  }
@@ -422,9 +732,39 @@ export async function dispatchSync(
422
732
  signal,
423
733
  fire,
424
734
  callSpan,
735
+ dispatchConfig,
736
+ dispatchWarning,
425
737
  } = input;
426
738
 
427
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
+ }
428
768
  const syncEnv: TaskRunEnv = {
429
769
  signal,
430
770
  modelRegistry: ctx.modelRegistry,
@@ -432,7 +772,10 @@ export async function dispatchSync(
432
772
  delegateStartedAt: startedAt,
433
773
  telemetryCallId: callSpan?.id,
434
774
  telemetryGeneration: callSpan?.generation,
775
+ telemetryConfig:
776
+ callSpan?.telemetryConfig ?? getTelemetryConfig(dispatchConfig),
435
777
  async: false,
778
+ config: dispatchConfig,
436
779
  onProgress: (p, u) => {
437
780
  updateProgressFromRun(p, u);
438
781
  fire();
@@ -440,13 +783,33 @@ export async function dispatchSync(
440
783
  onStatusChange: () => fire(),
441
784
  };
442
785
 
443
- const results = await mapConcurrentByModel(
444
- resolved,
786
+ let results = await mapConcurrentByModel(
787
+ executionResolved,
445
788
  (t) => getModelKey(t.model),
446
- getConcurrencyLimit,
789
+ (modelKey) => getConcurrencyLimit(modelKey, dispatchConfig),
447
790
  async (t, i) => runResolvedTask(syncEnv, t, progress[i]!, i),
448
791
  signal,
449
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
+ }
450
813
 
451
814
  // ── Format for LLM ────────────────────────────────────────────
452
815
  const finalResults: TaskResult[] = results;
@@ -457,10 +820,11 @@ export async function dispatchSync(
457
820
  parts.push(
458
821
  `${succeeded}/${finalResults.length} tasks completed successfully · ${fmtDuration(elapsedTotal)} wall time\n`,
459
822
  );
823
+ if (dispatchWarning) parts.push(`WARNING: ${dispatchWarning}`);
460
824
  for (let i = 0; i < finalResults.length; i++) {
461
825
  const r = finalResults[i]!;
462
826
  const t = resolved[i]!;
463
- parts.push(...formatCompletedTask(t, r));
827
+ parts.push(...formatCompletedTask(t, r, dispatchConfig));
464
828
  }
465
829
 
466
830
  const overlapWarning = formatTouchedOverlapWarning(
@@ -468,7 +832,14 @@ export async function dispatchSync(
468
832
  );
469
833
  if (overlapWarning) parts.push("", overlapWarning);
470
834
 
471
- 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";
472
843
  const totalTokens = finalResults.reduce((sum, r) => sum + r.tokens, 0);
473
844
  const totalCost = finalResults.reduce(
474
845
  (sum, r) => sum + r.usage.cost.total,
@@ -489,6 +860,7 @@ export async function dispatchSync(
489
860
  progress,
490
861
  parentModel: parentModelId,
491
862
  overlapWarning: overlapWarning || undefined,
863
+ dispatchWarning,
492
864
  },
493
865
  // Aggregate subagent spend so Pi folds it into the parent's
494
866
  // session/footer totals. Sync dispatch only — async results arrive via a
package/extension.ts CHANGED
@@ -22,6 +22,9 @@ import { invalidateHostDepsCache } from "./host.ts";
22
22
  import { registerProviderExtensionNotifier } from "./provider-extensions.ts";
23
23
  import { recordTreeNavigation, resetLeafTracking } from "./leaf.ts";
24
24
  import { closeAllPooledAgents } from "./pool.ts";
25
+ import { reconfigureGlobalConcurrency } from "./concurrency.ts";
26
+ import { reloadDelegateConfig, getMaxConcurrent } from "./config.ts";
27
+ import { warnLegacyDelegateSettingsMoved } from "./settings.ts";
25
28
  import {
26
29
  activeTicketSummary,
27
30
  clearDelegateStatusContext,
@@ -123,6 +126,17 @@ export default function delegateExtension(pi: ExtensionAPI): void {
123
126
  prepareArguments: normalizeDelegateArguments,
124
127
 
125
128
  async execute(_id, params: DelegateArguments, signal, onUpdate, ctx) {
129
+ // Reload user-edited delegate.json at the start of every execution.
130
+ // Help, poll, cancel, wait, and invalid calls observe new settings, and
131
+ // the global concurrency cap is reconfigured so hot-reloaded maxConcurrent
132
+ // takes effect for subsequent acquisitions. A parse/read error keeps the
133
+ // previous snapshot and warns instead of falling back to defaults.
134
+ warnLegacyDelegateSettingsMoved(ctx.cwd, (message) =>
135
+ ctx.ui.notify(message, "warning"),
136
+ );
137
+ reloadDelegateConfig();
138
+ reconfigureGlobalConcurrency(getMaxConcurrent());
139
+
126
140
  // Prime the UI notice for best-effort provider extensions that load for
127
141
  // subagents (host.ts consumes it where the fact is discovered). Every
128
142
  // execute re-primes so a stale ctx never sticks.