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