@fitlab-ai/agent-infra 0.9.15 → 0.11.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 (224) hide show
  1. package/README.md +2 -6
  2. package/README.zh-CN.md +2 -6
  3. package/bin/internal-cli.sh +0 -13
  4. package/dist/bin/internal-cli.js +11 -49
  5. package/dist/lib/agent-clients/adapters/codex-lifecycle/sandbox-controller.js +6 -2
  6. package/dist/lib/agent-clients/adapters/codex-lifecycle/store.js +43 -3
  7. package/dist/lib/fs/durable-write.js +2 -0
  8. package/dist/lib/internal/cli-route-inventory.js +3 -4
  9. package/dist/lib/internal/manual-validation.js +17 -8
  10. package/dist/lib/internal/platform-comment.js +60 -2
  11. package/dist/lib/internal/task-artifact.js +1 -1
  12. package/dist/lib/internal/task-finalization.js +2 -2
  13. package/dist/lib/internal/task-lifecycle.js +4 -4
  14. package/dist/lib/internal/task-operation-registry.js +22 -13
  15. package/dist/lib/internal/task-orchestration.js +2 -2
  16. package/dist/lib/internal/task-review.js +12 -7
  17. package/dist/lib/platform/activity-recovery.js +159 -0
  18. package/dist/lib/platform/issue-comments.js +89 -31
  19. package/dist/lib/platform/issues.js +1 -1
  20. package/dist/lib/platform/pr-change-report.js +5 -4
  21. package/dist/lib/platform/pr-summary.js +64 -32
  22. package/dist/lib/platform/pull-requests.js +3 -3
  23. package/dist/lib/platform/task-comment-projection.js +70 -0
  24. package/dist/lib/platform/task-identities.js +2 -9
  25. package/dist/lib/platform/task-recovery.js +55 -0
  26. package/dist/lib/platform/verification-sync.js +45 -7
  27. package/dist/lib/pr-review/evidence-grading.js +4 -8
  28. package/dist/lib/sandbox/commands/rm.js +21 -2
  29. package/dist/lib/sandbox/control/executor.js +4 -2
  30. package/dist/lib/sandbox/control/server.js +29 -9
  31. package/dist/lib/sandbox/control/state.js +7 -1
  32. package/dist/lib/sandbox/control/task-workflow.js +1 -1
  33. package/dist/lib/sandbox/control/workflow-executor.js +87 -35
  34. package/dist/lib/sandbox/index.js +1 -2
  35. package/dist/lib/sandbox/removal.js +116 -1011
  36. package/dist/lib/sandbox/workspace-identity.js +17 -1
  37. package/dist/lib/server/process-state.js +17 -5
  38. package/dist/lib/task/artifact-command.js +11 -30
  39. package/dist/lib/task/artifact-operations.js +17 -231
  40. package/dist/lib/task/artifact-recovery.js +476 -0
  41. package/dist/lib/task/artifact-repair-intent.js +90 -39
  42. package/dist/lib/task/commit-operation.js +31 -4
  43. package/dist/lib/task/control-authority.js +39 -8
  44. package/dist/lib/task/control-recovery.js +14 -2
  45. package/dist/lib/task/create-service.js +1 -8
  46. package/dist/lib/task/delegation-receipts.js +64 -3
  47. package/dist/lib/task/events.js +165 -23
  48. package/dist/lib/task/human-override.js +5 -8
  49. package/dist/lib/task/intermediate-cleanup.js +91 -8
  50. package/dist/lib/task/lifecycle-recovery.js +549 -0
  51. package/dist/lib/task/lifecycle.js +12 -3
  52. package/dist/lib/task/local-artifact-finalization.js +212 -123
  53. package/dist/lib/task/manual-validation-receipt.js +5 -1
  54. package/dist/lib/task/orchestration.js +52 -2
  55. package/dist/lib/task/pr-delivery-fact.js +12 -56
  56. package/dist/lib/task/recovery-warning.js +19 -0
  57. package/dist/lib/task/review-command.js +3 -2
  58. package/dist/lib/task/review-finalization.js +294 -74
  59. package/dist/lib/{host-control → task}/secure-fs.js +8 -19
  60. package/dist/lib/task/short-id.js +15 -7
  61. package/dist/lib/task/task-execution-lock.js +1 -1
  62. package/dist/lib/task/verification-engine.js +3 -14
  63. package/dist/lib/task/workflow-command.js +2 -1
  64. package/dist/lib/task/workflow-dispatch.js +161 -0
  65. package/dist/lib/version.js +1 -11
  66. package/dist/lifecycle-build-manifest.json +3 -3
  67. package/lib/agent-clients/adapters/codex-lifecycle/sandbox-controller.ts +7 -2
  68. package/lib/agent-clients/adapters/codex-lifecycle/store.ts +55 -3
  69. package/lib/fs/durable-write.ts +1 -0
  70. package/lib/internal/cli-route-inventory.ts +3 -4
  71. package/lib/internal/manual-validation.ts +17 -8
  72. package/lib/internal/platform-comment.ts +53 -2
  73. package/lib/internal/task-artifact.ts +1 -1
  74. package/lib/internal/task-finalization.ts +1 -1
  75. package/lib/internal/task-lifecycle.ts +5 -4
  76. package/lib/internal/task-operation-registry.ts +22 -13
  77. package/lib/internal/task-orchestration.ts +1 -1
  78. package/lib/internal/task-review.ts +11 -7
  79. package/lib/platform/activity-recovery.ts +179 -0
  80. package/lib/platform/issue-comments.ts +105 -30
  81. package/lib/platform/issues.ts +1 -1
  82. package/lib/platform/pr-change-report.ts +7 -5
  83. package/lib/platform/pr-summary.ts +64 -32
  84. package/lib/platform/pull-requests.ts +3 -3
  85. package/lib/platform/task-comment-projection.ts +79 -0
  86. package/lib/platform/task-identities.ts +3 -14
  87. package/lib/platform/task-recovery.ts +54 -0
  88. package/lib/platform/verification-sync.ts +48 -11
  89. package/lib/pr-review/evidence-grading.ts +4 -9
  90. package/lib/sandbox/commands/rm.ts +34 -2
  91. package/lib/sandbox/control/executor.ts +4 -2
  92. package/lib/sandbox/control/protocol.ts +6 -1
  93. package/lib/sandbox/control/server.ts +30 -10
  94. package/lib/sandbox/control/state.ts +9 -1
  95. package/lib/sandbox/control/task-workflow.ts +1 -1
  96. package/lib/sandbox/control/workflow-executor.ts +106 -35
  97. package/lib/sandbox/index.ts +1 -2
  98. package/lib/sandbox/removal.ts +126 -1128
  99. package/lib/sandbox/workspace-identity.ts +19 -3
  100. package/lib/server/process-state.ts +26 -5
  101. package/lib/task/artifact-command.ts +12 -29
  102. package/lib/task/artifact-operations.ts +22 -252
  103. package/lib/task/artifact-recovery.ts +572 -0
  104. package/lib/task/artifact-repair-intent.ts +126 -51
  105. package/lib/task/commit-operation.ts +35 -4
  106. package/lib/task/control-authority.ts +48 -15
  107. package/lib/task/control-recovery.ts +13 -1
  108. package/lib/task/create-service.ts +1 -10
  109. package/lib/task/delegation-receipts.ts +73 -2
  110. package/lib/task/events.ts +188 -24
  111. package/lib/task/human-override.ts +5 -8
  112. package/lib/task/intermediate-cleanup.ts +66 -12
  113. package/lib/task/lifecycle-recovery.ts +691 -0
  114. package/lib/task/lifecycle.ts +13 -4
  115. package/lib/task/local-artifact-finalization.ts +249 -131
  116. package/lib/task/manual-validation-receipt.ts +5 -1
  117. package/lib/task/orchestration.ts +65 -0
  118. package/lib/task/pr-delivery-fact.ts +15 -62
  119. package/lib/task/recovery-warning.ts +21 -0
  120. package/lib/task/review-command.ts +2 -1
  121. package/lib/task/review-finalization.ts +298 -84
  122. package/lib/{host-control → task}/secure-fs.ts +10 -32
  123. package/lib/task/short-id.ts +27 -5
  124. package/lib/task/task-execution-lock.ts +1 -1
  125. package/lib/task/verification-engine.ts +3 -17
  126. package/lib/task/verification-types.ts +0 -2
  127. package/lib/task/workflow-command.ts +2 -1
  128. package/lib/task/workflow-dispatch.ts +171 -0
  129. package/lib/version.ts +1 -15
  130. package/package.json +1 -1
  131. package/templates/.agents/rules/create-issue.en.md +1 -1
  132. package/templates/.agents/rules/create-issue.zh-CN.md +1 -1
  133. package/templates/.agents/rules/issue-sync.en.md +12 -2
  134. package/templates/.agents/rules/issue-sync.zh-CN.md +13 -5
  135. package/templates/.agents/rules/local-artifact-repair.en.md +47 -39
  136. package/templates/.agents/rules/local-artifact-repair.zh-CN.md +45 -36
  137. package/templates/.agents/skills/analyze-task/SKILL.en.md +6 -6
  138. package/templates/.agents/skills/analyze-task/SKILL.zh-CN.md +6 -6
  139. package/templates/.agents/skills/analyze-task/config/verify.en.json +1 -1
  140. package/templates/.agents/skills/analyze-task/config/verify.zh-CN.json +1 -1
  141. package/templates/.agents/skills/block-task/SKILL.en.md +2 -2
  142. package/templates/.agents/skills/block-task/SKILL.zh-CN.md +2 -2
  143. package/templates/.agents/skills/block-task/config/verify.json +1 -1
  144. package/templates/.agents/skills/cancel-task/SKILL.en.md +3 -3
  145. package/templates/.agents/skills/cancel-task/SKILL.zh-CN.md +3 -3
  146. package/templates/.agents/skills/cancel-task/config/verify.json +1 -1
  147. package/templates/.agents/skills/code-task/SKILL.en.md +7 -7
  148. package/templates/.agents/skills/code-task/SKILL.zh-CN.md +8 -8
  149. package/templates/.agents/skills/code-task/config/verify.en.json +1 -1
  150. package/templates/.agents/skills/code-task/config/verify.zh-CN.json +1 -1
  151. package/templates/.agents/skills/commit/reference/issue-metadata-sync.en.md +1 -1
  152. package/templates/.agents/skills/commit/reference/issue-metadata-sync.zh-CN.md +1 -1
  153. package/templates/.agents/skills/complete-manual-validation/SKILL.en.md +1 -1
  154. package/templates/.agents/skills/complete-manual-validation/SKILL.zh-CN.md +1 -1
  155. package/templates/.agents/skills/complete-task/SKILL.en.md +2 -2
  156. package/templates/.agents/skills/complete-task/SKILL.zh-CN.md +2 -2
  157. package/templates/.agents/skills/complete-task/config/verify.en.json +2 -2
  158. package/templates/.agents/skills/complete-task/config/verify.zh-CN.json +2 -2
  159. package/templates/.agents/skills/complete-task/reference/external-delivery.en.md +1 -1
  160. package/templates/.agents/skills/complete-task/reference/external-delivery.zh-CN.md +1 -1
  161. package/templates/.agents/skills/create-pr/SKILL.en.md +2 -2
  162. package/templates/.agents/skills/create-pr/SKILL.zh-CN.md +2 -2
  163. package/templates/.agents/skills/create-pr/config/verify.json +1 -1
  164. package/templates/.agents/skills/create-pr/reference/pr-body-template.en.md +2 -2
  165. package/templates/.agents/skills/create-pr/reference/pr-body-template.zh-CN.md +2 -2
  166. package/templates/.agents/skills/create-task/SKILL.en.md +5 -5
  167. package/templates/.agents/skills/create-task/SKILL.zh-CN.md +5 -5
  168. package/templates/.agents/skills/create-task/config/verify.json +1 -1
  169. package/templates/.agents/skills/import-issue/SKILL.en.md +8 -8
  170. package/templates/.agents/skills/import-issue/SKILL.zh-CN.md +8 -8
  171. package/templates/.agents/skills/import-issue/config/verify.json +2 -2
  172. package/templates/.agents/skills/plan-task/SKILL.en.md +4 -4
  173. package/templates/.agents/skills/plan-task/SKILL.zh-CN.md +4 -4
  174. package/templates/.agents/skills/plan-task/config/verify.en.json +1 -1
  175. package/templates/.agents/skills/plan-task/config/verify.zh-CN.json +1 -1
  176. package/templates/.agents/skills/restore-task/SKILL.en.md +11 -6
  177. package/templates/.agents/skills/restore-task/SKILL.zh-CN.md +11 -6
  178. package/templates/.agents/skills/restore-task/config/verify.json +1 -1
  179. package/templates/.agents/skills/review-analysis/SKILL.en.md +3 -3
  180. package/templates/.agents/skills/review-analysis/SKILL.zh-CN.md +3 -3
  181. package/templates/.agents/skills/review-analysis/config/verify.en.json +1 -1
  182. package/templates/.agents/skills/review-analysis/config/verify.zh-CN.json +1 -1
  183. package/templates/.agents/skills/review-analysis/reference/report-template.en.md +7 -7
  184. package/templates/.agents/skills/review-analysis/reference/report-template.zh-CN.md +6 -6
  185. package/templates/.agents/skills/review-code/SKILL.en.md +5 -4
  186. package/templates/.agents/skills/review-code/SKILL.zh-CN.md +5 -4
  187. package/templates/.agents/skills/review-code/config/verify.en.json +1 -1
  188. package/templates/.agents/skills/review-code/config/verify.zh-CN.json +1 -1
  189. package/templates/.agents/skills/review-code/reference/report-template.en.md +12 -12
  190. package/templates/.agents/skills/review-code/reference/report-template.zh-CN.md +12 -12
  191. package/templates/.agents/skills/review-plan/SKILL.en.md +3 -3
  192. package/templates/.agents/skills/review-plan/SKILL.zh-CN.md +3 -3
  193. package/templates/.agents/skills/review-plan/config/verify.en.json +1 -1
  194. package/templates/.agents/skills/review-plan/config/verify.zh-CN.json +1 -1
  195. package/templates/.agents/skills/review-plan/reference/report-template.en.md +7 -7
  196. package/templates/.agents/skills/review-plan/reference/report-template.zh-CN.md +7 -7
  197. package/templates/.agents/skills/review-pr/config/verify.en.json +1 -1
  198. package/templates/.agents/skills/review-pr/config/verify.zh-CN.json +1 -1
  199. package/templates/.agents/skills/review-pr/reference/evidence-grading.en.md +3 -3
  200. package/templates/.agents/skills/review-pr/reference/evidence-grading.zh-CN.md +3 -3
  201. package/templates/.agents/skills/run-manual-validation/config/verify.en.json +1 -1
  202. package/templates/.agents/skills/run-manual-validation/config/verify.zh-CN.json +1 -1
  203. package/templates/.agents/skills/run-task/SKILL.en.md +1 -1
  204. package/templates/.agents/skills/run-task/SKILL.zh-CN.md +1 -1
  205. package/templates/.agents/templates/task.en.md +1 -1
  206. package/templates/.agents/templates/task.zh-CN.md +1 -1
  207. package/templates/.claude/commands/import-issue.en.md +1 -1
  208. package/templates/.claude/commands/import-issue.zh-CN.md +1 -1
  209. package/templates/.claude/commands/restore-task.en.md +1 -1
  210. package/templates/.claude/commands/restore-task.zh-CN.md +1 -1
  211. package/dist/lib/host-control/audit.js +0 -19
  212. package/dist/lib/host-control/client.js +0 -142
  213. package/dist/lib/host-control/command.js +0 -49
  214. package/dist/lib/host-control/path.js +0 -160
  215. package/dist/lib/host-control/server.js +0 -159
  216. package/dist/lib/host-control/service.js +0 -112
  217. package/dist/lib/internal/host-control.js +0 -28
  218. package/lib/host-control/audit.ts +0 -22
  219. package/lib/host-control/client.ts +0 -172
  220. package/lib/host-control/command.ts +0 -60
  221. package/lib/host-control/path.ts +0 -167
  222. package/lib/host-control/server.ts +0 -180
  223. package/lib/host-control/service.ts +0 -133
  224. package/lib/internal/host-control.ts +0 -28
