@bermudi/pi-delegate 0.1.13 → 0.1.14

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.
@@ -3,11 +3,28 @@ import * as crypto from "node:crypto";
3
3
  import * as fs from "node:fs";
4
4
  import * as os from "node:os";
5
5
  import * as path from "node:path";
6
- import type { ResolvedTask, TaskIntegration, TaskResult } from "./types.ts";
6
+ import { mapConcurrent } from "./concurrency.ts";
7
+ import { revalidateFileAttribution } from "./file-tracking.ts";
8
+ import {
9
+ observeQuarantineSafety,
10
+ sessionQuarantineOf,
11
+ } from "./session-quarantine.ts";
12
+ import type {
13
+ FileAttribution,
14
+ ResolvedTask,
15
+ TaskIntegration,
16
+ TaskResult,
17
+ } from "./types.ts";
7
18
 
8
19
  const GIT_TIMEOUT_MS = 5 * 60 * 1000;
20
+ const PATH_CLASSIFICATION_CONCURRENCY = 16;
9
21
  const PROCESS_GRACE_MS = 500;
10
22
  let artifactBaseOverrideForTesting: string | undefined;
23
+ let removeWorktreeHookForTesting:
24
+ | ((
25
+ destination: string,
26
+ ) => boolean | undefined | Promise<boolean | undefined>)
27
+ | undefined;
11
28
 
12
29
  /** @internal Keep tests out of the developer's real ~/.pi directory. */
