@fitlab-ai/agent-infra 0.9.14 → 0.9.15

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.
Files changed (250) hide show
  1. package/README.md +10 -0
  2. package/README.zh-CN.md +10 -0
  3. package/bin/internal-cli.sh +45 -0
  4. package/dist/bin/internal-cli.js +93 -30
  5. package/dist/lib/agent-clients/adapters/codex-lifecycle/capability-store.js +165 -27
  6. package/dist/lib/agent-clients/adapters/codex-lifecycle/sandbox-controller.js +3 -2
  7. package/dist/lib/defaults.json +103 -103
  8. package/dist/lib/fs/durable-write.js +37 -0
  9. package/dist/lib/fs/file-lock.js +111 -0
  10. package/dist/lib/host-control/audit.js +19 -0
  11. package/dist/lib/host-control/client.js +142 -0
  12. package/dist/lib/host-control/command.js +49 -0
  13. package/dist/lib/host-control/path.js +160 -0
  14. package/dist/lib/host-control/secure-fs.js +140 -0
  15. package/dist/lib/host-control/server.js +159 -0
  16. package/dist/lib/host-control/service.js +112 -0
  17. package/dist/lib/init.js +11 -2
  18. package/dist/lib/internal/cli-route-inventory.js +3 -1
  19. package/dist/lib/internal/codex-lifecycle.js +6 -6
  20. package/dist/lib/internal/demo-promotion.js +322 -0
  21. package/dist/lib/internal/demo-transcript.js +454 -0
  22. package/dist/lib/internal/host-control.js +28 -0
  23. package/dist/lib/internal/manual-validation.js +322 -0
  24. package/dist/lib/internal/platform-pr.js +18 -4
  25. package/dist/lib/internal/release-workflow.js +63 -84
  26. package/dist/lib/internal/sandbox-control.js +149 -2
  27. package/dist/lib/internal/task-artifact.js +15 -262
  28. package/dist/lib/internal/task-create.js +6 -5
  29. package/dist/lib/internal/task-event.js +37 -37
  30. package/dist/lib/internal/task-operation-registry.js +101 -3
  31. package/dist/lib/internal/task-review.js +8 -53
  32. package/dist/lib/internal/task-validate.js +30 -10
  33. package/dist/lib/owned-path.js +33 -0
  34. package/dist/lib/platform/check-status.js +11 -0
  35. package/dist/lib/platform/comment-safety.js +4 -1
  36. package/dist/lib/platform/completion-backfill.js +1 -1
  37. package/dist/lib/platform/github-data.js +241 -0
  38. package/dist/lib/platform/github-provider.js +15 -20
  39. package/dist/lib/platform/issue-comments.js +2 -2
  40. package/dist/lib/platform/issues.js +1 -179
  41. package/dist/lib/platform/pr-checks.js +12 -65
  42. package/dist/lib/platform/pr-summary.js +55 -3
  43. package/dist/lib/platform/pull-requests.js +2 -105
  44. package/dist/lib/platform/snapshots.js +2 -0
  45. package/dist/lib/platform/verification-sync.js +127 -142
  46. package/dist/lib/release/workflow.js +1 -1
  47. package/dist/lib/sandbox/commands/create.js +7 -2
  48. package/dist/lib/sandbox/commands/rm.js +3 -1514
  49. package/dist/lib/sandbox/control/audit.js +203 -0
  50. package/dist/lib/sandbox/control/client.js +93 -54
  51. package/dist/lib/sandbox/control/executor.js +129 -65
  52. package/dist/lib/sandbox/control/finalization-response.js +27 -0
  53. package/dist/lib/sandbox/control/identity-sentinel.js +107 -0
  54. package/dist/lib/sandbox/control/lifecycle.js +36 -2
  55. package/dist/lib/sandbox/control/native-file-lock.js +6 -98
  56. package/dist/lib/sandbox/control/protocol.js +55 -4
  57. package/dist/lib/sandbox/control/server.js +491 -120
  58. package/dist/lib/sandbox/control/state.js +58 -0
  59. package/dist/lib/sandbox/control/task-workflow.js +74 -0
  60. package/dist/lib/sandbox/control/workflow-executor.js +99 -0
  61. package/dist/lib/sandbox/index.js +2 -2
  62. package/dist/lib/sandbox/recovery.js +28 -4
  63. package/dist/lib/sandbox/removal.js +1717 -0
  64. package/dist/lib/sandbox/task-cleanup.js +217 -0
  65. package/dist/lib/sandbox/workspace-view.js +13 -14
  66. package/dist/lib/task/activity-intent.js +2 -1
  67. package/dist/lib/task/activity-log.js +30 -24
  68. package/dist/lib/task/artifact-command.js +101 -0
  69. package/dist/lib/task/artifact-lifecycle.js +30 -28
  70. package/dist/lib/task/artifact-name.js +36 -0
  71. package/dist/lib/task/artifact-operations.js +23 -69
  72. package/dist/lib/task/artifact-receipts.js +3 -15
  73. package/dist/lib/task/artifact-repair-intent.js +87 -16
  74. package/dist/lib/task/artifact-schema.js +6 -14
  75. package/dist/lib/task/capabilities.js +12 -19
  76. package/dist/lib/task/codex-orchestration.js +71 -25
  77. package/dist/lib/task/commit-identity.js +28 -0
  78. package/dist/lib/task/commit-operation.js +2 -16
  79. package/dist/lib/task/control-authority.js +371 -5
  80. package/dist/lib/task/control-recovery.js +128 -0
  81. package/dist/lib/task/create.js +62 -9
  82. package/dist/lib/task/decision-details.js +2 -46
  83. package/dist/lib/task/events.js +120 -9
  84. package/dist/lib/task/finalization-state.js +9 -0
  85. package/dist/lib/task/human-override.js +3 -6
  86. package/dist/lib/task/implementation-inputs.js +3 -3
  87. package/dist/lib/task/intermediate-cleanup.js +383 -0
  88. package/dist/lib/task/invalidation.js +18 -65
  89. package/dist/lib/task/ledger-intents.js +7 -22
  90. package/dist/lib/task/ledger.js +1 -3
  91. package/dist/lib/task/lifecycle.js +19 -1
  92. package/dist/lib/task/local-artifact-finalization.js +132 -157
  93. package/dist/lib/task/manual-validation-completion.js +26 -0
  94. package/dist/lib/task/manual-validation-evidence.js +130 -0
  95. package/dist/lib/task/manual-validation-receipt.js +101 -0
  96. package/dist/lib/task/manual-validation-shared.js +40 -0
  97. package/dist/lib/task/manual-validation-transaction.js +243 -0
  98. package/dist/lib/task/markdown.js +47 -0
  99. package/dist/lib/task/orchestration.js +14 -17
  100. package/dist/lib/task/qualification-audit.js +20 -34
  101. package/dist/lib/task/review-artifacts.js +2 -18
  102. package/dist/lib/task/review-command.js +25 -0
  103. package/dist/lib/task/review-finalization.js +125 -193
  104. package/dist/lib/task/review-fingerprint.js +2 -2
  105. package/dist/lib/task/sections.js +34 -56
  106. package/dist/lib/task/verification-engine.js +116 -323
  107. package/dist/lib/task/verification.js +1 -1
  108. package/dist/lib/task/workflow-command.js +17 -0
  109. package/dist/lib/task/workflow-warning-intents.js +0 -16
  110. package/dist/lib/task/workflow-warnings.js +28 -2
  111. package/dist/lib/update.js +2 -0
  112. package/dist/lifecycle-build-manifest.json +3 -3
  113. package/lib/agent-clients/adapters/codex-lifecycle/capability-store.ts +223 -33
  114. package/lib/agent-clients/adapters/codex-lifecycle/sandbox-controller.ts +3 -2
  115. package/lib/fs/durable-write.ts +29 -0
  116. package/lib/fs/file-lock.ts +113 -0
  117. package/lib/host-control/audit.ts +22 -0
  118. package/lib/host-control/client.ts +172 -0
  119. package/lib/host-control/command.ts +60 -0
  120. package/lib/host-control/path.ts +167 -0
  121. package/lib/host-control/secure-fs.ts +135 -0
  122. package/lib/host-control/server.ts +180 -0
  123. package/lib/host-control/service.ts +133 -0
  124. package/lib/init.ts +15 -2
  125. package/lib/internal/cli-route-inventory.ts +3 -1
  126. package/lib/internal/codex-lifecycle.ts +6 -6
  127. package/lib/internal/demo-promotion.ts +354 -0
  128. package/lib/internal/demo-transcript.ts +419 -0
  129. package/lib/internal/host-control.ts +28 -0
  130. package/lib/internal/manual-validation.ts +306 -0
  131. package/lib/internal/platform-pr.ts +15 -4
  132. package/lib/internal/release-workflow.ts +74 -82
  133. package/lib/internal/sandbox-control.ts +162 -1
  134. package/lib/internal/task-artifact.ts +13 -216
  135. package/lib/internal/task-create.ts +5 -5
  136. package/lib/internal/task-event.ts +20 -11
  137. package/lib/internal/task-operation-registry.ts +111 -3
  138. package/lib/internal/task-review.ts +6 -51
  139. package/lib/internal/task-validate.ts +26 -10
  140. package/lib/owned-path.ts +26 -0
  141. package/lib/platform/check-status.ts +9 -0
  142. package/lib/platform/comment-safety.ts +5 -0
  143. package/lib/platform/completion-backfill.ts +1 -1
  144. package/lib/platform/github-data.ts +322 -0
  145. package/lib/platform/github-provider.ts +15 -20
  146. package/lib/platform/issue-comments.ts +3 -4
  147. package/lib/platform/issues.ts +4 -245
  148. package/lib/platform/pr-checks.ts +13 -77
  149. package/lib/platform/pr-summary.ts +61 -5
  150. package/lib/platform/pull-requests.ts +3 -170
  151. package/lib/platform/snapshots.ts +53 -0
  152. package/lib/platform/verification-sync.ts +145 -155
  153. package/lib/release/workflow.ts +3 -3
  154. package/lib/sandbox/commands/create.ts +7 -6
  155. package/lib/sandbox/commands/rm.ts +3 -1909
  156. package/lib/sandbox/control/audit.ts +253 -0
  157. package/lib/sandbox/control/client.ts +109 -66
  158. package/lib/sandbox/control/executor.ts +134 -74
  159. package/lib/sandbox/control/finalization-response.ts +29 -0
  160. package/lib/sandbox/control/identity-sentinel.ts +137 -0
  161. package/lib/sandbox/control/lifecycle.ts +45 -3
  162. package/lib/sandbox/control/native-file-lock.ts +6 -107
  163. package/lib/sandbox/control/protocol.ts +75 -6
  164. package/lib/sandbox/control/server.ts +538 -123
  165. package/lib/sandbox/control/state.ts +78 -0
  166. package/lib/sandbox/control/task-workflow.ts +95 -0
  167. package/lib/sandbox/control/workflow-executor.ts +102 -0
  168. package/lib/sandbox/index.ts +2 -2
  169. package/lib/sandbox/recovery.ts +28 -5
  170. package/lib/sandbox/removal.ts +2144 -0
  171. package/lib/sandbox/task-cleanup.ts +209 -0
  172. package/lib/sandbox/workspace-view.ts +17 -15
  173. package/lib/task/activity-intent.ts +2 -2
  174. package/lib/task/activity-log.ts +27 -19
  175. package/lib/task/artifact-command.ts +104 -0
  176. package/lib/task/artifact-lifecycle.ts +31 -26
  177. package/lib/task/artifact-name.ts +55 -0
  178. package/lib/task/artifact-operations.ts +22 -68
  179. package/lib/task/artifact-receipts.ts +3 -14
  180. package/lib/task/artifact-repair-intent.ts +99 -18
  181. package/lib/task/artifact-schema.ts +5 -34
  182. package/lib/task/capabilities.ts +13 -22
  183. package/lib/task/codex-orchestration.ts +85 -24
  184. package/lib/task/commit-identity.ts +31 -0
  185. package/lib/task/commit-operation.ts +2 -16
  186. package/lib/task/control-authority.ts +502 -5
  187. package/lib/task/control-recovery.ts +174 -0
  188. package/lib/task/create.ts +63 -8
  189. package/lib/task/decision-details.ts +3 -71
  190. package/lib/task/events.ts +131 -10
  191. package/lib/task/finalization-state.ts +11 -0
  192. package/lib/task/human-override.ts +3 -8
  193. package/lib/task/implementation-inputs.ts +3 -3
  194. package/lib/task/intermediate-cleanup.ts +455 -0
  195. package/lib/task/invalidation.ts +18 -62
  196. package/lib/task/ledger-intents.ts +7 -24
  197. package/lib/task/ledger.ts +1 -3
  198. package/lib/task/lifecycle.ts +17 -1
  199. package/lib/task/local-artifact-finalization.ts +148 -182
  200. package/lib/task/manual-validation-completion.ts +40 -0
  201. package/lib/task/manual-validation-evidence.ts +209 -0
  202. package/lib/task/manual-validation-receipt.ts +152 -0
  203. package/lib/task/manual-validation-shared.ts +50 -0
  204. package/lib/task/manual-validation-transaction.ts +304 -0
  205. package/lib/task/markdown.ts +70 -0
  206. package/lib/task/orchestration.ts +19 -19
  207. package/lib/task/qualification-audit.ts +22 -33
  208. package/lib/task/review-artifacts.ts +1 -20
  209. package/lib/task/review-command.ts +23 -0
  210. package/lib/task/review-finalization.ts +131 -259
  211. package/lib/task/review-fingerprint.ts +2 -2
  212. package/lib/task/sections.ts +33 -54
  213. package/lib/task/verification-engine.ts +110 -366
  214. package/lib/task/verification-types.ts +17 -0
  215. package/lib/task/verification.ts +1 -1
  216. package/lib/task/workflow-command.ts +18 -0
  217. package/lib/task/workflow-warning-intents.ts +0 -13
  218. package/lib/task/workflow-warnings.ts +21 -2
  219. package/lib/update.ts +6 -0
  220. package/package.json +6 -6
  221. package/templates/.agents/hooks/lifecycle-delegation.js +6 -6
  222. package/templates/.agents/rules/testing-discipline.en.md +2 -0
  223. package/templates/.agents/rules/testing-discipline.zh-CN.md +2 -0
  224. package/templates/.agents/scripts/package.json +3 -0
  225. package/templates/.agents/skills/complete-manual-validation/SKILL.en.md +32 -20
  226. package/templates/.agents/skills/complete-manual-validation/SKILL.zh-CN.md +32 -20
  227. package/templates/.agents/skills/complete-manual-validation/config/verify.en.json +1 -1
  228. package/templates/.agents/skills/complete-manual-validation/config/verify.zh-CN.json +1 -1
  229. package/templates/.agents/skills/complete-manual-validation/reference/report-template.en.md +2 -0
  230. package/templates/.agents/skills/complete-manual-validation/reference/report-template.zh-CN.md +2 -0
  231. package/templates/.agents/skills/complete-manual-validation/reference/summary-update.en.md +16 -7
  232. package/templates/.agents/skills/complete-manual-validation/reference/summary-update.zh-CN.md +16 -7
  233. package/templates/.agents/skills/complete-task/SKILL.en.md +1 -1
  234. package/templates/.agents/skills/complete-task/SKILL.zh-CN.md +1 -1
  235. package/templates/.agents/skills/release/SKILL.en.md +7 -3
  236. package/templates/.agents/skills/release/SKILL.zh-CN.md +7 -3
  237. package/templates/.agents/skills/run-manual-validation/SKILL.en.md +20 -3
  238. package/templates/.agents/skills/run-manual-validation/SKILL.zh-CN.md +20 -3
  239. package/templates/.agents/skills/run-manual-validation/reference/discovery-and-execution.en.md +4 -1
  240. package/templates/.agents/skills/run-manual-validation/reference/discovery-and-execution.zh-CN.md +4 -1
  241. package/templates/.agents/skills/run-manual-validation/reference/report-template.en.md +2 -0
  242. package/templates/.agents/skills/run-manual-validation/reference/report-template.zh-CN.md +2 -0
  243. package/templates/.agents/skills/run-task/SKILL.en.md +1 -1
  244. package/templates/.agents/skills/run-task/SKILL.zh-CN.md +1 -1
  245. package/templates/.agents/skills/test/SKILL.en.md +17 -6
  246. package/templates/.agents/skills/test/SKILL.zh-CN.md +17 -6
  247. package/templates/.claude/commands/run-manual-validation.en.md +1 -1
  248. package/templates/.claude/commands/run-manual-validation.zh-CN.md +1 -1
  249. package/dist/lib/platform/adapters.js +0 -56
  250. package/lib/platform/adapters.ts +0 -197