@@ -46,6 +46,7 @@ import { acquireSandboxResourceLock, type SandboxResourceLock } from './control/
46
46
  import { toolConfigDirCandidates, toolProjectDirCandidates } from './tools.ts';
47
47
  import type { SandboxTool } from './tools.ts';
48
48
  import { getProcessStartTime } from '../server/process-state.ts';
49
+ import { releaseStaleShortIdRegistry } from '../task/short-id.ts';
49
50
  import { fetchSandboxRows, type SandboxRow } from './commands/list-running.ts';
50
51
  import {
51
52
  formatIntermediateCleanupReport,
@@ -56,7 +57,6 @@ import {
56
57
  scanIntermediateCleanup
57
58
  } from '../task/intermediate-cleanup.ts';
58
59
  import { createSandboxControlBindingEvidence } from './task-cleanup.ts';
59
- import { withRepositoryMutationLock, withTaskExecutionLock } from '../task/task-execution-lock.ts';
60
60
  import {
61
61
  createCleanPermit,
62
62
  createDiscardPermit,
@@ -748,6 +748,25 @@ type RmTarget = {
748
748
  workspaceViewRoots: string[];
749
749
  };
750
750
 
751
+ type RemovalResourceKind = 'control-root' | 'workspace-view' | 'worktree' | 'branch' | 'tool' | 'shell' | 'share' | 'artifact' | 'intent';
752
+
753
+ export type SandboxRemovalResource = Readonly<{
754
+ kind: RemovalResourceKind;
755
+ path: string;
756
+ }>;
757
+
758
+ export type SandboxRemovalResourceDisclosure = Readonly<{
759
+ remove: readonly SandboxRemovalResource[];
760
+ preserve: readonly SandboxRemovalResource[];
761
+ }>;
762
+
763
+ type RemovalDisclosureTarget = Readonly<Pick<
764
+ RmTarget,
765
+ 'branch' | 'effectiveBranch' | 'existingWorktrees' | 'toolCandidates' | 'controlRoots' | 'workspaceViewRoots'
766
+ >>;
767
+
768
+ type RemovalSelection = Pick<SandboxRemovalTargetCommit, 'removeWorktree' | 'removeBranch' | 'removeShare'>;
769
+
751
770
  type RemovalActionKind = 'workspace' | 'worktree' | 'tool' | 'shell' | 'share';
752
771
 
753
772
  type RemovalActionPreparation = Readonly<{
@@ -789,89 +808,6 @@ type PromptDependencies = {
789
808
  isCancel?: typeof p.isCancel;
790
809
  };
791
810
 
792
- function projectSandboxControlRoots(config: SandboxConfig): string[] {
793
- const projectRoot = path.join(config.controlBase, config.project);
794
- let containers: fs.Dirent[];
795
- try { containers = fs.readdirSync(projectRoot, { withFileTypes: true }); }
796
- catch (error) {
797
- if (isMissingPathError(error)) return [];
798
- throw error;
799
- }
800
- return containers
801
- .filter((container) => container.isDirectory())
802
- .flatMap((container) => {
803
- const containerRoot = path.join(projectRoot, container.name);
804
- const identities = fs.readdirSync(containerRoot, { withFileTypes: true });
805
- return identities
806
- .filter((identity) => identity.isDirectory())
807
- .map((identity) => path.join(containerRoot, identity.name));
808
- });
809
- }
810
-
811
- function projectSandboxControlBindingEvidence(
812
- config: SandboxConfig,
813
- controlRoots: readonly string[] = projectSandboxControlRoots(config),
814
- removalJournals: readonly SandboxRemovalJournal[] = listSandboxRemovalJournals({ project: config.project })
815
- ): ReturnType<typeof createSandboxControlBindingEvidence> {
816
- return createSandboxControlBindingEvidence(config.repoRoot, controlRoots, removalJournals);
817
- }
818
-
819
- // Reasons that mean "intentionally preserved", not "cleanup is unsafe". A task
820
- // that is still active keeps its auxiliary intents by design, so preserving
821
- // them must not make its sandbox unremovable. Every other protected reason
822
- // signals tampering or an unreadable path and still blocks, as does any
823
- // failure, so unknown reasons stay fail-closed.
824
- const REMOVAL_PRESERVED_REASONS: ReadonlySet<string> = new Set([
825
- 'TASK_STATE_PROTECTED',
826
- 'TASK_NOT_FOUND',
827
- 'LFAI_STATE_PROTECTED',
828
- 'LFAI_RETRY_OR_WARNING_OPEN',
829
- 'FINALIZATION_RECEIPT_MISSING',
830
- 'FINALIZATION_RECEIPT_PENDING',
831
- 'AUXILIARY_ROOT_NOT_EMPTY'
832
- ]);
833
-
834
- function assertIntermediateCleanupPreflight(report: IntermediateCleanupReport): void {
835
- const blockers = report.items.filter((candidate) => candidate.disposition === 'failed'
836
- || (candidate.disposition === 'protected' && !REMOVAL_PRESERVED_REASONS.has(candidate.reason)));
837
- if (blockers.length === 0) return;
838
- throw new Error([
839
- 'SANDBOX_AUXILIARY_PREFLIGHT_FAILED:',
840
- ...blockers.map((candidate) => (
841
- `${candidate.kind} ${candidate.path} (${candidate.taskId ?? 'unbound'}; ${candidate.reason})`
842
- ))
843
- ].join('\n'));
844
- }
845
-
846
-
847
- function recoveryContexts(
848
- config: SandboxConfig,
849
- target: RmTarget,
850
- worktrees: readonly string[]
851
- ): Map<string, WorktreeRecoveryContext> {
852
- const candidates = worktreeDirCandidates(config, target.effectiveBranch).map((candidate) => path.resolve(candidate));
853
- const existingCandidates = candidates.filter((candidate) => fs.existsSync(candidate));
854
- if (existingCandidates.length !== 1) return new Map();
855
-
856
- const contexts = new Map<string, WorktreeRecoveryContext>();
857
- for (const worktree of worktrees) {
858
- const resolvedWorktree = path.resolve(worktree);
859
- if (resolvedWorktree !== existingCandidates[0]) continue;
860
- for (const root of target.controlRoots.filter((candidate) => fs.existsSync(candidate))) {
861
- assertLegacyCandidateEvidence(root, config.controlBase, config, target.effectiveBranch, target.matchedContainers);
862
- assertControlRootMatchesTarget(root, target.effectiveBranch, target.workspace);
863
- }
864
- contexts.set(resolvedWorktree, {
865
- repoRoot: config.repoRoot,
866
- worktreeBase: config.worktreeBase,
867
- branch: target.effectiveBranch,
868
- identitySource: target.workspace.mode,
869
- taskId: target.workspace.mode === 'task-bound' ? target.workspace.taskId : null
870
- });
871
- }
872
- return contexts;
873
- }
874
-
875
811
  function resolveRmTarget(
876
812
  config: SandboxConfig,
877
813
  tools: SandboxTool[],
@@ -890,14 +826,7 @@ function resolveRmTarget(
890
826
  const shellCandidates = shellConfigDirCandidates(config, branch);
891
827
  const existing = runEngine(engine, 'docker', ['ps', '-a', '--format', '{{.Names}}']).split('\n').filter(Boolean);
892
828
  const matchedContainers = options.discoveredContainers
893
- ? options.discoveredContainers.map((container) => {
894
- if (!existing.includes(container)) {
895
- throw new Error(
896
- `SANDBOX_CLEANUP_BATCH_PREFLIGHT_FAILED: discovered container '${container}' is no longer present`
897
- );
898
- }
899
- return container;
900
- })
829
+ ? [...options.discoveredContainers]
901
830
  : containerNameCandidates(config, branch).filter((name) => existing.includes(name));
902
831
 
903
832
  const workspace = cleanupTarget.workspace;
@@ -934,212 +863,18 @@ function resolveRmTarget(
934
863
  };
935
864
  }
936
865
 
937
- type CurrentContainerIdentity = Readonly<{ id: string; labels: Record<string, string> }>;
938
-
939
- function inspectNamedContainerIdentity(target: RmTarget, container: string): CurrentContainerIdentity {
940
- const raw = runSafeEngine(target.engine, 'docker', [
941
- 'inspect', '--format', '{{json .}}', container
942
- ]).trim();
943
- let parsed: unknown;
944
- try {
945
- parsed = JSON.parse(raw);
946
- } catch {
947
- throw new Error(`SANDBOX_WORKSPACE_IDENTITY_UNKNOWN: container '${container}' inspection is invalid`);
948
- }
949
- const value = Array.isArray(parsed) && parsed.length === 1 ? parsed[0] : parsed;
950
- if (!value || typeof value !== 'object' || Array.isArray(value)) {
951
- throw new Error(`SANDBOX_WORKSPACE_IDENTITY_UNKNOWN: container '${container}' inspection is incomplete`);
952
- }
953
- const candidate = value as {
954
- Id?: unknown;
955
- Config?: { Labels?: unknown };
956
- };
957
- if (typeof candidate.Id !== 'string' || !candidate.Id
958
- || !candidate.Config?.Labels || typeof candidate.Config.Labels !== 'object'
959
- || Array.isArray(candidate.Config.Labels)
960
- || Object.values(candidate.Config.Labels).some((value) => typeof value !== 'string')) {
961
- throw new Error(`SANDBOX_WORKSPACE_IDENTITY_UNKNOWN: container '${container}' inspection is incomplete`);
962
- }
963
- return { id: candidate.Id, labels: candidate.Config.Labels as Record<string, string> };
964
- }
965
-
966
- function assertMatchedContainerIdentities(config: SandboxConfig, target: RmTarget): Map<string, CurrentContainerIdentity> {
967
- const current = new Map<string, CurrentContainerIdentity>();
968
- for (const container of target.matchedContainers) {
969
- const observation = inspectNamedContainerIdentity(target, container);
970
- const stringLabels = observation.labels;
971
- const branch = stringLabels[sandboxBranchLabel(config)];
972
- if (branch !== target.branch) {
973
- throw new Error(
974
- `SANDBOX_WORKSPACE_IDENTITY_CONFLICT: container '${container}' branch is ${JSON.stringify(branch ?? null)}, `
975
- + `but this request is ${JSON.stringify(target.branch)}`
976
- );
977
- }
978
- const identity = parseSandboxWorkspaceIdentity(stringLabels, {
979
- mode: sandboxWorkspaceModeLabel(config),
980
- taskId: sandboxTaskIdLabel(config)
981
- });
982
- if (identity.mode === 'legacy-invalid' || !sameSandboxWorkspaceIdentity(identity, target.workspace)) {
983
- const existingDescription = identity.mode === 'task-bound'
984
- ? `task-bound:${identity.taskId}`
985
- : identity.mode;
986
- const requestedDescription = target.workspace.mode === 'task-bound'
987
- ? `task-bound:${target.workspace.taskId}`
988
- : target.workspace.mode;
989
- throw new Error(
990
- `SANDBOX_WORKSPACE_IDENTITY_CONFLICT: container '${container}' is ${existingDescription}, `
991
- + `but this request is ${requestedDescription}`
992
- );
993
- }
994
- current.set(container, observation);
995
- }
996
- return current;
997
- }
998
-
999
- function controlRootContainer(config: SandboxConfig, root: string): string {
1000
- const relative = path.relative(path.join(config.controlBase, config.project), root);
1001
- const [container, identity, ...extra] = relative.split(path.sep);
1002
- if (!container || !identity || extra.length > 0) {
1003
- throw new Error(`SANDBOX_CONTROL_TARGET_EVIDENCE_INVALID: ${root}`);
1004
- }
1005
- return container;
1006
- }
1007
-
1008
- function assertControlRootMatchesTarget(root: string, effectiveBranch: string, workspace: SandboxWorkspaceKey): void {
1009
- const manifestPath = path.join(root, 'manifest.json');
1010
- if (!fs.existsSync(manifestPath)) {
1011
- if (workspace.mode === 'task-bound') {
1012
- throw new Error(`SANDBOX_CONTROL_TARGET_EVIDENCE_MISSING: ${root}`);
1013
- }
1014
- return;
1015
- }
1016
- const manifest = readSandboxControlManifest(manifestPath);
1017
- const identityMatches = workspace.mode === 'task-bound'
1018
- ? manifest.mode === 'task-bound' && manifest.taskId === workspace.taskId
1019
- : manifest.mode === 'branch-only';
1020
- if (manifest.branch !== effectiveBranch || !identityMatches) {
1021
- throw new Error(`SANDBOX_CONTROL_TARGET_MISMATCH: ${root}`);
1022
- }
1023
- }
1024
-
1025
- function preflightRmTarget(
1026
- config: SandboxConfig,
1027
- target: RmTarget
1028
- ): void {
1029
- const current = assertMatchedContainerIdentities(config, target);
1030
- const manifests: ReturnType<typeof readSandboxControlManifest>[] = [];
1031
- for (const root of target.controlRoots.filter((candidate) => fs.existsSync(candidate))) {
1032
- assertLegacyCandidateEvidence(root, config.controlBase, config, target.effectiveBranch, target.matchedContainers);
1033
- assertControlRootMatchesTarget(root, target.effectiveBranch, target.workspace);
1034
- const manifestPath = path.join(root, 'manifest.json');
1035
- if (!fs.existsSync(manifestPath)) continue;
1036
- const manifest = readSandboxControlManifest(manifestPath);
1037
- const rootContainer = controlRootContainer(config, root);
1038
- if (manifest.container !== rootContainer) {
1039
- throw new Error(`SANDBOX_CONTROL_TARGET_MISMATCH: ${root}`);
1040
- }
1041
- const observed = current.get(manifest.container);
1042
- if (observed && (observed.id !== manifest.containerIdentity.id
1043
- || Object.entries(manifest.containerIdentity.labels).some(([key, value]) => observed.labels[key] !== value))) {
1044
- throw new Error(`SANDBOX_CONTROL_TARGET_MISMATCH: ${root}`);
1045
- }
1046
- manifests.push(manifest);
1047
- }
1048
-
1049
- if (target.workspace.mode !== 'task-bound') return;
1050
- const matched = new Set(target.matchedContainers);
1051
- const manifestContainers = manifests.map((manifest) => manifest.container);
1052
- if (matched.size > 0 && manifestContainers.some((container) => !matched.has(container))) {
1053
- throw new Error('SANDBOX_CONTROL_TARGET_MISMATCH: manifest container is outside the cleanup target');
1054
- }
1055
- for (const container of matched) {
1056
- const matches = manifests.filter((manifest) => manifest.container === container);
1057
- if (matches.length !== 1) {
1058
- throw new Error(`SANDBOX_CONTROL_TARGET_EVIDENCE_MISSING: container '${container}'`);
1059
- }
1060
- }
1061
- if (matched.size === 0 && manifests.length > 0) {
1062
- const supported = new Set(containerNameCandidates(config, target.effectiveBranch));
1063
- if (manifests.some((manifest) => !supported.has(manifest.container))) {
1064
- throw new Error('SANDBOX_CONTROL_TARGET_MISMATCH: manifest container is outside the cleanup target');
1065
- }
1066
- }
1067
- }
1068
-
1069
- type CleanupPathOwner = Readonly<{
1070
- branch: string;
1071
- managedPathCandidates: readonly string[];
1072
- }>;
1073
-
1074
- function assertCleanupGroupPathsDoNotOverlap(
1075
- groups: readonly CleanupGroup[],
1076
- protectedTargets: readonly CleanupPathOwner[] = []
1077
- ): void {
1078
- const owners = new Map<string, string>();
1079
- const addOwner = (branch: string, candidates: readonly string[]): void => {
1080
- for (const candidate of candidates) {
1081
- const key = sandboxManagedPathKey(candidate);
1082
- const existingBranch = owners.get(key);
1083
- if (existingBranch && existingBranch !== branch) {
1084
- throw new Error(
1085
- `SANDBOX_CLEANUP_BATCH_PREFLIGHT_FAILED: managed path '${candidate}' is shared by branches `
1086
- + `${JSON.stringify(existingBranch)} and ${JSON.stringify(branch)}`
1087
- );
1088
- }
1089
- owners.set(key, branch);
1090
- }
1091
- };
1092
- for (const group of groups) {
1093
- addOwner(group.cleanupTarget.branch, group.target.managedPathCandidates ?? []);
1094
- }
1095
- for (const target of protectedTargets) {
1096
- addOwner(target.branch, target.managedPathCandidates);
1097
- }
1098
- }
1099
-
1100
- function assertWorktreeBranchesMatchGroups(
1101
- groups: readonly CleanupGroup[],
1102
- inspections: readonly WorktreeInspection[]
1103
- ): void {
1104
- const byPath = new Map(
1105
- inspections.flatMap((inspection) => inspection.status === 'failed'
1106
- ? []
1107
- : [[sandboxManagedPathKey(inspection.snapshot.worktree), inspection.snapshot.branch] as const])
1108
- );
1109
- for (const group of groups) {
1110
- for (const worktree of group.target.existingWorktrees) {
1111
- const registeredBranch = byPath.get(sandboxManagedPathKey(worktree));
1112
- if (registeredBranch && registeredBranch !== group.cleanupTarget.branch) {
1113
- throw new Error(
1114
- `SANDBOX_CLEANUP_BATCH_PREFLIGHT_FAILED: worktree '${worktree}' is registered to branch `
1115
- + `${JSON.stringify(registeredBranch)}, but cleanup targets ${JSON.stringify(group.cleanupTarget.branch)}`
1116
- );
1117
- }
1118
- }
1119
- }
1120
- }
1121
-
1122
- function assertLegacyCandidateEvidence(
1123
- candidate: string,
1124
- base: string,
1125
- config: SandboxConfig,
1126
- effectiveBranch: string,
1127
- matchedContainers: readonly string[]
1128
- ): void {
1129
- const relative = path.relative(path.join(base, config.project), candidate);
1130
- const [container, identity] = relative.split(path.sep);
1131
- const [canonical, legacy] = containerNameCandidates(config, effectiveBranch);
1132
- if (!container || !identity || legacy === canonical || container !== legacy || matchedContainers.includes(container)) return;
1133
- const manifestPath = path.join(config.controlBase, config.project, container, identity, 'manifest.json');
1134
- if (!fs.existsSync(manifestPath)) {
1135
- throw new Error(`SANDBOX_CONTROL_TARGET_EVIDENCE_MISSING: ${candidate}`);
1136
- }
1137
- }
1138
-
1139
866
  function assertRemoved(target: string, label: string): void {
1140
867
  if (fs.existsSync(target)) throw new Error(`${label} still exists after removal: ${target}`);
1141
868
  }
1142
869
 
870
+ function sandboxContainersRemoved(engine: string, containers: readonly string[]): boolean {
871
+ if (containers.length === 0) return true;
872
+ const remaining = new Set(runEngine(engine, 'docker', ['ps', '-a', '--format', '{{.Names}}'])
873
+ .split('\n')
874
+ .filter(Boolean));
875
+ return containers.every((container) => !remaining.has(container));
876
+ }
877
+
1143
878
  function removeEmptyManagedParent(base: string, directory: string): void {
1144
879
  const parent = path.dirname(directory);
1145
880
  if (path.resolve(parent) === path.resolve(base)) return;
@@ -1153,103 +888,6 @@ function removeEmptyManagedParent(base: string, directory: string): void {
1153
888
  assertRemoved(parent, 'Empty sandbox container directory');
1154
889
  }
1155
890
 
1156
- async function removeExactSandboxContainer(
1157
- engine: string,
1158
- manifest: ReturnType<typeof readSandboxControlManifest>,
1159
- timeoutMs: number
1160
- ): Promise<void> {
1161
- const deadlineAt = Date.now() + timeoutMs;
1162
- const remaining = (): number => Math.max(1, deadlineAt - Date.now());
1163
- const inspect = () => inspectSandboxControlContainer(manifest, { timeoutMs: remaining() });
1164
- const runAuthorityCommand = (args: string[]): boolean => {
1165
- const command = manifest.authorityEvidence
1166
- ? commandForSandboxAuthority(manifest.authorityEvidence, 'docker', args)
1167
- : null;
1168
- return command ? runOk(command.cmd, command.args, { timeout: remaining() })
1169
- : runOkEngine(engine, 'docker', args, { timeout: remaining() });
1170
- };
1171
- const before = await inspect();
1172
- if (before.state === 'unknown') throw new Error(`SANDBOX_CONTROL_CONTAINER_UNKNOWN: ${before.reason}`);
1173
- if (before.state === 'absent') return;
1174
- if (before.running && !runAuthorityCommand(['stop', '--timeout', '1', manifest.containerIdentity.id])) {
1175
- const afterStop = await inspect();
1176
- if (afterStop.state !== 'absent' && afterStop.state !== 'found') {
1177
- throw new Error(`SANDBOX_CONTROL_CONTAINER_UNKNOWN: ${afterStop.reason}`);
1178
- }
1179
- if (afterStop.state === 'found' && afterStop.running) {
1180
- throw new Error(`Failed to stop sandbox container: ${manifest.containerIdentity.id}`);
1181
- }
1182
- }
1183
- const afterStop = await inspect();
1184
- if (afterStop.state === 'unknown') throw new Error(`SANDBOX_CONTROL_CONTAINER_UNKNOWN: ${afterStop.reason}`);
1185
- if (afterStop.state === 'found' && !runAuthorityCommand(['rm', manifest.containerIdentity.id])) {
1186
- const afterRemove = await inspect();
1187
- if (afterRemove.state !== 'absent') {
1188
- throw new Error(`Failed to remove sandbox container: ${manifest.containerIdentity.id}`);
1189
- }
1190
- }
1191
- }
1192
-
1193
- async function removeProjectControlRoots(
1194
- config: SandboxConfig,
1195
- engine: string,
1196
- options: Readonly<{ retainRemovalJournal?: boolean }> = {}
1197
- ): Promise<Set<string>> {
1198
- const containers = new Set<string>();
1199
- const projectRoot = path.join(config.controlBase, config.project);
1200
- if (!fs.existsSync(projectRoot)) return containers;
1201
- assertManagedPath(config.controlBase, projectRoot);
1202
- const projectStat = fs.lstatSync(projectRoot);
1203
- if (!projectStat.isDirectory() || projectStat.isSymbolicLink()) throw new Error('SANDBOX_CONTROL_CHANNEL_INVALID');
1204
- for (const root of projectSandboxControlRoots(config)) {
1205
- const manifestPath = path.join(root, 'manifest.json');
1206
- if (!fs.existsSync(manifestPath)) throw new Error(`SANDBOX_CONTROL_TARGET_EVIDENCE_MISSING: ${root}`);
1207
- const manifest = readSandboxControlManifest(manifestPath);
1208
- containers.add(manifest.container);
1209
- await removeSandboxControlRoot(root, {
1210
- inspectContainer: (timeoutMs) => inspectSandboxControlContainer(manifest, { timeoutMs }),
1211
- removeContainer: (timeoutMs) => removeExactSandboxContainer(engine, manifest, timeoutMs),
1212
- ...(options.retainRemovalJournal ? { retainRemovalJournal: true } : {})
1213
- });
1214
- }
1215
- return containers;
1216
- }
1217
-
1218
-
1219
- function completePurgeRemovalJournals(config: SandboxConfig): void {
1220
- const projectRoot = path.resolve(config.controlBase, config.project);
1221
- const projectJournals = listSandboxRemovalJournals({ project: config.project }).filter((journal) => {
1222
- const relativeRoot = path.relative(projectRoot, path.resolve(journal.target.controlRoot));
1223
- return relativeRoot !== '' && relativeRoot !== '..'
1224
- && !relativeRoot.startsWith(`..${path.sep}`) && !path.isAbsolute(relativeRoot);
1225
- });
1226
- if (projectJournals.some((journal) => !isDefaultSandboxRemovalJournal(journal))) {
1227
- throw new Error('SANDBOX_CONTROL_REMOVE_RECOVERY_PENDING');
1228
- }
1229
- for (const journal of projectJournals) {
1230
- const resourceLock = acquireSandboxResourceLock(`${journal.engine}:${journal.containerId}`, {
1231
- lockDomain: journal.lockDomain
1232
- });
1233
- try {
1234
- let current = journal;
1235
- const currentProcessStartTime = getProcessStartTime(process.pid);
1236
- const ownedByCurrentProcess = currentProcessStartTime !== null
1237
- && current.owner.pid === process.pid
1238
- && current.owner.startTime === currentProcessStartTime;
1239
- if (!ownedByCurrentProcess) {
1240
- current = claimSandboxRemovalJournal(current, { resourceLock });
1241
- }
1242
- advanceSandboxRemovalJournalToPhase(current, 'completed', resourceLock);
1243
- } finally {
1244
- resourceLock.release();
1245
- }
1246
- }
1247
- }
1248
-
1249
- function inspectionBlockers(inspections: readonly WorktreeInspection[]): WorktreeInspection[] {
1250
- return inspections.filter((inspection) => inspection.status !== 'clean');
1251
- }
1252
-
1253
891
  function blockerMessage(blockers: readonly WorktreeInspection[]): string {
1254
892
  return blockers.map((blocker) => {
1255
893
  if (blocker.status === 'failed') return `${JSON.stringify(blocker.worktree)}: ${blocker.message}`;
@@ -1312,11 +950,7 @@ async function runRmOneUnderRepositoryLock(
1312
950
  tools,
1313
951
  options.cleanupTarget ?? resolveSandboxCleanupTarget(branch, config.repoRoot)
1314
952
  );
1315
- const taskId = target.workspace.mode === 'task-bound' ? target.workspace.taskId : null;
1316
- const execute = (): Promise<IntermediateCleanupReport | null> => rmOneCore(config, tools, branch, { ...options, target });
1317
- return taskId
1318
- ? withTaskExecutionLock(config.repoRoot, taskId, 'sandbox-removal', execute)
1319
- : execute();
953
+ return rmOneCore(config, tools, branch, { ...options, target });
1320
954
  }
1321
955
 
1322
956
  async function rmOne(
@@ -1325,403 +959,85 @@ async function rmOne(
1325
959
  branch: string,
1326
960
  options: RmOneOptions = {}
1327
961
  ): Promise<void> {
1328
- await withRepositoryMutationLock(config.repoRoot, () => runRmOneUnderRepositoryLock(config, tools, branch, options));
962
+ await runRmOneUnderRepositoryLock(config, tools, branch, options);
1329
963
  }
1330
964
 
1331
- async function rmOneCore(
965
+ async function removeUncheckedSandbox(
1332
966
  config: SandboxConfig,
1333
- tools: SandboxTool[],
1334
- branch: string,
1335
- options: RmOneOptions = {}
1336
- ): Promise<IntermediateCleanupReport | null> {
1337
- const target = options.target ?? resolveRmTarget(
1338
- config,
1339
- tools,
1340
- options.cleanupTarget ?? resolveSandboxCleanupTarget(branch, config.repoRoot)
1341
- );
1342
- const { effectiveBranch, engine, matchedContainers, existingWorktrees, toolCandidates } = target;
1343
- const { workspace, controlRoots, workspaceViewRoots } = target;
1344
- preflightRmTarget(config, target);
1345
- const auxiliaryTaskId = workspace.mode === 'task-bound' ? workspace.taskId : null;
1346
- if (auxiliaryTaskId) {
1347
- const targetJournals = listSandboxRemovalJournals({
1348
- branch: effectiveBranch,
1349
- project: config.project,
1350
- targetDigest: removalTargetDigest(config, target)
1351
- });
1352
- assertIntermediateCleanupPreflight(scanIntermediateCleanup(config.repoRoot, {
1353
- preflight: true,
1354
- taskIds: [auxiliaryTaskId],
1355
- controlBindingEvidence: projectSandboxControlBindingEvidence(
1356
- config,
1357
- controlRoots,
1358
- targetJournals
1359
- )
1360
- }));
1361
- }
967
+ target: RmTarget,
968
+ options: RmOneOptions
969
+ ): Promise<null> {
1362
970
  const confirm = options.prompt?.confirm ?? p.confirm;
1363
971
  const isCancel = options.prompt?.isCancel ?? p.isCancel;
972
+ const { effectiveBranch, engine, matchedContainers, existingWorktrees, toolCandidates, controlRoots, workspaceViewRoots } = target;
1364
973
 
1365
- if (!options.quiet) {
1366
- p.intro(pc.cyan(`Removing sandbox for ${branch}`));
1367
- }
1368
-
1369
- const targetDigest = removalTargetDigest(config, target);
1370
- const existingJournals = listSandboxRemovalJournals({
1371
- branch: effectiveBranch,
1372
- project: config.project,
1373
- targetDigest
1374
- });
1375
- const persistedTarget = existingJournals[0]?.target;
1376
- for (const journal of existingJournals) {
1377
- if (!persistedTarget) break;
1378
- assertRemovalSelectionMatches(persistedTarget, journal.target);
1379
- }
1380
- const recovery = options.permits
1381
- ? new Map<string, WorktreeRecoveryContext>()
1382
- : recoveryContexts(config, target, existingWorktrees);
1383
- const journalPermits = options.permits ? new Map<string, WorktreeRemovalPermit>()
1384
- : permitsFromJournal(existingJournals, target, targetDigest);
1385
- const permits = options.permits
1386
- ? new Map(options.permits)
1387
- : journalPermits.size > 0
1388
- ? journalPermits
1389
- : await authorizeWorktrees(existingWorktrees, {
1390
- allowDirtyDiscard: options.allowDirtyDiscard ?? true,
1391
- assumeYes: Boolean(options.assumeYes)
1392
- }, {
1393
- ...options.prompt,
1394
- interactive: options.interactive ?? Boolean(process.stdin.isTTY),
1395
- recovery
1396
- });
1397
- for (const worktree of existingWorktrees) {
1398
- const permit = permits.get(path.resolve(worktree));
1399
- if (!permit) throw new Error(`Missing worktree removal permit: ${worktree}`);
1400
- const worktreeTombstone = removalTombstonePath(targetDigest, 'worktree', worktree);
1401
- const workspacePhasePending = existingJournals.some((journal) => (
1402
- sandboxRemovalPhaseIndex(journal.phase) < sandboxRemovalPhaseIndex('workspace-removed')
1403
- ));
1404
- if (fs.existsSync(worktree)
1405
- || (workspacePhasePending
1406
- && !isOwnedRemovalTombstone(config.worktreeBase, worktreeTombstone, {
1407
- version: 1,
1408
- targetDigest,
1409
- permitDigest: removalPermitDigest(permits),
1410
- kind: 'worktree',
1411
- source: worktree
1412
- }))) {
1413
- verifyWorktreePermit(permit);
1414
- }
1415
- }
974
+ if (!options.quiet) p.intro(pc.cyan(`Removing sandbox for ${target.branch}`));
1416
975
 
1417
- const recoveredRemovalChoice = persistedTarget;
1418
- const shouldRemoveWorktree = recoveredRemovalChoice
1419
- ? recoveredRemovalChoice.removeWorktree
1420
- : existingWorktrees.length === 0
1421
- ? false
1422
- : options.assumeYes
1423
- ? true
1424
- : await confirm({
1425
- message: `Remove worktree(s): ${existingWorktrees.join(', ')}?`,
1426
- initialValue: true
1427
- });
976
+ const shouldRemoveWorktree = existingWorktrees.length > 0 && !options.assumeYes
977
+ ? await confirm({ message: `Remove worktree(s): ${existingWorktrees.join(', ')}?`, initialValue: true })
978
+ : existingWorktrees.length > 0;
1428
979
  if (isCancel(shouldRemoveWorktree)) {
1429
- p.outro('Cancelled');
980
+ if (!options.quiet) p.outro('Cancelled');
1430
981
  return null;
1431
982
  }
1432
983
 
1433
- const shouldDeleteBranch = recoveredRemovalChoice
1434
- ? recoveredRemovalChoice.removeBranch
1435
- : shouldRemoveWorktree && existingWorktrees.length > 0
1436
- ? options.assumeYes
1437
- ? true
1438
- : await confirm({
1439
- message: `Also delete local branch '${effectiveBranch}'?`,
1440
- initialValue: true
1441
- })
1442
- : false;
984
+ const shouldDeleteBranch = Boolean(shouldRemoveWorktree) && existingWorktrees.length > 0 && !options.assumeYes
985
+ ? await confirm({ message: `Also delete local branch '${effectiveBranch}'?`, initialValue: true })
986
+ : Boolean(shouldRemoveWorktree) && existingWorktrees.length > 0;
1443
987
  if (isCancel(shouldDeleteBranch)) {
1444
- p.outro('Cancelled');
988
+ if (!options.quiet) p.outro('Cancelled');
1445
989
  return null;
1446
990
  }
1447
991
 
1448
992
  const sharePath = path.resolve(shareBranchDir(config, effectiveBranch));
1449
- const shouldRemoveShare = recoveredRemovalChoice
1450
- ? recoveredRemovalChoice.removeShare
1451
- : fs.existsSync(sharePath)
1452
- ? options.assumeYes
1453
- ? true
1454
- : await confirm({
1455
- message: `Remove share dir for branch '${effectiveBranch}' (${sharePath})?`,
1456
- initialValue: true
1457
- })
1458
- : false;
993
+ const shouldRemoveShare = fs.existsSync(sharePath) && !options.assumeYes
994
+ ? await confirm({ message: `Remove share dir for branch '${effectiveBranch}' (${sharePath})?`, initialValue: true })
995
+ : fs.existsSync(sharePath);
1459
996
  if (isCancel(shouldRemoveShare)) {
1460
- p.outro('Cancelled');
997
+ if (!options.quiet) p.outro('Cancelled');
1461
998
  return null;
1462
999
  }
1463
1000
 
1464
- const committedTarget = persistedTarget
1465
- ? {
1466
- ...persistedTarget,
1467
- permitDigest: removalPermitDigest(permits),
1468
- permits: removalPermitCommits(permits)
1469
- }
1470
- : removalTargetCommit(
1471
- config,
1472
- target,
1473
- permits,
1474
- Boolean(shouldRemoveWorktree),
1475
- Boolean(shouldDeleteBranch),
1476
- Boolean(shouldRemoveShare)
1477
- );
1478
- if (persistedTarget) assertRemovalSelectionMatches(persistedTarget, committedTarget);
1479
-
1480
- const coordinatedManifests = controlRoots
1481
- .filter((candidate) => fs.existsSync(candidate))
1482
- .flatMap((root) => {
1483
- const manifestPath = path.join(root, 'manifest.json');
1484
- return fs.existsSync(manifestPath)
1485
- ? [[root, readSandboxControlManifest(manifestPath), fs.readFileSync(manifestPath, 'utf8')] as const]
1486
- : [];
1487
- });
1488
- const resourceLocks = new Map<string, SandboxResourceLock>();
1489
- const recoveredContainerNames = new Set<string>();
1490
- try {
1491
- for (const [root, manifest] of [...coordinatedManifests].sort(([left], [right]) => left.localeCompare(right))) {
1492
- resourceLocks.set(root, acquireSandboxResourceLock(
1493
- `${manifest.engine}:${manifest.containerIdentity.id}`,
1494
- { lockDomain: manifest.authorityEvidence.lockDomain }
1495
- ));
1496
- }
1497
- for (const journal of existingJournals.filter((candidate) => !fs.existsSync(candidate.target.controlRoot))) {
1498
- if (!target.controlRoots.some((root) => path.resolve(root) === path.resolve(journal.target.controlRoot))) {
1499
- throw new Error('SANDBOX_CONTROL_REMOVAL_TARGET_MISMATCH');
1500
- }
1501
- assertRemovalSelectionMatches(committedTarget, journal.target);
1502
- if (journal.target.permitDigest !== removalPermitDigest(permits)) {
1503
- throw new Error('SANDBOX_CONTROL_REMOVAL_PERMIT_MISMATCH');
1504
- }
1505
- const lock = acquireSandboxResourceLock(`${journal.engine}:${journal.containerId}`, {
1506
- lockDomain: journal.lockDomain
1507
- });
1508
- resourceLocks.set(journal.target.controlRoot, lock);
1509
- const current = listSandboxRemovalJournals({
1510
- branch: journal.target.branch,
1511
- project: journal.target.project,
1512
- targetDigest: journal.target.targetDigest
1513
- }).find((candidate) => candidate.carrierIdentityDigest === journal.carrierIdentityDigest);
1514
- if (!current || current.revision !== journal.revision || current.handoffId !== journal.handoffId) {
1515
- throw new Error('SANDBOX_CONTROL_REMOVAL_JOURNAL_REVISION_MISMATCH');
1516
- }
1517
- const claimed = claimSandboxRemovalJournal(current, { resourceLock: lock });
1518
- if (!['carrier-finalizing', 'carrier-removed', 'workspace-finalizing', 'workspace-removed',
1519
- 'branch-finalizing', 'branch-removed', 'tool-finalizing', 'tool-removed',
1520
- 'shell-finalizing', 'shell-removed', 'share-finalizing', 'share-removed', 'completed']
1521
- .includes(claimed.phase)) {
1522
- throw new Error('SANDBOX_CONTROL_REMOVE_RECOVERY_PENDING');
1523
- }
1524
- const recovered = claimed.phase === 'carrier-finalizing'
1525
- ? advanceSandboxRemovalJournalPhase(claimed, 'carrier-removed', { resourceLock: lock })
1526
- : claimed;
1527
- recoveredContainerNames.add(controlRootContainer(config, journal.target.controlRoot));
1528
- }
1529
- cleanupCompletedRemovalArtifacts(config, target, committedTarget, targetDigest);
1530
- preflightRmTarget(config, target);
1531
- const coordinatedContainers = new Set(recoveredContainerNames);
1532
- for (const root of controlRoots.filter((candidate) => fs.existsSync(candidate))) {
1533
- assertLegacyCandidateEvidence(root, config.controlBase, config, effectiveBranch, matchedContainers);
1534
- assertControlRootMatchesTarget(root, effectiveBranch, workspace);
1535
- const manifestPath = path.join(root, 'manifest.json');
1536
- if (!fs.existsSync(manifestPath)) continue;
1537
- const currentRaw = fs.readFileSync(manifestPath, 'utf8');
1538
- const manifest = readSandboxControlManifest(manifestPath);
1539
- const initial = coordinatedManifests.find(([candidate]) => candidate === root);
1540
- if (!initial || currentRaw !== initial[2]) throw new Error('SANDBOX_CONTROL_MANIFEST_CHANGED');
1541
- coordinatedContainers.add(manifest.container);
1542
- await removeSandboxControlRoot(root, {
1543
- inspectContainer: (timeoutMs) => inspectSandboxControlContainer(manifest, { timeoutMs }),
1544
- removeContainer: (timeoutMs) => removeExactSandboxContainer(engine, manifest, timeoutMs),
1545
- resourceLock: resourceLocks.get(root),
1546
- retainRemovalJournal: true,
1547
- removalTarget: { ...committedTarget, controlRoot: path.resolve(root) }
1548
- });
1001
+ for (const container of matchedContainers) runSafeEngine(engine, 'docker', ['rm', '-f', container]);
1002
+ for (const root of controlRoots) {
1003
+ fs.rmSync(root, { recursive: true, force: true });
1549
1004
  removeEmptyManagedParent(path.join(config.controlBase, config.project), root);
1550
1005
  }
1551
-
1552
- if ([...recoveredContainerNames].some((name) => matchedContainers.includes(name))) {
1553
- throw new Error('SANDBOX_CONTROL_CONTAINER_REAPPEARED');
1006
+ for (const root of workspaceViewRoots) {
1007
+ fs.rmSync(root, { recursive: true, force: true });
1008
+ removeEmptyManagedParent(path.join(config.workspaceViewBase, config.project), root);
1554
1009
  }
1555
-
1556
- const uncoordinatedContainers = matchedContainers.filter((name) => !coordinatedContainers.has(name));
1557
- if (uncoordinatedContainers.length > 0) {
1558
- if (workspace.mode === 'task-bound') {
1559
- throw new Error(`SANDBOX_CONTROL_TARGET_EVIDENCE_MISSING: container '${uncoordinatedContainers[0]}'`);
1560
- }
1561
- const spinner = p.spinner();
1562
- spinner.start(`Stopping container(s): ${uncoordinatedContainers.join(', ')}`);
1563
- for (const name of uncoordinatedContainers) {
1564
- if (!runOkEngine(engine, 'docker', ['stop', name])) throw new Error(`Failed to stop sandbox container: ${name}`);
1565
- if (!runOkEngine(engine, 'docker', ['rm', name])) throw new Error(`Failed to remove sandbox container: ${name}`);
1566
- }
1567
- const remaining = runEngine(engine, 'docker', ['ps', '-a', '--format', '{{.Names}}']).split('\n').filter(Boolean);
1568
- const leftovers = uncoordinatedContainers.filter((name) => remaining.includes(name));
1569
- if (leftovers.length > 0) throw new Error(`Sandbox container(s) still exist after removal: ${leftovers.join(', ')}`);
1570
- spinner.stop(pc.green(`Removed container(s): ${uncoordinatedContainers.join(', ')}`));
1571
- } else {
1572
- p.log.warn(`No sandbox container found for '${branch}'`);
1010
+ for (const candidate of toolCandidates.flatMap(({ candidates }) => candidates)) fs.rmSync(candidate, { recursive: true, force: true });
1011
+ for (const shell of shellConfigDirCandidates(config, effectiveBranch)) fs.rmSync(shell, { recursive: true, force: true });
1012
+ if (shouldRemoveShare) fs.rmSync(sharePath, { recursive: true, force: true });
1013
+ if (shouldRemoveWorktree) {
1014
+ for (const worktree of existingWorktrees) fs.rmSync(worktree, { recursive: true, force: true });
1015
+ runSafe('git', ['-C', config.repoRoot, 'worktree', 'prune']);
1573
1016
  }
1574
-
1575
- const runWorkspaceCleanup = prepareRemovalAction(
1576
- config.project, target, targetDigest, 'workspace-finalizing', 'workspace-removed', resourceLocks
1577
- );
1578
- if (runWorkspaceCleanup.run) {
1579
- for (const [roots, base, label] of [
1580
- [workspaceViewRoots, path.join(config.workspaceViewBase, config.project), 'Workspace view'],
1581
- [controlRoots, path.join(config.controlBase, config.project), 'Control channel']
1582
- ] as const) {
1583
- for (const directory of roots.filter((candidate) => shouldStageManagedRemoval(
1584
- candidate,
1585
- removalTombstonePath(targetDigest, 'workspace', candidate),
1586
- runWorkspaceCleanup.recovering
1587
- ))) {
1588
- assertLegacyCandidateEvidence(directory, base === path.join(config.controlBase, config.project)
1589
- ? config.controlBase : config.workspaceViewBase, config, effectiveBranch, matchedContainers);
1590
- stageManagedRemoval(
1591
- base,
1592
- directory,
1593
- removalTombstonePath(targetDigest, 'workspace', directory),
1594
- runWorkspaceCleanup.recovering,
1595
- {
1596
- version: 1,
1597
- targetDigest,
1598
- permitDigest: committedTarget.permitDigest,
1599
- kind: 'workspace',
1600
- source: directory
1601
- }
1602
- );
1603
- if (!options.quiet) p.log.success(`${label} removed: ${directory}`);
1604
- }
1605
- }
1606
-
1607
- if (shouldRemoveWorktree) {
1608
- for (const worktree of existingWorktrees) {
1609
- const permit = permits.get(path.resolve(worktree));
1610
- if (!permit) throw new Error(`Missing worktree removal permit: ${worktree}`);
1611
- stageWorktreeRemoval(
1612
- config,
1613
- worktree,
1614
- permit,
1615
- removalTombstonePath(targetDigest, 'worktree', worktree),
1616
- runWorkspaceCleanup.recovering,
1617
- {
1618
- version: 1,
1619
- targetDigest,
1620
- permitDigest: committedTarget.permitDigest,
1621
- kind: 'worktree',
1622
- source: worktree
1623
- }
1624
- );
1625
- }
1626
- }
1627
- } else {
1628
- assertRemovalPathsAbsent([
1629
- ...removalActionPaths(target, config, 'workspace-removed'),
1630
- ...(shouldRemoveWorktree ? existingWorktrees : [])
1631
- ]);
1017
+ if (shouldDeleteBranch) runSafe('git', ['-C', config.repoRoot, 'branch', '-D', effectiveBranch]);
1018
+ if (!sandboxContainersRemoved(engine, matchedContainers)) {
1019
+ throw new Error(`SANDBOX_REMOVAL_CONTAINER_STILL_PRESENT: ${matchedContainers.join(', ')}`);
1632
1020
  }
1633
- advanceRemovalJournals(config.project, target, targetDigest, 'workspace-removed', resourceLocks);
1634
- cleanupCompletedRemovalArtifacts(config, target, committedTarget, targetDigest);
1635
- for (const [roots, base] of [
1636
- [workspaceViewRoots, path.join(config.workspaceViewBase, config.project)],
1637
- [controlRoots, path.join(config.controlBase, config.project)]
1638
- ] as const) {
1639
- for (const directory of roots) {
1640
- if (!fs.existsSync(directory)) removeEmptyManagedParent(base, directory);
1641
- }
1021
+ if (target.workspace.mode === 'task-bound') {
1022
+ releaseStaleShortIdRegistry(config.repoRoot, target.workspace.taskId);
1642
1023
  }
1643
1024
 
1644
- const runBranchCleanup = prepareRemovalAction(
1645
- config.project, target, targetDigest, 'branch-finalizing', 'branch-removed', resourceLocks
1646
- );
1647
- if (runBranchCleanup.run) {
1648
- if (shouldRemoveWorktree && shouldDeleteBranch) {
1649
- stageBranchRemoval(config, effectiveBranch, targetDigest, permits, runBranchCleanup.recovering);
1650
- }
1651
- } else if (shouldRemoveWorktree && shouldDeleteBranch
1652
- && runOk('git', ['-C', config.repoRoot, 'show-ref', '--verify', `refs/heads/${effectiveBranch}`])) {
1653
- throw new Error(`SANDBOX_CONTROL_REMOVAL_TARGET_MISMATCH: ${effectiveBranch}`);
1654
- }
1655
- advanceRemovalJournals(config.project, target, targetDigest, 'branch-removed', resourceLocks);
1656
- cleanupCompletedRemovalArtifacts(config, target, committedTarget, targetDigest);
1657
-
1658
- const directoryStages = [
1659
- { kind: 'tool', entries: toolCandidates.flatMap(({ tool, candidates }) =>
1660
- candidates.map((dir) => ({ dir, base: tool.sandboxBase, label: tool.name + ' state' }))) },
1661
- { kind: 'shell', entries: shellConfigDirCandidates(config, effectiveBranch)
1662
- .map((dir) => ({ dir, base: config.shellConfigBase, label: 'Shell config' })) },
1663
- { kind: 'share', entries: shouldRemoveShare
1664
- ? [{ dir: sharePath, base: config.shareBase, label: 'Share dir' }] : [] }
1665
- ] as const;
1666
- for (const { kind, entries } of directoryStages) {
1667
- const completedPhase = `${kind}-removed` as const;
1668
- const action = prepareRemovalAction(
1669
- config.project, target, targetDigest, `${kind}-finalizing`, completedPhase, resourceLocks
1670
- );
1671
- if (action.run) {
1672
- for (const { dir, base, label } of entries) {
1673
- const tombstone = removalTombstonePath(targetDigest, kind, dir);
1674
- if (!shouldStageManagedRemoval(dir, tombstone, action.recovering)) continue;
1675
- stageManagedRemoval(base, dir, tombstone, action.recovering, {
1676
- version: 1, targetDigest, permitDigest: committedTarget.permitDigest, kind, source: dir
1677
- });
1678
- p.log.success(`${label} removed: ${dir}`);
1679
- }
1680
- } else if (kind !== 'share' || shouldRemoveShare) {
1681
- assertRemovalPathsAbsent(removalActionPaths(target, config, completedPhase));
1682
- }
1683
- advanceRemovalJournals(config.project, target, targetDigest, completedPhase, resourceLocks);
1684
- cleanupCompletedRemovalArtifacts(config, target, committedTarget, targetDigest);
1685
- }
1686
-
1687
- advanceRemovalJournals(config.project, target, targetDigest, 'completed', resourceLocks);
1688
-
1689
- let auxiliaryReport: IntermediateCleanupReport | null = null;
1690
- if (auxiliaryTaskId) {
1691
- const finalControlBindingEvidence = projectSandboxControlBindingEvidence(
1692
- config,
1693
- controlRoots,
1694
- listSandboxRemovalJournals({ branch: effectiveBranch, project: config.project, targetDigest })
1695
- );
1696
- const report = cleanupIntermediateUnderRemovalCoordinator(config.repoRoot, {
1697
- taskIds: [auxiliaryTaskId],
1698
- controlBindingEvidence: finalControlBindingEvidence,
1699
- lockedTaskIds: new Set([auxiliaryTaskId])
1700
- });
1701
- auxiliaryReport = report;
1702
- if (!options.quiet) {
1703
- for (const line of formatIntermediateCleanupReport(report)) p.log.message(line);
1704
- }
1705
- if (!report.remaining.some((item) => item.taskId === auxiliaryTaskId)) {
1706
- for (const journal of listSandboxRemovalJournals({
1707
- branch: effectiveBranch,
1708
- project: config.project,
1709
- targetDigest
1710
- })) clearSandboxRemovalJournalRecord(journal);
1711
- }
1712
- } else {
1713
- for (const journal of listSandboxRemovalJournals({
1714
- branch: effectiveBranch,
1715
- project: config.project,
1716
- targetDigest
1717
- })) clearSandboxRemovalJournalRecord(journal);
1718
- }
1025
+ if (!options.quiet) p.outro('Sandbox removed');
1026
+ return null;
1027
+ }
1719
1028
 
1720
- if (!options.quiet) p.outro(pc.green('Sandbox removed'));
1721
- return auxiliaryReport;
1722
- } finally {
1723
- for (const lock of [...resourceLocks.values()].reverse()) lock.release();
1724
- }
1029
+ async function rmOneCore(
1030
+ config: SandboxConfig,
1031
+ tools: SandboxTool[],
1032
+ branch: string,
1033
+ options: RmOneOptions = {}
1034
+ ): Promise<IntermediateCleanupReport | null> {
1035
+ const target = options.target ?? resolveRmTarget(
1036
+ config,
1037
+ tools,
1038
+ options.cleanupTarget ?? resolveSandboxCleanupTarget(branch, config.repoRoot)
1039
+ );
1040
+ return removeUncheckedSandbox(config, target, options);
1725
1041
  }
1726
1042
 
1727
1043
  async function rmPurge(
@@ -1729,7 +1045,7 @@ async function rmPurge(
1729
1045
  tools: SandboxTool[],
1730
1046
  prompt: PromptDependencies = {}
1731
1047
  ): Promise<void> {
1732
- return withRepositoryMutationLock(config.repoRoot, () => rmPurgeCore(config, tools, prompt));
1048
+ return rmPurgeCore(config, tools, prompt);
1733
1049
  }
1734
1050
 
1735
1051
  async function rmPurgeCore(
@@ -1742,170 +1058,36 @@ async function rmPurgeCore(
1742
1058
  const isCancel = prompt.isCancel ?? p.isCancel;
1743
1059
  p.intro(pc.cyan(`Removing all sandboxes for ${config.project}`));
1744
1060
 
1745
- const worktrees = fs.existsSync(config.worktreeBase)
1746
- ? fs.readdirSync(config.worktreeBase)
1747
- .map((entry) => path.join(config.worktreeBase, entry))
1748
- .filter((entry) => {
1749
- try { return fs.statSync(entry).isDirectory(); } catch { return false; }
1750
- })
1751
- : [];
1752
- const inspections = inspectWorktrees(worktrees);
1753
- const blockers = inspectionBlockers(inspections);
1754
- if (blockers.length > 0) {
1755
- throw new Error(`Refusing to purge because worktree preflight found blocker(s):\n${blockerMessage(blockers)}`);
1756
- }
1757
- const permits = cleanPermits(inspections);
1758
-
1759
- assertIntermediateCleanupPreflight(scanIntermediateCleanup(config.repoRoot, {
1760
- preflight: true,
1761
- controlBindingEvidence: projectSandboxControlBindingEvidence(config)
1762
- }));
1763
-
1764
- const coordinatedContainers = await removeProjectControlRoots(config, engine, {
1765
- retainRemovalJournal: true
1766
- });
1767
-
1768
1061
  const containers = runEngine(engine, 'docker', [
1769
- 'ps',
1770
- '-a',
1771
- '--filter',
1772
- `label=${sandboxLabel(config)}`,
1773
- '--format',
1774
- '{{.Names}}'
1775
- ]);
1776
- const uncoordinatedContainers = containers.split('\n').filter((name) => name && !coordinatedContainers.has(name));
1777
- if (uncoordinatedContainers.length > 0) {
1778
- const spinner = p.spinner();
1779
- spinner.start('Stopping project sandbox containers...');
1780
- for (const name of uncoordinatedContainers) {
1781
- if (!runOkEngine(engine, 'docker', ['stop', name])) throw new Error(`Failed to stop sandbox container: ${name}`);
1782
- if (!runOkEngine(engine, 'docker', ['rm', name])) throw new Error(`Failed to remove sandbox container: ${name}`);
1783
- }
1784
- const remaining = runEngine(engine, 'docker', [
1785
- 'ps', '-a', '--filter', `label=${sandboxLabel(config)}`, '--format', '{{.Names}}'
1786
- ]);
1787
- const leftovers = remaining.split('\n').filter((name) => name && !coordinatedContainers.has(name));
1788
- if (leftovers.length > 0) throw new Error(`Project sandbox container(s) still exist after removal: ${leftovers.join(', ')}`);
1789
- spinner.stop(pc.green('Project sandbox containers removed'));
1790
- } else {
1791
- p.log.warn('No project sandbox containers found');
1792
- }
1062
+ 'ps', '-a', '--filter', `label=${sandboxLabel(config)}`, '--format', '{{.Names}}'
1063
+ ]).split('\n').filter(Boolean);
1064
+ for (const name of containers) runSafeEngine(engine, 'docker', ['rm', '-f', name]);
1793
1065
 
1066
+ const worktrees = fs.existsSync(config.worktreeBase)
1067
+ ? fs.readdirSync(config.worktreeBase).map((entry) => path.join(config.worktreeBase, entry))
1068
+ : [];
1794
1069
  if (worktrees.length > 0) {
1795
- const shouldRemoveWorktrees = await confirm({
1796
- message: `Remove all worktrees in ${config.worktreeBase}?`,
1797
- initialValue: true
1798
- });
1799
-
1800
- if (!isCancel(shouldRemoveWorktrees) && shouldRemoveWorktrees) {
1801
- for (const dir of worktrees) {
1802
- const permit = permits.get(path.resolve(dir));
1803
- if (!permit) throw new Error(`Missing worktree removal permit: ${dir}`);
1804
- removeWorktreeDir(config.repoRoot, config.worktreeBase, dir, permit, {
1805
- allowRegisteredPathFallback: engine === ENGINES.WSL2
1806
- });
1807
- assertRemoved(dir, 'Worktree');
1808
- }
1070
+ const selected = await confirm({ message: `Remove all worktrees in ${config.worktreeBase}?`, initialValue: true });
1071
+ if (!isCancel(selected) && selected) {
1072
+ for (const worktree of worktrees) fs.rmSync(worktree, { recursive: true, force: true });
1809
1073
  runSafe('git', ['-C', config.repoRoot, 'worktree', 'prune']);
1810
- const registered = new Set(runSafe('git', ['-C', config.repoRoot, 'worktree', 'list', '--porcelain'])
1811
- .split('\n').filter((line) => line.startsWith('worktree ')).map((line) => path.resolve(line.slice(9))));
1812
- const leftovers = worktrees.filter((dir) => registered.has(path.resolve(dir)));
1813
- if (leftovers.length > 0) throw new Error(`Worktree(s) still registered after removal: ${leftovers.join(', ')}`);
1814
- }
1815
- }
1816
-
1817
- for (const dir of projectToolDirs(config, tools)) {
1818
- if (fs.existsSync(dir)) {
1819
- removeManagedDir(path.dirname(dir), dir);
1820
- assertRemoved(dir, 'Tool state');
1821
- p.log.success(`Removed tool state: ${dir}`);
1822
- }
1823
- }
1824
-
1825
- if (fs.existsSync(config.shellConfigBase) && fs.readdirSync(config.shellConfigBase).length > 0) {
1826
- const shouldRemoveShellConfigs = await confirm({
1827
- message: `Remove all shell config dirs in ${config.shellConfigBase}?`,
1828
- initialValue: true
1829
- });
1830
-
1831
- if (!isCancel(shouldRemoveShellConfigs) && shouldRemoveShellConfigs) {
1832
- for (const entry of fs.readdirSync(config.shellConfigBase)) {
1833
- const dir = path.join(config.shellConfigBase, entry);
1834
- removeManagedDir(config.shellConfigBase, dir);
1835
- assertRemoved(dir, 'Shell config');
1836
- }
1837
- p.log.success(`Project shell config dirs removed: ${config.shellConfigBase}`);
1838
1074
  }
1839
1075
  }
1840
1076
 
1841
- if (fs.existsSync(config.shareBase) && fs.readdirSync(config.shareBase).length > 0) {
1842
- const shouldRemoveAllShares = await confirm({
1843
- message: `Remove all share dirs for project (${config.shareBase})?`,
1844
- initialValue: true
1845
- });
1846
- if (!isCancel(shouldRemoveAllShares) && shouldRemoveAllShares) {
1847
- removeManagedDir(path.dirname(config.shareBase), config.shareBase);
1848
- assertRemoved(config.shareBase, 'Project share dir');
1849
- p.log.success(`Project share dirs removed: ${config.shareBase}`);
1850
- }
1077
+ for (const dir of projectToolDirs(config, tools)) fs.rmSync(dir, { recursive: true, force: true });
1078
+ if (fs.existsSync(config.shellConfigBase)) {
1079
+ const selected = await confirm({ message: `Remove all shell config dirs in ${config.shellConfigBase}?`, initialValue: true });
1080
+ if (!isCancel(selected) && selected) fs.rmSync(config.shellConfigBase, { recursive: true, force: true });
1851
1081
  }
1852
-
1853
- for (const [base, label] of [
1854
- [config.workspaceViewBase, 'workspace views'],
1855
- [config.controlBase, 'control channels']
1856
- ] as const) {
1857
- const projectDir = path.join(base, config.project);
1858
- if (fs.existsSync(projectDir)) {
1859
- removeManagedDir(base, projectDir);
1860
- assertRemoved(projectDir, `Project ${label}`);
1861
- p.log.success(`Removed project ${label}: ${projectDir}`);
1862
- }
1863
- }
1864
-
1865
- const shouldRemoveImage = await confirm({
1866
- message: `Remove image ${config.imageName}?`,
1867
- initialValue: false
1868
- });
1869
- if (!isCancel(shouldRemoveImage) && shouldRemoveImage) {
1870
- runSafeEngine(engine, 'docker', ['rmi', config.imageName]);
1082
+ if (fs.existsSync(config.shareBase)) {
1083
+ const selected = await confirm({ message: `Remove all share dirs for project (${config.shareBase})?`, initialValue: true });
1084
+ if (!isCancel(selected) && selected) fs.rmSync(config.shareBase, { recursive: true, force: true });
1871
1085
  }
1872
-
1873
- pruneSandboxDanglingImages(config, engine);
1874
-
1875
- const cleanupPurgeAuxiliary = (): void => {
1876
- const report = cleanupIntermediateUnderRemovalCoordinator(config.repoRoot, {
1877
- controlBindingEvidence: projectSandboxControlBindingEvidence(config)
1878
- });
1879
- for (const line of formatIntermediateCleanupReport(report)) p.log.message(line);
1880
- if (!report.remaining.some((item) => item.taskId !== null)) {
1881
- for (const journal of listSandboxRemovalJournals({ project: config.project })) {
1882
- if (journal.phase === 'completed') clearSandboxRemovalJournalRecord(journal);
1883
- }
1884
- }
1885
- };
1886
-
1887
- if (isManagedEngine(engine)) {
1888
- if (engine === ENGINES.WSL2) {
1889
- completePurgeRemovalJournals(config);
1890
- cleanupPurgeAuxiliary();
1891
- p.log.warn('Windows uses Docker Desktop with WSL2. Stop it from Docker Desktop or run "wsl --shutdown" manually.');
1892
- p.outro(pc.green('All project sandboxes removed'));
1893
- return;
1894
- }
1895
-
1896
- const name = engineDisplayName(engine);
1897
- const shouldStopVm = await confirm({
1898
- message: `Stop ${name} VM?`,
1899
- initialValue: false
1900
- });
1901
- if (!isCancel(shouldStopVm) && shouldStopVm) {
1902
- stopManagedVm(config);
1903
- }
1086
+ for (const base of [config.workspaceViewBase, config.controlBase]) {
1087
+ fs.rmSync(path.join(base, config.project), { recursive: true, force: true });
1904
1088
  }
1905
-
1906
- completePurgeRemovalJournals(config);
1907
- cleanupPurgeAuxiliary();
1908
-
1089
+ const removeImage = await confirm({ message: `Remove image ${config.imageName}?`, initialValue: false });
1090
+ if (!isCancel(removeImage) && removeImage) runSafeEngine(engine, 'docker', ['rmi', config.imageName]);
1909
1091
  p.outro(pc.green('All project sandboxes removed'));
1910
1092
  }
1911
1093
 
@@ -1914,7 +1096,7 @@ async function rmUnbound(
1914
1096
  tools: SandboxTool[],
1915
1097
  options: { dryRun: boolean; assumeYes: boolean }
1916
1098
  ): Promise<void> {
1917
- return withRepositoryMutationLock(config.repoRoot, () => rmUnboundCore(config, tools, options));
1099
+ return rmUnboundCore(config, tools, options);
1918
1100
  }
1919
1101
 
1920
1102
  async function rmUnboundCore(
@@ -1923,222 +1105,38 @@ async function rmUnboundCore(
1923
1105
  options: { dryRun: boolean; assumeYes: boolean }
1924
1106
  ): Promise<void> {
1925
1107
  const engine = detectEngine(config);
1926
- const controlBindingEvidence = projectSandboxControlBindingEvidence(config);
1927
- const { running, nonRunning } = fetchSandboxRows(
1108
+ const listed = fetchSandboxRows(
1928
1109
  engine,
1929
1110
  sandboxLabel(config),
1930
1111
  sandboxBranchLabel(config),
1931
1112
  { mode: sandboxWorkspaceModeLabel(config), taskId: sandboxTaskIdLabel(config) }
1932
1113
  );
1933
- const rows = [...running, ...nonRunning];
1934
-
1935
- p.intro(pc.cyan(`Removing sandboxes not bound to an active task for ${config.project}`));
1936
- const intermediatePreview = scanIntermediateCleanup(config.repoRoot, { controlBindingEvidence });
1937
-
1938
- const candidates: CleanupCandidate[] = [];
1939
- const protectedCandidates: ProtectedCleanupCandidate[] = [];
1940
- for (const row of rows) {
1941
- if (!row.branch || !row.workspaceMode || row.workspaceMode === 'legacy-invalid') {
1942
- throw new Error(`SANDBOX_CLEANUP_BATCH_PREFLIGHT_FAILED: container '${row.name}' has incomplete workspace identity`);
1943
- }
1944
- if (row.workspaceMode === 'task-bound') {
1945
- if (!row.taskId) {
1946
- throw new Error(`SANDBOX_CLEANUP_BATCH_PREFLIGHT_FAILED: container '${row.name}' has no task id`);
1947
- }
1948
- try {
1949
- const cleanupTarget = resolveSandboxCleanupTarget(row.taskId, config.repoRoot, { allowProtected: true });
1950
- if (cleanupTarget.branch !== row.branch
1951
- || cleanupTarget.workspace.mode !== 'task-bound'
1952
- || cleanupTarget.workspace.taskId !== row.taskId) {
1953
- throw new Error(`SANDBOX_CLEANUP_BATCH_PREFLIGHT_FAILED: container '${row.name}' task identity conflicts with task.md`);
1954
- }
1955
- candidates.push({ row, cleanupTarget });
1956
- } catch (error) {
1957
- if (!isMissingTaskRecordError(error)) throw error;
1958
- protectedCandidates.push({
1959
- row,
1960
- branch: row.branch,
1961
- identity: `task-bound:${row.taskId}`,
1962
- reason: 'TASK_NOT_FOUND'
1963
- });
1964
- }
1965
- continue;
1966
- }
1967
- candidates.push({
1968
- row,
1969
- cleanupTarget: {
1970
- requestedRef: row.branch,
1971
- branch: row.branch,
1972
- workspace: { mode: 'branch-only' },
1973
- taskState: 'branch-only'
1974
- } satisfies SandboxCleanupTarget
1975
- });
1976
- }
1977
- const branchIdentities = new Map<string, string>();
1978
- const groupedCandidates = new Map<string, CleanupCandidate[]>();
1979
- for (const candidate of protectedCandidates) {
1980
- const existingIdentity = branchIdentities.get(candidate.branch);
1981
- if (existingIdentity && existingIdentity !== candidate.identity) {
1982
- throw new Error(
1983
- `SANDBOX_CLEANUP_BATCH_PREFLIGHT_FAILED: branch '${candidate.branch}' has conflicting workspace identities`
1984
- );
1985
- }
1986
- branchIdentities.set(candidate.branch, candidate.identity);
1987
- }
1988
- for (const candidate of candidates) {
1989
- const identity = candidate.cleanupTarget.workspace.mode === 'task-bound'
1990
- ? `task-bound:${candidate.cleanupTarget.workspace.taskId}`
1991
- : 'branch-only';
1992
- const existingIdentity = branchIdentities.get(candidate.cleanupTarget.branch);
1993
- if (existingIdentity && existingIdentity !== identity) {
1994
- throw new Error(
1995
- `SANDBOX_CLEANUP_BATCH_PREFLIGHT_FAILED: branch '${candidate.cleanupTarget.branch}' has conflicting workspace identities`
1996
- );
1997
- }
1998
- branchIdentities.set(candidate.cleanupTarget.branch, identity);
1999
- const groupKey = `${candidate.cleanupTarget.branch}\0${identity}`;
2000
- const group = groupedCandidates.get(groupKey) ?? [];
2001
- group.push(candidate);
2002
- groupedCandidates.set(groupKey, group);
2003
- }
2004
- const groups: CleanupGroup[] = [...groupedCandidates.values()].map((groupCandidates) => {
2005
- const cleanupTarget = groupCandidates[0]!.cleanupTarget;
2006
- const target = resolveRmTarget(config, tools, cleanupTarget, {
2007
- discoveredContainers: groupCandidates.map(({ row }) => row.name)
2008
- });
2009
- preflightRmTarget(config, target);
2010
- return { candidates: groupCandidates, cleanupTarget, target };
2011
- });
2012
- const protectedTargets = protectedCandidates.map(({ row, branch }) => {
2013
- const target = resolveRmTarget(config, tools, {
2014
- requestedRef: branch,
2015
- branch,
2016
- workspace: { mode: 'task-bound', taskId: row.taskId! },
2017
- taskState: 'completed'
2018
- }, { discoveredContainers: [row.name] });
2019
- return { branch, managedPathCandidates: target.managedPathCandidates ?? [] };
2020
- });
2021
- assertCleanupGroupPathsDoNotOverlap(groups, protectedTargets);
2022
-
2023
- const removableGroups = groups.filter(({ cleanupTarget }) =>
2024
- cleanupTarget.taskState === 'completed' || cleanupTarget.taskState === 'branch-only'
2025
- );
2026
- const rowTaskIds = new Set(groups.flatMap(({ cleanupTarget }) => (
2027
- cleanupTarget.workspace.mode === 'task-bound' ? [cleanupTarget.workspace.taskId] : []
2028
- )));
2029
- const cleanupOnlyTaskIds = new Set(intermediatePreview.items
2030
- .filter((candidate) => candidate.disposition === 'planned' && candidate.taskId !== null && !rowTaskIds.has(candidate.taskId))
2031
- .map((candidate) => candidate.taskId!));
2032
- const removable = removableGroups.flatMap(({ candidates: groupCandidates }) => groupCandidates);
2033
- for (const { candidates: groupCandidates, cleanupTarget } of groups.filter(({ cleanupTarget }) =>
2034
- cleanupTarget.taskState !== 'completed' && cleanupTarget.taskState !== 'branch-only'
2035
- )) {
2036
- for (const { row } of groupCandidates) {
2037
- p.log.message(`Skipped protected sandbox ${row.name} (${cleanupTarget.taskState})`);
2038
- }
2039
- }
2040
- for (const { row, reason } of protectedCandidates) {
2041
- p.log.message(`Skipped protected sandbox ${row.name} (${reason})`);
2042
- }
2043
-
2044
- for (const line of formatIntermediateCleanupReport(intermediatePreview)) p.log.message(line);
2045
-
2046
- const hasAuxiliaryWork = intermediatePreview.items.some((candidate) => candidate.disposition === 'planned');
2047
- if (removableGroups.length === 0 && !hasAuxiliaryWork) {
2048
- p.outro('No removable sandboxes: every container is bound to a protected task (or none exist)');
2049
- return;
2050
- }
2051
-
2052
- const inspections = inspectWorktrees(removableGroups.flatMap(({ target }) => target.existingWorktrees));
2053
- assertWorktreeBranchesMatchGroups(removableGroups, inspections);
2054
- const blockers = inspectionBlockers(inspections);
2055
- const permits = cleanPermits(inspections);
2056
-
2057
- for (const { row } of removable) {
2058
- p.log.message(`${row.name} ${row.branch}`);
2059
- }
2060
- if (blockers.length > 0) p.log.error(blockerMessage(blockers));
2061
-
1114
+ const rows = [...listed.running, ...listed.nonRunning];
1115
+ p.intro(pc.cyan(`Removing sandboxes for ${config.project}`));
2062
1116
  if (options.dryRun) {
2063
- p.outro(`Dry run: ${removable.length} sandbox(es) inspected, nothing deleted`);
1117
+ p.outro(`Dry run: ${rows.length} sandbox(es) found, nothing deleted`);
2064
1118
  return;
2065
1119
  }
2066
-
2067
- if (blockers.length > 0) {
2068
- throw new Error(`Refusing batch removal because worktree preflight found blocker(s):\n${blockerMessage(blockers)}`);
2069
- }
2070
-
2071
- if (!options.assumeYes && !process.stdin.isTTY) {
2072
- throw new Error(
2073
- 'Refusing to remove sandboxes without confirmation in a non-interactive shell; pass --yes to proceed.'
2074
- );
2075
- }
2076
-
2077
- const failures: { branch: string; message: string }[] = [];
2078
- const actualReports: IntermediateCleanupReport[] = [];
2079
- let failedRows = 0;
2080
- for (const group of removableGroups) {
2081
- try {
2082
- const report = await runRmOneUnderRepositoryLock(config, tools, group.cleanupTarget.branch, {
2083
- assumeYes: options.assumeYes,
2084
- quiet: true,
2085
- target: group.target,
2086
- cleanupTarget: group.cleanupTarget,
2087
- permits,
2088
- allowDirtyDiscard: false
2089
- });
2090
- if (report) actualReports.push(report);
2091
- } catch (error) {
2092
- failedRows += group.candidates.length;
2093
- failures.push({ branch: group.cleanupTarget.branch, message: error instanceof Error ? error.message : String(error) });
2094
- const taskId = group.cleanupTarget.workspace.mode === 'task-bound'
2095
- ? group.cleanupTarget.workspace.taskId
2096
- : null;
2097
- if (taskId) {
2098
- const failedReport = cleanupIntermediateUnderRemovalCoordinator(config.repoRoot, {
2099
- dryRun: true,
2100
- taskIds: [taskId],
2101
- controlBindingEvidence
2102
- });
2103
- actualReports.push(protectIntermediateCleanupReport(failedReport, 'SANDBOX_ROW_REMOVAL_FAILED'));
2104
- }
2105
- }
2106
- }
2107
-
2108
- const auxiliaryTaskIds = [...cleanupOnlyTaskIds].sort();
2109
- const finalControlBindingEvidence = projectSandboxControlBindingEvidence(config);
2110
- const cleanupOnlyReport = cleanupIntermediateUnderRemovalCoordinator(config.repoRoot, {
2111
- taskIds: auxiliaryTaskIds,
2112
- controlBindingEvidence: finalControlBindingEvidence
2113
- });
2114
- actualReports.push(cleanupOnlyReport);
2115
- const intermediateReport = mergeIntermediateCleanupReports(actualReports);
2116
- for (const line of formatIntermediateCleanupReport(intermediateReport)) p.log.message(line);
2117
-
2118
- if (cleanupOnlyReport.remaining.every((item) => item.taskId === null)) {
2119
- for (const taskId of auxiliaryTaskIds) {
2120
- let branch: string;
2121
- try {
2122
- branch = resolveSandboxCleanupTarget(taskId, config.repoRoot, { allowProtected: true }).branch;
2123
- } catch {
2124
- continue;
2125
- }
2126
- for (const journal of listSandboxRemovalJournals({ project: config.project, branch })) {
2127
- clearSandboxRemovalJournalRecord(journal);
2128
- }
2129
- }
2130
- }
2131
-
2132
- if (failures.length > 0) {
2133
- for (const failure of failures) {
2134
- p.log.error(`Failed to remove '${failure.branch}': ${failure.message}`);
1120
+ for (const row of rows) {
1121
+ if (!row.branch) {
1122
+ runSafeEngine(engine, 'docker', ['rm', '-f', row.name]);
1123
+ continue;
2135
1124
  }
2136
- throw new Error(
2137
- `Removed ${removable.length - failedRows}/${removable.length} sandbox(es); ${failures.length} failed`
2138
- );
1125
+ const cleanupTarget: SandboxCleanupTarget = {
1126
+ requestedRef: row.branch,
1127
+ branch: row.branch,
1128
+ workspace: row.workspaceMode === 'task-bound' && row.taskId
1129
+ ? { mode: 'task-bound', taskId: row.taskId }
1130
+ : { mode: 'branch-only' },
1131
+ taskState: 'branch-only'
1132
+ };
1133
+ const target = resolveRmTarget(config, tools, cleanupTarget, { discoveredContainers: [row.name] });
1134
+ await rmOne(config, tools, cleanupTarget.branch, {
1135
+ assumeYes: options.assumeYes,
1136
+ target,
1137
+ cleanupTarget
1138
+ });
2139
1139
  }
2140
-
2141
- p.outro(pc.green(`Removed ${removable.length} sandbox(es)`));
1140
+ p.outro(pc.green(`Removed ${rows.length} sandbox(es)`));
2142
1141
  }
2143
-
2144
1142
  export { authorizeWorktrees, rmOne, rmPurge, rmUnbound };