@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/extension.ts CHANGED
@@ -1,11 +1,6 @@
1
1
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
- import {
3
- handleCancel,
4
- handlePoll,
5
- handleWait,
6
- cancelTicketForShutdown,
7
- ticketRegistry,
8
- } from "./tickets.ts";
2
+ import { registerSubagentBrowser } from "./browser.ts";
3
+ import { getDefaultDelegateRuntime, type DelegateRuntime } from "./runtime.ts";
9
4
  import { discoverAgents } from "./agents.ts";
10
5
  import { getSubagentManualMarkdown } from "./manual.ts";
11
6
  import {
@@ -25,7 +20,6 @@ import { hostCompatError } from "./host-compat.ts";
25
20
  import { invalidateHostDepsCache } from "./host.ts";
26
21
  import { registerProviderExtensionNotifier } from "./provider-extensions.ts";
27
22
  import { recordTreeNavigation, resetLeafTracking } from "./leaf.ts";
28
- import { closeAllPooledAgents } from "./pool.ts";
29
23
  import { reconfigureGlobalConcurrency } from "./concurrency.ts";
30
24
  import { reloadDelegateConfig, getMaxConcurrent } from "./config.ts";
31
25
  import {
@@ -130,12 +124,20 @@ export function _setShutdownDrainTimeoutForTesting(
130
124
  shutdownDrainTimeoutMs = timeoutMs ?? DEFAULT_SHUTDOWN_DRAIN_TIMEOUT_MS;
131
125
  }
132
126
 
133
- /** Register the delegate tool and clean up its parent-session resources. */
134
- export default function delegateExtension(pi: ExtensionAPI): void {
127
+ /** Register the delegate tool and clean up its parent-session resources.
128
+ *
129
+ * Production uses the module default runtime. Tests/embedders may pass a
130
+ * fresh runtime as the second argument so all tool execution, status, and
131
+ * shutdown operate in an isolated pool/ticket environment. */
132
+ export default function delegateExtension(
133
+ pi: ExtensionAPI,
134
+ runtime: DelegateRuntime = getDefaultDelegateRuntime(),
135
+ ): void {
135
136
  // A /reload can reuse this module instance after the previous runtime closed
136
137
  // its SQLite handle. Permit the new runtime to open a fresh backend; stale
137
138
  // workers from the old runtime remain blocked from reopening it.
138
139
  prepareTelemetryForSession();
140
+ const browserHistory = registerSubagentBrowser(pi, runtime);
139
141
 
140
142
  // Async completion arrives as a custom message after the original tool call
141
143
  // has returned. Give it the same compact/expanded UI as sync results while
@@ -156,6 +158,8 @@ export default function delegateExtension(pi: ExtensionAPI): void {
156
158
  prepareArguments: normalizeDelegateArguments,
157
159
 
158
160
  async execute(_id, params: DelegateArguments, signal, onUpdate, ctx) {
161
+ const browserGeneration = browserHistory.generation;
162
+ const captureBrowser = ctx.mode === "tui" && !params.async;
159
163
  // Reload user-edited delegate.json at the start of every execution.
160
164
  // Help, poll, cancel, wait, and invalid calls observe new settings, and
161
165
  // the global concurrency cap is reconfigured so hot-reloaded maxConcurrent
@@ -237,17 +241,27 @@ export default function delegateExtension(pi: ExtensionAPI): void {
237
241
 
238
242
  // ── Poll action ───────────────────────────────────────────────────
239
243
  if (params.ticketAction === "poll") {
240
- const result = handlePoll(params, ctx);
244
+ const result = runtime.tickets.handlePoll(params, ctx);
241
245
  succeedCall();
242
246
  return result;
243
247
  }
244
248
 
245
249
  // ── Cancel action ─────────────────────────────────────────────────
250
+ if (params.ticketAction === "pause" || params.ticketAction === "resume") {
251
+ const result = runtime.tickets.handlePause({
252
+ ticket: params.ticket,
253
+ ticketAction: params.ticketAction,
254
+ });
255
+ syncDelegateStatus(ctx, runtime);
256
+ succeedCall();
257
+ return result;
258
+ }
259
+
246
260
  if (params.ticketAction === "cancel") {
247
- const result = handleCancel(params);
261
+ const result = runtime.tickets.handleCancel(params);
248
262
  // A forced cancel flips the ticket to "cancelling" — keep the
249
263
  // footer status in step (deduped; the preview path is a no-op).
250
- syncDelegateStatus(ctx);
264
+ syncDelegateStatus(ctx, runtime);
251
265
  succeedCall();
252
266
  return result;
253
267
  }
@@ -255,7 +269,12 @@ export default function delegateExtension(pi: ExtensionAPI): void {
255
269
  // ── Wait action ────────────────────────────────────────────────────
256
270
  if (params.ticketAction === "wait") {
257
271
  try {
258
- const result = await handleWait(params, signal, onUpdate, ctx);
272
+ const result = await runtime.tickets.handleWait(
273
+ params,
274
+ signal,
275
+ onUpdate,
276
+ ctx,
277
+ );
259
278
  succeedCall();
260
279
  return result;
261
280
  } catch (err) {
@@ -283,7 +302,7 @@ export default function delegateExtension(pi: ExtensionAPI): void {
283
302
  invalidateHostDepsCache();
284
303
  // Keep the footer-status pipeline in step exactly as a normal
285
304
  // dispatch would (deduped no-op when nothing is running).
286
- syncDelegateStatus(ctx);
305
+ syncDelegateStatus(ctx, runtime);
287
306
  return await dispatchDelegate({
288
307
  pi,
289
308
  params: { ...params, tasks: [bridgeSessionControlTask(params)] },
@@ -297,6 +316,7 @@ export default function delegateExtension(pi: ExtensionAPI): void {
297
316
  signal,
298
317
  onUpdate,
299
318
  callSpan,
319
+ runtime,
300
320
  });
301
321
  }
302
322
 
@@ -318,14 +338,14 @@ export default function delegateExtension(pi: ExtensionAPI): void {
318
338
  // dispatch narrows it to DelegateToolCtx (which has no `ui`). The
319
339
  // status push itself is a deduped no-op here; dispatchAsync re-syncs
320
340
  // after registering its ticket.
321
- syncDelegateStatus(ctx);
341
+ syncDelegateStatus(ctx, runtime);
322
342
 
323
343
  // Keep expensive host deps shared within this dispatch, not indefinitely
324
344
  // across dispatches: edits to auth/models/settings/context files must be
325
345
  // visible without restarting Pi.
326
346
  invalidateHostDepsCache();
327
347
  try {
328
- return await dispatchDelegate({
348
+ const result = await dispatchDelegate({
329
349
  pi,
330
350
  params,
331
351
  ctx,
@@ -336,10 +356,25 @@ export default function delegateExtension(pi: ExtensionAPI): void {
336
356
  tools: pi.getActiveTools(),
337
357
  },
338
358
  signal,
339
- onUpdate,
359
+ onUpdate: captureBrowser
360
+ ? (update) => {
361
+ browserHistory.update(
362
+ _id,
363
+ update.details,
364
+ false,
365
+ browserGeneration,
366
+ );
367
+ onUpdate?.(update);
368
+ }
369
+ : onUpdate,
340
370
  callSpan,
371
+ runtime,
341
372
  });
373
+ if (captureBrowser)
374
+ browserHistory.update(_id, result.details, true, browserGeneration);
375
+ return result;
342
376
  } catch (err) {
377
+ if (captureBrowser) browserHistory.fail(_id, err, browserGeneration);
343
378
  failCall();
344
379
  throw err;
345
380
  }
@@ -360,15 +395,15 @@ export default function delegateExtension(pi: ExtensionAPI): void {
360
395
  // The turn settling with live tickets is the "looks idle but isn't" moment:
361
396
  // warn once per ticket. The footer status carries it from there.
362
397
  pi.on("agent_settled", (_event, ctx) => {
363
- notifyActiveTicketsOnSettled(ctx);
398
+ notifyActiveTicketsOnSettled(ctx, runtime);
364
399
  });
365
400
 
366
401
  // Session replacements are cancellable — confirm before killing live work.
367
402
  pi.on("session_before_switch", (_event, ctx) =>
368
- guardSessionReplacement(ctx, "switch"),
403
+ guardSessionReplacement(ctx, "switch", runtime),
369
404
  );
370
405
  pi.on("session_before_fork", (_event, ctx) =>
371
- guardSessionReplacement(ctx, "fork"),
406
+ guardSessionReplacement(ctx, "fork", runtime),
372
407
  );
373
408
 
374
409
  // /tree navigation stays inside the same session: nothing is torn down and
@@ -376,10 +411,12 @@ export default function delegateExtension(pi: ExtensionAPI): void {
376
411
  // user moves to. Ask first, and record the new leaf either way so delivery
377
412
  // can detect the mismatch (issue #30). `session_tree` also fires for
378
413
  // extension-driven ctx.navigateTree, which never reaches the guard.
379
- pi.on("session_before_tree", (_event, ctx) => guardTreeNavigation(ctx));
414
+ pi.on("session_before_tree", (_event, ctx) =>
415
+ guardTreeNavigation(ctx, runtime),
416
+ );
380
417
  pi.on("session_tree", (event, ctx) => {
381
418
  recordTreeNavigation(event.newLeafId);
382
- syncDelegateStatus(ctx);
419
+ syncDelegateStatus(ctx, runtime);
383
420
  });
384
421
 
385
422
  // ── Session shutdown: abort tickets and dispose live pooled sessions ──
@@ -395,7 +432,7 @@ export default function delegateExtension(pi: ExtensionAPI): void {
395
432
  // leave a trace. For quit the TUI is already stopped — stderr lands in
396
433
  // the scrollback. For reload the TUI survives — warn in place. Switch
397
434
  // and fork already passed the confirm guard above.
398
- const active = activeTicketSummary();
435
+ const active = activeTicketSummary(runtime);
399
436
  if (active.tickets.length) {
400
437
  if (event.reason === "quit") {
401
438
  console.error(
@@ -416,15 +453,15 @@ export default function delegateExtension(pi: ExtensionAPI): void {
416
453
  }
417
454
  }
418
455
 
419
- for (const ticket of ticketRegistry.values()) {
456
+ for (const ticket of runtime.tickets.values()) {
420
457
  if (ticket.status === "running" || ticket.status === "cancelling") {
421
- cancelTicketForShutdown(ticket);
458
+ runtime.tickets.cancelTicketForShutdown(ticket);
422
459
  }
423
460
  // Include already-cancelled tickets too: a repeated shutdown event can
424
461
  // race the first handler while its workers are still unwinding.
425
462
  if (ticket.completion) ticketCompletions.push(ticket.completion);
426
463
  }
427
- syncDelegateStatus(ctx);
464
+ syncDelegateStatus(ctx, runtime);
428
465
  // The runtime is invalidated right after this handler returns; aborted
429
466
  // tickets keep unwinding asynchronously and must find no cached ctx (or
430
467
  // captured pi) to touch. The cancelled completion path still writes one
@@ -443,7 +480,7 @@ export default function delegateExtension(pi: ExtensionAPI): void {
443
480
  // SQLite still stays open until both cleanup paths finish.
444
481
  let poolCleanup: Promise<void>;
445
482
  try {
446
- poolCleanup = closeAllPooledAgents();
483
+ poolCleanup = runtime.pool.closeAllPooledAgents();
447
484
  } catch (error) {
448
485
  console.error("[delegate] pooled-session shutdown start failed", error);
449
486
  poolCleanup = Promise.resolve();
package/format.ts CHANGED
@@ -530,7 +530,10 @@ export function formatCompletedTask(
530
530
  if (integration.patchPath)
531
531
  parts.push(`full patch: ${integration.patchPath}`);
532
532
  if (integration.worktreePath)
533
- parts.push(`conflict worktree: ${integration.worktreePath}`);
533
+ parts.push(`recovery worktree: ${integration.worktreePath}`);
534
+ if (integration.status === "retained") {
535
+ parts.push(`not applied: ${integration.reason}`);
536
+ }
534
537
  for (const conflict of integration.conflicts ?? []) {
535
538
  parts.push(`conflict: ${conflict.path}: ${conflict.reason}`);
536
539
  }
@@ -583,6 +586,7 @@ export function latestActivity(p: TaskProgress): ToolActivity | null {
583
586
  * - "last: read src/bar.ts" after a tool completes and the model is thinking
584
587
  * - "thinking" when no activity has been recorded yet */
585
588
  export function formatActivityLabel(p: TaskProgress): string {
589
+ if (p.paused) return "paused between turns";
586
590
  const activity = inFlightActivity(p) ?? latestActivity(p);
587
591
  if (!activity) return "thinking";
588
592
  const call = sanitizeTerminalLine(
@@ -596,6 +600,7 @@ export function formatActivityLabel(p: TaskProgress): string {
596
600
  * as {@link formatActivityLabel} but adds elapsed time for in-flight tools and
597
601
  * a completion/error icon for finished ones. */
598
602
  export function compactActivity(p: TaskProgress): string {
603
+ if (p.paused) return "paused between turns";
599
604
  const activity = inFlightActivity(p) ?? latestActivity(p);
600
605
  if (!activity) return "thinking…";
601
606
  const call = sanitizeTerminalLine(
@@ -653,18 +658,31 @@ export function findTouchedOverlaps(
653
658
  results: readonly {
654
659
  attributedFiles?: string[];
655
660
  workspace?: WorkspaceMode;
661
+ serializedGroup?: number;
662
+ incomplete?: string;
656
663
  }[],
657
664
  ): string[] {
658
- const counts = new Map<string, number>();
665
+ const owners = new Map<string, (typeof results)[number][]>();
666
+ const overlaps = new Set<string>();
659
667
  for (const r of results) {
660
- for (const f of r.attributedFiles ?? []) {
661
- counts.set(f, (counts.get(f) ?? 0) + 1);
668
+ for (const f of new Set(r.attributedFiles ?? [])) {
669
+ const previous = owners.get(f) ?? [];
670
+ if (
671
+ previous.some(
672
+ (other) =>
673
+ r.serializedGroup === undefined ||
674
+ r.serializedGroup !== other.serializedGroup ||
675
+ r.incomplete !== undefined ||
676
+ other.incomplete !== undefined,
677
+ )
678
+ ) {
679
+ overlaps.add(f);
680
+ }
681
+ previous.push(r);
682
+ owners.set(f, previous);
662
683
  }
663
684
  }
664
- return [...counts.entries()]
665
- .filter(([, count]) => count > 1)
666
- .map(([file]) => file)
667
- .sort();
685
+ return [...overlaps].sort();
668
686
  }
669
687
 
670
688
  /**
@@ -677,5 +695,5 @@ export function findTouchedOverlaps(
677
695
  */
678
696
  export function formatTouchedOverlapWarning(overlaps: string[]): string | null {
679
697
  if (!overlaps.length) return null;
680
- return `WARNING: These tasks reported touching the same file(s): ${overlaps.join(", ")}. Delegate does not isolate or serialize file access and does not roll back completed writes.`;
698
+ return `WARNING: Tasks without a verified ordering reported touching the same file(s): ${overlaps.join(", ")}. File reports do not prove simultaneous writes or a conflict; completed writes are not rolled back.`;
681
699
  }
package/host.ts CHANGED
@@ -85,7 +85,7 @@ export interface HostDepsOptions {
85
85
  * host deps are cached per (agentDir + cwd + systemPrompt): the expensive
86
86
  * `reload()` (skills, project AGENTS.md discovery) runs once per distinct combo, then
87
87
  * is reused across concurrent subagents. Provider-configured or
88
- * allowlisted-extension tasks always receive fresh host deps. For ad-hoc
88
+ * allowlisted-extension tasks always receive fresh host deps. For inline
89
89
  * tasks (no named agent) pass undefined to use the discovered prompt.
90
90
  */
91
91
  systemPrompt?: string;
@@ -25,6 +25,7 @@ let removeWorktreeHookForTesting:
25
25
  destination: string,
26
26
  ) => boolean | undefined | Promise<boolean | undefined>)
27
27
  | undefined;
28
+ let beforeSourceApplyHookForTesting: (() => void | Promise<void>) | undefined;
28
29
 
29
30
  /** @internal Keep tests out of the developer's real ~/.pi directory. */
30
31
  export function _setIsolatedArtifactRootForTesting(
@@ -44,6 +45,12 @@ export function _setRemoveWorktreeHookForTesting(
44
45
  removeWorktreeHookForTesting = hook;
45
46
  }
46
47
 
48
+ export function _setBeforeSourceApplyHookForTesting(
49
+ hook: (() => void | Promise<void>) | undefined,
50
+ ): void {
51
+ beforeSourceApplyHookForTesting = hook;
52
+ }
53
+
47
54
  interface CommandResult {
48
55
  stdout: string;
49
56
  stderr: string;
@@ -698,9 +705,18 @@ interface IsolatedWorker {
698
705
  patchPath: string;
699
706
  }
700
707
 
708
+ export interface IsolatedReconcileOptions {
709
+ shouldApplySource?: () => boolean;
710
+ retainedReason?: string;
711
+ signal?: AbortSignal;
712
+ }
713
+
701
714
  export interface PreparedIsolatedBatch {
702
715
  resolved: ResolvedTask[];
703
- reconcile(results: TaskResult[]): Promise<TaskResult[]>;
716
+ reconcile(
717
+ results: TaskResult[],
718
+ options?: IsolatedReconcileOptions,
719
+ ): Promise<TaskResult[]>;
704
720
  }
705
721
 
706
722
  async function restoreAfterFailedApply(
@@ -732,10 +748,45 @@ async function restoreAfterFailedApply(
732
748
  }
733
749
  }
734
750
 
751
+ async function retainAcceptedProposals(
752
+ group: IsolatedGroup,
753
+ workers: Map<number, IsolatedWorker>,
754
+ results: TaskResult[],
755
+ accepted: Map<number, string[]>,
756
+ pristineRoot: string,
757
+ reason: string,
758
+ ): Promise<void> {
759
+ let cleanupIssue:
760
+ { status: "failed"; reason: string; recoveryPath: string } | undefined;
761
+ try {
762
+ await requireWorktreeRemoved(group.sourceRoot, pristineRoot);
763
+ } catch (error) {
764
+ cleanupIssue = {
765
+ status: "failed",
766
+ reason: error instanceof Error ? error.message : String(error),
767
+ recoveryPath: pristineRoot,
768
+ };
769
+ }
770
+ for (const [taskIndex, proposedFiles] of accepted) {
771
+ const worker = workers.get(taskIndex)!;
772
+ results[taskIndex]!.integration = {
773
+ status: "retained",
774
+ reason,
775
+ proposedFiles,
776
+ appliedFiles: [],
777
+ baselineRef: group.baselineRef,
778
+ proposalRef: worker.proposalRef,
779
+ patchPath: worker.patchPath,
780
+ ...(cleanupIssue ? { cleanupIssue } : {}),
781
+ };
782
+ }
783
+ }
784
+
735
785
  async function reconcileGroup(
736
786
  group: IsolatedGroup,
737
787
  workers: Map<number, IsolatedWorker>,
738
788
  results: TaskResult[],
789
+ options: IsolatedReconcileOptions,
739
790
  ): Promise<void> {
740
791
  let integratedCommit = group.baselineCommit;
741
792
  const accepted = new Map<number, string[]>();
@@ -948,6 +999,17 @@ async function reconcileGroup(
948
999
  await requireWorktreeRemoved(group.sourceRoot, pristineRoot);
949
1000
  return;
950
1001
  }
1002
+ if (options.shouldApplySource && !options.shouldApplySource()) {
1003
+ await retainAcceptedProposals(
1004
+ group,
1005
+ workers,
1006
+ results,
1007
+ accepted,
1008
+ pristineRoot,
1009
+ options.retainedReason ?? "Source application was cancelled.",
1010
+ );
1011
+ return;
1012
+ }
951
1013
 
952
1014
  const currentTree = await snapshotTree(
953
1015
  group.sourceRoot,
@@ -995,6 +1057,18 @@ async function reconcileGroup(
995
1057
  group.baselineCommit,
996
1058
  integratedCommit,
997
1059
  );
1060
+ await beforeSourceApplyHookForTesting?.();
1061
+ if (options.shouldApplySource && !options.shouldApplySource()) {
1062
+ await retainAcceptedProposals(
1063
+ group,
1064
+ workers,
1065
+ results,
1066
+ accepted,
1067
+ pristineRoot,
1068
+ options.retainedReason ?? "Source application was cancelled.",
1069
+ );
1070
+ return;
1071
+ }
998
1072
  const applyIndex = path.join(group.artifactRoot, "apply.index");
999
1073
  await fs.promises.rm(applyIndex, { force: true });
1000
1074
  const env = {
@@ -1010,12 +1084,25 @@ async function reconcileGroup(
1010
1084
  cwd: group.sourceRoot,
1011
1085
  env,
1012
1086
  });
1087
+ if (options.shouldApplySource && !options.shouldApplySource()) {
1088
+ await retainAcceptedProposals(
1089
+ group,
1090
+ workers,
1091
+ results,
1092
+ accepted,
1093
+ pristineRoot,
1094
+ options.retainedReason ?? "Source application was cancelled.",
1095
+ );
1096
+ return;
1097
+ }
1013
1098
  await git(["apply", "--binary", "--index", finalPatch], {
1014
1099
  cwd: group.sourceRoot,
1015
1100
  env,
1101
+ signal: options.signal,
1016
1102
  });
1017
1103
  } catch (error) {
1018
1104
  const recoveryRoot = path.join(group.artifactRoot, "failed-apply-files");
1105
+ let rollbackSucceeded = true;
1019
1106
  try {
1020
1107
  await restoreAfterFailedApply(
1021
1108
  group.sourceRoot,
@@ -1024,11 +1111,23 @@ async function reconcileGroup(
1024
1111
  recoveryRoot,
1025
1112
  );
1026
1113
  } catch (rollbackError) {
1114
+ rollbackSucceeded = false;
1027
1115
  console.error(
1028
1116
  "[delegate] isolated apply rollback failed; recovery artifacts retained",
1029
1117
  rollbackError,
1030
1118
  );
1031
1119
  }
1120
+ if (options.signal?.aborted && rollbackSucceeded) {
1121
+ await retainAcceptedProposals(
1122
+ group,
1123
+ workers,
1124
+ results,
1125
+ accepted,
1126
+ pristineRoot,
1127
+ options.retainedReason ?? "Source application was cancelled.",
1128
+ );
1129
+ return;
1130
+ }
1032
1131
  for (const [taskIndex] of accepted) {
1033
1132
  const integration = results[taskIndex]!.integration!;
1034
1133
  const worker = workers.get(taskIndex)!;
@@ -1145,24 +1244,35 @@ async function cleanupCompletedGroupRefs(
1145
1244
  group: IsolatedGroup,
1146
1245
  workers: Map<number, IsolatedWorker>,
1147
1246
  results: readonly TaskResult[],
1148
- ): Promise<void> {
1247
+ ): Promise<boolean> {
1149
1248
  const disposableProposalRefs: string[] = [];
1150
1249
  let retainsRecoveryArtifacts = false;
1250
+ let retainsPrivateRefs = false;
1151
1251
 
1152
1252
  for (const taskIndex of group.taskIndexes) {
1153
- const status = results[taskIndex]?.integration?.status;
1154
- if (status === "conflict" || status === "apply_failed") {
1253
+ const integration = results[taskIndex]?.integration;
1254
+ const status = integration?.status;
1255
+ if (!integration) {
1256
+ retainsRecoveryArtifacts = true;
1257
+ retainsPrivateRefs = true;
1258
+ } else if (
1259
+ status === "retained" ||
1260
+ status === "conflict" ||
1261
+ status === "apply_failed"
1262
+ ) {
1155
1263
  retainsRecoveryArtifacts = true;
1264
+ retainsPrivateRefs = true;
1156
1265
  } else if (
1157
1266
  status === "applied_unverified" ||
1158
1267
  status === "no_changes" ||
1159
1268
  status === "discarded"
1160
1269
  ) {
1270
+ if (integration.cleanupIssue) retainsRecoveryArtifacts = true;
1161
1271
  disposableProposalRefs.push(workers.get(taskIndex)!.proposalRef);
1162
1272
  }
1163
1273
  }
1164
1274
 
1165
- const refs = retainsRecoveryArtifacts
1275
+ const refs = retainsPrivateRefs
1166
1276
  ? disposableProposalRefs
1167
1277
  : [...disposableProposalRefs, group.baselineRef];
1168
1278
  try {
@@ -1172,6 +1282,7 @@ async function cleanupCompletedGroupRefs(
1172
1282
  // turn a successfully applied source change into a reported failure.
1173
1283
  console.error("[delegate] failed to clean completed isolated refs", error);
1174
1284
  }
1285
+ return retainsRecoveryArtifacts;
1175
1286
  }
1176
1287
 
1177
1288
  /** Prepare detached worktrees from one synthetic commit per Git root. The
@@ -1315,11 +1426,14 @@ export async function prepareIsolatedBatch(
1315
1426
 
1316
1427
  return {
1317
1428
  resolved: translated,
1318
- async reconcile(results: TaskResult[]): Promise<TaskResult[]> {
1429
+ async reconcile(
1430
+ results: TaskResult[],
1431
+ options: IsolatedReconcileOptions = {},
1432
+ ): Promise<TaskResult[]> {
1319
1433
  for (const group of groupsByRoot.values()) {
1320
1434
  try {
1321
1435
  try {
1322
- await reconcileGroup(group, workers, results);
1436
+ await reconcileGroup(group, workers, results, options);
1323
1437
  } catch (error) {
1324
1438
  console.error(
1325
1439
  "[delegate] isolated group reconciliation failed",
@@ -1332,13 +1446,45 @@ export async function prepareIsolatedBatch(
1332
1446
  error,
1333
1447
  );
1334
1448
  }
1335
- await cleanupCompletedGroupRefs(group, workers, results);
1449
+ const retainsRecoveryArtifacts = await cleanupCompletedGroupRefs(
1450
+ group,
1451
+ workers,
1452
+ results,
1453
+ );
1454
+ if (!retainsRecoveryArtifacts) {
1455
+ try {
1456
+ await fs.promises.rm(group.artifactRoot, {
1457
+ recursive: true,
1458
+ force: true,
1459
+ });
1460
+ } catch (error) {
1461
+ console.error(
1462
+ `[delegate] failed to remove completed isolated artifacts '${group.artifactRoot}'`,
1463
+ error,
1464
+ );
1465
+ }
1466
+ }
1336
1467
  } finally {
1337
1468
  // Unblocks and serializes any safety-confirmed quarantine cleanups
1338
1469
  // only after all candidate/source/ref work for this group is done.
1339
1470
  group.finishReconciliation();
1340
1471
  }
1341
1472
  }
1473
+ try {
1474
+ await fs.promises.rmdir(batchArtifactRoot);
1475
+ } catch (error) {
1476
+ if (!(
1477
+ error instanceof Error &&
1478
+ "code" in error &&
1479
+ ((error as NodeJS.ErrnoException).code === "ENOENT" ||
1480
+ (error as NodeJS.ErrnoException).code === "ENOTEMPTY")
1481
+ )) {
1482
+ console.error(
1483
+ `[delegate] failed to remove isolated batch directory '${batchArtifactRoot}'`,
1484
+ error,
1485
+ );
1486
+ }
1487
+ }
1342
1488
  return results;
1343
1489
  },
1344
1490
  };