@fitlab-ai/agent-infra 0.9.14 → 0.10.0

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