@bermudi/pi-delegate 0.1.17 → 0.1.19
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 +14 -13
- package/agents.ts +1 -1
- package/concurrency.ts +7 -0
- package/delegate.ts +6 -0
- package/dispatch.ts +460 -163
- package/extension.ts +35 -26
- package/format.ts +4 -1
- package/host.ts +1 -1
- package/isolated-workspace.ts +154 -8
- package/lifecycle.ts +31 -20
- package/manual.ts +11 -7
- package/model.ts +34 -2
- package/package.json +2 -1
- package/parent-context.ts +1 -1
- package/pool.ts +492 -428
- package/render-branches.ts +2 -1
- package/render-result.ts +6 -0
- package/runtime.ts +36 -0
- package/schema.ts +22 -13
- package/status.ts +24 -11
- package/task-resolution.ts +12 -9
- package/test-harness.ts +81 -0
- package/ticket-format.ts +4 -3
- package/tickets.ts +672 -576
- package/types.ts +19 -0
- package/workspace.ts +58 -27
package/dispatch.ts
CHANGED
|
@@ -1,14 +1,6 @@
|
|
|
1
1
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
import type { AgentToolUpdateCallback } from "@earendil-works/pi-agent-core";
|
|
3
|
-
import {
|
|
4
|
-
ticketRegistry,
|
|
5
|
-
generateTicketId,
|
|
6
|
-
deliverTicketResults,
|
|
7
|
-
sweepTickets,
|
|
8
|
-
resolveFinalTicketStatus,
|
|
9
|
-
settleTicket,
|
|
10
|
-
notifyWaiters,
|
|
11
|
-
} from "./tickets.ts";
|
|
3
|
+
import { getDefaultDelegateRuntime, type DelegateRuntime } from "./runtime.ts";
|
|
12
4
|
import {
|
|
13
5
|
getConcurrencyLimit,
|
|
14
6
|
getMaxAsyncTickets,
|
|
@@ -18,7 +10,7 @@ import {
|
|
|
18
10
|
import type { DelegateConfig } from "./config.ts";
|
|
19
11
|
import { getCurrentLeafId } from "./leaf.ts";
|
|
20
12
|
import { getModelKey, mapConcurrentByModel } from "./concurrency.ts";
|
|
21
|
-
import { aggregateTaskResults, sumUsage } from "./usage.ts";
|
|
13
|
+
import { aggregateTaskResults, emptyUsage, sumUsage } from "./usage.ts";
|
|
22
14
|
import { runResolvedTask, updateProgressFromRun } from "./lifecycle.ts";
|
|
23
15
|
import {
|
|
24
16
|
fmtDuration,
|
|
@@ -36,8 +28,13 @@ import {
|
|
|
36
28
|
type SharedWriteConflict,
|
|
37
29
|
} from "./shared-write-safety.ts";
|
|
38
30
|
import type { CallSpan } from "./telemetry.ts";
|
|
39
|
-
import {
|
|
31
|
+
import {
|
|
32
|
+
prepareIsolatedBatch,
|
|
33
|
+
type IsolatedReconcileOptions,
|
|
34
|
+
type PreparedIsolatedBatch,
|
|
35
|
+
} from "./isolated-workspace.ts";
|
|
40
36
|
import { sanitizeTerminalLine } from "./utils.ts";
|
|
37
|
+
import { checkScratchWorkspaceSupport } from "./workspace.ts";
|
|
41
38
|
import { quarantinedTasks } from "./session-quarantine.ts";
|
|
42
39
|
import type {
|
|
43
40
|
AgentConfig,
|
|
@@ -135,6 +132,7 @@ export function makeFireUpdater(
|
|
|
135
132
|
resolved: ResolvedTask[],
|
|
136
133
|
parentModelId: string | undefined,
|
|
137
134
|
dispatchWarning?: string,
|
|
135
|
+
serializedNotice?: string,
|
|
138
136
|
): () => void {
|
|
139
137
|
return () =>
|
|
140
138
|
onUpdate?.({
|
|
@@ -150,6 +148,7 @@ export function makeFireUpdater(
|
|
|
150
148
|
progress: [...progress],
|
|
151
149
|
parentModel: parentModelId,
|
|
152
150
|
dispatchWarning,
|
|
151
|
+
serializedNotice,
|
|
153
152
|
},
|
|
154
153
|
});
|
|
155
154
|
}
|
|
@@ -165,6 +164,10 @@ export interface AsyncDispatchInput {
|
|
|
165
164
|
callSpan?: CallSpan;
|
|
166
165
|
dispatchConfig: DelegateConfig;
|
|
167
166
|
dispatchWarning?: string;
|
|
167
|
+
/** Same-call shared-writer groups admission serialized into task order. */
|
|
168
|
+
serializedGroups?: SharedWriteConflict[];
|
|
169
|
+
serializedNotice?: string;
|
|
170
|
+
runtime?: DelegateRuntime;
|
|
168
171
|
}
|
|
169
172
|
|
|
170
173
|
/** Inputs needed by the sync (blocking) dispatch path. */
|
|
@@ -179,6 +182,10 @@ export interface SyncDispatchInput {
|
|
|
179
182
|
callSpan?: CallSpan;
|
|
180
183
|
dispatchConfig: DelegateConfig;
|
|
181
184
|
dispatchWarning?: string;
|
|
185
|
+
/** Same-call shared-writer groups admission serialized into task order. */
|
|
186
|
+
serializedGroups?: SharedWriteConflict[];
|
|
187
|
+
serializedNotice?: string;
|
|
188
|
+
runtime?: DelegateRuntime;
|
|
182
189
|
}
|
|
183
190
|
|
|
184
191
|
/** Inputs for the normal task-validation, resolution, and dispatch path. */
|
|
@@ -192,6 +199,7 @@ export interface DelegateDispatchInput {
|
|
|
192
199
|
signal: AbortSignal | undefined;
|
|
193
200
|
onUpdate: AgentToolUpdateCallback<DelegateDetails> | undefined;
|
|
194
201
|
callSpan?: CallSpan;
|
|
202
|
+
runtime?: DelegateRuntime;
|
|
195
203
|
}
|
|
196
204
|
|
|
197
205
|
function taskReference(task: DispatchableTask, index: number): string {
|
|
@@ -207,12 +215,42 @@ function asAdmissionWriter(task: ResolvedTask): ResolvedTask {
|
|
|
207
215
|
: task;
|
|
208
216
|
}
|
|
209
217
|
|
|
210
|
-
|
|
218
|
+
const SCRATCH_VIABILITY_TIMEOUT_MS = 5_000;
|
|
219
|
+
|
|
220
|
+
/** Verify scratch viability for the given task cwds before a rejection message
|
|
221
|
+
* recommends it. Resolves with the clause text to append: a recommendation
|
|
222
|
+
* when every cwd passes the same read-only pre-flight scratch setup runs,
|
|
223
|
+
* otherwise a short unavailability note carrying the blocking reason.
|
|
224
|
+
* Uncertainty (timeout, unexpected error) fails closed — no recommendation. */
|
|
225
|
+
async function scratchRecommendation(cwds: readonly string[]): Promise<string> {
|
|
226
|
+
if (!cwds.length) return "";
|
|
227
|
+
try {
|
|
228
|
+
await Promise.all(
|
|
229
|
+
cwds.map((cwd) =>
|
|
230
|
+
checkScratchWorkspaceSupport(
|
|
231
|
+
cwd,
|
|
232
|
+
AbortSignal.timeout(SCRATCH_VIABILITY_TIMEOUT_MS),
|
|
233
|
+
),
|
|
234
|
+
),
|
|
235
|
+
);
|
|
236
|
+
return ', or use workspace: "scratch" when changes may be discarded';
|
|
237
|
+
} catch (error) {
|
|
238
|
+
const reason = error instanceof Error ? error.message : String(error);
|
|
239
|
+
return ` workspace: "scratch" is unavailable in this checkout (${reason})`;
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/** Compose the shared-write rejection after the admission lock has released:
|
|
244
|
+
* the scratch clause is verified against scratch's own pre-flight, so the
|
|
245
|
+
* message never vouches for a mode this checkout cannot run (linked Git
|
|
246
|
+
* worktrees, nested repositories, non-Linux hosts). */
|
|
247
|
+
async function sharedWriteRejection(
|
|
211
248
|
tasks: DispatchableTask[],
|
|
212
249
|
parentModelId: string | undefined,
|
|
213
250
|
conflicts: SharedWriteConflict[],
|
|
214
|
-
references: readonly string[]
|
|
215
|
-
|
|
251
|
+
references: readonly string[],
|
|
252
|
+
cwds: readonly string[],
|
|
253
|
+
): Promise<DelegateToolResult> {
|
|
216
254
|
const scopes = conflicts
|
|
217
255
|
.map(({ scope, taskIndexes }) => {
|
|
218
256
|
const refs = taskIndexes
|
|
@@ -221,6 +259,7 @@ function sharedWriteRejection(
|
|
|
221
259
|
return `${refs} share ${scope.kind === "git" ? "Git root" : "directory"} '${scope.root}'.`;
|
|
222
260
|
})
|
|
223
261
|
.join(" ");
|
|
262
|
+
const scratch = await scratchRecommendation(cwds);
|
|
224
263
|
return {
|
|
225
264
|
content: [
|
|
226
265
|
{
|
|
@@ -228,7 +267,7 @@ function sharedWriteRejection(
|
|
|
228
267
|
text:
|
|
229
268
|
`Rejected before dispatch; no tasks were started. ${scopes} ` +
|
|
230
269
|
"Each listed task has mutating or unclassified tool capability, so concurrent shared execution could silently overwrite work. " +
|
|
231
|
-
|
|
270
|
+
`Run them sequentially, use workspace: "isolated" for Git-backed ordered reconciliation${scratch}. ` +
|
|
232
271
|
"External processes remain outside this check.",
|
|
233
272
|
},
|
|
234
273
|
],
|
|
@@ -236,26 +275,64 @@ function sharedWriteRejection(
|
|
|
236
275
|
};
|
|
237
276
|
}
|
|
238
277
|
|
|
239
|
-
function sharedWriteSafetyFailure(
|
|
278
|
+
async function sharedWriteSafetyFailure(
|
|
240
279
|
tasks: DispatchableTask[],
|
|
241
280
|
parentModelId: string | undefined,
|
|
242
281
|
error: unknown,
|
|
243
|
-
|
|
282
|
+
cwds: readonly string[],
|
|
283
|
+
): Promise<DelegateToolResult> {
|
|
244
284
|
const detail =
|
|
245
285
|
error instanceof Error ? error.message : "Unknown workspace error.";
|
|
286
|
+
const scratch = await scratchRecommendation(cwds);
|
|
246
287
|
return {
|
|
247
288
|
content: [
|
|
248
289
|
{
|
|
249
290
|
type: "text",
|
|
250
291
|
text:
|
|
251
292
|
`Rejected before dispatch; no tasks were started because shared-write safety could not be verified. ${detail} ` +
|
|
252
|
-
|
|
293
|
+
`Fix the task directory or Git metadata, run tasks sequentially, use workspace: "isolated" for Git-backed ordered reconciliation${scratch}.`,
|
|
253
294
|
},
|
|
254
295
|
],
|
|
255
296
|
details: { tasks, results: [], progress: [], parentModel: parentModelId },
|
|
256
297
|
};
|
|
257
298
|
}
|
|
258
299
|
|
|
300
|
+
/** Build the predecessor gate that runs serialized task chains one at a time,
|
|
301
|
+
* in task order. Each chained successor awaits its predecessor's completion
|
|
302
|
+
* before acquiring a global slot; `complete` must be called in every task's
|
|
303
|
+
* `finally` so throw, abort, and queued-abort paths all unblock successors. */
|
|
304
|
+
function buildSerializationGate(
|
|
305
|
+
groups: readonly SharedWriteConflict[] | undefined,
|
|
306
|
+
):
|
|
307
|
+
| {
|
|
308
|
+
beforeAcquire: (index: number) => Promise<void>;
|
|
309
|
+
complete: (index: number) => void;
|
|
310
|
+
}
|
|
311
|
+
| undefined {
|
|
312
|
+
if (!groups?.length) return undefined;
|
|
313
|
+
const predecessor = new Map<number, number>();
|
|
314
|
+
for (const { taskIndexes } of groups) {
|
|
315
|
+
for (let position = 1; position < taskIndexes.length; position++) {
|
|
316
|
+
predecessor.set(taskIndexes[position]!, taskIndexes[position - 1]!);
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
if (predecessor.size === 0) return undefined;
|
|
320
|
+
const settled = new Map<number, Promise<void>>();
|
|
321
|
+
const resolvers = new Map<number, () => void>();
|
|
322
|
+
for (const index of new Set(predecessor.values())) {
|
|
323
|
+
let resolve!: () => void;
|
|
324
|
+
settled.set(index, new Promise<void>((r) => (resolve = r)));
|
|
325
|
+
resolvers.set(index, resolve);
|
|
326
|
+
}
|
|
327
|
+
return {
|
|
328
|
+
beforeAcquire: async (index) => {
|
|
329
|
+
const predecessorIndex = predecessor.get(index);
|
|
330
|
+
if (predecessorIndex !== undefined) await settled.get(predecessorIndex);
|
|
331
|
+
},
|
|
332
|
+
complete: (index) => resolvers.get(index)?.(),
|
|
333
|
+
};
|
|
334
|
+
}
|
|
335
|
+
|
|
259
336
|
/** Validate, resolve, and dispatch a non-short-circuit delegate operation. */
|
|
260
337
|
export async function dispatchDelegate(
|
|
261
338
|
input: DelegateDispatchInput,
|
|
@@ -276,10 +353,11 @@ export async function dispatchDelegate(
|
|
|
276
353
|
signal,
|
|
277
354
|
onUpdate,
|
|
278
355
|
callSpan,
|
|
356
|
+
runtime = getDefaultDelegateRuntime(),
|
|
279
357
|
} = input;
|
|
280
358
|
const tasks = params.tasks ?? [];
|
|
281
359
|
|
|
282
|
-
const validationError = validateTasks(tasks, agents, parentModelId);
|
|
360
|
+
const validationError = validateTasks(tasks, agents, parentModelId, runtime);
|
|
283
361
|
if (validationError) {
|
|
284
362
|
callSpan?.finish({
|
|
285
363
|
status: "failed",
|
|
@@ -296,6 +374,7 @@ export async function dispatchDelegate(
|
|
|
296
374
|
agents,
|
|
297
375
|
parentDefaults,
|
|
298
376
|
dispatchConfig,
|
|
377
|
+
runtime,
|
|
299
378
|
);
|
|
300
379
|
if (resolveResult.error !== undefined) {
|
|
301
380
|
callSpan?.finish({
|
|
@@ -310,36 +389,28 @@ export async function dispatchDelegate(
|
|
|
310
389
|
};
|
|
311
390
|
}
|
|
312
391
|
const resolved = resolveResult.tasks;
|
|
313
|
-
if (params.async && resolved.some((task) => task.workspace === "isolated")) {
|
|
314
|
-
callSpan?.finish({
|
|
315
|
-
status: "failed",
|
|
316
|
-
totalTokens: 0,
|
|
317
|
-
totalCost: 0,
|
|
318
|
-
wallMs: Date.now() - callSpan.startedAt,
|
|
319
|
-
});
|
|
320
|
-
return {
|
|
321
|
-
content: [
|
|
322
|
-
{
|
|
323
|
-
type: "text",
|
|
324
|
-
text: 'Invalid delegate call: workspace "isolated" is synchronous; remove async.',
|
|
325
|
-
},
|
|
326
|
-
],
|
|
327
|
-
details: {
|
|
328
|
-
tasks,
|
|
329
|
-
results: [],
|
|
330
|
-
progress: [],
|
|
331
|
-
parentModel: parentModelId,
|
|
332
|
-
},
|
|
333
|
-
};
|
|
334
|
-
}
|
|
335
392
|
|
|
336
|
-
|
|
393
|
+
// Mutated inside the admission lock: unsafe mode's standing warning, and the
|
|
394
|
+
// serialization notice built when same-call shared writers are ordered into
|
|
395
|
+
// task order instead of rejected. Mutually exclusive by construction —
|
|
396
|
+
// unsafe mode skips admission entirely.
|
|
397
|
+
let dispatchWarning = dispatchConfig.allowUnsafeSharedWrites
|
|
337
398
|
? UNSAFE_SHARED_WRITES_WARNING
|
|
338
399
|
: undefined;
|
|
400
|
+
let serializedNotice: string | undefined;
|
|
401
|
+
let serializedGroups: SharedWriteConflict[] | undefined;
|
|
339
402
|
const signalWasAbortedBeforeAdmission = signal?.aborted === true;
|
|
340
403
|
let syncReservation: symbol | undefined;
|
|
341
404
|
let admissionResult:
|
|
342
|
-
|
|
405
|
+
| DelegateToolResult
|
|
406
|
+
| { progress: TaskProgress[]; fire: () => void }
|
|
407
|
+
| {
|
|
408
|
+
rejected: {
|
|
409
|
+
conflicts: SharedWriteConflict[];
|
|
410
|
+
references: string[];
|
|
411
|
+
cwds: string[];
|
|
412
|
+
};
|
|
413
|
+
};
|
|
343
414
|
|
|
344
415
|
try {
|
|
345
416
|
admissionResult = await withSharedWriteAdmissionLock(async () => {
|
|
@@ -360,7 +431,7 @@ export async function dispatchDelegate(
|
|
|
360
431
|
taskReference(tasks[index]!, index),
|
|
361
432
|
);
|
|
362
433
|
|
|
363
|
-
for (const ticket of
|
|
434
|
+
for (const ticket of runtime.tickets.values()) {
|
|
364
435
|
if (
|
|
365
436
|
ticket.status !== "running" &&
|
|
366
437
|
ticket.status !== "cancelling" &&
|
|
@@ -391,29 +462,66 @@ export async function dispatchDelegate(
|
|
|
391
462
|
}
|
|
392
463
|
|
|
393
464
|
const incomingCount = resolved.length;
|
|
394
|
-
const
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
465
|
+
const rejectConflicts: SharedWriteConflict[] = [];
|
|
466
|
+
const serializedConflicts: SharedWriteConflict[] = [];
|
|
467
|
+
const rejectedCwds = new Set<string>();
|
|
468
|
+
for (const conflict of await findSharedWriteConflicts(
|
|
469
|
+
[...incomingForSafety, ...activeResolved],
|
|
470
|
+
signal,
|
|
471
|
+
)) {
|
|
472
|
+
const { taskIndexes } = conflict;
|
|
473
|
+
if (!taskIndexes.some((index) => index < incomingCount)) continue;
|
|
474
|
+
if (taskIndexes.every((index) => index < incomingCount)) {
|
|
475
|
+
const workspaces = new Set(
|
|
476
|
+
taskIndexes.map((index) => resolved[index]?.workspace),
|
|
477
|
+
);
|
|
478
|
+
if (workspaces.has("isolated")) {
|
|
479
|
+
if (workspaces.size === 1) {
|
|
480
|
+
// Multiple isolated tasks in this call intentionally share
|
|
481
|
+
// one baseline/root; no shared writer is involved.
|
|
482
|
+
continue;
|
|
483
|
+
}
|
|
484
|
+
// Mixed isolated/shared in this call: a shared writer cannot be
|
|
485
|
+
// ordered against the isolated reservation window (baseline
|
|
486
|
+
// snapshot through reconciliation).
|
|
487
|
+
rejectConflicts.push(conflict);
|
|
488
|
+
} else {
|
|
489
|
+
// Same-call shared writers: serialize in task order instead of
|
|
490
|
+
// rejecting. Concurrent dispatch is unordered anyway, so
|
|
491
|
+
// serial-in-dispatch-order refines the same contract.
|
|
492
|
+
serializedConflicts.push(conflict);
|
|
493
|
+
}
|
|
494
|
+
} else {
|
|
495
|
+
// An active writer (async ticket, running sync dispatch, or
|
|
496
|
+
// quarantined task) is involved: queueing behind unknown-duration
|
|
497
|
+
// work cannot be ordered safely here.
|
|
498
|
+
rejectConflicts.push(conflict);
|
|
499
|
+
}
|
|
500
|
+
for (const index of taskIndexes) {
|
|
501
|
+
if (index < incomingCount) rejectedCwds.add(resolved[index]!.cwd);
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
if (rejectConflicts.length) {
|
|
505
|
+
return {
|
|
506
|
+
rejected: {
|
|
507
|
+
conflicts: rejectConflicts,
|
|
508
|
+
references,
|
|
509
|
+
cwds: [...rejectedCwds],
|
|
510
|
+
},
|
|
511
|
+
};
|
|
512
|
+
}
|
|
513
|
+
if (serializedConflicts.length) {
|
|
514
|
+
serializedGroups = serializedConflicts;
|
|
515
|
+
serializedNotice = `Serialized: ${serializedConflicts
|
|
516
|
+
.map(
|
|
517
|
+
({ scope, taskIndexes }) =>
|
|
518
|
+
`${taskIndexes
|
|
519
|
+
.map((index) => references[index] ?? `Task ${index + 1}`)
|
|
520
|
+
.join(", ")} share ${
|
|
521
|
+
scope.kind === "git" ? "Git root" : "directory"
|
|
522
|
+
} '${scope.root}' — running one at a time in task order.`,
|
|
523
|
+
)
|
|
524
|
+
.join(" ")}`;
|
|
417
525
|
}
|
|
418
526
|
}
|
|
419
527
|
|
|
@@ -425,6 +533,7 @@ export async function dispatchDelegate(
|
|
|
425
533
|
resolved,
|
|
426
534
|
parentModelId,
|
|
427
535
|
dispatchWarning,
|
|
536
|
+
serializedNotice,
|
|
428
537
|
);
|
|
429
538
|
fire();
|
|
430
539
|
|
|
@@ -439,6 +548,9 @@ export async function dispatchDelegate(
|
|
|
439
548
|
callSpan,
|
|
440
549
|
dispatchConfig,
|
|
441
550
|
dispatchWarning,
|
|
551
|
+
serializedGroups,
|
|
552
|
+
serializedNotice,
|
|
553
|
+
runtime,
|
|
442
554
|
});
|
|
443
555
|
}
|
|
444
556
|
|
|
@@ -483,18 +595,29 @@ export async function dispatchDelegate(
|
|
|
483
595
|
totalCost: 0,
|
|
484
596
|
wallMs: Date.now() - callSpan.startedAt,
|
|
485
597
|
});
|
|
486
|
-
return sharedWriteSafetyFailure(tasks, parentModelId, error
|
|
598
|
+
return await sharedWriteSafetyFailure(tasks, parentModelId, error, [
|
|
599
|
+
...new Set(resolved.map((task) => task.cwd)),
|
|
600
|
+
]);
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
if ("rejected" in admissionResult) {
|
|
604
|
+
callSpan?.finish({
|
|
605
|
+
status: "failed",
|
|
606
|
+
totalTokens: 0,
|
|
607
|
+
totalCost: 0,
|
|
608
|
+
wallMs: Date.now() - (callSpan?.startedAt ?? Date.now()),
|
|
609
|
+
});
|
|
610
|
+
const { conflicts, references, cwds } = admissionResult.rejected;
|
|
611
|
+
return await sharedWriteRejection(
|
|
612
|
+
tasks,
|
|
613
|
+
parentModelId,
|
|
614
|
+
conflicts,
|
|
615
|
+
references,
|
|
616
|
+
cwds,
|
|
617
|
+
);
|
|
487
618
|
}
|
|
488
619
|
|
|
489
620
|
if ("content" in admissionResult) {
|
|
490
|
-
if (admissionResult.content[0]?.text.includes("Rejected before dispatch")) {
|
|
491
|
-
callSpan?.finish({
|
|
492
|
-
status: "failed",
|
|
493
|
-
totalTokens: 0,
|
|
494
|
-
totalCost: 0,
|
|
495
|
-
wallMs: Date.now() - callSpan.startedAt,
|
|
496
|
-
});
|
|
497
|
-
}
|
|
498
621
|
return admissionResult;
|
|
499
622
|
}
|
|
500
623
|
|
|
@@ -510,6 +633,9 @@ export async function dispatchDelegate(
|
|
|
510
633
|
callSpan,
|
|
511
634
|
dispatchConfig,
|
|
512
635
|
dispatchWarning,
|
|
636
|
+
serializedGroups,
|
|
637
|
+
serializedNotice,
|
|
638
|
+
runtime,
|
|
513
639
|
});
|
|
514
640
|
} finally {
|
|
515
641
|
if (syncReservation) activeSyncDispatches.delete(syncReservation);
|
|
@@ -519,8 +645,12 @@ export async function dispatchDelegate(
|
|
|
519
645
|
/** Deliver a settled ticket and, when leaf affinity downgraded delivery to a
|
|
520
646
|
* non-waking `nextTurn` message, tell the human — otherwise the completion is
|
|
521
647
|
* silent apart from the footer clearing. */
|
|
522
|
-
function finishTicketDelivery(
|
|
523
|
-
|
|
648
|
+
function finishTicketDelivery(
|
|
649
|
+
pi: ExtensionAPI,
|
|
650
|
+
ticket: AsyncTicket,
|
|
651
|
+
runtime: DelegateRuntime,
|
|
652
|
+
): void {
|
|
653
|
+
if (runtime.tickets.deliverTicketResults(pi, ticket) === "deferred") {
|
|
524
654
|
notifyCrossLeafDelivery(ticket);
|
|
525
655
|
}
|
|
526
656
|
}
|
|
@@ -541,6 +671,88 @@ function settleAsyncCall(
|
|
|
541
671
|
callSpan.finish({ status, totalTokens, totalCost, wallMs });
|
|
542
672
|
}
|
|
543
673
|
|
|
674
|
+
function taskCompletedSuccessfully(result: TaskResult): boolean {
|
|
675
|
+
return (
|
|
676
|
+
!result.error &&
|
|
677
|
+
result.integration?.status !== "retained" &&
|
|
678
|
+
result.integration?.status !== "conflict" &&
|
|
679
|
+
result.integration?.status !== "apply_failed"
|
|
680
|
+
);
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
function completeUnexpectedResults(
|
|
684
|
+
resolved: readonly ResolvedTask[],
|
|
685
|
+
progress: TaskProgress[],
|
|
686
|
+
current: readonly (TaskResult | undefined)[],
|
|
687
|
+
error: unknown,
|
|
688
|
+
): TaskResult[] {
|
|
689
|
+
const reason = error instanceof Error ? error.message : String(error);
|
|
690
|
+
return resolved.map((task, index) => {
|
|
691
|
+
const result = current[index];
|
|
692
|
+
if (result) return result;
|
|
693
|
+
const p = progress[index]!;
|
|
694
|
+
p.status = "failed";
|
|
695
|
+
p.error = reason;
|
|
696
|
+
return {
|
|
697
|
+
id: task.id,
|
|
698
|
+
agent: task.agentName,
|
|
699
|
+
resumedFrom: task.resumeFromDisplay,
|
|
700
|
+
output: "",
|
|
701
|
+
error: reason,
|
|
702
|
+
durationMs: p.durationMs,
|
|
703
|
+
tokens: 0,
|
|
704
|
+
usage: emptyUsage(),
|
|
705
|
+
workspace: task.workspace,
|
|
706
|
+
touchedFiles: [],
|
|
707
|
+
attributedFiles: [],
|
|
708
|
+
};
|
|
709
|
+
});
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
async function reconcileIsolatedResults(
|
|
713
|
+
batch: PreparedIsolatedBatch,
|
|
714
|
+
resolved: readonly ResolvedTask[],
|
|
715
|
+
results: TaskResult[],
|
|
716
|
+
options?: IsolatedReconcileOptions,
|
|
717
|
+
): Promise<TaskResult[]> {
|
|
718
|
+
try {
|
|
719
|
+
return await batch.reconcile(results, options);
|
|
720
|
+
} catch (error) {
|
|
721
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
722
|
+
console.error("[delegate] isolated reconciliation failed", error);
|
|
723
|
+
for (let index = 0; index < results.length; index++) {
|
|
724
|
+
if (resolved[index]?.workspace !== "isolated") continue;
|
|
725
|
+
const existing = results[index]?.integration;
|
|
726
|
+
results[index] = {
|
|
727
|
+
...results[index]!,
|
|
728
|
+
integration: {
|
|
729
|
+
status: "apply_failed",
|
|
730
|
+
proposedFiles: existing?.proposedFiles ?? [],
|
|
731
|
+
appliedFiles: [],
|
|
732
|
+
conflicts: [
|
|
733
|
+
...(existing?.conflicts ?? []),
|
|
734
|
+
{ path: "(batch)", reason: detail },
|
|
735
|
+
],
|
|
736
|
+
...(existing?.baselineRef
|
|
737
|
+
? { baselineRef: existing.baselineRef }
|
|
738
|
+
: {}),
|
|
739
|
+
...(existing?.proposalRef
|
|
740
|
+
? { proposalRef: existing.proposalRef }
|
|
741
|
+
: {}),
|
|
742
|
+
...(existing?.patchPath ? { patchPath: existing.patchPath } : {}),
|
|
743
|
+
...(existing?.worktreePath
|
|
744
|
+
? { worktreePath: existing.worktreePath }
|
|
745
|
+
: {}),
|
|
746
|
+
...(existing?.cleanupIssue
|
|
747
|
+
? { cleanupIssue: existing.cleanupIssue }
|
|
748
|
+
: {}),
|
|
749
|
+
},
|
|
750
|
+
};
|
|
751
|
+
}
|
|
752
|
+
return results;
|
|
753
|
+
}
|
|
754
|
+
}
|
|
755
|
+
|
|
544
756
|
/** Fire-and-forget background execution. Registers an `AsyncTicket`, kicks off
|
|
545
757
|
* the concurrent run, and returns the ticket acknowledgment immediately.
|
|
546
758
|
* Results are delivered via `deliverTicketResults` when all tasks settle. */
|
|
@@ -555,10 +767,13 @@ export function dispatchAsync(input: AsyncDispatchInput): DelegateToolResult {
|
|
|
555
767
|
callSpan,
|
|
556
768
|
dispatchConfig,
|
|
557
769
|
dispatchWarning,
|
|
770
|
+
serializedGroups,
|
|
771
|
+
serializedNotice,
|
|
772
|
+
runtime = getDefaultDelegateRuntime(),
|
|
558
773
|
} = input;
|
|
559
774
|
|
|
560
|
-
sweepTickets();
|
|
561
|
-
const runningCount = [...
|
|
775
|
+
runtime.tickets.sweepTickets();
|
|
776
|
+
const runningCount = [...runtime.tickets.values()].filter(
|
|
562
777
|
(t) => t.status === "running" || t.status === "cancelling",
|
|
563
778
|
).length;
|
|
564
779
|
const maxAsyncTickets = getMaxAsyncTickets(dispatchConfig);
|
|
@@ -580,7 +795,7 @@ export function dispatchAsync(input: AsyncDispatchInput): DelegateToolResult {
|
|
|
580
795
|
};
|
|
581
796
|
}
|
|
582
797
|
|
|
583
|
-
const ticketId = generateTicketId();
|
|
798
|
+
const ticketId = runtime.tickets.generateTicketId();
|
|
584
799
|
const controller = new AbortController();
|
|
585
800
|
const ticket: AsyncTicket = {
|
|
586
801
|
id: ticketId,
|
|
@@ -602,17 +817,18 @@ export function dispatchAsync(input: AsyncDispatchInput): DelegateToolResult {
|
|
|
602
817
|
telemetryConfig: callSpan?.telemetryConfig,
|
|
603
818
|
workersSettled: false,
|
|
604
819
|
dispatchWarning,
|
|
820
|
+
serializedNotice,
|
|
605
821
|
// Capture the dispatch-scoped snapshot so async workers and later poll/wait
|
|
606
822
|
// formatting use the same retry/stall/output/provider settings that were
|
|
607
823
|
// in effect when the ticket was spawned.
|
|
608
824
|
config: dispatchConfig,
|
|
609
825
|
};
|
|
610
|
-
|
|
826
|
+
runtime.tickets.set(ticketId, ticket);
|
|
611
827
|
callSpan?.spawn();
|
|
612
828
|
// Footer visibility for the new background work (see status.ts). Uses the
|
|
613
829
|
// ctx cached from the dispatch path in extension.ts — DelegateToolCtx is
|
|
614
830
|
// the intentionally narrowed surface and does not carry `ui`.
|
|
615
|
-
syncDelegateStatus();
|
|
831
|
+
syncDelegateStatus(undefined, runtime);
|
|
616
832
|
|
|
617
833
|
// Capture values for the closure — do NOT use `signal` from execute()
|
|
618
834
|
// The parent turn's signal dies when execute() returns.
|
|
@@ -630,16 +846,17 @@ export function dispatchAsync(input: AsyncDispatchInput): DelegateToolResult {
|
|
|
630
846
|
callSpan?.telemetryConfig ?? getTelemetryConfig(dispatchConfig),
|
|
631
847
|
async: true,
|
|
632
848
|
config: dispatchConfig,
|
|
849
|
+
runtime,
|
|
633
850
|
onProgress: (p, u) => {
|
|
634
851
|
updateProgressFromRun(p, u);
|
|
635
|
-
notifyWaiters(ticket);
|
|
852
|
+
runtime.tickets.notifyWaiters(ticket);
|
|
636
853
|
// Live subagent counts in the footer. Deduped by text, so only
|
|
637
854
|
// running/pending count transitions trigger a render.
|
|
638
|
-
syncDelegateStatus();
|
|
855
|
+
syncDelegateStatus(undefined, runtime);
|
|
639
856
|
},
|
|
640
857
|
onStatusChange: () => {
|
|
641
|
-
notifyWaiters(ticket);
|
|
642
|
-
syncDelegateStatus();
|
|
858
|
+
runtime.tickets.notifyWaiters(ticket);
|
|
859
|
+
syncDelegateStatus(undefined, runtime);
|
|
643
860
|
},
|
|
644
861
|
};
|
|
645
862
|
|
|
@@ -652,27 +869,92 @@ export function dispatchAsync(input: AsyncDispatchInput): DelegateToolResult {
|
|
|
652
869
|
// In particular, result delivery is allowed to fail without re-entering the
|
|
653
870
|
// worker completion path; the terminal ticket remains available to poll.
|
|
654
871
|
const finishLiveSettlement = (t: AsyncTicket): void => {
|
|
655
|
-
syncDelegateStatus();
|
|
872
|
+
syncDelegateStatus(undefined, runtime);
|
|
656
873
|
settleAsyncCall(t, callSpan);
|
|
657
|
-
finishTicketDelivery(pi, t);
|
|
874
|
+
finishTicketDelivery(pi, t, runtime);
|
|
658
875
|
};
|
|
659
876
|
|
|
660
|
-
const completion =
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
877
|
+
const completion = (async () => {
|
|
878
|
+
try {
|
|
879
|
+
let executionResolved = resolved;
|
|
880
|
+
let isolatedBatch: PreparedIsolatedBatch | undefined;
|
|
881
|
+
try {
|
|
882
|
+
isolatedBatch = await prepareIsolatedBatch(resolved, ticketSignal);
|
|
883
|
+
if (isolatedBatch) executionResolved = isolatedBatch.resolved;
|
|
884
|
+
} catch (error) {
|
|
885
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
886
|
+
throw new Error(
|
|
887
|
+
`Isolated workspace setup failed; no subagents were started. ${detail}`,
|
|
888
|
+
{ cause: error },
|
|
889
|
+
);
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
let results: TaskResult[];
|
|
893
|
+
const gate = buildSerializationGate(serializedGroups);
|
|
894
|
+
try {
|
|
895
|
+
results = await mapConcurrentByModel(
|
|
896
|
+
executionResolved,
|
|
897
|
+
(t) => getModelKey(t.model),
|
|
898
|
+
(modelKey) => getConcurrencyLimit(modelKey, dispatchConfig),
|
|
899
|
+
async (t, i) => {
|
|
900
|
+
try {
|
|
901
|
+
const result = await runResolvedTask(
|
|
902
|
+
asyncEnv,
|
|
903
|
+
t,
|
|
904
|
+
ticket.progress[i]!,
|
|
905
|
+
i,
|
|
906
|
+
);
|
|
907
|
+
ticket.results[i] = result;
|
|
908
|
+
return result;
|
|
909
|
+
} finally {
|
|
910
|
+
gate?.complete(i);
|
|
911
|
+
}
|
|
912
|
+
},
|
|
913
|
+
ticketSignal,
|
|
914
|
+
gate?.beforeAcquire,
|
|
915
|
+
);
|
|
916
|
+
} catch (error) {
|
|
917
|
+
results = completeUnexpectedResults(
|
|
918
|
+
resolved,
|
|
919
|
+
ticket.progress,
|
|
920
|
+
ticket.results,
|
|
921
|
+
error,
|
|
922
|
+
);
|
|
923
|
+
if (isolatedBatch) {
|
|
924
|
+
results = await reconcileIsolatedResults(
|
|
925
|
+
isolatedBatch,
|
|
926
|
+
resolved,
|
|
927
|
+
results,
|
|
928
|
+
{
|
|
929
|
+
shouldApplySource: () => false,
|
|
930
|
+
retainedReason:
|
|
931
|
+
"Batch execution failed before source application; completed proposals were retained for recovery.",
|
|
932
|
+
},
|
|
933
|
+
);
|
|
934
|
+
}
|
|
935
|
+
ticket.results = [...results];
|
|
936
|
+
throw error;
|
|
937
|
+
}
|
|
938
|
+
if (isolatedBatch) {
|
|
939
|
+
results = await reconcileIsolatedResults(
|
|
940
|
+
isolatedBatch,
|
|
941
|
+
resolved,
|
|
942
|
+
results,
|
|
943
|
+
{
|
|
944
|
+
shouldApplySource: () =>
|
|
945
|
+
ticket.status === "running" && !ticketSignal.aborted,
|
|
946
|
+
signal: ticketSignal,
|
|
947
|
+
retainedReason:
|
|
948
|
+
"The async ticket was cancelled before source application; the proposal was retained for recovery.",
|
|
949
|
+
},
|
|
950
|
+
);
|
|
951
|
+
ticket.results = [...results];
|
|
952
|
+
}
|
|
953
|
+
|
|
954
|
+
// Worker execution and isolated reconciliation are complete. Publish that
|
|
955
|
+
// before terminal formatting/delivery so the final spill projection is
|
|
956
|
+
// safely memoized; shutdown may already have formatted an intentionally
|
|
957
|
+
// uncached partial snapshot while this flag was false.
|
|
676
958
|
ticket.workersSettled = true;
|
|
677
959
|
// Shutdown marks the ticket terminal before cooperative worker aborts
|
|
678
960
|
// have finished. Still write one final aggregate after every result has
|
|
@@ -682,43 +964,36 @@ export function dispatchAsync(input: AsyncDispatchInput): DelegateToolResult {
|
|
|
682
964
|
settleAsyncCall(ticket, callSpan);
|
|
683
965
|
return;
|
|
684
966
|
}
|
|
685
|
-
//
|
|
686
|
-
//
|
|
687
|
-
//
|
|
688
|
-
|
|
689
|
-
// NOT be marked "done" — that would mask incomplete work as
|
|
690
|
-
// complete. resolveFinalTicketStatus returns "failed" for that
|
|
691
|
-
// case and for any case with a failed task.
|
|
692
|
-
// A "cancelling" ticket that outlived its workers settles as
|
|
693
|
-
// "cancelled": the per-task results record what actually happened;
|
|
694
|
-
// the ticket state reports that the batch was aborted by the caller.
|
|
695
|
-
settleTicket(ticket, {
|
|
967
|
+
// Use progress (set by runResolvedTask) for settled-ness so a partial
|
|
968
|
+
// ticket can never be marked done. A cancelling ticket reports cancelled
|
|
969
|
+
// even when some workers completed before the abort.
|
|
970
|
+
runtime.tickets.settleTicket(ticket, {
|
|
696
971
|
status:
|
|
697
972
|
ticket.status === "running"
|
|
698
|
-
? resolveFinalTicketStatus(ticket)
|
|
973
|
+
? runtime.tickets.resolveFinalTicketStatus(ticket)
|
|
699
974
|
: "cancelled",
|
|
700
975
|
});
|
|
701
976
|
finishLiveSettlement(ticket);
|
|
702
|
-
})
|
|
703
|
-
|
|
704
|
-
//
|
|
977
|
+
} catch (err) {
|
|
978
|
+
// Preparation and reconciliation join the same terminal path as workers:
|
|
979
|
+
// any unexpected rejection must leave the ticket pollable and settled.
|
|
705
980
|
ticket.workersSettled = true;
|
|
706
|
-
// Defense-in-depth — should not happen if individual tasks catch properly.
|
|
707
|
-
// Even an unexpected worker rejection must leave the shutdown aggregate
|
|
708
|
-
// with every result that did settle, without touching the stale UI.
|
|
709
981
|
if (ticket.status === "cancelled") {
|
|
710
982
|
settleAsyncCall(ticket, callSpan);
|
|
711
983
|
return;
|
|
712
984
|
}
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
985
|
+
const cancelling = ticket.status === "cancelling";
|
|
986
|
+
runtime.tickets.settleTicket(ticket, {
|
|
987
|
+
status: cancelling ? "cancelled" : "failed",
|
|
988
|
+
...(cancelling
|
|
989
|
+
? {}
|
|
990
|
+
: { error: err instanceof Error ? err.message : String(err) }),
|
|
716
991
|
});
|
|
717
992
|
finishLiveSettlement(ticket);
|
|
718
|
-
}
|
|
719
|
-
.finally(() => {
|
|
993
|
+
} finally {
|
|
720
994
|
ticket.workersSettled = true;
|
|
721
|
-
}
|
|
995
|
+
}
|
|
996
|
+
})();
|
|
722
997
|
ticket.completion = completion;
|
|
723
998
|
|
|
724
999
|
return {
|
|
@@ -728,6 +1003,7 @@ export function dispatchAsync(input: AsyncDispatchInput): DelegateToolResult {
|
|
|
728
1003
|
text: [
|
|
729
1004
|
`Async ticket: ${ticketId}`,
|
|
730
1005
|
`${resolved.length} task(s) dispatched · ${runningCount + 1}/${maxAsyncTickets} async slots in use`,
|
|
1006
|
+
...(serializedNotice ? [serializedNotice] : []),
|
|
731
1007
|
...(dispatchWarning ? [`WARNING: ${dispatchWarning}`] : []),
|
|
732
1008
|
"",
|
|
733
1009
|
"Work is detached. Stop this turn to let final results auto-deliver.",
|
|
@@ -745,6 +1021,7 @@ export function dispatchAsync(input: AsyncDispatchInput): DelegateToolResult {
|
|
|
745
1021
|
status: ticket.status,
|
|
746
1022
|
elapsedMs: Date.now() - ticket.created,
|
|
747
1023
|
dispatchWarning,
|
|
1024
|
+
serializedNotice,
|
|
748
1025
|
},
|
|
749
1026
|
};
|
|
750
1027
|
}
|
|
@@ -765,6 +1042,9 @@ export async function dispatchSync(
|
|
|
765
1042
|
callSpan,
|
|
766
1043
|
dispatchConfig,
|
|
767
1044
|
dispatchWarning,
|
|
1045
|
+
serializedGroups,
|
|
1046
|
+
serializedNotice,
|
|
1047
|
+
runtime = getDefaultDelegateRuntime(),
|
|
768
1048
|
} = input;
|
|
769
1049
|
|
|
770
1050
|
const startedAt = Date.now();
|
|
@@ -807,6 +1087,7 @@ export async function dispatchSync(
|
|
|
807
1087
|
callSpan?.telemetryConfig ?? getTelemetryConfig(dispatchConfig),
|
|
808
1088
|
async: false,
|
|
809
1089
|
config: dispatchConfig,
|
|
1090
|
+
runtime,
|
|
810
1091
|
onProgress: (p, u) => {
|
|
811
1092
|
updateProgressFromRun(p, u);
|
|
812
1093
|
fire();
|
|
@@ -814,32 +1095,51 @@ export async function dispatchSync(
|
|
|
814
1095
|
onStatusChange: () => fire(),
|
|
815
1096
|
};
|
|
816
1097
|
|
|
817
|
-
|
|
818
|
-
executionResolved,
|
|
819
|
-
(t) => getModelKey(t.model),
|
|
820
|
-
(modelKey) => getConcurrencyLimit(modelKey, dispatchConfig),
|
|
821
|
-
async (t, i) => runResolvedTask(syncEnv, t, progress[i]!, i),
|
|
822
|
-
signal,
|
|
1098
|
+
const partialResults: (TaskResult | undefined)[] = new Array(
|
|
1099
|
+
executionResolved.length,
|
|
823
1100
|
);
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
1101
|
+
let results: TaskResult[];
|
|
1102
|
+
let isolatedReconciled = false;
|
|
1103
|
+
const gate = buildSerializationGate(serializedGroups);
|
|
1104
|
+
try {
|
|
1105
|
+
results = await mapConcurrentByModel(
|
|
1106
|
+
executionResolved,
|
|
1107
|
+
(t) => getModelKey(t.model),
|
|
1108
|
+
(modelKey) => getConcurrencyLimit(modelKey, dispatchConfig),
|
|
1109
|
+
async (t, i) => {
|
|
1110
|
+
try {
|
|
1111
|
+
const result = await runResolvedTask(syncEnv, t, progress[i]!, i);
|
|
1112
|
+
partialResults[i] = result;
|
|
1113
|
+
return result;
|
|
1114
|
+
} finally {
|
|
1115
|
+
gate?.complete(i);
|
|
1116
|
+
}
|
|
1117
|
+
},
|
|
1118
|
+
signal,
|
|
1119
|
+
gate?.beforeAcquire,
|
|
1120
|
+
);
|
|
1121
|
+
} catch (error) {
|
|
1122
|
+
if (!isolatedBatch) throw error;
|
|
1123
|
+
results = completeUnexpectedResults(
|
|
1124
|
+
resolved,
|
|
1125
|
+
progress,
|
|
1126
|
+
partialResults,
|
|
1127
|
+
error,
|
|
1128
|
+
);
|
|
1129
|
+
results = await reconcileIsolatedResults(isolatedBatch, resolved, results, {
|
|
1130
|
+
shouldApplySource: () => false,
|
|
1131
|
+
retainedReason:
|
|
1132
|
+
"Batch execution failed before source application; completed proposals were retained for recovery.",
|
|
1133
|
+
});
|
|
1134
|
+
isolatedReconciled = true;
|
|
1135
|
+
}
|
|
1136
|
+
if (isolatedBatch && !isolatedReconciled) {
|
|
1137
|
+
results = await reconcileIsolatedResults(isolatedBatch, resolved, results, {
|
|
1138
|
+
shouldApplySource: () => !signal?.aborted,
|
|
1139
|
+
signal,
|
|
1140
|
+
retainedReason:
|
|
1141
|
+
"The parent cancelled before source application; the proposal was retained for recovery.",
|
|
1142
|
+
});
|
|
843
1143
|
}
|
|
844
1144
|
|
|
845
1145
|
// ── Format for LLM ────────────────────────────────────────────
|
|
@@ -847,10 +1147,11 @@ export async function dispatchSync(
|
|
|
847
1147
|
const elapsedTotal = Date.now() - startedAt;
|
|
848
1148
|
|
|
849
1149
|
const parts: string[] = [];
|
|
850
|
-
const succeeded = finalResults.filter(
|
|
1150
|
+
const succeeded = finalResults.filter(taskCompletedSuccessfully).length;
|
|
851
1151
|
parts.push(
|
|
852
1152
|
`${succeeded}/${finalResults.length} tasks completed successfully · ${fmtDuration(elapsedTotal)} wall time\n`,
|
|
853
1153
|
);
|
|
1154
|
+
if (serializedNotice) parts.push(serializedNotice);
|
|
854
1155
|
if (dispatchWarning) parts.push(`WARNING: ${dispatchWarning}`);
|
|
855
1156
|
for (let i = 0; i < finalResults.length; i++) {
|
|
856
1157
|
const r = finalResults[i]!;
|
|
@@ -863,14 +1164,9 @@ export async function dispatchSync(
|
|
|
863
1164
|
);
|
|
864
1165
|
if (overlapWarning) parts.push("", overlapWarning);
|
|
865
1166
|
|
|
866
|
-
const status = finalResults.
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
r.integration?.status === "conflict" ||
|
|
870
|
-
r.integration?.status === "apply_failed",
|
|
871
|
-
)
|
|
872
|
-
? "failed"
|
|
873
|
-
: "success";
|
|
1167
|
+
const status = finalResults.every(taskCompletedSuccessfully)
|
|
1168
|
+
? "success"
|
|
1169
|
+
: "failed";
|
|
874
1170
|
const totalTokens = finalResults.reduce((sum, r) => sum + r.tokens, 0);
|
|
875
1171
|
const totalCost = finalResults.reduce(
|
|
876
1172
|
(sum, r) => sum + r.usage.cost.total,
|
|
@@ -893,6 +1189,7 @@ export async function dispatchSync(
|
|
|
893
1189
|
elapsedMs: elapsedTotal,
|
|
894
1190
|
overlapWarning: overlapWarning || undefined,
|
|
895
1191
|
dispatchWarning,
|
|
1192
|
+
serializedNotice,
|
|
896
1193
|
},
|
|
897
1194
|
// Aggregate subagent spend so Pi folds it into the parent's
|
|
898
1195
|
// session/footer totals. Sync dispatch only — async results arrive via a
|