@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/README.md +57 -4
- package/concurrency.ts +55 -16
- package/config.ts +584 -50
- package/delegate.ts +11 -3
- package/dispatch.ts +436 -65
- package/extension.ts +16 -4
- package/format.ts +52 -6
- package/host-cache.ts +70 -0
- package/host.ts +169 -811
- package/isolated-workspace.ts +857 -0
- package/lifecycle.ts +699 -520
- package/manual.ts +4 -7
- package/package.json +1 -1
- package/pi-package-source.ts +293 -0
- package/pool.ts +23 -1
- package/provider-extensions.ts +537 -0
- package/quiescence.ts +262 -0
- package/render-branches.ts +30 -0
- package/render-result.ts +8 -5
- package/runner.ts +35 -141
- package/schema.ts +133 -202
- package/settings.ts +202 -84
- package/shared-write-safety.ts +273 -0
- package/task-resolution.ts +121 -32
- package/telemetry.ts +135 -68
- package/ticket-format.ts +331 -0
- package/tickets.ts +101 -258
- package/tools.ts +12 -0
- package/trusted-paths.ts +71 -0
- package/types.ts +83 -16
package/dispatch.ts
CHANGED
|
@@ -6,10 +6,16 @@ import {
|
|
|
6
6
|
deliverTicketResults,
|
|
7
7
|
sweepTickets,
|
|
8
8
|
resolveFinalTicketStatus,
|
|
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
|
+
);
|
|
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
|
-
|
|
189
|
-
|
|
190
|
-
|
|
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);
|
|
@@ -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
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
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
|
-
|
|
374
|
-
ticket.status
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
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}/${
|
|
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
|
-
|
|
445
|
-
|
|
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(
|
|
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
|