@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/README.md +57 -4
- package/concurrency.ts +55 -16
- package/config.ts +584 -50
- package/delegate.ts +8 -1
- package/dispatch.ts +410 -38
- package/extension.ts +14 -0
- package/format.ts +50 -4
- package/host.ts +6 -1
- package/isolated-workspace.ts +857 -0
- package/lifecycle.ts +50 -16
- package/manual.ts +13 -9
- package/package.json +1 -1
- package/pool.ts +23 -1
- package/provider-extensions.ts +11 -2
- package/render-branches.ts +30 -0
- package/render-result.ts +8 -5
- package/runner.ts +3 -1
- package/schema.ts +58 -51
- package/settings.ts +202 -84
- package/shared-write-safety.ts +273 -0
- package/task-resolution.ts +87 -16
- package/telemetry.ts +135 -68
- package/ticket-format.ts +13 -5
- package/tickets.ts +23 -11
- package/types.ts +79 -0
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 {
|
|
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 {
|
|
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 {
|
|
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
|
-
//
|
|
151
|
-
//
|
|
152
|
-
|
|
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(
|
|
178
|
-
const progress = initProgress(resolved);
|
|
179
|
-
const fire = makeFireUpdater(
|
|
180
|
-
onUpdate,
|
|
289
|
+
const resolved = resolveTasks(
|
|
181
290
|
tasks,
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
291
|
+
ctx,
|
|
292
|
+
agents,
|
|
293
|
+
parentDefaults,
|
|
294
|
+
dispatchConfig,
|
|
185
295
|
);
|
|
186
|
-
|
|
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
|
-
|
|
189
|
-
|
|
190
|
-
|
|
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 {
|
|
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
|
-
|
|
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}/${
|
|
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}/${
|
|
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
|
-
|
|
444
|
-
|
|
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(
|
|
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.
|