@@ -0,0 +1,2144 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { createHash } from 'node:crypto';
4
+ import * as p from '@clack/prompts';
5
+ import pc from 'picocolors';
6
+ import type { SandboxConfig } from './config.ts';
7
+ import {
8
+ assertValidBranchName,
9
+ containerNameCandidates,
10
+ sandboxBranchLabel,
11
+ sandboxLabel,
12
+ sandboxTaskIdLabel,
13
+ sandboxWorkspaceModeLabel,
14
+ shareBranchDir,
15
+ shellConfigDirCandidates,
16
+ worktreeDirCandidates
17
+ } from './constants.ts';
18
+ import { ENGINES, detectEngine, engineDisplayName, isManagedEngine, stopManagedVm } from './engine.ts';
19
+ import { pruneSandboxDanglingImages } from './image-prune.ts';
20
+ import { assertManagedPath, removeManagedDir, removeWorktreeDir } from './managed-fs.ts';
21
+ import { run, runEngine, runOk, runOkEngine, runSafe, runSafeEngine } from './shell.ts';
22
+ import {
23
+ parseSandboxWorkspaceIdentity,
24
+ resolveSandboxCleanupTarget,
25
+ sameSandboxWorkspaceIdentity,
26
+ type SandboxCleanupTarget,
27
+ type SandboxWorkspaceKey
28
+ } from './workspace-identity.ts';
29
+ import { sandboxControlPaths, sandboxWorkspaceViewPaths } from './workspace-view.ts';
30
+ import {
31
+ advanceSandboxRemovalJournalToPhase,
32
+ advanceSandboxRemovalJournalPhase,
33
+ claimSandboxRemovalJournal,
34
+ clearSandboxRemovalJournalRecord,
35
+ isDefaultSandboxRemovalJournal,
36
+ listSandboxRemovalJournals,
37
+ removeSandboxControlRoot,
38
+ readSandboxControlManifest,
39
+ sandboxRemovalPhaseIndex,
40
+ type SandboxRemovalJournal,
41
+ type SandboxRemovalTargetCommit
42
+ } from './control/lifecycle.ts';
43
+ import { inspectSandboxControlContainer } from './control/container-identity.ts';
44
+ import { commandForSandboxAuthority } from './engines/authority.ts';
45
+ import { acquireSandboxResourceLock, type SandboxResourceLock } from './control/native-file-lock.ts';
46
+ import { toolConfigDirCandidates, toolProjectDirCandidates } from './tools.ts';
47
+ import type { SandboxTool } from './tools.ts';
48
+ import { getProcessStartTime } from '../server/process-state.ts';
49
+ import { fetchSandboxRows, type SandboxRow } from './commands/list-running.ts';
50
+ import {
51
+ formatIntermediateCleanupReport,
52
+ cleanupIntermediateUnderRemovalCoordinator,
53
+ protectIntermediateCleanupReport,
54
+ mergeIntermediateCleanupReports,
55
+ type IntermediateCleanupReport,
56
+ scanIntermediateCleanup
57
+ } from '../task/intermediate-cleanup.ts';
58
+ import { createSandboxControlBindingEvidence } from './task-cleanup.ts';
59
+ import { withRepositoryMutationLock, withTaskExecutionLock } from '../task/task-execution-lock.ts';
60
+ import {
61
+ createCleanPermit,
62
+ createDiscardPermit,
63
+ formatWorktreeSnapshot,
64
+ inspectRecoveredWorktree,
65
+ inspectWorktrees,
66
+ verifyWorktreePermit
67
+ } from './worktree-safety.ts';
68
+ import type {
69
+ WorktreeInspection,
70
+ WorktreeRecoveryContext,
71
+ WorktreeRemovalPermit
72
+ } from './worktree-safety.ts';
73
+
74
+ function projectToolDirs(config: SandboxConfig, tools: SandboxTool[]): string[] {
75
+ return tools.flatMap((tool) => toolProjectDirCandidates(tool, config.project));
76
+ }
77
+
78
+ function isMissingPathError(error: unknown): boolean {
79
+ return Boolean(error && typeof error === 'object' && 'code' in error
80
+ && (error as { code?: unknown }).code === 'ENOENT');
81
+ }
82
+
83
+ function canonicalPath(input: string): string {
84
+ try { return fs.realpathSync.native(input); }
85
+ catch { return path.resolve(input); }
86
+ }
87
+
88
+ function isMissingTaskRecordError(error: unknown): boolean {
89
+ return error instanceof Error && /^Task not found: TASK-\d{8}-\d{6}$/.test(error.message);
90
+ }
91
+
92
+ function digestCleanupValue(value: unknown): string {
93
+ return createHash('sha256').update(JSON.stringify(value)).digest('hex');
94
+ }
95
+
96
+ function removalTargetDigest(config: SandboxConfig, target: RmTarget): string {
97
+ return digestCleanupValue({
98
+ project: config.project,
99
+ branch: target.effectiveBranch,
100
+ workspace: target.workspace,
101
+ controlRoots: target.controlRoots.map((candidate) => path.resolve(candidate)),
102
+ workspaceViewRoots: target.workspaceViewRoots.map((candidate) => path.resolve(candidate)),
103
+ managedPathCandidates: (target.managedPathCandidates ?? []).map((candidate) => path.resolve(candidate)).sort()
104
+ });
105
+ }
106
+
107
+ function removalPermitDigest(permits: ReadonlyMap<string, WorktreeRemovalPermit>): string {
108
+ return digestCleanupValue([...permits.entries()]
109
+ .map(([worktree, permit]) => [path.resolve(worktree), permit.mode, permit.snapshot.identity])
110
+ .sort(([left], [right]) => String(left).localeCompare(String(right))));
111
+ }
112
+
113
+ function removalPermitCommits(permits: ReadonlyMap<string, WorktreeRemovalPermit>): SandboxRemovalTargetCommit['permits'] {
114
+ return [...permits.entries()]
115
+ .map(([worktree, permit]) => ({
116
+ path: path.resolve(worktree),
117
+ mode: permit.mode,
118
+ snapshot: permit.snapshot
119
+ }))
120
+ .sort((left, right) => left.path.localeCompare(right.path));
121
+ }
122
+
123
+ function removalTargetCommit(
124
+ config: SandboxConfig,
125
+ target: RmTarget,
126
+ permits: ReadonlyMap<string, WorktreeRemovalPermit>,
127
+ removeWorktree: boolean,
128
+ removeBranch: boolean,
129
+ removeShare: boolean
130
+ ): SandboxRemovalTargetCommit {
131
+ return {
132
+ branch: target.effectiveBranch,
133
+ project: config.project,
134
+ controlRoot: path.resolve(target.controlRoots[0] ?? path.join(config.controlBase, config.project)),
135
+ targetDigest: removalTargetDigest(config, target),
136
+ permitDigest: removalPermitDigest(permits),
137
+ removeWorktree,
138
+ removeBranch,
139
+ removeShare,
140
+ worktreePaths: target.existingWorktrees.map((candidate) => path.resolve(candidate)).sort(),
141
+ workspaceViewPaths: target.workspaceViewRoots.map((candidate) => path.resolve(candidate)).sort(),
142
+ toolPaths: target.toolCandidates.flatMap(({ candidates }) => candidates.map((candidate) => path.resolve(candidate))).sort(),
143
+ shellPaths: shellConfigDirCandidates(config, target.effectiveBranch).map((candidate) => path.resolve(candidate)).sort(),
144
+ sharePath: path.resolve(shareBranchDir(config, target.effectiveBranch)),
145
+ permits: removalPermitCommits(permits)
146
+ };
147
+ }
148
+
149
+ function assertRemovalSelectionMatches(
150
+ expected: SandboxRemovalTargetCommit,
151
+ actual: SandboxRemovalTargetCommit
152
+ ): void {
153
+ const normalize = (value: SandboxRemovalTargetCommit): unknown => ({
154
+ ...value,
155
+ controlRoot: '',
156
+ permits: value.permits
157
+ });
158
+ if (JSON.stringify(normalize(expected)) !== JSON.stringify(normalize(actual))) {
159
+ throw new Error('SANDBOX_CONTROL_REMOVAL_TARGET_MISMATCH');
160
+ }
161
+ }
162
+
163
+ function advanceRemovalJournals(
164
+ project: string,
165
+ target: RmTarget,
166
+ targetDigest: string,
167
+ phase: SandboxRemovalJournal['phase'],
168
+ resourceLocks: ReadonlyMap<string, SandboxResourceLock>
169
+ ): SandboxRemovalJournal[] {
170
+ return listSandboxRemovalJournals({ branch: target.effectiveBranch, project, targetDigest }).map((journal) => {
171
+ if (sandboxRemovalPhaseIndex(journal.phase) >= sandboxRemovalPhaseIndex(phase)) return journal;
172
+ const lock = resourceLocks.get(journal.target.controlRoot);
173
+ if (!lock) throw new Error('SANDBOX_CONTROL_REMOVAL_LOCK_MISMATCH');
174
+ return advanceSandboxRemovalJournalToPhase(journal, phase, lock);
175
+ });
176
+ }
177
+
178
+ function prepareRemovalAction(
179
+ project: string,
180
+ target: RmTarget,
181
+ targetDigest: string,
182
+ startPhase: SandboxRemovalJournal['phase'],
183
+ completedPhase: SandboxRemovalJournal['phase'],
184
+ resourceLocks: ReadonlyMap<string, SandboxResourceLock>
185
+ ): RemovalActionPreparation {
186
+ const journals = listSandboxRemovalJournals({
187
+ branch: target.effectiveBranch,
188
+ project,
189
+ targetDigest
190
+ });
191
+ const startIndex = sandboxRemovalPhaseIndex(startPhase);
192
+ const completedIndex = sandboxRemovalPhaseIndex(completedPhase);
193
+ const shouldRun = journals.length === 0
194
+ || journals.some((journal) => sandboxRemovalPhaseIndex(journal.phase) < completedIndex);
195
+ const recovering = journals.some((journal) => {
196
+ const index = sandboxRemovalPhaseIndex(journal.phase);
197
+ return index >= startIndex && index < completedIndex;
198
+ });
199
+ advanceRemovalJournals(project, target, targetDigest, startPhase, resourceLocks);
200
+ return { run: shouldRun, recovering };
201
+ }
202
+
203
+ function assertRemovalPathsAbsent(paths: readonly string[]): void {
204
+ const remaining = paths.filter((candidate) => fs.existsSync(candidate));
205
+ if (remaining.length > 0) {
206
+ throw new Error(`SANDBOX_CONTROL_REMOVAL_TARGET_MISMATCH: ${remaining[0]}`);
207
+ }
208
+ }
209
+
210
+ function assertBranchRemovalIdentity(
211
+ config: SandboxConfig,
212
+ branch: string,
213
+ permits: ReadonlyMap<string, WorktreeRemovalPermit>
214
+ ): void {
215
+ if (!runOk('git', ['-C', config.repoRoot, 'show-ref', '--verify', `refs/heads/${branch}`])) return;
216
+ const expectedHeads = new Set(
217
+ [...permits.values()]
218
+ .filter((permit) => permit.snapshot.branch === branch)
219
+ .map((permit) => permit.snapshot.head)
220
+ );
221
+ if (expectedHeads.size === 0) {
222
+ throw new Error(`SANDBOX_CONTROL_REMOVAL_TARGET_MISMATCH: ${branch}`);
223
+ }
224
+ const actualHead = runSafe('git', ['-C', config.repoRoot, 'rev-parse', `refs/heads/${branch}`]);
225
+ if (!expectedHeads.has(actualHead)) {
226
+ throw new Error(`SANDBOX_CONTROL_REMOVAL_TARGET_MISMATCH: ${branch}`);
227
+ }
228
+ }
229
+
230
+ function removalTombstonePath(targetDigest: string, kind: RemovalActionKind, source: string): string {
231
+ const sourceDigest = createHash('sha256')
232
+ .update(`${kind}\0${path.resolve(source)}`)
233
+ .digest('hex')
234
+ .slice(0, 24);
235
+ return path.join(
236
+ path.dirname(path.resolve(source)),
237
+ `.agent-infra-removal-${targetDigest.slice(0, 16)}-${kind}-${sourceDigest}`
238
+ );
239
+ }
240
+
241
+ type RemovalTombstoneOwnership = Readonly<{
242
+ version: 1;
243
+ targetDigest: string;
244
+ permitDigest: string;
245
+ kind: RemovalActionKind;
246
+ source: string;
247
+ }>;
248
+
249
+ type RemovalSourceIdentity = Readonly<{
250
+ dev: string;
251
+ ino: string;
252
+ }>;
253
+
254
+ type RemovalTombstoneRecord = RemovalTombstoneOwnership & Readonly<{
255
+ sourceIdentity: RemovalSourceIdentity;
256
+ }>;
257
+
258
+ const REMOVAL_TOMBSTONE_MARKER = '.agent-infra-removal-ownership.json';
259
+ const REMOVAL_TOMBSTONE_PAYLOAD = 'payload';
260
+
261
+ function removalSourceClaimPath(source: string, ownership: RemovalTombstoneOwnership): string {
262
+ const claimDigest = createHash('sha256')
263
+ .update(JSON.stringify(ownership))
264
+ .digest('hex')
265
+ .slice(0, 24);
266
+ return path.join(source, `.agent-infra-removal-source-${claimDigest}.json`);
267
+ }
268
+
269
+ function removalSourceIdentity(source: string): RemovalSourceIdentity {
270
+ const stat = fs.lstatSync(source, { bigint: true });
271
+ if (!stat.isDirectory() || stat.isSymbolicLink() || stat.dev === 0n || stat.ino === 0n) {
272
+ throw new Error(`SANDBOX_CONTROL_REMOVAL_TARGET_MISMATCH: ${source}`);
273
+ }
274
+ return { dev: String(stat.dev), ino: String(stat.ino) };
275
+ }
276
+
277
+ function sameRemovalSourceIdentity(left: RemovalSourceIdentity, right: RemovalSourceIdentity): boolean {
278
+ return left.dev === right.dev && left.ino === right.ino;
279
+ }
280
+
281
+ function removalSourceClaimMatches(
282
+ source: string,
283
+ ownership: RemovalTombstoneOwnership,
284
+ identity: RemovalSourceIdentity
285
+ ): boolean {
286
+ try {
287
+ const claimPath = removalSourceClaimPath(source, ownership);
288
+ const claimStat = fs.lstatSync(claimPath);
289
+ if (!claimStat.isFile() || claimStat.isSymbolicLink()) return false;
290
+ const record = JSON.parse(fs.readFileSync(claimPath, 'utf8')) as Partial<RemovalTombstoneRecord>;
291
+ return record.version === ownership.version
292
+ && record.targetDigest === ownership.targetDigest
293
+ && record.permitDigest === ownership.permitDigest
294
+ && record.kind === ownership.kind
295
+ && typeof record.source === 'string'
296
+ && path.resolve(record.source) === path.resolve(ownership.source)
297
+ && record.sourceIdentity?.dev === identity.dev
298
+ && record.sourceIdentity?.ino === identity.ino;
299
+ } catch {
300
+ return false;
301
+ }
302
+ }
303
+
304
+ function isOwnedRemovalSourceClaim(source: string, ownership: RemovalTombstoneOwnership): boolean {
305
+ try {
306
+ const claimPath = removalSourceClaimPath(source, ownership);
307
+ const claimStat = fs.lstatSync(claimPath);
308
+ if (!claimStat.isFile() || claimStat.isSymbolicLink()) return false;
309
+ const record = JSON.parse(fs.readFileSync(claimPath, 'utf8')) as Partial<RemovalTombstoneRecord>;
310
+ return record.version === ownership.version
311
+ && record.targetDigest === ownership.targetDigest
312
+ && record.permitDigest === ownership.permitDigest
313
+ && record.kind === ownership.kind
314
+ && typeof record.source === 'string'
315
+ && path.resolve(record.source) === path.resolve(ownership.source);
316
+ } catch {
317
+ return false;
318
+ }
319
+ }
320
+
321
+ function removeOwnedSourceClaim(source: string, ownership: RemovalTombstoneOwnership): void {
322
+ try {
323
+ const claimPath = removalSourceClaimPath(source, ownership);
324
+ if (isOwnedRemovalSourceClaim(source, ownership)) fs.unlinkSync(claimPath);
325
+ } catch {
326
+ // Preserve the source when the claim cannot be revalidated.
327
+ }
328
+ }
329
+
330
+ function readRemovalTombstoneRecord(root: string, tombstone: string): RemovalTombstoneRecord | null {
331
+ assertManagedPath(root, tombstone);
332
+ try {
333
+ const tombstoneStat = fs.lstatSync(tombstone);
334
+ const markerStat = fs.lstatSync(removalTombstoneMarker(tombstone));
335
+ if (!tombstoneStat.isDirectory() || tombstoneStat.isSymbolicLink()
336
+ || !markerStat.isFile() || markerStat.isSymbolicLink()) return null;
337
+ const record = JSON.parse(fs.readFileSync(removalTombstoneMarker(tombstone), 'utf8')) as Partial<RemovalTombstoneRecord>;
338
+ if (record.version !== 1
339
+ || typeof record.targetDigest !== 'string'
340
+ || typeof record.permitDigest !== 'string'
341
+ || typeof record.kind !== 'string'
342
+ || typeof record.source !== 'string'
343
+ || record.sourceIdentity?.dev === undefined
344
+ || record.sourceIdentity.ino === undefined) return null;
345
+ return record as RemovalTombstoneRecord;
346
+ } catch {
347
+ return null;
348
+ }
349
+ }
350
+
351
+ function removalTombstonePayload(tombstone: string): string {
352
+ return path.join(tombstone, REMOVAL_TOMBSTONE_PAYLOAD);
353
+ }
354
+
355
+ function removalTombstoneMarker(tombstone: string): string {
356
+ return path.join(tombstone, REMOVAL_TOMBSTONE_MARKER);
357
+ }
358
+
359
+ function isOwnedRemovalTombstone(
360
+ root: string,
361
+ tombstone: string,
362
+ ownership: RemovalTombstoneOwnership
363
+ ): boolean {
364
+ const record = readRemovalTombstoneRecord(root, tombstone);
365
+ return record !== null
366
+ && record.version === 1
367
+ && record.targetDigest === ownership.targetDigest
368
+ && record.permitDigest === ownership.permitDigest
369
+ && record.kind === ownership.kind
370
+ && typeof record.source === 'string'
371
+ && path.resolve(record.source) === path.resolve(ownership.source);
372
+ }
373
+
374
+ function isOwnedRemovalPayload(
375
+ root: string,
376
+ tombstone: string,
377
+ ownership: RemovalTombstoneOwnership
378
+ ): boolean {
379
+ const record = readRemovalTombstoneRecord(root, tombstone);
380
+ if (!record || record.targetDigest !== ownership.targetDigest
381
+ || record.permitDigest !== ownership.permitDigest
382
+ || record.kind !== ownership.kind
383
+ || path.resolve(record.source) !== path.resolve(ownership.source)) return false;
384
+ try {
385
+ const payload = removalTombstonePayload(tombstone);
386
+ const payloadIdentity = removalSourceIdentity(payload);
387
+ return sameRemovalSourceIdentity(payloadIdentity, record.sourceIdentity)
388
+ && removalSourceClaimMatches(payload, ownership, record.sourceIdentity);
389
+ } catch {
390
+ return false;
391
+ }
392
+ }
393
+
394
+ function assertOwnedRemovalTombstone(
395
+ root: string,
396
+ tombstone: string,
397
+ ownership: RemovalTombstoneOwnership
398
+ ): void {
399
+ if (!isOwnedRemovalTombstone(root, tombstone, ownership)) {
400
+ throw new Error(`SANDBOX_CONTROL_REMOVAL_TARGET_MISMATCH: ${ownership.source}`);
401
+ }
402
+ }
403
+
404
+ function shouldStageManagedRemoval(source: string, tombstone: string, recovering: boolean): boolean {
405
+ return fs.existsSync(source) || (recovering && fs.existsSync(tombstone));
406
+ }
407
+
408
+ function stageManagedRemoval(
409
+ root: string,
410
+ source: string,
411
+ tombstone: string,
412
+ recovering: boolean,
413
+ ownership: RemovalTombstoneOwnership
414
+ ): void {
415
+ assertManagedPath(root, source);
416
+ assertManagedPath(root, tombstone);
417
+ const sourceExists = fs.existsSync(source);
418
+ const tombstoneExists = fs.existsSync(tombstone);
419
+ if (tombstoneExists) {
420
+ assertOwnedRemovalTombstone(root, tombstone, ownership);
421
+ if (sourceExists) {
422
+ throw new Error(`SANDBOX_CONTROL_REMOVAL_TARGET_MISMATCH: ${source}`);
423
+ }
424
+ if (recovering) {
425
+ if (!isOwnedRemovalPayload(root, tombstone, ownership)) {
426
+ throw new Error(`SANDBOX_CONTROL_REMOVAL_TARGET_MISMATCH: ${source}`);
427
+ }
428
+ return;
429
+ }
430
+ throw new Error(`SANDBOX_CONTROL_REMOVAL_TARGET_MISMATCH: ${source}`);
431
+ }
432
+ if (recovering) {
433
+ throw new Error(`SANDBOX_CONTROL_REMOVAL_ACTION_OUTCOME_UNKNOWN: ${source}`);
434
+ }
435
+ if (!sourceExists) return;
436
+ // The operator authorizes cleanup of this managed target after execution stops.
437
+ // Path checks detect conflicts; they do not provide an atomic source-instance move.
438
+ const expectedIdentity = removalSourceIdentity(source);
439
+ const claimPath = removalSourceClaimPath(source, ownership);
440
+ try {
441
+ fs.writeFileSync(claimPath, `${JSON.stringify({ ...ownership, sourceIdentity: expectedIdentity })}\n`, {
442
+ encoding: 'utf8',
443
+ mode: 0o600,
444
+ flag: 'wx'
445
+ });
446
+ } catch {
447
+ throw new Error(`SANDBOX_CONTROL_REMOVAL_TARGET_MISMATCH: ${source}`);
448
+ }
449
+ if (!sameRemovalSourceIdentity(expectedIdentity, removalSourceIdentity(source))) {
450
+ removeOwnedSourceClaim(source, ownership);
451
+ throw new Error(`SANDBOX_CONTROL_REMOVAL_TARGET_MISMATCH: ${source}`);
452
+ }
453
+ let moved = false;
454
+ try {
455
+ fs.mkdirSync(tombstone, { mode: 0o700 });
456
+ fs.writeFileSync(removalTombstoneMarker(tombstone), `${JSON.stringify({ ...ownership, sourceIdentity: expectedIdentity })}\n`, {
457
+ encoding: 'utf8',
458
+ mode: 0o600,
459
+ flag: 'wx'
460
+ });
461
+ fs.renameSync(source, removalTombstonePayload(tombstone));
462
+ moved = true;
463
+ if (!isOwnedRemovalPayload(root, tombstone, ownership)) {
464
+ throw new Error(`SANDBOX_CONTROL_REMOVAL_TARGET_MISMATCH: ${source}`);
465
+ }
466
+ } catch (error) {
467
+ if (!moved) removeOwnedSourceClaim(source, ownership);
468
+ throw error;
469
+ }
470
+ }
471
+
472
+ function cleanupManagedTombstones(
473
+ root: string,
474
+ targetDigest: string,
475
+ permitDigest: string,
476
+ kind: RemovalActionKind,
477
+ sources: readonly string[]
478
+ ): void {
479
+ for (const source of sources) {
480
+ const tombstone = removalTombstonePath(targetDigest, kind, source);
481
+ if (!fs.existsSync(tombstone)) continue;
482
+ const ownership = { version: 1, targetDigest, permitDigest, kind, source } as const;
483
+ if (!isOwnedRemovalTombstone(root, tombstone, ownership)) continue;
484
+ if (!isOwnedRemovalPayload(root, tombstone, ownership)) {
485
+ throw new Error(`SANDBOX_CONTROL_REMOVAL_TARGET_MISMATCH: ${source}`);
486
+ }
487
+ removeManagedDir(root, tombstone);
488
+ }
489
+ }
490
+
491
+ function branchRemovalTombstoneRef(targetDigest: string): string {
492
+ return `refs/agent-infra/sandbox-removal/${targetDigest}/branch`;
493
+ }
494
+
495
+ function branchPermitHead(
496
+ branch: string,
497
+ permits: ReadonlyMap<string, WorktreeRemovalPermit>
498
+ ): string | null {
499
+ const heads = new Set(
500
+ [...permits.values()]
501
+ .filter((permit) => permit.snapshot.branch === branch)
502
+ .map((permit) => permit.snapshot.head)
503
+ );
504
+ return heads.size === 1 ? [...heads][0]! : null;
505
+ }
506
+
507
+ function stageBranchRemoval(
508
+ config: SandboxConfig,
509
+ branch: string,
510
+ targetDigest: string,
511
+ permits: ReadonlyMap<string, WorktreeRemovalPermit>,
512
+ recovering: boolean
513
+ ): void {
514
+ const expectedHead = branchPermitHead(branch, permits);
515
+ if (!expectedHead) throw new Error(`SANDBOX_CONTROL_REMOVAL_TARGET_MISMATCH: ${branch}`);
516
+ const branchName = `refs/heads/${branch}`;
517
+ const tombstone = branchRemovalTombstoneRef(targetDigest);
518
+ const branchExists = runOk('git', ['-C', config.repoRoot, 'show-ref', '--verify', branchName]);
519
+ const tombstoneExists = runOk('git', ['-C', config.repoRoot, 'show-ref', '--verify', tombstone]);
520
+ if (branchExists && tombstoneExists) {
521
+ throw new Error(`SANDBOX_CONTROL_REMOVAL_TARGET_MISMATCH: ${branch}`);
522
+ }
523
+ if (recovering) {
524
+ if (branchExists && !tombstoneExists) {
525
+ throw new Error(`SANDBOX_CONTROL_REMOVAL_ACTION_OUTCOME_UNKNOWN: ${branch}`);
526
+ }
527
+ if (tombstoneExists && runSafe('git', ['-C', config.repoRoot, 'rev-parse', tombstone]) !== expectedHead) {
528
+ throw new Error(`SANDBOX_CONTROL_REMOVAL_TARGET_MISMATCH: ${branch}`);
529
+ }
530
+ return;
531
+ }
532
+ if (!branchExists) return;
533
+ assertBranchRemovalIdentity(config, branch, permits);
534
+ const actualHead = runSafe('git', ['-C', config.repoRoot, 'rev-parse', branchName]);
535
+ if (!runOk('git', ['-C', config.repoRoot, 'update-ref', tombstone, actualHead, '0'.repeat(40)])) {
536
+ throw new Error(`SANDBOX_CONTROL_REMOVAL_TARGET_MISMATCH: ${branch}`);
537
+ }
538
+ if (!runOk('git', ['-C', config.repoRoot, 'update-ref', '-d', branchName, actualHead])) {
539
+ throw new Error(`SANDBOX_CONTROL_REMOVAL_TARGET_MISMATCH: ${branch}`);
540
+ }
541
+ }
542
+
543
+ function cleanupBranchTombstone(
544
+ config: SandboxConfig,
545
+ branch: string,
546
+ targetDigest: string,
547
+ permits: ReadonlyMap<string, WorktreeRemovalPermit>
548
+ ): void {
549
+ const tombstone = branchRemovalTombstoneRef(targetDigest);
550
+ if (!runOk('git', ['-C', config.repoRoot, 'show-ref', '--verify', tombstone])) return;
551
+ const expectedHead = branchPermitHead(branch, permits);
552
+ if (!expectedHead || runSafe('git', ['-C', config.repoRoot, 'rev-parse', tombstone]) !== expectedHead) {
553
+ throw new Error(`SANDBOX_CONTROL_REMOVAL_TARGET_MISMATCH: ${branch}`);
554
+ }
555
+ if (!runOk('git', ['-C', config.repoRoot, 'update-ref', '-d', tombstone, expectedHead])) {
556
+ throw new Error(`SANDBOX_CONTROL_REMOVAL_TARGET_MISMATCH: ${branch}`);
557
+ }
558
+ }
559
+
560
+ function worktreeRegistration(
561
+ config: SandboxConfig,
562
+ worktree: string
563
+ ): { head: string; branch: string | null } | null {
564
+ const lines = runSafe('git', ['-C', config.repoRoot, 'worktree', 'list', '--porcelain']).split('\n');
565
+ let current: { path: string; head: string; branch: string | null } | null = null;
566
+ const flush = (): { head: string; branch: string | null } | null => {
567
+ if (!current || path.resolve(current.path) !== path.resolve(worktree)) return null;
568
+ return { head: current.head, branch: current.branch };
569
+ };
570
+ for (const line of lines) {
571
+ if (line.startsWith('worktree ')) {
572
+ const previous = flush();
573
+ if (previous) return previous;
574
+ current = { path: line.slice('worktree '.length), head: '', branch: null };
575
+ } else if (current && line.startsWith('HEAD ')) {
576
+ current.head = line.slice('HEAD '.length);
577
+ } else if (current && line.startsWith('branch ')) {
578
+ current.branch = line.slice('branch '.length);
579
+ }
580
+ }
581
+ return flush();
582
+ }
583
+
584
+ function assertWorktreeRegistrationIdentity(
585
+ config: SandboxConfig,
586
+ worktree: string,
587
+ permit: WorktreeRemovalPermit
588
+ ): void {
589
+ const registration = worktreeRegistration(config, worktree);
590
+ if (!registration) return;
591
+ if (registration.head !== permit.snapshot.head
592
+ || registration.branch !== `refs/heads/${permit.snapshot.branch}`) {
593
+ throw new Error(`SANDBOX_CONTROL_REMOVAL_TARGET_MISMATCH: ${worktree}`);
594
+ }
595
+ }
596
+
597
+ function pruneWorktreeRegistration(config: SandboxConfig, worktree: string): void {
598
+ runSafe('git', ['-C', config.repoRoot, 'worktree', 'prune']);
599
+ if (worktreeRegistration(config, worktree)) {
600
+ throw new Error(`SANDBOX_CONTROL_REMOVAL_ACTION_OUTCOME_UNKNOWN: ${worktree}`);
601
+ }
602
+ }
603
+
604
+ function stageWorktreeRemoval(
605
+ config: SandboxConfig,
606
+ worktree: string,
607
+ permit: WorktreeRemovalPermit,
608
+ tombstone: string,
609
+ recovering: boolean,
610
+ ownership: RemovalTombstoneOwnership
611
+ ): void {
612
+ assertManagedPath(config.worktreeBase, worktree);
613
+ assertManagedPath(config.worktreeBase, tombstone);
614
+ const worktreeExists = fs.existsSync(worktree);
615
+ const tombstoneExists = fs.existsSync(tombstone);
616
+ if (worktreeExists || !recovering) verifyWorktreePermit(permit);
617
+ if (recovering) {
618
+ if (worktreeExists || !tombstoneExists) {
619
+ if (worktreeExists && tombstoneExists) {
620
+ throw new Error(`SANDBOX_CONTROL_REMOVAL_TARGET_MISMATCH: ${worktree}`);
621
+ }
622
+ throw new Error(`SANDBOX_CONTROL_REMOVAL_ACTION_OUTCOME_UNKNOWN: ${worktree}`);
623
+ }
624
+ assertOwnedRemovalTombstone(config.worktreeBase, tombstone, ownership);
625
+ assertWorktreeRegistrationIdentity(config, worktree, permit);
626
+ pruneWorktreeRegistration(config, worktree);
627
+ return;
628
+ }
629
+ if (!worktreeExists) return;
630
+ stageManagedRemoval(config.worktreeBase, worktree, tombstone, false, ownership);
631
+ pruneWorktreeRegistration(config, worktree);
632
+ }
633
+
634
+ function cleanupCompletedRemovalArtifacts(
635
+ config: SandboxConfig,
636
+ target: RmTarget,
637
+ committedTarget: SandboxRemovalTargetCommit,
638
+ targetDigest: string
639
+ ): void {
640
+ const journals = listSandboxRemovalJournals({
641
+ branch: target.effectiveBranch,
642
+ project: config.project,
643
+ targetDigest
644
+ });
645
+ if (journals.length === 0) return;
646
+ const completed = (phase: SandboxRemovalJournal['phase']): boolean => journals.every((journal) => (
647
+ sandboxRemovalPhaseIndex(journal.phase) >= sandboxRemovalPhaseIndex(phase)
648
+ ));
649
+ if (completed('workspace-removed')) {
650
+ cleanupManagedTombstones(
651
+ config.workspaceViewBase, targetDigest, committedTarget.permitDigest, 'workspace', target.workspaceViewRoots
652
+ );
653
+ cleanupManagedTombstones(
654
+ path.join(config.controlBase, config.project), targetDigest, committedTarget.permitDigest, 'workspace', target.controlRoots
655
+ );
656
+ cleanupManagedTombstones(
657
+ config.worktreeBase, targetDigest, committedTarget.permitDigest, 'worktree', committedTarget.worktreePaths
658
+ );
659
+ }
660
+ if (completed('branch-removed')) {
661
+ cleanupBranchTombstone(config, target.effectiveBranch, targetDigest, new Map(
662
+ committedTarget.permits.map((permit) => [path.resolve(permit.path), {
663
+ mode: permit.mode,
664
+ snapshot: permit.snapshot
665
+ }])
666
+ ));
667
+ }
668
+ if (completed('tool-removed')) {
669
+ cleanupManagedTombstones(config.home, targetDigest, committedTarget.permitDigest, 'tool', committedTarget.toolPaths);
670
+ }
671
+ if (completed('shell-removed')) {
672
+ cleanupManagedTombstones(config.shellConfigBase, targetDigest, committedTarget.permitDigest, 'shell', committedTarget.shellPaths);
673
+ }
674
+ if (completed('share-removed')) {
675
+ cleanupManagedTombstones(config.shareBase, targetDigest, committedTarget.permitDigest, 'share', [committedTarget.sharePath]);
676
+ }
677
+ }
678
+
679
+ function removalActionPaths(target: RmTarget, config: SandboxConfig, phase: SandboxRemovalJournal['phase']): string[] {
680
+ if (phase === 'workspace-removed') {
681
+ return [...target.workspaceViewRoots, ...target.controlRoots];
682
+ }
683
+ if (phase === 'tool-removed') {
684
+ return target.toolCandidates.flatMap(({ candidates }) => candidates);
685
+ }
686
+ if (phase === 'shell-removed') {
687
+ return shellConfigDirCandidates(config, target.effectiveBranch);
688
+ }
689
+ if (phase === 'share-removed') {
690
+ return [shareBranchDir(config, target.effectiveBranch)];
691
+ }
692
+ return [];
693
+ }
694
+
695
+ function permitsFromJournal(
696
+ journals: readonly SandboxRemovalJournal[],
697
+ target: RmTarget,
698
+ expectedTargetDigest: string
699
+ ): Map<string, WorktreeRemovalPermit> {
700
+ const permits = new Map<string, WorktreeRemovalPermit>();
701
+ for (const journal of journals) {
702
+ if (journal.target.targetDigest !== expectedTargetDigest) continue;
703
+ if (journal.target.branch !== target.effectiveBranch) {
704
+ throw new Error('SANDBOX_CONTROL_REMOVAL_TARGET_MISMATCH');
705
+ }
706
+ for (const entry of journal.target.permits) {
707
+ if (path.resolve(entry.path) !== path.resolve(entry.snapshot.worktree)) {
708
+ throw new Error('SANDBOX_CONTROL_REMOVAL_PERMIT_MISMATCH');
709
+ }
710
+ permits.set(path.resolve(entry.path), {
711
+ mode: entry.mode,
712
+ snapshot: entry.snapshot
713
+ });
714
+ }
715
+ }
716
+ return permits;
717
+ }
718
+
719
+ export function sandboxManagedPathKey(
720
+ candidate: string,
721
+ platform: NodeJS.Platform = process.platform,
722
+ resolveExistingPath: (resolved: string) => string = (resolved) => fs.realpathSync.native(resolved)
723
+ ): string {
724
+ const resolved = path.resolve(candidate);
725
+ let identity = resolved;
726
+ try {
727
+ identity = resolveExistingPath(resolved);
728
+ } catch (error) {
729
+ if (!isMissingPathError(error)) {
730
+ throw new Error(
731
+ `SANDBOX_CLEANUP_BATCH_PREFLIGHT_FAILED: unable to determine managed path identity for '${resolved}'`
732
+ );
733
+ }
734
+ }
735
+ return platform === 'win32' ? identity.toLowerCase() : identity;
736
+ }
737
+
738
+ type RmTarget = {
739
+ branch: string;
740
+ effectiveBranch: string;
741
+ engine: string;
742
+ matchedContainers: string[];
743
+ existingWorktrees: string[];
744
+ toolCandidates: Array<{ tool: SandboxTool; candidates: string[] }>;
745
+ managedPathCandidates?: string[];
746
+ workspace: SandboxWorkspaceKey;
747
+ controlRoots: string[];
748
+ workspaceViewRoots: string[];
749
+ };
750
+
751
+ type RemovalActionKind = 'workspace' | 'worktree' | 'tool' | 'shell' | 'share';
752
+
753
+ type RemovalActionPreparation = Readonly<{
754
+ run: boolean;
755
+ recovering: boolean;
756
+ }>;
757
+
758
+ type CleanupCandidate = Readonly<{
759
+ row: SandboxRow;
760
+ cleanupTarget: SandboxCleanupTarget;
761
+ }>;
762
+
763
+ type ProtectedCleanupCandidate = Readonly<{
764
+ row: SandboxRow;
765
+ branch: string;
766
+ identity: string;
767
+ reason: string;
768
+ }>;
769
+
770
+ type CleanupGroup = Readonly<{
771
+ candidates: readonly CleanupCandidate[];
772
+ cleanupTarget: SandboxCleanupTarget;
773
+ target: RmTarget;
774
+ }>;
775
+
776
+ type RmOneOptions = {
777
+ assumeYes?: boolean;
778
+ interactive?: boolean;
779
+ quiet?: boolean;
780
+ target?: RmTarget;
781
+ cleanupTarget?: SandboxCleanupTarget;
782
+ permits?: ReadonlyMap<string, WorktreeRemovalPermit>;
783
+ allowDirtyDiscard?: boolean;
784
+ prompt?: PromptDependencies;
785
+ };
786
+
787
+ type PromptDependencies = {
788
+ confirm?: typeof p.confirm;
789
+ isCancel?: typeof p.isCancel;
790
+ };
791
+
792
+ function projectSandboxControlRoots(config: SandboxConfig): string[] {
793
+ const projectRoot = path.join(config.controlBase, config.project);
794
+ let containers: fs.Dirent[];
795
+ try { containers = fs.readdirSync(projectRoot, { withFileTypes: true }); }
796
+ catch (error) {
797
+ if (isMissingPathError(error)) return [];
798
+ throw error;
799
+ }
800
+ return containers
801
+ .filter((container) => container.isDirectory())
802
+ .flatMap((container) => {
803
+ const containerRoot = path.join(projectRoot, container.name);
804
+ const identities = fs.readdirSync(containerRoot, { withFileTypes: true });
805
+ return identities
806
+ .filter((identity) => identity.isDirectory())
807
+ .map((identity) => path.join(containerRoot, identity.name));
808
+ });
809
+ }
810
+
811
+ function projectSandboxControlBindingEvidence(
812
+ config: SandboxConfig,
813
+ controlRoots: readonly string[] = projectSandboxControlRoots(config),
814
+ removalJournals: readonly SandboxRemovalJournal[] = listSandboxRemovalJournals({ project: config.project })
815
+ ): ReturnType<typeof createSandboxControlBindingEvidence> {
816
+ return createSandboxControlBindingEvidence(config.repoRoot, controlRoots, removalJournals);
817
+ }
818
+
819
+ // Reasons that mean "intentionally preserved", not "cleanup is unsafe". A task
820
+ // that is still active keeps its auxiliary intents by design, so preserving
821
+ // them must not make its sandbox unremovable. Every other protected reason
822
+ // signals tampering or an unreadable path and still blocks, as does any
823
+ // failure, so unknown reasons stay fail-closed.
824
+ const REMOVAL_PRESERVED_REASONS: ReadonlySet<string> = new Set([
825
+ 'TASK_STATE_PROTECTED',
826
+ 'TASK_NOT_FOUND',
827
+ 'LFAI_STATE_PROTECTED',
828
+ 'LFAI_RETRY_OR_WARNING_OPEN',
829
+ 'FINALIZATION_RECEIPT_MISSING',
830
+ 'FINALIZATION_RECEIPT_PENDING',
831
+ 'AUXILIARY_ROOT_NOT_EMPTY'
832
+ ]);
833
+
834
+ function assertIntermediateCleanupPreflight(report: IntermediateCleanupReport): void {
835
+ const blockers = report.items.filter((candidate) => candidate.disposition === 'failed'
836
+ || (candidate.disposition === 'protected' && !REMOVAL_PRESERVED_REASONS.has(candidate.reason)));
837
+ if (blockers.length === 0) return;
838
+ throw new Error([
839
+ 'SANDBOX_AUXILIARY_PREFLIGHT_FAILED:',
840
+ ...blockers.map((candidate) => (
841
+ `${candidate.kind} ${candidate.path} (${candidate.taskId ?? 'unbound'}; ${candidate.reason})`
842
+ ))
843
+ ].join('\n'));
844
+ }
845
+
846
+
847
+ function recoveryContexts(
848
+ config: SandboxConfig,
849
+ target: RmTarget,
850
+ worktrees: readonly string[]
851
+ ): Map<string, WorktreeRecoveryContext> {
852
+ const candidates = worktreeDirCandidates(config, target.effectiveBranch).map((candidate) => path.resolve(candidate));
853
+ const existingCandidates = candidates.filter((candidate) => fs.existsSync(candidate));
854
+ if (existingCandidates.length !== 1) return new Map();
855
+
856
+ const contexts = new Map<string, WorktreeRecoveryContext>();
857
+ for (const worktree of worktrees) {
858
+ const resolvedWorktree = path.resolve(worktree);
859
+ if (resolvedWorktree !== existingCandidates[0]) continue;
860
+ for (const root of target.controlRoots.filter((candidate) => fs.existsSync(candidate))) {
861
+ assertLegacyCandidateEvidence(root, config.controlBase, config, target.effectiveBranch, target.matchedContainers);
862
+ assertControlRootMatchesTarget(root, target.effectiveBranch, target.workspace);
863
+ }
864
+ contexts.set(resolvedWorktree, {
865
+ repoRoot: config.repoRoot,
866
+ worktreeBase: config.worktreeBase,
867
+ branch: target.effectiveBranch,
868
+ identitySource: target.workspace.mode,
869
+ taskId: target.workspace.mode === 'task-bound' ? target.workspace.taskId : null
870
+ });
871
+ }
872
+ return contexts;
873
+ }
874
+
875
+ function resolveRmTarget(
876
+ config: SandboxConfig,
877
+ tools: SandboxTool[],
878
+ cleanupTarget: SandboxCleanupTarget,
879
+ options: Readonly<{ discoveredContainers?: readonly string[] }> = {}
880
+ ): RmTarget {
881
+ assertValidBranchName(cleanupTarget.branch);
882
+ const engine = detectEngine(config);
883
+ const branch = cleanupTarget.branch;
884
+ const effectiveBranch = branch;
885
+ const worktreeCandidates = worktreeDirCandidates(config, branch);
886
+ const toolCandidates = tools.map((tool) => ({
887
+ tool,
888
+ candidates: toolConfigDirCandidates(tool, config.project, branch)
889
+ }));
890
+ const shellCandidates = shellConfigDirCandidates(config, branch);
891
+ const existing = runEngine(engine, 'docker', ['ps', '-a', '--format', '{{.Names}}']).split('\n').filter(Boolean);
892
+ const matchedContainers = options.discoveredContainers
893
+ ? options.discoveredContainers.map((container) => {
894
+ if (!existing.includes(container)) {
895
+ throw new Error(
896
+ `SANDBOX_CLEANUP_BATCH_PREFLIGHT_FAILED: discovered container '${container}' is no longer present`
897
+ );
898
+ }
899
+ return container;
900
+ })
901
+ : containerNameCandidates(config, branch).filter((name) => existing.includes(name));
902
+
903
+ const workspace = cleanupTarget.workspace;
904
+ const identities: SandboxWorkspaceKey[] = [workspace];
905
+ const containers = options.discoveredContainers
906
+ ? matchedContainers
907
+ : [...new Set([...containerNameCandidates(config, effectiveBranch), ...matchedContainers])];
908
+ const controlRoots = containers.flatMap((container) => identities.map((identity) => sandboxControlPaths({
909
+ base: config.controlBase, project: config.project, container, identity
910
+ }).root));
911
+ const workspaceViewRoots = containers.flatMap((container) => identities.map((identity) => sandboxWorkspaceViewPaths({
912
+ base: config.workspaceViewBase, project: config.project, container, identity
913
+ }).root));
914
+ const managedPathCandidates = [...new Set([
915
+ ...worktreeCandidates,
916
+ ...toolCandidates.flatMap(({ candidates }) => candidates),
917
+ ...shellCandidates,
918
+ shareBranchDir(config, branch),
919
+ ...controlRoots,
920
+ ...workspaceViewRoots
921
+ ].map((candidate) => path.resolve(candidate)))];
922
+
923
+ return {
924
+ branch,
925
+ effectiveBranch,
926
+ engine,
927
+ matchedContainers,
928
+ existingWorktrees: worktreeCandidates.filter((candidate) => fs.existsSync(candidate)),
929
+ toolCandidates,
930
+ managedPathCandidates,
931
+ workspace,
932
+ controlRoots: [...new Set(controlRoots)],
933
+ workspaceViewRoots: [...new Set(workspaceViewRoots)]
934
+ };
935
+ }
936
+
937
+ type CurrentContainerIdentity = Readonly<{ id: string; labels: Record<string, string> }>;
938
+
939
+ function inspectNamedContainerIdentity(target: RmTarget, container: string): CurrentContainerIdentity {
940
+ const raw = runSafeEngine(target.engine, 'docker', [
941
+ 'inspect', '--format', '{{json .}}', container
942
+ ]).trim();
943
+ let parsed: unknown;
944
+ try {
945
+ parsed = JSON.parse(raw);
946
+ } catch {
947
+ throw new Error(`SANDBOX_WORKSPACE_IDENTITY_UNKNOWN: container '${container}' inspection is invalid`);
948
+ }
949
+ const value = Array.isArray(parsed) && parsed.length === 1 ? parsed[0] : parsed;
950
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
951
+ throw new Error(`SANDBOX_WORKSPACE_IDENTITY_UNKNOWN: container '${container}' inspection is incomplete`);
952
+ }
953
+ const candidate = value as {
954
+ Id?: unknown;
955
+ Config?: { Labels?: unknown };
956
+ };
957
+ if (typeof candidate.Id !== 'string' || !candidate.Id
958
+ || !candidate.Config?.Labels || typeof candidate.Config.Labels !== 'object'
959
+ || Array.isArray(candidate.Config.Labels)
960
+ || Object.values(candidate.Config.Labels).some((value) => typeof value !== 'string')) {
961
+ throw new Error(`SANDBOX_WORKSPACE_IDENTITY_UNKNOWN: container '${container}' inspection is incomplete`);
962
+ }
963
+ return { id: candidate.Id, labels: candidate.Config.Labels as Record<string, string> };
964
+ }
965
+
966
+ function assertMatchedContainerIdentities(config: SandboxConfig, target: RmTarget): Map<string, CurrentContainerIdentity> {
967
+ const current = new Map<string, CurrentContainerIdentity>();
968
+ for (const container of target.matchedContainers) {
969
+ const observation = inspectNamedContainerIdentity(target, container);
970
+ const stringLabels = observation.labels;
971
+ const branch = stringLabels[sandboxBranchLabel(config)];
972
+ if (branch !== target.branch) {
973
+ throw new Error(
974
+ `SANDBOX_WORKSPACE_IDENTITY_CONFLICT: container '${container}' branch is ${JSON.stringify(branch ?? null)}, `
975
+ + `but this request is ${JSON.stringify(target.branch)}`
976
+ );
977
+ }
978
+ const identity = parseSandboxWorkspaceIdentity(stringLabels, {
979
+ mode: sandboxWorkspaceModeLabel(config),
980
+ taskId: sandboxTaskIdLabel(config)
981
+ });
982
+ if (identity.mode === 'legacy-invalid' || !sameSandboxWorkspaceIdentity(identity, target.workspace)) {
983
+ const existingDescription = identity.mode === 'task-bound'
984
+ ? `task-bound:${identity.taskId}`
985
+ : identity.mode;
986
+ const requestedDescription = target.workspace.mode === 'task-bound'
987
+ ? `task-bound:${target.workspace.taskId}`
988
+ : target.workspace.mode;
989
+ throw new Error(
990
+ `SANDBOX_WORKSPACE_IDENTITY_CONFLICT: container '${container}' is ${existingDescription}, `
991
+ + `but this request is ${requestedDescription}`
992
+ );
993
+ }
994
+ current.set(container, observation);
995
+ }
996
+ return current;
997
+ }
998
+
999
+ function controlRootContainer(config: SandboxConfig, root: string): string {
1000
+ const relative = path.relative(path.join(config.controlBase, config.project), root);
1001
+ const [container, identity, ...extra] = relative.split(path.sep);
1002
+ if (!container || !identity || extra.length > 0) {
1003
+ throw new Error(`SANDBOX_CONTROL_TARGET_EVIDENCE_INVALID: ${root}`);
1004
+ }
1005
+ return container;
1006
+ }
1007
+
1008
+ function assertControlRootMatchesTarget(root: string, effectiveBranch: string, workspace: SandboxWorkspaceKey): void {
1009
+ const manifestPath = path.join(root, 'manifest.json');
1010
+ if (!fs.existsSync(manifestPath)) {
1011
+ if (workspace.mode === 'task-bound') {
1012
+ throw new Error(`SANDBOX_CONTROL_TARGET_EVIDENCE_MISSING: ${root}`);
1013
+ }
1014
+ return;
1015
+ }
1016
+ const manifest = readSandboxControlManifest(manifestPath);
1017
+ const identityMatches = workspace.mode === 'task-bound'
1018
+ ? manifest.mode === 'task-bound' && manifest.taskId === workspace.taskId
1019
+ : manifest.mode === 'branch-only';
1020
+ if (manifest.branch !== effectiveBranch || !identityMatches) {
1021
+ throw new Error(`SANDBOX_CONTROL_TARGET_MISMATCH: ${root}`);
1022
+ }
1023
+ }
1024
+
1025
+ function preflightRmTarget(
1026
+ config: SandboxConfig,
1027
+ target: RmTarget
1028
+ ): void {
1029
+ const current = assertMatchedContainerIdentities(config, target);
1030
+ const manifests: ReturnType<typeof readSandboxControlManifest>[] = [];
1031
+ for (const root of target.controlRoots.filter((candidate) => fs.existsSync(candidate))) {
1032
+ assertLegacyCandidateEvidence(root, config.controlBase, config, target.effectiveBranch, target.matchedContainers);
1033
+ assertControlRootMatchesTarget(root, target.effectiveBranch, target.workspace);
1034
+ const manifestPath = path.join(root, 'manifest.json');
1035
+ if (!fs.existsSync(manifestPath)) continue;
1036
+ const manifest = readSandboxControlManifest(manifestPath);
1037
+ const rootContainer = controlRootContainer(config, root);
1038
+ if (manifest.container !== rootContainer) {
1039
+ throw new Error(`SANDBOX_CONTROL_TARGET_MISMATCH: ${root}`);
1040
+ }
1041
+ const observed = current.get(manifest.container);
1042
+ if (observed && (observed.id !== manifest.containerIdentity.id
1043
+ || Object.entries(manifest.containerIdentity.labels).some(([key, value]) => observed.labels[key] !== value))) {
1044
+ throw new Error(`SANDBOX_CONTROL_TARGET_MISMATCH: ${root}`);
1045
+ }
1046
+ manifests.push(manifest);
1047
+ }
1048
+
1049
+ if (target.workspace.mode !== 'task-bound') return;
1050
+ const matched = new Set(target.matchedContainers);
1051
+ const manifestContainers = manifests.map((manifest) => manifest.container);
1052
+ if (matched.size > 0 && manifestContainers.some((container) => !matched.has(container))) {
1053
+ throw new Error('SANDBOX_CONTROL_TARGET_MISMATCH: manifest container is outside the cleanup target');
1054
+ }
1055
+ for (const container of matched) {
1056
+ const matches = manifests.filter((manifest) => manifest.container === container);
1057
+ if (matches.length !== 1) {
1058
+ throw new Error(`SANDBOX_CONTROL_TARGET_EVIDENCE_MISSING: container '${container}'`);
1059
+ }
1060
+ }
1061
+ if (matched.size === 0 && manifests.length > 0) {
1062
+ const supported = new Set(containerNameCandidates(config, target.effectiveBranch));
1063
+ if (manifests.some((manifest) => !supported.has(manifest.container))) {
1064
+ throw new Error('SANDBOX_CONTROL_TARGET_MISMATCH: manifest container is outside the cleanup target');
1065
+ }
1066
+ }
1067
+ }
1068
+
1069
+ type CleanupPathOwner = Readonly<{
1070
+ branch: string;
1071
+ managedPathCandidates: readonly string[];
1072
+ }>;
1073
+
1074
+ function assertCleanupGroupPathsDoNotOverlap(
1075
+ groups: readonly CleanupGroup[],
1076
+ protectedTargets: readonly CleanupPathOwner[] = []
1077
+ ): void {
1078
+ const owners = new Map<string, string>();
1079
+ const addOwner = (branch: string, candidates: readonly string[]): void => {
1080
+ for (const candidate of candidates) {
1081
+ const key = sandboxManagedPathKey(candidate);
1082
+ const existingBranch = owners.get(key);
1083
+ if (existingBranch && existingBranch !== branch) {
1084
+ throw new Error(
1085
+ `SANDBOX_CLEANUP_BATCH_PREFLIGHT_FAILED: managed path '${candidate}' is shared by branches `
1086
+ + `${JSON.stringify(existingBranch)} and ${JSON.stringify(branch)}`
1087
+ );
1088
+ }
1089
+ owners.set(key, branch);
1090
+ }
1091
+ };
1092
+ for (const group of groups) {
1093
+ addOwner(group.cleanupTarget.branch, group.target.managedPathCandidates ?? []);
1094
+ }
1095
+ for (const target of protectedTargets) {
1096
+ addOwner(target.branch, target.managedPathCandidates);
1097
+ }
1098
+ }
1099
+
1100
+ function assertWorktreeBranchesMatchGroups(
1101
+ groups: readonly CleanupGroup[],
1102
+ inspections: readonly WorktreeInspection[]
1103
+ ): void {
1104
+ const byPath = new Map(
1105
+ inspections.flatMap((inspection) => inspection.status === 'failed'
1106
+ ? []
1107
+ : [[sandboxManagedPathKey(inspection.snapshot.worktree), inspection.snapshot.branch] as const])
1108
+ );
1109
+ for (const group of groups) {
1110
+ for (const worktree of group.target.existingWorktrees) {
1111
+ const registeredBranch = byPath.get(sandboxManagedPathKey(worktree));
1112
+ if (registeredBranch && registeredBranch !== group.cleanupTarget.branch) {
1113
+ throw new Error(
1114
+ `SANDBOX_CLEANUP_BATCH_PREFLIGHT_FAILED: worktree '${worktree}' is registered to branch `
1115
+ + `${JSON.stringify(registeredBranch)}, but cleanup targets ${JSON.stringify(group.cleanupTarget.branch)}`
1116
+ );
1117
+ }
1118
+ }
1119
+ }
1120
+ }
1121
+
1122
+ function assertLegacyCandidateEvidence(
1123
+ candidate: string,
1124
+ base: string,
1125
+ config: SandboxConfig,
1126
+ effectiveBranch: string,
1127
+ matchedContainers: readonly string[]
1128
+ ): void {
1129
+ const relative = path.relative(path.join(base, config.project), candidate);
1130
+ const [container, identity] = relative.split(path.sep);
1131
+ const [canonical, legacy] = containerNameCandidates(config, effectiveBranch);
1132
+ if (!container || !identity || legacy === canonical || container !== legacy || matchedContainers.includes(container)) return;
1133
+ const manifestPath = path.join(config.controlBase, config.project, container, identity, 'manifest.json');
1134
+ if (!fs.existsSync(manifestPath)) {
1135
+ throw new Error(`SANDBOX_CONTROL_TARGET_EVIDENCE_MISSING: ${candidate}`);
1136
+ }
1137
+ }
1138
+
1139
+ function assertRemoved(target: string, label: string): void {
1140
+ if (fs.existsSync(target)) throw new Error(`${label} still exists after removal: ${target}`);
1141
+ }
1142
+
1143
+ function removeEmptyManagedParent(base: string, directory: string): void {
1144
+ const parent = path.dirname(directory);
1145
+ if (path.resolve(parent) === path.resolve(base)) return;
1146
+ assertManagedPath(base, parent);
1147
+ try {
1148
+ fs.rmdirSync(parent);
1149
+ } catch (error) {
1150
+ if (['ENOENT', 'ENOTEMPTY', 'EEXIST'].includes((error as NodeJS.ErrnoException).code ?? '')) return;
1151
+ throw error;
1152
+ }
1153
+ assertRemoved(parent, 'Empty sandbox container directory');
1154
+ }
1155
+
1156
+ async function removeExactSandboxContainer(
1157
+ engine: string,
1158
+ manifest: ReturnType<typeof readSandboxControlManifest>,
1159
+ timeoutMs: number
1160
+ ): Promise<void> {
1161
+ const deadlineAt = Date.now() + timeoutMs;
1162
+ const remaining = (): number => Math.max(1, deadlineAt - Date.now());
1163
+ const inspect = () => inspectSandboxControlContainer(manifest, { timeoutMs: remaining() });
1164
+ const runAuthorityCommand = (args: string[]): boolean => {
1165
+ const command = manifest.authorityEvidence
1166
+ ? commandForSandboxAuthority(manifest.authorityEvidence, 'docker', args)
1167
+ : null;
1168
+ return command ? runOk(command.cmd, command.args, { timeout: remaining() })
1169
+ : runOkEngine(engine, 'docker', args, { timeout: remaining() });
1170
+ };
1171
+ const before = await inspect();
1172
+ if (before.state === 'unknown') throw new Error(`SANDBOX_CONTROL_CONTAINER_UNKNOWN: ${before.reason}`);
1173
+ if (before.state === 'absent') return;
1174
+ if (before.running && !runAuthorityCommand(['stop', '--timeout', '1', manifest.containerIdentity.id])) {
1175
+ const afterStop = await inspect();
1176
+ if (afterStop.state !== 'absent' && afterStop.state !== 'found') {
1177
+ throw new Error(`SANDBOX_CONTROL_CONTAINER_UNKNOWN: ${afterStop.reason}`);
1178
+ }
1179
+ if (afterStop.state === 'found' && afterStop.running) {
1180
+ throw new Error(`Failed to stop sandbox container: ${manifest.containerIdentity.id}`);
1181
+ }
1182
+ }
1183
+ const afterStop = await inspect();
1184
+ if (afterStop.state === 'unknown') throw new Error(`SANDBOX_CONTROL_CONTAINER_UNKNOWN: ${afterStop.reason}`);
1185
+ if (afterStop.state === 'found' && !runAuthorityCommand(['rm', manifest.containerIdentity.id])) {
1186
+ const afterRemove = await inspect();
1187
+ if (afterRemove.state !== 'absent') {
1188
+ throw new Error(`Failed to remove sandbox container: ${manifest.containerIdentity.id}`);
1189
+ }
1190
+ }
1191
+ }
1192
+
1193
+ async function removeProjectControlRoots(
1194
+ config: SandboxConfig,
1195
+ engine: string,
1196
+ options: Readonly<{ retainRemovalJournal?: boolean }> = {}
1197
+ ): Promise<Set<string>> {
1198
+ const containers = new Set<string>();
1199
+ const projectRoot = path.join(config.controlBase, config.project);
1200
+ if (!fs.existsSync(projectRoot)) return containers;
1201
+ assertManagedPath(config.controlBase, projectRoot);
1202
+ const projectStat = fs.lstatSync(projectRoot);
1203
+ if (!projectStat.isDirectory() || projectStat.isSymbolicLink()) throw new Error('SANDBOX_CONTROL_CHANNEL_INVALID');
1204
+ for (const root of projectSandboxControlRoots(config)) {
1205
+ const manifestPath = path.join(root, 'manifest.json');
1206
+ if (!fs.existsSync(manifestPath)) throw new Error(`SANDBOX_CONTROL_TARGET_EVIDENCE_MISSING: ${root}`);
1207
+ const manifest = readSandboxControlManifest(manifestPath);
1208
+ containers.add(manifest.container);
1209
+ await removeSandboxControlRoot(root, {
1210
+ inspectContainer: (timeoutMs) => inspectSandboxControlContainer(manifest, { timeoutMs }),
1211
+ removeContainer: (timeoutMs) => removeExactSandboxContainer(engine, manifest, timeoutMs),
1212
+ ...(options.retainRemovalJournal ? { retainRemovalJournal: true } : {})
1213
+ });
1214
+ }
1215
+ return containers;
1216
+ }
1217
+
1218
+
1219
+ function completePurgeRemovalJournals(config: SandboxConfig): void {
1220
+ const projectRoot = path.resolve(config.controlBase, config.project);
1221
+ const projectJournals = listSandboxRemovalJournals({ project: config.project }).filter((journal) => {
1222
+ const relativeRoot = path.relative(projectRoot, path.resolve(journal.target.controlRoot));
1223
+ return relativeRoot !== '' && relativeRoot !== '..'
1224
+ && !relativeRoot.startsWith(`..${path.sep}`) && !path.isAbsolute(relativeRoot);
1225
+ });
1226
+ if (projectJournals.some((journal) => !isDefaultSandboxRemovalJournal(journal))) {
1227
+ throw new Error('SANDBOX_CONTROL_REMOVE_RECOVERY_PENDING');
1228
+ }
1229
+ for (const journal of projectJournals) {
1230
+ const resourceLock = acquireSandboxResourceLock(`${journal.engine}:${journal.containerId}`, {
1231
+ lockDomain: journal.lockDomain
1232
+ });
1233
+ try {
1234
+ let current = journal;
1235
+ const currentProcessStartTime = getProcessStartTime(process.pid);
1236
+ const ownedByCurrentProcess = currentProcessStartTime !== null
1237
+ && current.owner.pid === process.pid
1238
+ && current.owner.startTime === currentProcessStartTime;
1239
+ if (!ownedByCurrentProcess) {
1240
+ current = claimSandboxRemovalJournal(current, { resourceLock });
1241
+ }
1242
+ advanceSandboxRemovalJournalToPhase(current, 'completed', resourceLock);
1243
+ } finally {
1244
+ resourceLock.release();
1245
+ }
1246
+ }
1247
+ }
1248
+
1249
+ function inspectionBlockers(inspections: readonly WorktreeInspection[]): WorktreeInspection[] {
1250
+ return inspections.filter((inspection) => inspection.status !== 'clean');
1251
+ }
1252
+
1253
+ function blockerMessage(blockers: readonly WorktreeInspection[]): string {
1254
+ return blockers.map((blocker) => {
1255
+ if (blocker.status === 'failed') return `${JSON.stringify(blocker.worktree)}: ${blocker.message}`;
1256
+ return `${formatWorktreeSnapshot(blocker.snapshot)}\n Action: commit, stash, clean, or remove this sandbox interactively.`;
1257
+ }).join('\n');
1258
+ }
1259
+
1260
+ function cleanPermits(inspections: readonly WorktreeInspection[]): Map<string, WorktreeRemovalPermit> {
1261
+ const permits = new Map<string, WorktreeRemovalPermit>();
1262
+ for (const inspection of inspections) {
1263
+ if (inspection.status === 'clean') permits.set(inspection.snapshot.worktree, createCleanPermit(inspection.snapshot));
1264
+ }
1265
+ return permits;
1266
+ }
1267
+
1268
+ async function authorizeWorktrees(
1269
+ worktrees: readonly string[],
1270
+ { allowDirtyDiscard, assumeYes }: { allowDirtyDiscard: boolean; assumeYes: boolean },
1271
+ {
1272
+ interactive = Boolean(process.stdin.isTTY),
1273
+ confirm = p.confirm,
1274
+ isCancel = p.isCancel,
1275
+ recovery = new Map<string, WorktreeRecoveryContext>()
1276
+ }: PromptDependencies & { interactive?: boolean; recovery?: ReadonlyMap<string, WorktreeRecoveryContext> } = {}
1277
+ ): Promise<Map<string, WorktreeRemovalPermit>> {
1278
+ const inspections = worktrees.map((worktree) => {
1279
+ const inspection = inspectWorktrees([worktree])[0]!;
1280
+ const context = recovery.get(path.resolve(worktree));
1281
+ return inspection.status === 'failed' && context
1282
+ ? inspectRecoveredWorktree(worktree, context)
1283
+ : inspection;
1284
+ });
1285
+ const failures = inspections.filter((inspection) => inspection.status === 'failed');
1286
+ if (failures.length > 0) throw new Error(`Unable to inspect worktree(s):\n${blockerMessage(failures)}`);
1287
+ const permits = cleanPermits(inspections);
1288
+ for (const inspection of inspections) {
1289
+ if (inspection.status !== 'dirty') continue;
1290
+ if (!allowDirtyDiscard || assumeYes || !interactive) {
1291
+ throw new Error(`Refusing to remove dirty worktree(s):\n${blockerMessage([inspection])}`);
1292
+ }
1293
+ p.log.warn(formatWorktreeSnapshot(inspection.snapshot));
1294
+ const confirmed = await confirm({
1295
+ message: 'Discard this worktree and all its uncommitted changes, including later changes?',
1296
+ initialValue: false
1297
+ });
1298
+ if (isCancel(confirmed) || !confirmed) throw new Error('Dirty worktree removal cancelled; nothing was deleted');
1299
+ permits.set(inspection.snapshot.worktree, createDiscardPermit(inspection.snapshot));
1300
+ }
1301
+ return permits;
1302
+ }
1303
+
1304
+ async function runRmOneUnderRepositoryLock(
1305
+ config: SandboxConfig,
1306
+ tools: SandboxTool[],
1307
+ branch: string,
1308
+ options: RmOneOptions = {}
1309
+ ): Promise<IntermediateCleanupReport | null> {
1310
+ const target = options.target ?? resolveRmTarget(
1311
+ config,
1312
+ tools,
1313
+ options.cleanupTarget ?? resolveSandboxCleanupTarget(branch, config.repoRoot)
1314
+ );
1315
+ const taskId = target.workspace.mode === 'task-bound' ? target.workspace.taskId : null;
1316
+ const execute = (): Promise<IntermediateCleanupReport | null> => rmOneCore(config, tools, branch, { ...options, target });
1317
+ return taskId
1318
+ ? withTaskExecutionLock(config.repoRoot, taskId, 'sandbox-removal', execute)
1319
+ : execute();
1320
+ }
1321
+
1322
+ async function rmOne(
1323
+ config: SandboxConfig,
1324
+ tools: SandboxTool[],
1325
+ branch: string,
1326
+ options: RmOneOptions = {}
1327
+ ): Promise<void> {
1328
+ await withRepositoryMutationLock(config.repoRoot, () => runRmOneUnderRepositoryLock(config, tools, branch, options));
1329
+ }
1330
+
1331
+ async function rmOneCore(
1332
+ config: SandboxConfig,
1333
+ tools: SandboxTool[],
1334
+ branch: string,
1335
+ options: RmOneOptions = {}
1336
+ ): Promise<IntermediateCleanupReport | null> {
1337
+ const target = options.target ?? resolveRmTarget(
1338
+ config,
1339
+ tools,
1340
+ options.cleanupTarget ?? resolveSandboxCleanupTarget(branch, config.repoRoot)
1341
+ );
1342
+ const { effectiveBranch, engine, matchedContainers, existingWorktrees, toolCandidates } = target;
1343
+ const { workspace, controlRoots, workspaceViewRoots } = target;
1344
+ preflightRmTarget(config, target);
1345
+ const auxiliaryTaskId = workspace.mode === 'task-bound' ? workspace.taskId : null;
1346
+ if (auxiliaryTaskId) {
1347
+ const targetJournals = listSandboxRemovalJournals({
1348
+ branch: effectiveBranch,
1349
+ project: config.project,
1350
+ targetDigest: removalTargetDigest(config, target)
1351
+ });
1352
+ assertIntermediateCleanupPreflight(scanIntermediateCleanup(config.repoRoot, {
1353
+ preflight: true,
1354
+ taskIds: [auxiliaryTaskId],
1355
+ controlBindingEvidence: projectSandboxControlBindingEvidence(
1356
+ config,
1357
+ controlRoots,
1358
+ targetJournals
1359
+ )
1360
+ }));
1361
+ }
1362
+ const confirm = options.prompt?.confirm ?? p.confirm;
1363
+ const isCancel = options.prompt?.isCancel ?? p.isCancel;
1364
+
1365
+ if (!options.quiet) {
1366
+ p.intro(pc.cyan(`Removing sandbox for ${branch}`));
1367
+ }
1368
+
1369
+ const targetDigest = removalTargetDigest(config, target);
1370
+ const existingJournals = listSandboxRemovalJournals({
1371
+ branch: effectiveBranch,
1372
+ project: config.project,
1373
+ targetDigest
1374
+ });
1375
+ const persistedTarget = existingJournals[0]?.target;
1376
+ for (const journal of existingJournals) {
1377
+ if (!persistedTarget) break;
1378
+ assertRemovalSelectionMatches(persistedTarget, journal.target);
1379
+ }
1380
+ const recovery = options.permits
1381
+ ? new Map<string, WorktreeRecoveryContext>()
1382
+ : recoveryContexts(config, target, existingWorktrees);
1383
+ const journalPermits = options.permits ? new Map<string, WorktreeRemovalPermit>()
1384
+ : permitsFromJournal(existingJournals, target, targetDigest);
1385
+ const permits = options.permits
1386
+ ? new Map(options.permits)
1387
+ : journalPermits.size > 0
1388
+ ? journalPermits
1389
+ : await authorizeWorktrees(existingWorktrees, {
1390
+ allowDirtyDiscard: options.allowDirtyDiscard ?? true,
1391
+ assumeYes: Boolean(options.assumeYes)
1392
+ }, {
1393
+ ...options.prompt,
1394
+ interactive: options.interactive ?? Boolean(process.stdin.isTTY),
1395
+ recovery
1396
+ });
1397
+ for (const worktree of existingWorktrees) {
1398
+ const permit = permits.get(path.resolve(worktree));
1399
+ if (!permit) throw new Error(`Missing worktree removal permit: ${worktree}`);
1400
+ const worktreeTombstone = removalTombstonePath(targetDigest, 'worktree', worktree);
1401
+ const workspacePhasePending = existingJournals.some((journal) => (
1402
+ sandboxRemovalPhaseIndex(journal.phase) < sandboxRemovalPhaseIndex('workspace-removed')
1403
+ ));
1404
+ if (fs.existsSync(worktree)
1405
+ || (workspacePhasePending
1406
+ && !isOwnedRemovalTombstone(config.worktreeBase, worktreeTombstone, {
1407
+ version: 1,
1408
+ targetDigest,
1409
+ permitDigest: removalPermitDigest(permits),
1410
+ kind: 'worktree',
1411
+ source: worktree
1412
+ }))) {
1413
+ verifyWorktreePermit(permit);
1414
+ }
1415
+ }
1416
+
1417
+ const recoveredRemovalChoice = persistedTarget;
1418
+ const shouldRemoveWorktree = recoveredRemovalChoice
1419
+ ? recoveredRemovalChoice.removeWorktree
1420
+ : existingWorktrees.length === 0
1421
+ ? false
1422
+ : options.assumeYes
1423
+ ? true
1424
+ : await confirm({
1425
+ message: `Remove worktree(s): ${existingWorktrees.join(', ')}?`,
1426
+ initialValue: true
1427
+ });
1428
+ if (isCancel(shouldRemoveWorktree)) {
1429
+ p.outro('Cancelled');
1430
+ return null;
1431
+ }
1432
+
1433
+ const shouldDeleteBranch = recoveredRemovalChoice
1434
+ ? recoveredRemovalChoice.removeBranch
1435
+ : shouldRemoveWorktree && existingWorktrees.length > 0
1436
+ ? options.assumeYes
1437
+ ? true
1438
+ : await confirm({
1439
+ message: `Also delete local branch '${effectiveBranch}'?`,
1440
+ initialValue: true
1441
+ })
1442
+ : false;
1443
+ if (isCancel(shouldDeleteBranch)) {
1444
+ p.outro('Cancelled');
1445
+ return null;
1446
+ }
1447
+
1448
+ const sharePath = path.resolve(shareBranchDir(config, effectiveBranch));
1449
+ const shouldRemoveShare = recoveredRemovalChoice
1450
+ ? recoveredRemovalChoice.removeShare
1451
+ : fs.existsSync(sharePath)
1452
+ ? options.assumeYes
1453
+ ? true
1454
+ : await confirm({
1455
+ message: `Remove share dir for branch '${effectiveBranch}' (${sharePath})?`,
1456
+ initialValue: true
1457
+ })
1458
+ : false;
1459
+ if (isCancel(shouldRemoveShare)) {
1460
+ p.outro('Cancelled');
1461
+ return null;
1462
+ }
1463
+
1464
+ const committedTarget = persistedTarget
1465
+ ? {
1466
+ ...persistedTarget,
1467
+ permitDigest: removalPermitDigest(permits),
1468
+ permits: removalPermitCommits(permits)
1469
+ }
1470
+ : removalTargetCommit(
1471
+ config,
1472
+ target,
1473
+ permits,
1474
+ Boolean(shouldRemoveWorktree),
1475
+ Boolean(shouldDeleteBranch),
1476
+ Boolean(shouldRemoveShare)
1477
+ );
1478
+ if (persistedTarget) assertRemovalSelectionMatches(persistedTarget, committedTarget);
1479
+
1480
+ const coordinatedManifests = controlRoots
1481
+ .filter((candidate) => fs.existsSync(candidate))
1482
+ .flatMap((root) => {
1483
+ const manifestPath = path.join(root, 'manifest.json');
1484
+ return fs.existsSync(manifestPath)
1485
+ ? [[root, readSandboxControlManifest(manifestPath), fs.readFileSync(manifestPath, 'utf8')] as const]
1486
+ : [];
1487
+ });
1488
+ const resourceLocks = new Map<string, SandboxResourceLock>();
1489
+ const recoveredContainerNames = new Set<string>();
1490
+ try {
1491
+ for (const [root, manifest] of [...coordinatedManifests].sort(([left], [right]) => left.localeCompare(right))) {
1492
+ resourceLocks.set(root, acquireSandboxResourceLock(
1493
+ `${manifest.engine}:${manifest.containerIdentity.id}`,
1494
+ { lockDomain: manifest.authorityEvidence.lockDomain }
1495
+ ));
1496
+ }
1497
+ for (const journal of existingJournals.filter((candidate) => !fs.existsSync(candidate.target.controlRoot))) {
1498
+ if (!target.controlRoots.some((root) => path.resolve(root) === path.resolve(journal.target.controlRoot))) {
1499
+ throw new Error('SANDBOX_CONTROL_REMOVAL_TARGET_MISMATCH');
1500
+ }
1501
+ assertRemovalSelectionMatches(committedTarget, journal.target);
1502
+ if (journal.target.permitDigest !== removalPermitDigest(permits)) {
1503
+ throw new Error('SANDBOX_CONTROL_REMOVAL_PERMIT_MISMATCH');
1504
+ }
1505
+ const lock = acquireSandboxResourceLock(`${journal.engine}:${journal.containerId}`, {
1506
+ lockDomain: journal.lockDomain
1507
+ });
1508
+ resourceLocks.set(journal.target.controlRoot, lock);
1509
+ const current = listSandboxRemovalJournals({
1510
+ branch: journal.target.branch,
1511
+ project: journal.target.project,
1512
+ targetDigest: journal.target.targetDigest
1513
+ }).find((candidate) => candidate.carrierIdentityDigest === journal.carrierIdentityDigest);
1514
+ if (!current || current.revision !== journal.revision || current.handoffId !== journal.handoffId) {
1515
+ throw new Error('SANDBOX_CONTROL_REMOVAL_JOURNAL_REVISION_MISMATCH');
1516
+ }
1517
+ const claimed = claimSandboxRemovalJournal(current, { resourceLock: lock });
1518
+ if (!['carrier-finalizing', 'carrier-removed', 'workspace-finalizing', 'workspace-removed',
1519
+ 'branch-finalizing', 'branch-removed', 'tool-finalizing', 'tool-removed',
1520
+ 'shell-finalizing', 'shell-removed', 'share-finalizing', 'share-removed', 'completed']
1521
+ .includes(claimed.phase)) {
1522
+ throw new Error('SANDBOX_CONTROL_REMOVE_RECOVERY_PENDING');
1523
+ }
1524
+ const recovered = claimed.phase === 'carrier-finalizing'
1525
+ ? advanceSandboxRemovalJournalPhase(claimed, 'carrier-removed', { resourceLock: lock })
1526
+ : claimed;
1527
+ recoveredContainerNames.add(controlRootContainer(config, journal.target.controlRoot));
1528
+ }
1529
+ cleanupCompletedRemovalArtifacts(config, target, committedTarget, targetDigest);
1530
+ preflightRmTarget(config, target);
1531
+ const coordinatedContainers = new Set(recoveredContainerNames);
1532
+ for (const root of controlRoots.filter((candidate) => fs.existsSync(candidate))) {
1533
+ assertLegacyCandidateEvidence(root, config.controlBase, config, effectiveBranch, matchedContainers);
1534
+ assertControlRootMatchesTarget(root, effectiveBranch, workspace);
1535
+ const manifestPath = path.join(root, 'manifest.json');
1536
+ if (!fs.existsSync(manifestPath)) continue;
1537
+ const currentRaw = fs.readFileSync(manifestPath, 'utf8');
1538
+ const manifest = readSandboxControlManifest(manifestPath);
1539
+ const initial = coordinatedManifests.find(([candidate]) => candidate === root);
1540
+ if (!initial || currentRaw !== initial[2]) throw new Error('SANDBOX_CONTROL_MANIFEST_CHANGED');
1541
+ coordinatedContainers.add(manifest.container);
1542
+ await removeSandboxControlRoot(root, {
1543
+ inspectContainer: (timeoutMs) => inspectSandboxControlContainer(manifest, { timeoutMs }),
1544
+ removeContainer: (timeoutMs) => removeExactSandboxContainer(engine, manifest, timeoutMs),
1545
+ resourceLock: resourceLocks.get(root),
1546
+ retainRemovalJournal: true,
1547
+ removalTarget: { ...committedTarget, controlRoot: path.resolve(root) }
1548
+ });
1549
+ removeEmptyManagedParent(path.join(config.controlBase, config.project), root);
1550
+ }
1551
+
1552
+ if ([...recoveredContainerNames].some((name) => matchedContainers.includes(name))) {
1553
+ throw new Error('SANDBOX_CONTROL_CONTAINER_REAPPEARED');
1554
+ }
1555
+
1556
+ const uncoordinatedContainers = matchedContainers.filter((name) => !coordinatedContainers.has(name));
1557
+ if (uncoordinatedContainers.length > 0) {
1558
+ if (workspace.mode === 'task-bound') {
1559
+ throw new Error(`SANDBOX_CONTROL_TARGET_EVIDENCE_MISSING: container '${uncoordinatedContainers[0]}'`);
1560
+ }
1561
+ const spinner = p.spinner();
1562
+ spinner.start(`Stopping container(s): ${uncoordinatedContainers.join(', ')}`);
1563
+ for (const name of uncoordinatedContainers) {
1564
+ if (!runOkEngine(engine, 'docker', ['stop', name])) throw new Error(`Failed to stop sandbox container: ${name}`);
1565
+ if (!runOkEngine(engine, 'docker', ['rm', name])) throw new Error(`Failed to remove sandbox container: ${name}`);
1566
+ }
1567
+ const remaining = runEngine(engine, 'docker', ['ps', '-a', '--format', '{{.Names}}']).split('\n').filter(Boolean);
1568
+ const leftovers = uncoordinatedContainers.filter((name) => remaining.includes(name));
1569
+ if (leftovers.length > 0) throw new Error(`Sandbox container(s) still exist after removal: ${leftovers.join(', ')}`);
1570
+ spinner.stop(pc.green(`Removed container(s): ${uncoordinatedContainers.join(', ')}`));
1571
+ } else {
1572
+ p.log.warn(`No sandbox container found for '${branch}'`);
1573
+ }
1574
+
1575
+ const runWorkspaceCleanup = prepareRemovalAction(
1576
+ config.project, target, targetDigest, 'workspace-finalizing', 'workspace-removed', resourceLocks
1577
+ );
1578
+ if (runWorkspaceCleanup.run) {
1579
+ for (const [roots, base, label] of [
1580
+ [workspaceViewRoots, path.join(config.workspaceViewBase, config.project), 'Workspace view'],
1581
+ [controlRoots, path.join(config.controlBase, config.project), 'Control channel']
1582
+ ] as const) {
1583
+ for (const directory of roots.filter((candidate) => shouldStageManagedRemoval(
1584
+ candidate,
1585
+ removalTombstonePath(targetDigest, 'workspace', candidate),
1586
+ runWorkspaceCleanup.recovering
1587
+ ))) {
1588
+ assertLegacyCandidateEvidence(directory, base === path.join(config.controlBase, config.project)
1589
+ ? config.controlBase : config.workspaceViewBase, config, effectiveBranch, matchedContainers);
1590
+ stageManagedRemoval(
1591
+ base,
1592
+ directory,
1593
+ removalTombstonePath(targetDigest, 'workspace', directory),
1594
+ runWorkspaceCleanup.recovering,
1595
+ {
1596
+ version: 1,
1597
+ targetDigest,
1598
+ permitDigest: committedTarget.permitDigest,
1599
+ kind: 'workspace',
1600
+ source: directory
1601
+ }
1602
+ );
1603
+ if (!options.quiet) p.log.success(`${label} removed: ${directory}`);
1604
+ }
1605
+ }
1606
+
1607
+ if (shouldRemoveWorktree) {
1608
+ for (const worktree of existingWorktrees) {
1609
+ const permit = permits.get(path.resolve(worktree));
1610
+ if (!permit) throw new Error(`Missing worktree removal permit: ${worktree}`);
1611
+ stageWorktreeRemoval(
1612
+ config,
1613
+ worktree,
1614
+ permit,
1615
+ removalTombstonePath(targetDigest, 'worktree', worktree),
1616
+ runWorkspaceCleanup.recovering,
1617
+ {
1618
+ version: 1,
1619
+ targetDigest,
1620
+ permitDigest: committedTarget.permitDigest,
1621
+ kind: 'worktree',
1622
+ source: worktree
1623
+ }
1624
+ );
1625
+ }
1626
+ }
1627
+ } else {
1628
+ assertRemovalPathsAbsent([
1629
+ ...removalActionPaths(target, config, 'workspace-removed'),
1630
+ ...(shouldRemoveWorktree ? existingWorktrees : [])
1631
+ ]);
1632
+ }
1633
+ advanceRemovalJournals(config.project, target, targetDigest, 'workspace-removed', resourceLocks);
1634
+ cleanupCompletedRemovalArtifacts(config, target, committedTarget, targetDigest);
1635
+ for (const [roots, base] of [
1636
+ [workspaceViewRoots, path.join(config.workspaceViewBase, config.project)],
1637
+ [controlRoots, path.join(config.controlBase, config.project)]
1638
+ ] as const) {
1639
+ for (const directory of roots) {
1640
+ if (!fs.existsSync(directory)) removeEmptyManagedParent(base, directory);
1641
+ }
1642
+ }
1643
+
1644
+ const runBranchCleanup = prepareRemovalAction(
1645
+ config.project, target, targetDigest, 'branch-finalizing', 'branch-removed', resourceLocks
1646
+ );
1647
+ if (runBranchCleanup.run) {
1648
+ if (shouldRemoveWorktree && shouldDeleteBranch) {
1649
+ stageBranchRemoval(config, effectiveBranch, targetDigest, permits, runBranchCleanup.recovering);
1650
+ }
1651
+ } else if (shouldRemoveWorktree && shouldDeleteBranch
1652
+ && runOk('git', ['-C', config.repoRoot, 'show-ref', '--verify', `refs/heads/${effectiveBranch}`])) {
1653
+ throw new Error(`SANDBOX_CONTROL_REMOVAL_TARGET_MISMATCH: ${effectiveBranch}`);
1654
+ }
1655
+ advanceRemovalJournals(config.project, target, targetDigest, 'branch-removed', resourceLocks);
1656
+ cleanupCompletedRemovalArtifacts(config, target, committedTarget, targetDigest);
1657
+
1658
+ const directoryStages = [
1659
+ { kind: 'tool', entries: toolCandidates.flatMap(({ tool, candidates }) =>
1660
+ candidates.map((dir) => ({ dir, base: tool.sandboxBase, label: tool.name + ' state' }))) },
1661
+ { kind: 'shell', entries: shellConfigDirCandidates(config, effectiveBranch)
1662
+ .map((dir) => ({ dir, base: config.shellConfigBase, label: 'Shell config' })) },
1663
+ { kind: 'share', entries: shouldRemoveShare
1664
+ ? [{ dir: sharePath, base: config.shareBase, label: 'Share dir' }] : [] }
1665
+ ] as const;
1666
+ for (const { kind, entries } of directoryStages) {
1667
+ const completedPhase = `${kind}-removed` as const;
1668
+ const action = prepareRemovalAction(
1669
+ config.project, target, targetDigest, `${kind}-finalizing`, completedPhase, resourceLocks
1670
+ );
1671
+ if (action.run) {
1672
+ for (const { dir, base, label } of entries) {
1673
+ const tombstone = removalTombstonePath(targetDigest, kind, dir);
1674
+ if (!shouldStageManagedRemoval(dir, tombstone, action.recovering)) continue;
1675
+ stageManagedRemoval(base, dir, tombstone, action.recovering, {
1676
+ version: 1, targetDigest, permitDigest: committedTarget.permitDigest, kind, source: dir
1677
+ });
1678
+ p.log.success(`${label} removed: ${dir}`);
1679
+ }
1680
+ } else if (kind !== 'share' || shouldRemoveShare) {
1681
+ assertRemovalPathsAbsent(removalActionPaths(target, config, completedPhase));
1682
+ }
1683
+ advanceRemovalJournals(config.project, target, targetDigest, completedPhase, resourceLocks);
1684
+ cleanupCompletedRemovalArtifacts(config, target, committedTarget, targetDigest);
1685
+ }
1686
+
1687
+ advanceRemovalJournals(config.project, target, targetDigest, 'completed', resourceLocks);
1688
+
1689
+ let auxiliaryReport: IntermediateCleanupReport | null = null;
1690
+ if (auxiliaryTaskId) {
1691
+ const finalControlBindingEvidence = projectSandboxControlBindingEvidence(
1692
+ config,
1693
+ controlRoots,
1694
+ listSandboxRemovalJournals({ branch: effectiveBranch, project: config.project, targetDigest })
1695
+ );
1696
+ const report = cleanupIntermediateUnderRemovalCoordinator(config.repoRoot, {
1697
+ taskIds: [auxiliaryTaskId],
1698
+ controlBindingEvidence: finalControlBindingEvidence,
1699
+ lockedTaskIds: new Set([auxiliaryTaskId])
1700
+ });
1701
+ auxiliaryReport = report;
1702
+ if (!options.quiet) {
1703
+ for (const line of formatIntermediateCleanupReport(report)) p.log.message(line);
1704
+ }
1705
+ if (!report.remaining.some((item) => item.taskId === auxiliaryTaskId)) {
1706
+ for (const journal of listSandboxRemovalJournals({
1707
+ branch: effectiveBranch,
1708
+ project: config.project,
1709
+ targetDigest
1710
+ })) clearSandboxRemovalJournalRecord(journal);
1711
+ }
1712
+ } else {
1713
+ for (const journal of listSandboxRemovalJournals({
1714
+ branch: effectiveBranch,
1715
+ project: config.project,
1716
+ targetDigest
1717
+ })) clearSandboxRemovalJournalRecord(journal);
1718
+ }
1719
+
1720
+ if (!options.quiet) p.outro(pc.green('Sandbox removed'));
1721
+ return auxiliaryReport;
1722
+ } finally {
1723
+ for (const lock of [...resourceLocks.values()].reverse()) lock.release();
1724
+ }
1725
+ }
1726
+
1727
+ async function rmPurge(
1728
+ config: SandboxConfig,
1729
+ tools: SandboxTool[],
1730
+ prompt: PromptDependencies = {}
1731
+ ): Promise<void> {
1732
+ return withRepositoryMutationLock(config.repoRoot, () => rmPurgeCore(config, tools, prompt));
1733
+ }
1734
+
1735
+ async function rmPurgeCore(
1736
+ config: SandboxConfig,
1737
+ tools: SandboxTool[],
1738
+ prompt: PromptDependencies = {}
1739
+ ): Promise<void> {
1740
+ const engine = detectEngine(config);
1741
+ const confirm = prompt.confirm ?? p.confirm;
1742
+ const isCancel = prompt.isCancel ?? p.isCancel;
1743
+ p.intro(pc.cyan(`Removing all sandboxes for ${config.project}`));
1744
+
1745
+ const worktrees = fs.existsSync(config.worktreeBase)
1746
+ ? fs.readdirSync(config.worktreeBase)
1747
+ .map((entry) => path.join(config.worktreeBase, entry))
1748
+ .filter((entry) => {
1749
+ try { return fs.statSync(entry).isDirectory(); } catch { return false; }
1750
+ })
1751
+ : [];
1752
+ const inspections = inspectWorktrees(worktrees);
1753
+ const blockers = inspectionBlockers(inspections);
1754
+ if (blockers.length > 0) {
1755
+ throw new Error(`Refusing to purge because worktree preflight found blocker(s):\n${blockerMessage(blockers)}`);
1756
+ }
1757
+ const permits = cleanPermits(inspections);
1758
+
1759
+ assertIntermediateCleanupPreflight(scanIntermediateCleanup(config.repoRoot, {
1760
+ preflight: true,
1761
+ controlBindingEvidence: projectSandboxControlBindingEvidence(config)
1762
+ }));
1763
+
1764
+ const coordinatedContainers = await removeProjectControlRoots(config, engine, {
1765
+ retainRemovalJournal: true
1766
+ });
1767
+
1768
+ const containers = runEngine(engine, 'docker', [
1769
+ 'ps',
1770
+ '-a',
1771
+ '--filter',
1772
+ `label=${sandboxLabel(config)}`,
1773
+ '--format',
1774
+ '{{.Names}}'
1775
+ ]);
1776
+ const uncoordinatedContainers = containers.split('\n').filter((name) => name && !coordinatedContainers.has(name));
1777
+ if (uncoordinatedContainers.length > 0) {
1778
+ const spinner = p.spinner();
1779
+ spinner.start('Stopping project sandbox containers...');
1780
+ for (const name of uncoordinatedContainers) {
1781
+ if (!runOkEngine(engine, 'docker', ['stop', name])) throw new Error(`Failed to stop sandbox container: ${name}`);
1782
+ if (!runOkEngine(engine, 'docker', ['rm', name])) throw new Error(`Failed to remove sandbox container: ${name}`);
1783
+ }
1784
+ const remaining = runEngine(engine, 'docker', [
1785
+ 'ps', '-a', '--filter', `label=${sandboxLabel(config)}`, '--format', '{{.Names}}'
1786
+ ]);
1787
+ const leftovers = remaining.split('\n').filter((name) => name && !coordinatedContainers.has(name));
1788
+ if (leftovers.length > 0) throw new Error(`Project sandbox container(s) still exist after removal: ${leftovers.join(', ')}`);
1789
+ spinner.stop(pc.green('Project sandbox containers removed'));
1790
+ } else {
1791
+ p.log.warn('No project sandbox containers found');
1792
+ }
1793
+
1794
+ if (worktrees.length > 0) {
1795
+ const shouldRemoveWorktrees = await confirm({
1796
+ message: `Remove all worktrees in ${config.worktreeBase}?`,
1797
+ initialValue: true
1798
+ });
1799
+
1800
+ if (!isCancel(shouldRemoveWorktrees) && shouldRemoveWorktrees) {
1801
+ for (const dir of worktrees) {
1802
+ const permit = permits.get(path.resolve(dir));
1803
+ if (!permit) throw new Error(`Missing worktree removal permit: ${dir}`);
1804
+ removeWorktreeDir(config.repoRoot, config.worktreeBase, dir, permit, {
1805
+ allowRegisteredPathFallback: engine === ENGINES.WSL2
1806
+ });
1807
+ assertRemoved(dir, 'Worktree');
1808
+ }
1809
+ runSafe('git', ['-C', config.repoRoot, 'worktree', 'prune']);
1810
+ const registered = new Set(runSafe('git', ['-C', config.repoRoot, 'worktree', 'list', '--porcelain'])
1811
+ .split('\n').filter((line) => line.startsWith('worktree ')).map((line) => path.resolve(line.slice(9))));
1812
+ const leftovers = worktrees.filter((dir) => registered.has(path.resolve(dir)));
1813
+ if (leftovers.length > 0) throw new Error(`Worktree(s) still registered after removal: ${leftovers.join(', ')}`);
1814
+ }
1815
+ }
1816
+
1817
+ for (const dir of projectToolDirs(config, tools)) {
1818
+ if (fs.existsSync(dir)) {
1819
+ removeManagedDir(path.dirname(dir), dir);
1820
+ assertRemoved(dir, 'Tool state');
1821
+ p.log.success(`Removed tool state: ${dir}`);
1822
+ }
1823
+ }
1824
+
1825
+ if (fs.existsSync(config.shellConfigBase) && fs.readdirSync(config.shellConfigBase).length > 0) {
1826
+ const shouldRemoveShellConfigs = await confirm({
1827
+ message: `Remove all shell config dirs in ${config.shellConfigBase}?`,
1828
+ initialValue: true
1829
+ });
1830
+
1831
+ if (!isCancel(shouldRemoveShellConfigs) && shouldRemoveShellConfigs) {
1832
+ for (const entry of fs.readdirSync(config.shellConfigBase)) {
1833
+ const dir = path.join(config.shellConfigBase, entry);
1834
+ removeManagedDir(config.shellConfigBase, dir);
1835
+ assertRemoved(dir, 'Shell config');
1836
+ }
1837
+ p.log.success(`Project shell config dirs removed: ${config.shellConfigBase}`);
1838
+ }
1839
+ }
1840
+
1841
+ if (fs.existsSync(config.shareBase) && fs.readdirSync(config.shareBase).length > 0) {
1842
+ const shouldRemoveAllShares = await confirm({
1843
+ message: `Remove all share dirs for project (${config.shareBase})?`,
1844
+ initialValue: true
1845
+ });
1846
+ if (!isCancel(shouldRemoveAllShares) && shouldRemoveAllShares) {
1847
+ removeManagedDir(path.dirname(config.shareBase), config.shareBase);
1848
+ assertRemoved(config.shareBase, 'Project share dir');
1849
+ p.log.success(`Project share dirs removed: ${config.shareBase}`);
1850
+ }
1851
+ }
1852
+
1853
+ for (const [base, label] of [
1854
+ [config.workspaceViewBase, 'workspace views'],
1855
+ [config.controlBase, 'control channels']
1856
+ ] as const) {
1857
+ const projectDir = path.join(base, config.project);
1858
+ if (fs.existsSync(projectDir)) {
1859
+ removeManagedDir(base, projectDir);
1860
+ assertRemoved(projectDir, `Project ${label}`);
1861
+ p.log.success(`Removed project ${label}: ${projectDir}`);
1862
+ }
1863
+ }
1864
+
1865
+ const shouldRemoveImage = await confirm({
1866
+ message: `Remove image ${config.imageName}?`,
1867
+ initialValue: false
1868
+ });
1869
+ if (!isCancel(shouldRemoveImage) && shouldRemoveImage) {
1870
+ runSafeEngine(engine, 'docker', ['rmi', config.imageName]);
1871
+ }
1872
+
1873
+ pruneSandboxDanglingImages(config, engine);
1874
+
1875
+ const cleanupPurgeAuxiliary = (): void => {
1876
+ const report = cleanupIntermediateUnderRemovalCoordinator(config.repoRoot, {
1877
+ controlBindingEvidence: projectSandboxControlBindingEvidence(config)
1878
+ });
1879
+ for (const line of formatIntermediateCleanupReport(report)) p.log.message(line);
1880
+ if (!report.remaining.some((item) => item.taskId !== null)) {
1881
+ for (const journal of listSandboxRemovalJournals({ project: config.project })) {
1882
+ if (journal.phase === 'completed') clearSandboxRemovalJournalRecord(journal);
1883
+ }
1884
+ }
1885
+ };
1886
+
1887
+ if (isManagedEngine(engine)) {
1888
+ if (engine === ENGINES.WSL2) {
1889
+ completePurgeRemovalJournals(config);
1890
+ cleanupPurgeAuxiliary();
1891
+ p.log.warn('Windows uses Docker Desktop with WSL2. Stop it from Docker Desktop or run "wsl --shutdown" manually.');
1892
+ p.outro(pc.green('All project sandboxes removed'));
1893
+ return;
1894
+ }
1895
+
1896
+ const name = engineDisplayName(engine);
1897
+ const shouldStopVm = await confirm({
1898
+ message: `Stop ${name} VM?`,
1899
+ initialValue: false
1900
+ });
1901
+ if (!isCancel(shouldStopVm) && shouldStopVm) {
1902
+ stopManagedVm(config);
1903
+ }
1904
+ }
1905
+
1906
+ completePurgeRemovalJournals(config);
1907
+ cleanupPurgeAuxiliary();
1908
+
1909
+ p.outro(pc.green('All project sandboxes removed'));
1910
+ }
1911
+
1912
+ async function rmUnbound(
1913
+ config: SandboxConfig,
1914
+ tools: SandboxTool[],
1915
+ options: { dryRun: boolean; assumeYes: boolean }
1916
+ ): Promise<void> {
1917
+ return withRepositoryMutationLock(config.repoRoot, () => rmUnboundCore(config, tools, options));
1918
+ }
1919
+
1920
+ async function rmUnboundCore(
1921
+ config: SandboxConfig,
1922
+ tools: SandboxTool[],
1923
+ options: { dryRun: boolean; assumeYes: boolean }
1924
+ ): Promise<void> {
1925
+ const engine = detectEngine(config);
1926
+ const controlBindingEvidence = projectSandboxControlBindingEvidence(config);
1927
+ const { running, nonRunning } = fetchSandboxRows(
1928
+ engine,
1929
+ sandboxLabel(config),
1930
+ sandboxBranchLabel(config),
1931
+ { mode: sandboxWorkspaceModeLabel(config), taskId: sandboxTaskIdLabel(config) }
1932
+ );
1933
+ const rows = [...running, ...nonRunning];
1934
+
1935
+ p.intro(pc.cyan(`Removing sandboxes not bound to an active task for ${config.project}`));
1936
+ const intermediatePreview = scanIntermediateCleanup(config.repoRoot, { controlBindingEvidence });
1937
+
1938
+ const candidates: CleanupCandidate[] = [];
1939
+ const protectedCandidates: ProtectedCleanupCandidate[] = [];
1940
+ for (const row of rows) {
1941
+ if (!row.branch || !row.workspaceMode || row.workspaceMode === 'legacy-invalid') {
1942
+ throw new Error(`SANDBOX_CLEANUP_BATCH_PREFLIGHT_FAILED: container '${row.name}' has incomplete workspace identity`);
1943
+ }
1944
+ if (row.workspaceMode === 'task-bound') {
1945
+ if (!row.taskId) {
1946
+ throw new Error(`SANDBOX_CLEANUP_BATCH_PREFLIGHT_FAILED: container '${row.name}' has no task id`);
1947
+ }
1948
+ try {
1949
+ const cleanupTarget = resolveSandboxCleanupTarget(row.taskId, config.repoRoot, { allowProtected: true });
1950
+ if (cleanupTarget.branch !== row.branch
1951
+ || cleanupTarget.workspace.mode !== 'task-bound'
1952
+ || cleanupTarget.workspace.taskId !== row.taskId) {
1953
+ throw new Error(`SANDBOX_CLEANUP_BATCH_PREFLIGHT_FAILED: container '${row.name}' task identity conflicts with task.md`);
1954
+ }
1955
+ candidates.push({ row, cleanupTarget });
1956
+ } catch (error) {
1957
+ if (!isMissingTaskRecordError(error)) throw error;
1958
+ protectedCandidates.push({
1959
+ row,
1960
+ branch: row.branch,
1961
+ identity: `task-bound:${row.taskId}`,
1962
+ reason: 'TASK_NOT_FOUND'
1963
+ });
1964
+ }
1965
+ continue;
1966
+ }
1967
+ candidates.push({
1968
+ row,
1969
+ cleanupTarget: {
1970
+ requestedRef: row.branch,
1971
+ branch: row.branch,
1972
+ workspace: { mode: 'branch-only' },
1973
+ taskState: 'branch-only'
1974
+ } satisfies SandboxCleanupTarget
1975
+ });
1976
+ }
1977
+ const branchIdentities = new Map<string, string>();
1978
+ const groupedCandidates = new Map<string, CleanupCandidate[]>();
1979
+ for (const candidate of protectedCandidates) {
1980
+ const existingIdentity = branchIdentities.get(candidate.branch);
1981
+ if (existingIdentity && existingIdentity !== candidate.identity) {
1982
+ throw new Error(
1983
+ `SANDBOX_CLEANUP_BATCH_PREFLIGHT_FAILED: branch '${candidate.branch}' has conflicting workspace identities`
1984
+ );
1985
+ }
1986
+ branchIdentities.set(candidate.branch, candidate.identity);
1987
+ }
1988
+ for (const candidate of candidates) {
1989
+ const identity = candidate.cleanupTarget.workspace.mode === 'task-bound'
1990
+ ? `task-bound:${candidate.cleanupTarget.workspace.taskId}`
1991
+ : 'branch-only';
1992
+ const existingIdentity = branchIdentities.get(candidate.cleanupTarget.branch);
1993
+ if (existingIdentity && existingIdentity !== identity) {
1994
+ throw new Error(
1995
+ `SANDBOX_CLEANUP_BATCH_PREFLIGHT_FAILED: branch '${candidate.cleanupTarget.branch}' has conflicting workspace identities`
1996
+ );
1997
+ }
1998
+ branchIdentities.set(candidate.cleanupTarget.branch, identity);
1999
+ const groupKey = `${candidate.cleanupTarget.branch}\0${identity}`;
2000
+ const group = groupedCandidates.get(groupKey) ?? [];
2001
+ group.push(candidate);
2002
+ groupedCandidates.set(groupKey, group);
2003
+ }
2004
+ const groups: CleanupGroup[] = [...groupedCandidates.values()].map((groupCandidates) => {
2005
+ const cleanupTarget = groupCandidates[0]!.cleanupTarget;
2006
+ const target = resolveRmTarget(config, tools, cleanupTarget, {
2007
+ discoveredContainers: groupCandidates.map(({ row }) => row.name)
2008
+ });
2009
+ preflightRmTarget(config, target);
2010
+ return { candidates: groupCandidates, cleanupTarget, target };
2011
+ });
2012
+ const protectedTargets = protectedCandidates.map(({ row, branch }) => {
2013
+ const target = resolveRmTarget(config, tools, {
2014
+ requestedRef: branch,
2015
+ branch,
2016
+ workspace: { mode: 'task-bound', taskId: row.taskId! },
2017
+ taskState: 'completed'
2018
+ }, { discoveredContainers: [row.name] });
2019
+ return { branch, managedPathCandidates: target.managedPathCandidates ?? [] };
2020
+ });
2021
+ assertCleanupGroupPathsDoNotOverlap(groups, protectedTargets);
2022
+
2023
+ const removableGroups = groups.filter(({ cleanupTarget }) =>
2024
+ cleanupTarget.taskState === 'completed' || cleanupTarget.taskState === 'branch-only'
2025
+ );
2026
+ const rowTaskIds = new Set(groups.flatMap(({ cleanupTarget }) => (
2027
+ cleanupTarget.workspace.mode === 'task-bound' ? [cleanupTarget.workspace.taskId] : []
2028
+ )));
2029
+ const cleanupOnlyTaskIds = new Set(intermediatePreview.items
2030
+ .filter((candidate) => candidate.disposition === 'planned' && candidate.taskId !== null && !rowTaskIds.has(candidate.taskId))
2031
+ .map((candidate) => candidate.taskId!));
2032
+ const removable = removableGroups.flatMap(({ candidates: groupCandidates }) => groupCandidates);
2033
+ for (const { candidates: groupCandidates, cleanupTarget } of groups.filter(({ cleanupTarget }) =>
2034
+ cleanupTarget.taskState !== 'completed' && cleanupTarget.taskState !== 'branch-only'
2035
+ )) {
2036
+ for (const { row } of groupCandidates) {
2037
+ p.log.message(`Skipped protected sandbox ${row.name} (${cleanupTarget.taskState})`);
2038
+ }
2039
+ }
2040
+ for (const { row, reason } of protectedCandidates) {
2041
+ p.log.message(`Skipped protected sandbox ${row.name} (${reason})`);
2042
+ }
2043
+
2044
+ for (const line of formatIntermediateCleanupReport(intermediatePreview)) p.log.message(line);
2045
+
2046
+ const hasAuxiliaryWork = intermediatePreview.items.some((candidate) => candidate.disposition === 'planned');
2047
+ if (removableGroups.length === 0 && !hasAuxiliaryWork) {
2048
+ p.outro('No removable sandboxes: every container is bound to a protected task (or none exist)');
2049
+ return;
2050
+ }
2051
+
2052
+ const inspections = inspectWorktrees(removableGroups.flatMap(({ target }) => target.existingWorktrees));
2053
+ assertWorktreeBranchesMatchGroups(removableGroups, inspections);
2054
+ const blockers = inspectionBlockers(inspections);
2055
+ const permits = cleanPermits(inspections);
2056
+
2057
+ for (const { row } of removable) {
2058
+ p.log.message(`${row.name} ${row.branch}`);
2059
+ }
2060
+ if (blockers.length > 0) p.log.error(blockerMessage(blockers));
2061
+
2062
+ if (options.dryRun) {
2063
+ p.outro(`Dry run: ${removable.length} sandbox(es) inspected, nothing deleted`);
2064
+ return;
2065
+ }
2066
+
2067
+ if (blockers.length > 0) {
2068
+ throw new Error(`Refusing batch removal because worktree preflight found blocker(s):\n${blockerMessage(blockers)}`);
2069
+ }
2070
+
2071
+ if (!options.assumeYes && !process.stdin.isTTY) {
2072
+ throw new Error(
2073
+ 'Refusing to remove sandboxes without confirmation in a non-interactive shell; pass --yes to proceed.'
2074
+ );
2075
+ }
2076
+
2077
+ const failures: { branch: string; message: string }[] = [];
2078
+ const actualReports: IntermediateCleanupReport[] = [];
2079
+ let failedRows = 0;
2080
+ for (const group of removableGroups) {
2081
+ try {
2082
+ const report = await runRmOneUnderRepositoryLock(config, tools, group.cleanupTarget.branch, {
2083
+ assumeYes: options.assumeYes,
2084
+ quiet: true,
2085
+ target: group.target,
2086
+ cleanupTarget: group.cleanupTarget,
2087
+ permits,
2088
+ allowDirtyDiscard: false
2089
+ });
2090
+ if (report) actualReports.push(report);
2091
+ } catch (error) {
2092
+ failedRows += group.candidates.length;
2093
+ failures.push({ branch: group.cleanupTarget.branch, message: error instanceof Error ? error.message : String(error) });
2094
+ const taskId = group.cleanupTarget.workspace.mode === 'task-bound'
2095
+ ? group.cleanupTarget.workspace.taskId
2096
+ : null;
2097
+ if (taskId) {
2098
+ const failedReport = cleanupIntermediateUnderRemovalCoordinator(config.repoRoot, {
2099
+ dryRun: true,
2100
+ taskIds: [taskId],
2101
+ controlBindingEvidence
2102
+ });
2103
+ actualReports.push(protectIntermediateCleanupReport(failedReport, 'SANDBOX_ROW_REMOVAL_FAILED'));
2104
+ }
2105
+ }
2106
+ }
2107
+
2108
+ const auxiliaryTaskIds = [...cleanupOnlyTaskIds].sort();
2109
+ const finalControlBindingEvidence = projectSandboxControlBindingEvidence(config);
2110
+ const cleanupOnlyReport = cleanupIntermediateUnderRemovalCoordinator(config.repoRoot, {
2111
+ taskIds: auxiliaryTaskIds,
2112
+ controlBindingEvidence: finalControlBindingEvidence
2113
+ });
2114
+ actualReports.push(cleanupOnlyReport);
2115
+ const intermediateReport = mergeIntermediateCleanupReports(actualReports);
2116
+ for (const line of formatIntermediateCleanupReport(intermediateReport)) p.log.message(line);
2117
+
2118
+ if (cleanupOnlyReport.remaining.every((item) => item.taskId === null)) {
2119
+ for (const taskId of auxiliaryTaskIds) {
2120
+ let branch: string;
2121
+ try {
2122
+ branch = resolveSandboxCleanupTarget(taskId, config.repoRoot, { allowProtected: true }).branch;
2123
+ } catch {
2124
+ continue;
2125
+ }
2126
+ for (const journal of listSandboxRemovalJournals({ project: config.project, branch })) {
2127
+ clearSandboxRemovalJournalRecord(journal);
2128
+ }
2129
+ }
2130
+ }
2131
+
2132
+ if (failures.length > 0) {
2133
+ for (const failure of failures) {
2134
+ p.log.error(`Failed to remove '${failure.branch}': ${failure.message}`);
2135
+ }
2136
+ throw new Error(
2137
+ `Removed ${removable.length - failedRows}/${removable.length} sandbox(es); ${failures.length} failed`
2138
+ );
2139
+ }
2140
+
2141
+ p.outro(pc.green(`Removed ${removable.length} sandbox(es)`));
2142
+ }
2143
+
2144
+ export { authorizeWorktrees, rmOne, rmPurge, rmUnbound };