13
30
  export function _setIsolatedArtifactRootForTesting(
@@ -16,6 +33,17 @@ export function _setIsolatedArtifactRootForTesting(
16
33
  artifactBaseOverrideForTesting = root;
17
34
  }
18
35
 
36
+ /** @internal Deterministic failure/order seam for worktree cleanup tests. */
37
+ export function _setRemoveWorktreeHookForTesting(
38
+ hook:
39
+ | ((
40
+ destination: string,
41
+ ) => boolean | undefined | Promise<boolean | undefined>)
42
+ | undefined,
43
+ ): void {
44
+ removeWorktreeHookForTesting = hook;
45
+ }
46
+
19
47
  interface CommandResult {
20
48
  stdout: string;
21
49
  stderr: string;
@@ -97,6 +125,344 @@ function isWithin(root: string, candidate: string): boolean {
97
125
  );
98
126
  }
99
127
 
128
+ function errnoOf(error: unknown): string {
129
+ return error instanceof Error && "code" in error
130
+ ? String((error as NodeJS.ErrnoException).code ?? "UNKNOWN")
131
+ : "UNKNOWN";
132
+ }
133
+
134
+ function pathEntryExists(candidate: string): boolean {
135
+ try {
136
+ fs.lstatSync(candidate);
137
+ return true;
138
+ } catch (error) {
139
+ const code = errnoOf(error);
140
+ if (code === "ENOENT" || code === "ENOTDIR") return false;
141
+ console.error(
142
+ `[delegate] could not verify isolated recovery path ${JSON.stringify(candidate)} (errno=${code}); treating it as retained`,
143
+ );
144
+ return true;
145
+ }
146
+ }
147
+
148
+ function logPathCanonicalizationFailure(
149
+ candidate: string,
150
+ error: unknown,
151
+ ): void {
152
+ const safeCandidate = JSON.stringify(
153
+ candidate.length > 1_024
154
+ ? `${candidate.slice(0, 1_024)}…[truncated ${candidate.length - 1_024} chars]`
155
+ : candidate,
156
+ )
157
+ .replace(/\u2028/g, "\\u2028")
158
+ .replace(/\u2029/g, "\\u2029");
159
+ console.error(
160
+ `[delegate] could not canonicalize reported path ${safeCandidate} (errno=${errnoOf(error)}); retaining uncertain attribution`,
161
+ );
162
+ }
163
+
164
+ interface PhysicalReportedPath {
165
+ path: string;
166
+ /** The path crossed a symlink that could not be fully resolved, or path
167
+ * resolution itself failed. Such attribution must be retained rather than
168
+ * assumed to be an ordinary path inside the disposable worktree. */
169
+ uncertain: boolean;
170
+ }
171
+
172
+ /** Resolve symlinked ancestors even when the reported leaf no longer exists.
173
+ * The final component is followed only for explicit edit/write attribution:
174
+ * Git may instead be reporting a symlink node that was itself added or changed.
175
+ * Resolution failures are data, not reconciliation failures; callers retain
176
+ * uncertain attribution conservatively. */
177
+ async function physicalReportedPath(
178
+ candidate: string,
179
+ followFinalSymlink: boolean,
180
+ ): Promise<PhysicalReportedPath> {
181
+ let cursor = followFinalSymlink ? candidate : path.dirname(candidate);
182
+ const suffix = followFinalSymlink ? [] : [path.basename(candidate)];
183
+
184
+ for (;;) {
185
+ try {
186
+ const physical = await fs.promises.realpath(cursor);
187
+ return { path: path.resolve(physical, ...suffix), uncertain: false };
188
+ } catch (error) {
189
+ const code =
190
+ error instanceof Error && "code" in error
191
+ ? (error as NodeJS.ErrnoException).code
192
+ : undefined;
193
+ if (code !== "ENOENT" && code !== "ENOTDIR") {
194
+ logPathCanonicalizationFailure(candidate, error);
195
+ return { path: candidate, uncertain: true };
196
+ }
197
+
198
+ // realpath reports ENOENT for a dangling symlink. Before walking up to
199
+ // an existing parent (which would incorrectly make it look internal),
200
+ // preserve the symlink's intended target and mark it uncertain.
201
+ let stat: fs.Stats | undefined;
202
+ try {
203
+ stat = await fs.promises.lstat(cursor);
204
+ } catch (lstatError) {
205
+ const lstatCode =
206
+ lstatError instanceof Error && "code" in lstatError
207
+ ? (lstatError as NodeJS.ErrnoException).code
208
+ : undefined;
209
+ if (lstatCode !== "ENOENT" && lstatCode !== "ENOTDIR") {
210
+ logPathCanonicalizationFailure(candidate, lstatError);
211
+ return { path: candidate, uncertain: true };
212
+ }
213
+ }
214
+ if (stat?.isSymbolicLink()) {
215
+ try {
216
+ const target = await fs.promises.readlink(cursor);
217
+ const intended = path.resolve(
218
+ path.dirname(cursor),
219
+ target,
220
+ ...suffix,
221
+ );
222
+ const resolved = await physicalReportedPath(intended, true);
223
+ return { path: resolved.path, uncertain: true };
224
+ } catch (error) {
225
+ const code = errnoOf(error);
226
+ if (code !== "ENOENT" && code !== "ENOTDIR") {
227
+ logPathCanonicalizationFailure(candidate, error);
228
+ }
229
+ return { path: candidate, uncertain: true };
230
+ }
231
+ }
232
+
233
+ const parent = path.dirname(cursor);
234
+ if (parent === cursor) {
235
+ return { path: candidate, uncertain: true };
236
+ }
237
+ suffix.unshift(path.basename(cursor));
238
+ cursor = parent;
239
+ }
240
+ }
241
+ }
242
+
243
+ function mapReportedPathToSource(
244
+ group: IsolatedGroup,
245
+ physicalWorkerRoot: string,
246
+ candidate: string,
247
+ ): string {
248
+ return isWithin(physicalWorkerRoot, candidate)
249
+ ? path.join(group.sourceRoot, path.relative(physicalWorkerRoot, candidate))
250
+ : candidate;
251
+ }
252
+
253
+ async function changedWorkerPaths(
254
+ workerRoot: string,
255
+ baselineCommit: string,
256
+ ): Promise<Set<string> | undefined> {
257
+ try {
258
+ const [tracked, untracked] = await Promise.all([
259
+ git(["diff", "--name-only", "-z", "--no-renames", baselineCommit, "--"], {
260
+ cwd: workerRoot,
261
+ }),
262
+ git(["ls-files", "--others", "--exclude-standard", "-z"], {
263
+ cwd: workerRoot,
264
+ }),
265
+ ]);
266
+ return new Set(
267
+ `${tracked.stdout}${untracked.stdout}`
268
+ .split("\0")
269
+ .filter(Boolean)
270
+ .map((relative) => path.resolve(workerRoot, relative)),
271
+ );
272
+ } catch (error) {
273
+ // Reporting is conservative when Git evidence cannot be reconstructed, but
274
+ // the missing evidence must be visible and attributable to its worker.
275
+ console.error(
276
+ `[delegate] failed to collect isolated Git change evidence for worker '${workerRoot}'`,
277
+ error,
278
+ );
279
+ return undefined;
280
+ }
281
+ }
282
+
283
+ async function classifyReportedFiles(
284
+ group: IsolatedGroup,
285
+ worker: IsolatedWorker,
286
+ result: TaskResult,
287
+ ): Promise<void> {
288
+ const physicalWorkerRoot = await fs.promises.realpath(worker.workerRoot);
289
+ const gitChanged = await changedWorkerPaths(
290
+ worker.workerRoot,
291
+ group.baselineCommit,
292
+ );
293
+ const structured = (result.fileAttributions ?? []).map(
294
+ revalidateFileAttribution,
295
+ );
296
+ const legacyCandidates = structured.length
297
+ ? []
298
+ : [
299
+ ...new Set(
300
+ (result.attributedFiles ?? []).map((candidate) =>
301
+ path.resolve(worker.cwd, candidate),
302
+ ),
303
+ ),
304
+ ];
305
+ const attributionByLexical = new Map(
306
+ structured.map((entry) => [path.resolve(entry.lexicalPath), entry]),
307
+ );
308
+ const legacySet = new Set(legacyCandidates);
309
+ const distinctPhysicalSnapshots = new Set(
310
+ structured
311
+ .filter(
312
+ (entry) =>
313
+ entry.preExecutionPhysicalPath !== undefined &&
314
+ path.resolve(entry.preExecutionPhysicalPath) !==
315
+ path.resolve(entry.lexicalPath),
316
+ )
317
+ .map((entry) => path.resolve(entry.preExecutionPhysicalPath!)),
318
+ );
319
+
320
+ // Trusted physical snapshots are immutable evidence: never resolve them
321
+ // against the final worker tree. The associated lexical path is used only to
322
+ // decide whether a symlink node itself changed. Legacy string-only evidence
323
+ // retains the older conservative final-view classification.
324
+ const candidates = [
325
+ ...result.touchedFiles.map((candidate) => ({
326
+ kind: "touched" as const,
327
+ candidate,
328
+ })),
329
+ ...structured.map((attribution) => ({
330
+ kind: "structured" as const,
331
+ attribution,
332
+ })),
333
+ ...legacyCandidates.map((candidate) => ({
334
+ kind: "legacy" as const,
335
+ candidate,
336
+ })),
337
+ ];
338
+ const classified = await mapConcurrent(
339
+ candidates,
340
+ PATH_CLASSIFICATION_CONCURRENCY,
341
+ async (entry) => {
342
+ if (entry.kind === "touched") {
343
+ const absolute = path.resolve(worker.cwd, entry.candidate);
344
+ if (distinctPhysicalSnapshots.has(absolute)) {
345
+ return {
346
+ kind: "touched" as const,
347
+ path: mapReportedPathToSource(group, physicalWorkerRoot, absolute),
348
+ };
349
+ }
350
+ const associated =
351
+ attributionByLexical.get(absolute) ??
352
+ (legacySet.has(absolute) ? true : undefined);
353
+ const finalStat = await fs.promises.lstat(absolute).catch(() => null);
354
+ if (
355
+ associated &&
356
+ finalStat?.isSymbolicLink() &&
357
+ gitChanged !== undefined &&
358
+ !gitChanged.has(absolute)
359
+ ) {
360
+ return { kind: "touched" as const, path: undefined };
361
+ }
362
+ const resolved = await physicalReportedPath(absolute, false);
363
+ return {
364
+ kind: "touched" as const,
365
+ path: mapReportedPathToSource(
366
+ group,
367
+ physicalWorkerRoot,
368
+ resolved.path,
369
+ ),
370
+ };
371
+ }
372
+
373
+ if (entry.kind === "structured") {
374
+ const evidence = entry.attribution;
375
+ const physical = evidence.preExecutionPhysicalPath;
376
+ const resolved = {
377
+ path: physical
378
+ ? path.resolve(physical)
379
+ : path.resolve(evidence.lexicalPath),
380
+ uncertain: evidence.uncertain || physical === undefined,
381
+ };
382
+ return {
383
+ kind: "attributed" as const,
384
+ value: {
385
+ ...resolved,
386
+ evidence,
387
+ reportedPath: mapReportedPathToSource(
388
+ group,
389
+ physicalWorkerRoot,
390
+ resolved.path,
391
+ ),
392
+ },
393
+ };
394
+ }
395
+
396
+ const resolved = await physicalReportedPath(entry.candidate, true);
397
+ return {
398
+ kind: "attributed" as const,
399
+ value: {
400
+ ...resolved,
401
+ evidence: undefined,
402
+ reportedPath: mapReportedPathToSource(
403
+ group,
404
+ physicalWorkerRoot,
405
+ resolved.path,
406
+ ),
407
+ },
408
+ };
409
+ },
410
+ );
411
+ const touched = classified
412
+ .filter(
413
+ (
414
+ entry,
415
+ ): entry is Extract<(typeof classified)[number], { kind: "touched" }> =>
416
+ entry.kind === "touched",
417
+ )
418
+ .map((entry) => entry.path)
419
+ .filter((candidate): candidate is string => candidate !== undefined);
420
+ const attributed = classified
421
+ .filter(
422
+ (
423
+ entry,
424
+ ): entry is Extract<
425
+ (typeof classified)[number],
426
+ { kind: "attributed" }
427
+ > => entry.kind === "attributed",
428
+ )
429
+ .map((entry) => entry.value);
430
+ const escapedOrUncertain = attributed.filter(
431
+ (candidate) =>
432
+ candidate.uncertain || !isWithin(physicalWorkerRoot, candidate.path),
433
+ );
434
+
435
+ result.fileAttributions = escapedOrUncertain
436
+ .filter(
437
+ (
438
+ candidate,
439
+ ): candidate is typeof candidate & { evidence: FileAttribution } =>
440
+ candidate.evidence !== undefined,
441
+ )
442
+ .map((candidate) => ({
443
+ ...candidate.evidence,
444
+ lexicalPath: mapReportedPathToSource(
445
+ group,
446
+ physicalWorkerRoot,
447
+ path.resolve(candidate.evidence.lexicalPath),
448
+ ),
449
+ preExecutionPhysicalPath:
450
+ candidate.evidence.preExecutionPhysicalPath === undefined
451
+ ? undefined
452
+ : candidate.reportedPath,
453
+ uncertain: candidate.uncertain,
454
+ }));
455
+ result.attributedFiles = [
456
+ ...new Set(escapedOrUncertain.map((candidate) => candidate.reportedPath)),
457
+ ];
458
+ result.touchedFiles = [
459
+ ...new Set([
460
+ ...touched,
461
+ ...escapedOrUncertain.map((candidate) => candidate.reportedPath),
462
+ ]),
463
+ ];
464
+ }
465
+
100
466
  async function repositoryRoot(cwd: string): Promise<string> {
101
467
  const physicalCwd = await fs.promises.realpath(cwd);
102
468
  let root: string;
@@ -180,27 +546,21 @@ async function removeWorktree(
180
546
  root: string,
181
547
  destination: string,
182
548
  ): Promise<boolean> {
549
+ const override = await removeWorktreeHookForTesting?.(destination);
550
+ if (override !== undefined) return override;
183
551
  try {
184
552
  await git(["worktree", "remove", "--force", destination], { cwd: root });
185
- return true;
553
+ if (!pathEntryExists(destination)) return true;
554
+ console.error(
555
+ `[delegate] Git reported isolated worktree removal success but the recovery path remains at ${JSON.stringify(destination)}`,
556
+ );
557
+ return false;
186
558
  } catch (error) {
187
- let stillRegistered = true;
188
- try {
189
- const listed = await git(["worktree", "list", "--porcelain", "-z"], {
190
- cwd: root,
191
- });
192
- stillRegistered = listed.stdout
193
- .split("\0")
194
- .some(
195
- (entry) =>
196
- entry.startsWith("worktree ") &&
197
- path.resolve(entry.slice("worktree ".length)) ===
198
- path.resolve(destination),
199
- );
200
- } catch {
201
- // Fail closed: if registration cannot be checked, preserve the files.
202
- }
203
- if (!stillRegistered) return true;
559
+ // The command can report failure after completing removal. Only treat that
560
+ // as success when the recovery path itself is definitely gone.
561
+ if (!pathEntryExists(destination)) return true;
562
+ // An unregistered directory is still recovery evidence. Do not silently
563
+ // call it removed or recursively delete it after Git declined the cleanup.
204
564
  console.error(
205
565
  `[delegate] failed to remove isolated worktree '${destination}'`,
206
566
  error,
@@ -209,6 +569,16 @@ async function removeWorktree(
209
569
  }
210
570
  }
211
571
 
572
+ async function requireWorktreeRemoved(
573
+ root: string,
574
+ destination: string,
575
+ ): Promise<void> {
576
+ if (await removeWorktree(root, destination)) return;
577
+ throw new Error(
578
+ `Could not remove isolated worktree; recovery workspace retained at ${JSON.stringify(destination)}.`,
579
+ );
580
+ }
581
+
212
582
  async function changedFiles(
213
583
  root: string,
214
584
  from: string,
@@ -299,6 +669,25 @@ interface IsolatedGroup {
299
669
  baselineCommit: string;
300
670
  baselineRef: string;
301
671
  taskIndexes: number[];
672
+ /** Deferred quarantine cleanup must not run Git worktree operations while the
673
+ * group is still reconciling candidates/source state. */
674
+ reconciliationDone: Promise<void>;
675
+ finishReconciliation: () => void;
676
+ deferredCleanupTail: Promise<void>;
677
+ }
678
+
679
+ function enqueueDeferredGroupCleanup(
680
+ group: IsolatedGroup,
681
+ cleanup: () => Promise<void>,
682
+ ): Promise<void> {
683
+ const run = async () => {
684
+ await group.reconciliationDone;
685
+ await cleanup();
686
+ };
687
+ const queued = group.deferredCleanupTail.then(run, run);
688
+ // Keep the serialization chain usable after a surfaced callback failure.
689
+ group.deferredCleanupTail = queued.catch(() => {});
690
+ return queued;
302
691
  }
303
692
 
304
693
  interface IsolatedWorker {
@@ -357,33 +746,100 @@ async function reconcileGroup(
357
746
  const worker = workers.get(taskIndex)!;
358
747
  const result = results[taskIndex]!;
359
748
  result.workspace = "isolated";
360
- result.touchedFiles = result.touchedFiles.map((candidate) => {
361
- const absolute = path.resolve(worker.cwd, candidate);
362
- return isWithin(worker.workerRoot, absolute)
363
- ? path.join(
364
- group.sourceRoot,
365
- path.relative(worker.workerRoot, absolute),
366
- )
367
- : absolute;
368
- });
369
- // Writes inside the worktree did not touch the source concurrently.
370
- // Preserve only explicitly attributed paths that escaped the worktree.
371
- result.attributedFiles = (result.attributedFiles ?? [])
372
- .map((candidate) => path.resolve(worker.cwd, candidate))
373
- .filter((candidate) => !isWithin(worker.workerRoot, candidate));
374
- if (result.error) {
749
+
750
+ const quarantine = sessionQuarantineOf(result);
751
+ if (quarantine) {
752
+ result.error ??=
753
+ "AgentSession quiescence was abandoned; the isolated proposal was not applied.";
375
754
  result.integration = {
376
755
  status: "discarded",
377
756
  proposedFiles: [],
378
757
  appliedFiles: [],
758
+ cleanupIssue: {
759
+ status: "deferred",
760
+ reason:
761
+ "Worker cleanup is deferred until background AgentSession safety is confirmed; the recovery path may be removed after safe cleanup.",
762
+ recoveryPath: worker.workerRoot,
763
+ },
379
764
  };
380
- await removeWorktree(group.sourceRoot, worker.workerRoot);
765
+ console.error(
766
+ `[delegate] retaining abandoned isolated worker '${worker.workerRoot}' until its AgentSession is confirmed quiescent; proposal will not be snapshotted or applied`,
767
+ );
768
+ observeQuarantineSafety(
769
+ quarantine,
770
+ "deferred isolated worker cleanup",
771
+ async () =>
772
+ enqueueDeferredGroupCleanup(group, async () => {
773
+ try {
774
+ await stopWorkspaceProcesses(worker.workerRoot);
775
+ const removed = await removeWorktree(
776
+ group.sourceRoot,
777
+ worker.workerRoot,
778
+ );
779
+ if (removed) {
780
+ console.error(
781
+ `[delegate] safely cleaned deferred isolated worker '${worker.workerRoot}'`,
782
+ );
783
+ } else {
784
+ console.error(
785
+ `[delegate] deferred isolated worker cleanup could not remove '${worker.workerRoot}'; retaining it as recovery evidence`,
786
+ );
787
+ }
788
+ } catch (error) {
789
+ console.error(
790
+ `[delegate] deferred isolated worker cleanup failed for '${worker.workerRoot}'; retaining it`,
791
+ error,
792
+ );
793
+ }
794
+ }),
795
+ (error) => {
796
+ console.error(
797
+ `[delegate] isolated AgentSession safety monitor failed; retaining worker '${worker.workerRoot}' indefinitely`,
798
+ error,
799
+ );
800
+ },
801
+ );
381
802
  continue;
382
803
  }
383
804
 
384
805
  let proposedFiles: string[] = [];
385
806
  try {
807
+ // A child that outlived prompt quiescence can still race symlink
808
+ // inspection. Stop it before reporting paths even when its task failed.
386
809
  await stopWorkspaceProcesses(worker.workerRoot);
810
+
811
+ if (result.error) {
812
+ let classificationIssues:
813
+ Array<{ path: string; reason: string }> | undefined;
814
+ try {
815
+ await classifyReportedFiles(group, worker, result);
816
+ } catch (error) {
817
+ const reason = error instanceof Error ? error.message : String(error);
818
+ classificationIssues = [{ path: "(attribution)", reason }];
819
+ console.error(
820
+ `[delegate] failed to classify paths for already-failed isolated worker '${worker.workerRoot}'; discarding and removing it without changing the task failure`,
821
+ error,
822
+ );
823
+ }
824
+ if (classificationIssues) {
825
+ // The original lists may contain worker-relative paths that become
826
+ // dangling as soon as the disposable worktree is removed. Never
827
+ // publish those stale paths after classification failed.
828
+ result.touchedFiles = [];
829
+ result.attributedFiles = [];
830
+ result.fileAttributions = [];
831
+ }
832
+ result.integration = {
833
+ status: "discarded",
834
+ proposedFiles: [],
835
+ appliedFiles: [],
836
+ ...(classificationIssues ? { classificationIssues } : {}),
837
+ };
838
+ await requireWorktreeRemoved(group.sourceRoot, worker.workerRoot);
839
+ continue;
840
+ }
841
+
842
+ await classifyReportedFiles(group, worker, result);
387
843
  const proposalTree = await snapshotTree(
388
844
  worker.workerRoot,
389
845
  group.baselineCommit,
@@ -409,7 +865,7 @@ async function reconcileGroup(
409
865
  group.baselineCommit,
410
866
  proposalCommit,
411
867
  );
412
- await removeWorktree(group.sourceRoot, worker.workerRoot);
868
+ await requireWorktreeRemoved(group.sourceRoot, worker.workerRoot);
413
869
 
414
870
  if (!proposedFiles.length) {
415
871
  result.integration = {
@@ -444,7 +900,7 @@ async function reconcileGroup(
444
900
  proposedFiles,
445
901
  appliedFiles: proposedFiles,
446
902
  };
447
- await removeWorktree(group.sourceRoot, candidateRoot);
903
+ await requireWorktreeRemoved(group.sourceRoot, candidateRoot);
448
904
  } catch (error) {
449
905
  const reason =
450
906
  error instanceof GitCommandError
@@ -469,7 +925,7 @@ async function reconcileGroup(
469
925
  worker.proposalRef,
470
926
  );
471
927
  const patchExists = fs.existsSync(worker.patchPath);
472
- const worktreeExists = fs.existsSync(worker.workerRoot);
928
+ const worktreeExists = pathEntryExists(worker.workerRoot);
473
929
  result.integration = {
474
930
  status: "apply_failed",
475
931
  proposedFiles,
@@ -489,7 +945,7 @@ async function reconcileGroup(
489
945
  }
490
946
 
491
947
  if (integratedCommit === group.baselineCommit) {
492
- await removeWorktree(group.sourceRoot, pristineRoot);
948
+ await requireWorktreeRemoved(group.sourceRoot, pristineRoot);
493
949
  return;
494
950
  }
495
951
 
@@ -523,7 +979,7 @@ async function reconcileGroup(
523
979
  patchPath: worker.patchPath,
524
980
  };
525
981
  }
526
- await removeWorktree(group.sourceRoot, pristineRoot);
982
+ await requireWorktreeRemoved(group.sourceRoot, pristineRoot);
527
983
  return;
528
984
  }
529
985
 
@@ -595,7 +1051,27 @@ async function reconcileGroup(
595
1051
  return;
596
1052
  }
597
1053
 
598
- await removeWorktree(group.sourceRoot, pristineRoot);
1054
+ try {
1055
+ await requireWorktreeRemoved(group.sourceRoot, pristineRoot);
1056
+ } catch (error) {
1057
+ const reason = error instanceof Error ? error.message : String(error);
1058
+ // Source application has already succeeded. Cleanup failure must not
1059
+ // rewrite that durable outcome as apply_failed; publish recovery metadata
1060
+ // separately before the result is delivered.
1061
+ for (const [taskIndex] of accepted) {
1062
+ const integration = results[taskIndex]!.integration;
1063
+ if (integration?.status !== "applied_unverified") continue;
1064
+ integration.cleanupIssue = {
1065
+ status: "failed",
1066
+ reason,
1067
+ recoveryPath: pristineRoot,
1068
+ };
1069
+ }
1070
+ console.error(
1071
+ `[delegate] applied isolated changes, but failed to remove recovery worktree '${pristineRoot}'; applied_unverified remains authoritative`,
1072
+ error,
1073
+ );
1074
+ }
599
1075
  }
600
1076
 
601
1077
  async function deletePrivateRefs(
@@ -637,14 +1113,7 @@ async function markGroupReconciliationFailure(
637
1113
  for (const taskIndex of group.taskIndexes) {
638
1114
  const result = results[taskIndex]!;
639
1115
  const current = result.integration;
640
- if (
641
- current?.status === "conflict" ||
642
- current?.status === "apply_failed" ||
643
- current?.status === "no_changes" ||
644
- current?.status === "discarded"
645
- ) {
646
- continue;
647
- }
1116
+ if (current?.status === "conflict") continue;
648
1117
 
649
1118
  const worker = workers.get(taskIndex)!;
650
1119
  const proposalExists = await privateRefExists(
@@ -652,11 +1121,12 @@ async function markGroupReconciliationFailure(
652
1121
  worker.proposalRef,
653
1122
  );
654
1123
  const patchExists = fs.existsSync(worker.patchPath);
655
- const recoveryWorktree = fs.existsSync(worker.workerRoot)
656
- ? worker.workerRoot
657
- : fs.existsSync(pristineRoot)
658
- ? pristineRoot
659
- : undefined;
1124
+ let recoveryWorktree: string | undefined;
1125
+ if (pathEntryExists(worker.workerRoot)) {
1126
+ recoveryWorktree = worker.workerRoot;
1127
+ } else if (pathEntryExists(pristineRoot)) {
1128
+ recoveryWorktree = pristineRoot;
1129
+ }
660
1130
  result.workspace = "isolated";
661
1131
  result.integration = {
662
1132
  status: "apply_failed",
@@ -767,6 +1237,10 @@ export async function prepareIsolatedBatch(
767
1237
  cwd: sourceRoot,
768
1238
  signal,
769
1239
  });
1240
+ let finishReconciliation!: () => void;
1241
+ const reconciliationDone = new Promise<void>((resolve) => {
1242
+ finishReconciliation = resolve;
1243
+ });
770
1244
  group = {
771
1245
  sourceRoot,
772
1246
  sourceHead,
@@ -774,6 +1248,9 @@ export async function prepareIsolatedBatch(
774
1248
  baselineCommit,
775
1249
  baselineRef,
776
1250
  taskIndexes: [],
1251
+ reconciliationDone,
1252
+ finishReconciliation,
1253
+ deferredCleanupTail: Promise.resolve(),
777
1254
  };
778
1255
  groupsByRoot.set(sourceRoot, group);
779
1256
  }
@@ -841,15 +1318,26 @@ export async function prepareIsolatedBatch(
841
1318
  async reconcile(results: TaskResult[]): Promise<TaskResult[]> {
842
1319
  for (const group of groupsByRoot.values()) {
843
1320
  try {
844
- await reconcileGroup(group, workers, results);
845
- } catch (error) {
846
- console.error(
847
- "[delegate] isolated group reconciliation failed",
848
- error,
849
- );
850
- await markGroupReconciliationFailure(group, workers, results, error);
1321
+ try {
1322
+ await reconcileGroup(group, workers, results);
1323
+ } catch (error) {
1324
+ console.error(
1325
+ "[delegate] isolated group reconciliation failed",
1326
+ error,
1327
+ );
1328
+ await markGroupReconciliationFailure(
1329
+ group,
1330
+ workers,
1331
+ results,
1332
+ error,
1333
+ );
1334
+ }
1335
+ await cleanupCompletedGroupRefs(group, workers, results);
1336
+ } finally {
1337
+ // Unblocks and serializes any safety-confirmed quarantine cleanups
1338
+ // only after all candidate/source/ref work for this group is done.
1339
+ group.finishReconciliation();
851
1340
  }
852
- await cleanupCompletedGroupRefs(group, workers, results);
853
1341
  }
854
1342
  return results;
855
1343
  },