@fitlab-ai/agent-infra 0.9.7 → 0.9.9

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 (195) hide show
  1. package/README.md +1 -1
  2. package/README.zh-CN.md +1 -1
  3. package/dist/bin/internal-cli.js +11 -1
  4. package/dist/lib/agent-clients/adapters/claude-code.js +3 -3
  5. package/dist/lib/agent-clients/adapters/codex-lifecycle/app-server.js +60 -22
  6. package/dist/lib/agent-clients/adapters/codex-lifecycle/build-identity.js +8 -1
  7. package/dist/lib/agent-clients/adapters/codex-lifecycle/capability-store.js +175 -11
  8. package/dist/lib/agent-clients/adapters/codex-lifecycle/controller-context.js +147 -0
  9. package/dist/lib/agent-clients/adapters/codex-lifecycle/evidence.js +14 -0
  10. package/dist/lib/agent-clients/adapters/codex-lifecycle/sandbox-controller.js +86 -69
  11. package/dist/lib/agent-clients/adapters/codex-lifecycle/store.js +2 -1
  12. package/dist/lib/decide.js +68 -14
  13. package/dist/lib/git/workflow.js +10 -1
  14. package/dist/lib/init.js +1 -1
  15. package/dist/lib/internal/codex-lifecycle.js +19 -5
  16. package/dist/lib/internal/codex-sandbox-controller.js +6 -13
  17. package/dist/lib/internal/git-workflow.js +5 -7
  18. package/dist/lib/internal/platform-pr.js +20 -7
  19. package/dist/lib/internal/release-workflow.js +3 -4
  20. package/dist/lib/internal/sandbox-control.js +79 -2
  21. package/dist/lib/internal/task-activity.js +57 -7
  22. package/dist/lib/internal/task-event.js +57 -4
  23. package/dist/lib/internal/task-finalization.js +54 -0
  24. package/dist/lib/internal/task-ledger.js +59 -5
  25. package/dist/lib/internal/task-lifecycle.js +59 -8
  26. package/dist/lib/internal/task-orchestration.js +161 -288
  27. package/dist/lib/internal/task-override.js +131 -0
  28. package/dist/lib/internal/task-review.js +64 -6
  29. package/dist/lib/internal/task-verify.js +65 -3
  30. package/dist/lib/internal/task-warning.js +57 -4
  31. package/dist/lib/platform/pr-summary.js +53 -13
  32. package/dist/lib/platform/pull-requests.js +205 -41
  33. package/dist/lib/runtime/agent-runtime.js +56 -0
  34. package/dist/lib/sandbox/commands/create.js +389 -311
  35. package/dist/lib/sandbox/commands/rm.js +316 -55
  36. package/dist/lib/sandbox/control/client.js +190 -6
  37. package/dist/lib/sandbox/control/container-identity.js +1 -1
  38. package/dist/lib/sandbox/control/controller-registration.js +273 -0
  39. package/dist/lib/sandbox/control/executor.js +145 -5
  40. package/dist/lib/sandbox/control/lifecycle.js +395 -73
  41. package/dist/lib/sandbox/control/protocol.js +67 -6
  42. package/dist/lib/sandbox/control/state.js +27 -11
  43. package/dist/lib/sandbox/engine.js +14 -7
  44. package/dist/lib/sandbox/index.js +5 -3
  45. package/dist/lib/sandbox/managed-fs.js +5 -0
  46. package/dist/lib/sandbox/mounts.js +20 -5
  47. package/dist/lib/sandbox/recovery.js +78 -10
  48. package/dist/lib/sandbox/task-resolver.js +50 -6
  49. package/dist/lib/sandbox/workspace-identity.js +27 -0
  50. package/dist/lib/sandbox/workspace-view.js +62 -40
  51. package/dist/lib/sandbox/worktree-safety.js +215 -2
  52. package/dist/lib/server/process-state.js +51 -1
  53. package/dist/lib/task/activity-intent.js +4 -1
  54. package/dist/lib/task/codex-orchestration.js +158 -33
  55. package/dist/lib/task/commands/status.js +59 -1
  56. package/dist/lib/task/commit-operation.js +335 -0
  57. package/dist/lib/task/commit-policy.js +18 -0
  58. package/dist/lib/task/decision-intents.js +4 -2
  59. package/dist/lib/task/delegation-receipts.js +279 -17
  60. package/dist/lib/task/events.js +9 -5
  61. package/dist/lib/task/finalization.js +567 -0
  62. package/dist/lib/task/guard-override.js +31 -0
  63. package/dist/lib/task/human-override.js +1240 -0
  64. package/dist/lib/task/ledger-intents.js +6 -3
  65. package/dist/lib/task/lifecycle.js +69 -14
  66. package/dist/lib/task/operation-outcome.js +33 -0
  67. package/dist/lib/task/orchestration.js +199 -719
  68. package/dist/lib/task/review-finalization.js +3 -2
  69. package/dist/lib/task/short-id.js +59 -1
  70. package/dist/lib/task/task-execution-lock.js +6 -1
  71. package/dist/lib/task/verification-engine.js +138 -59
  72. package/dist/lib/task/verification.js +15 -2
  73. package/dist/lib/task/workflow-warning-intents.js +54 -3
  74. package/dist/lib/task/write.js +2 -1
  75. package/dist/lifecycle-build-manifest.json +3 -3
  76. package/lib/agent-clients/adapters/claude-code.ts +3 -3
  77. package/lib/agent-clients/adapters/codex-lifecycle/app-server.ts +63 -20
  78. package/lib/agent-clients/adapters/codex-lifecycle/build-identity.ts +12 -0
  79. package/lib/agent-clients/adapters/codex-lifecycle/capability-store.ts +278 -13
  80. package/lib/agent-clients/adapters/codex-lifecycle/controller-context.ts +180 -0
  81. package/lib/agent-clients/adapters/codex-lifecycle/evidence.ts +19 -0
  82. package/lib/agent-clients/adapters/codex-lifecycle/manifest-files.json +9 -1
  83. package/lib/agent-clients/adapters/codex-lifecycle/sandbox-controller.ts +112 -93
  84. package/lib/agent-clients/adapters/codex-lifecycle/store.ts +2 -1
  85. package/lib/decide.ts +59 -14
  86. package/lib/git/workflow.ts +11 -2
  87. package/lib/init.ts +1 -1
  88. package/lib/internal/codex-lifecycle.ts +18 -5
  89. package/lib/internal/codex-sandbox-controller.ts +6 -12
  90. package/lib/internal/git-workflow.ts +5 -6
  91. package/lib/internal/platform-pr.ts +14 -7
  92. package/lib/internal/release-workflow.ts +3 -4
  93. package/lib/internal/sandbox-control.ts +92 -2
  94. package/lib/internal/task-activity.ts +46 -6
  95. package/lib/internal/task-event.ts +53 -4
  96. package/lib/internal/task-finalization.ts +68 -0
  97. package/lib/internal/task-ledger.ts +48 -4
  98. package/lib/internal/task-lifecycle.ts +56 -12
  99. package/lib/internal/task-orchestration.ts +32 -107
  100. package/lib/internal/task-override.ts +138 -0
  101. package/lib/internal/task-review.ts +51 -6
  102. package/lib/internal/task-verify.ts +52 -3
  103. package/lib/internal/task-warning.ts +44 -3
  104. package/lib/platform/pr-summary.ts +65 -14
  105. package/lib/platform/pull-requests.ts +211 -41
  106. package/lib/runtime/agent-runtime.ts +76 -0
  107. package/lib/sandbox/commands/create.ts +88 -4
  108. package/lib/sandbox/commands/rm.ts +413 -61
  109. package/lib/sandbox/config.ts +2 -1
  110. package/lib/sandbox/control/client.ts +255 -6
  111. package/lib/sandbox/control/container-identity.ts +3 -3
  112. package/lib/sandbox/control/controller-registration.ts +347 -0
  113. package/lib/sandbox/control/executor.ts +172 -6
  114. package/lib/sandbox/control/lifecycle.ts +465 -77
  115. package/lib/sandbox/control/protocol.ts +85 -9
  116. package/lib/sandbox/control/state.ts +35 -18
  117. package/lib/sandbox/engine.ts +20 -9
  118. package/lib/sandbox/index.ts +5 -3
  119. package/lib/sandbox/managed-fs.ts +5 -0
  120. package/lib/sandbox/mounts.ts +23 -5
  121. package/lib/sandbox/recovery.ts +90 -9
  122. package/lib/sandbox/task-resolver.ts +58 -7
  123. package/lib/sandbox/workspace-identity.ts +50 -2
  124. package/lib/sandbox/workspace-view.ts +73 -43
  125. package/lib/sandbox/worktree-safety.ts +236 -2
  126. package/lib/server/process-state.ts +48 -1
  127. package/lib/task/activity-intent.ts +4 -2
  128. package/lib/task/codex-orchestration.ts +167 -33
  129. package/lib/task/commands/status.ts +78 -1
  130. package/lib/task/commit-operation.ts +430 -0
  131. package/lib/task/commit-policy.ts +30 -0
  132. package/lib/task/decision-intents.ts +4 -2
  133. package/lib/task/delegation-receipts.ts +304 -19
  134. package/lib/task/events.ts +10 -5
  135. package/lib/task/finalization.ts +719 -0
  136. package/lib/task/guard-override.ts +50 -0
  137. package/lib/task/human-override.ts +1503 -0
  138. package/lib/task/ledger-intents.ts +6 -3
  139. package/lib/task/lifecycle.ts +71 -14
  140. package/lib/task/operation-outcome.ts +48 -0
  141. package/lib/task/orchestration.ts +239 -922
  142. package/lib/task/review-finalization.ts +6 -2
  143. package/lib/task/short-id.ts +65 -1
  144. package/lib/task/task-execution-lock.ts +10 -1
  145. package/lib/task/verification-engine.ts +145 -64
  146. package/lib/task/verification.ts +18 -3
  147. package/lib/task/workflow-warning-intents.ts +61 -4
  148. package/lib/task/write.ts +4 -1
  149. package/package.json +2 -2
  150. package/templates/.agents/QUICKSTART.en.md +2 -2
  151. package/templates/.agents/QUICKSTART.zh-CN.md +2 -2
  152. package/templates/.agents/hooks/lifecycle-delegation.js +9 -24
  153. package/templates/.agents/rules/lifecycle-orchestration.en.md +4 -3
  154. package/templates/.agents/rules/lifecycle-orchestration.zh-CN.md +4 -3
  155. package/templates/.agents/rules/task-management.en.md +2 -2
  156. package/templates/.agents/rules/task-management.zh-CN.md +2 -2
  157. package/templates/.agents/skills/block-task/SKILL.en.md +2 -4
  158. package/templates/.agents/skills/block-task/SKILL.zh-CN.md +2 -4
  159. package/templates/.agents/skills/cancel-task/SKILL.en.md +3 -3
  160. package/templates/.agents/skills/cancel-task/SKILL.zh-CN.md +3 -3
  161. package/templates/.agents/skills/close-codescan/SKILL.en.md +6 -2
  162. package/templates/.agents/skills/close-codescan/SKILL.zh-CN.md +6 -2
  163. package/templates/.agents/skills/close-dependabot/SKILL.en.md +6 -2
  164. package/templates/.agents/skills/close-dependabot/SKILL.zh-CN.md +6 -2
  165. package/templates/.agents/skills/commit/SKILL.en.md +41 -125
  166. package/templates/.agents/skills/commit/SKILL.zh-CN.md +52 -123
  167. package/templates/.agents/skills/commit/reference/commit-message.en.md +1 -1
  168. package/templates/.agents/skills/commit/reference/commit-message.zh-CN.md +1 -1
  169. package/templates/.agents/skills/commit/reference/commit-orchestration.en.md +14 -43
  170. package/templates/.agents/skills/commit/reference/commit-orchestration.zh-CN.md +14 -49
  171. package/templates/.agents/skills/commit/reference/pr-summary-sync.en.md +1 -1
  172. package/templates/.agents/skills/commit/reference/pr-summary-sync.zh-CN.md +1 -1
  173. package/templates/.agents/skills/commit/reference/task-status-update.en.md +2 -35
  174. package/templates/.agents/skills/commit/reference/task-status-update.zh-CN.md +2 -35
  175. package/templates/.agents/skills/complete-task/SKILL.en.md +31 -38
  176. package/templates/.agents/skills/complete-task/SKILL.zh-CN.md +31 -38
  177. package/templates/.agents/skills/complete-task/config/verify.en.json +1 -0
  178. package/templates/.agents/skills/complete-task/config/verify.zh-CN.json +1 -0
  179. package/templates/.agents/skills/complete-task/reference/external-delivery.en.md +1 -1
  180. package/templates/.agents/skills/complete-task/reference/external-delivery.zh-CN.md +1 -1
  181. package/templates/.agents/skills/create-pr/SKILL.en.md +6 -4
  182. package/templates/.agents/skills/create-pr/SKILL.zh-CN.md +6 -4
  183. package/templates/.agents/skills/create-pr/reference/comment-publish.en.md +2 -2
  184. package/templates/.agents/skills/create-pr/reference/comment-publish.zh-CN.md +2 -2
  185. package/templates/.agents/skills/run-task/SKILL.en.md +5 -5
  186. package/templates/.agents/skills/run-task/SKILL.zh-CN.md +5 -5
  187. package/templates/.agents/skills/run-task/reference/host-validation.en.md +3 -1
  188. package/templates/.agents/skills/run-task/reference/host-validation.zh-CN.md +3 -1
  189. package/templates/.agents/skills/watch-pr/reference/monitor-and-heal.en.md +1 -1
  190. package/templates/.agents/skills/watch-pr/reference/monitor-and-heal.zh-CN.md +1 -1
  191. package/templates/.codex/hooks.json +4 -4
  192. package/dist/lib/task/commit-finalization.js +0 -305
  193. package/dist/lib/task/commit-intent.js +0 -165
  194. package/lib/task/commit-finalization.ts +0 -419
  195. package/lib/task/commit-intent.ts +0 -230
@@ -4,66 +4,107 @@ import { parseArgs } from 'node:util';
4
4
  import * as p from '@clack/prompts';
5
5
  import pc from 'picocolors';
6
6
  import { loadConfig } from '../config.js';
7
- import { assertValidBranchName, containerNameCandidates, sandboxBranchLabel, sandboxLabel, shareBranchDir, shellConfigDirCandidates, worktreeDirCandidates } from '../constants.js';
7
+ import { assertValidBranchName, containerNameCandidates, sandboxBranchLabel, sandboxLabel, sandboxTaskIdLabel, sandboxWorkspaceModeLabel, shareBranchDir, shellConfigDirCandidates, worktreeDirCandidates } from '../constants.js';
8
8
  import { ENGINES, detectEngine, engineDisplayName, isManagedEngine, stopManagedVm } from '../engine.js';
9
9
  import { pruneSandboxDanglingImages } from '../image-prune.js';
10
10
  import { assertManagedPath, removeManagedDir, removeWorktreeDir } from '../managed-fs.js';
11
11
  import { runEngine, runOk, runOkEngine, runSafe, runSafeEngine } from '../shell.js';
12
- import { resolveSandboxTarget } from '../workspace-identity.js';
12
+ import { parseSandboxWorkspaceIdentity, resolveSandboxCleanupTarget, sameSandboxWorkspaceIdentity } from '../workspace-identity.js';
13
13
  import { sandboxControlPaths, sandboxWorkspaceViewPaths } from '../workspace-view.js';
14
14
  import { removeSandboxControlRoot, readSandboxControlManifest } from '../control/lifecycle.js';
15
15
  import { inspectSandboxControlContainer } from '../control/container-identity.js';
16
16
  import { toolConfigDirCandidates, toolProjectDirCandidates } from '../tools.js';
17
17
  import { createSandboxCapabilityPlan } from '../agent-client-reconciler.js';
18
18
  import { fetchSandboxRows } from './list-running.js';
19
- import { lookupShortIdByBranch } from '../../task/short-id.js';
20
- import { createCleanPermit, createDiscardPermit, formatWorktreeSnapshot, inspectWorktrees, verifyWorktreePermit } from '../worktree-safety.js';
19
+ import { createCleanPermit, createDiscardPermit, formatWorktreeSnapshot, inspectRecoveredWorktree, inspectWorktrees, verifyWorktreePermit } from '../worktree-safety.js';
21
20
  const USAGE = `Usage:
22
- ai sandbox rm <branch> Remove one sandbox (branch | TASK-id | short id)
23
- ai sandbox rm --unbound [--dry-run] [--yes] Remove every sandbox not bound to an active task
21
+ 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
22
+ ai sandbox rm --unbound [--dry-run] [--yes] Remove completed task-bound and branch-only sandboxes; active, blocked, and archive tasks are protected
24
23
  ai sandbox rm --purge Tear down ALL sandboxes for the project (containers, worktrees, image, VM)`;
25
24
  export { assertManagedPath } from '../managed-fs.js';
26
25
  function projectToolDirs(config, tools) {
27
26
  return tools.flatMap((tool) => toolProjectDirCandidates(tool, config.project));
28
27
  }
29
- function resolveRmTarget(config, tools, branch) {
30
- assertValidBranchName(branch);
28
+ function isMissingPathError(error) {
29
+ return Boolean(error && typeof error === 'object' && 'code' in error
30
+ && error.code === 'ENOENT');
31
+ }
32
+ export function sandboxManagedPathKey(candidate, platform = process.platform, resolveExistingPath = (resolved) => fs.realpathSync.native(resolved)) {
33
+ const resolved = path.resolve(candidate);
34
+ let identity = resolved;
35
+ try {
36
+ identity = resolveExistingPath(resolved);
37
+ }
38
+ catch (error) {
39
+ if (!isMissingPathError(error)) {
40
+ throw new Error(`SANDBOX_CLEANUP_BATCH_PREFLIGHT_FAILED: unable to determine managed path identity for '${resolved}'`);
41
+ }
42
+ }
43
+ return platform === 'win32' ? identity.toLowerCase() : identity;
44
+ }
45
+ function recoveryContexts(config, target, worktrees) {
46
+ const candidates = worktreeDirCandidates(config, target.effectiveBranch).map((candidate) => path.resolve(candidate));
47
+ const existingCandidates = candidates.filter((candidate) => fs.existsSync(candidate));
48
+ if (existingCandidates.length !== 1)
49
+ return new Map();
50
+ const contexts = new Map();
51
+ for (const worktree of worktrees) {
52
+ const resolvedWorktree = path.resolve(worktree);
53
+ if (resolvedWorktree !== existingCandidates[0])
54
+ continue;
55
+ for (const root of target.controlRoots.filter((candidate) => fs.existsSync(candidate))) {
56
+ assertLegacyCandidateEvidence(root, config.controlBase, config, target.effectiveBranch, target.matchedContainers);
57
+ assertControlRootMatchesTarget(root, target.effectiveBranch, target.workspace);
58
+ }
59
+ contexts.set(resolvedWorktree, {
60
+ repoRoot: config.repoRoot,
61
+ worktreeBase: config.worktreeBase,
62
+ branch: target.effectiveBranch,
63
+ identitySource: target.workspace.mode,
64
+ taskId: target.workspace.mode === 'task-bound' ? target.workspace.taskId : null
65
+ });
66
+ }
67
+ return contexts;
68
+ }
69
+ function resolveRmTarget(config, tools, cleanupTarget, options = {}) {
70
+ assertValidBranchName(cleanupTarget.branch);
31
71
  const engine = detectEngine(config);
32
- let effectiveBranch = branch;
33
- let worktreeCandidates = worktreeDirCandidates(config, branch);
34
- let toolCandidates = tools.map((tool) => ({
72
+ const branch = cleanupTarget.branch;
73
+ const effectiveBranch = branch;
74
+ const worktreeCandidates = worktreeDirCandidates(config, branch);
75
+ const toolCandidates = tools.map((tool) => ({
35
76
  tool,
36
77
  candidates: toolConfigDirCandidates(tool, config.project, branch)
37
78
  }));
79
+ const shellCandidates = shellConfigDirCandidates(config, branch);
38
80
  const existing = runEngine(engine, 'docker', ['ps', '-a', '--format', '{{.Names}}']).split('\n').filter(Boolean);
39
- const matchedContainers = containerNameCandidates(config, branch).filter((name) => existing.includes(name));
40
- if (matchedContainers.length > 0) {
41
- const resolvedBranch = runEngine(engine, 'docker', [
42
- 'inspect',
43
- '-f',
44
- `{{ index .Config.Labels "${sandboxBranchLabel(config)}" }}`,
45
- matchedContainers[0] ?? ''
46
- ]);
47
- if (resolvedBranch) {
48
- effectiveBranch = resolvedBranch;
49
- worktreeCandidates = worktreeDirCandidates(config, effectiveBranch);
50
- toolCandidates = tools.map((tool) => ({
51
- tool,
52
- candidates: toolConfigDirCandidates(tool, config.project, effectiveBranch)
53
- }));
54
- }
55
- }
56
- const workspace = resolveSandboxTarget(effectiveBranch, config.repoRoot).workspace;
57
- const identities = workspace.mode === 'task-bound'
58
- ? [workspace, { mode: 'branch-only' }]
59
- : [workspace];
60
- const containers = [...new Set([...containerNameCandidates(config, effectiveBranch), ...matchedContainers])];
81
+ const matchedContainers = options.discoveredContainers
82
+ ? options.discoveredContainers.map((container) => {
83
+ if (!existing.includes(container)) {
84
+ throw new Error(`SANDBOX_CLEANUP_BATCH_PREFLIGHT_FAILED: discovered container '${container}' is no longer present`);
85
+ }
86
+ return container;
87
+ })
88
+ : containerNameCandidates(config, branch).filter((name) => existing.includes(name));
89
+ const workspace = cleanupTarget.workspace;
90
+ const identities = [workspace];
91
+ const containers = options.discoveredContainers
92
+ ? matchedContainers
93
+ : [...new Set([...containerNameCandidates(config, effectiveBranch), ...matchedContainers])];
61
94
  const controlRoots = containers.flatMap((container) => identities.map((identity) => sandboxControlPaths({
62
95
  base: config.controlBase, project: config.project, container, identity
63
96
  }).root));
64
97
  const workspaceViewRoots = containers.flatMap((container) => identities.map((identity) => sandboxWorkspaceViewPaths({
65
98
  base: config.workspaceViewBase, project: config.project, container, identity
66
99
  }).root));
100
+ const managedPathCandidates = [...new Set([
101
+ ...worktreeCandidates,
102
+ ...toolCandidates.flatMap(({ candidates }) => candidates),
103
+ ...shellCandidates,
104
+ shareBranchDir(config, branch),
105
+ ...controlRoots,
106
+ ...workspaceViewRoots
107
+ ].map((candidate) => path.resolve(candidate)))];
67
108
  return {
68
109
  branch,
69
110
  effectiveBranch,
@@ -71,24 +112,157 @@ function resolveRmTarget(config, tools, branch) {
71
112
  matchedContainers,
72
113
  existingWorktrees: worktreeCandidates.filter((candidate) => fs.existsSync(candidate)),
73
114
  toolCandidates,
115
+ managedPathCandidates,
74
116
  workspace,
75
117
  controlRoots: [...new Set(controlRoots)],
76
118
  workspaceViewRoots: [...new Set(workspaceViewRoots)]
77
119
  };
78
120
  }
121
+ function inspectNamedContainerIdentity(target, container) {
122
+ const raw = runSafeEngine(target.engine, 'docker', [
123
+ 'inspect', '--format', '{{json .}}', container
124
+ ]).trim();
125
+ let parsed;
126
+ try {
127
+ parsed = JSON.parse(raw);
128
+ }
129
+ catch {
130
+ throw new Error(`SANDBOX_WORKSPACE_IDENTITY_UNKNOWN: container '${container}' inspection is invalid`);
131
+ }
132
+ const value = Array.isArray(parsed) && parsed.length === 1 ? parsed[0] : parsed;
133
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
134
+ throw new Error(`SANDBOX_WORKSPACE_IDENTITY_UNKNOWN: container '${container}' inspection is incomplete`);
135
+ }
136
+ const candidate = value;
137
+ if (typeof candidate.Id !== 'string' || !candidate.Id
138
+ || !candidate.Config?.Labels || typeof candidate.Config.Labels !== 'object'
139
+ || Array.isArray(candidate.Config.Labels)
140
+ || Object.values(candidate.Config.Labels).some((value) => typeof value !== 'string')) {
141
+ throw new Error(`SANDBOX_WORKSPACE_IDENTITY_UNKNOWN: container '${container}' inspection is incomplete`);
142
+ }
143
+ return { id: candidate.Id, labels: candidate.Config.Labels };
144
+ }
145
+ function assertMatchedContainerIdentities(config, target) {
146
+ const current = new Map();
147
+ for (const container of target.matchedContainers) {
148
+ const observation = inspectNamedContainerIdentity(target, container);
149
+ const stringLabels = observation.labels;
150
+ const branch = stringLabels[sandboxBranchLabel(config)];
151
+ if (branch !== target.branch) {
152
+ throw new Error(`SANDBOX_WORKSPACE_IDENTITY_CONFLICT: container '${container}' branch is ${JSON.stringify(branch ?? null)}, `
153
+ + `but this request is ${JSON.stringify(target.branch)}`);
154
+ }
155
+ const identity = parseSandboxWorkspaceIdentity(stringLabels, {
156
+ mode: sandboxWorkspaceModeLabel(config),
157
+ taskId: sandboxTaskIdLabel(config)
158
+ });
159
+ if (identity.mode === 'legacy-invalid' || !sameSandboxWorkspaceIdentity(identity, target.workspace)) {
160
+ const existingDescription = identity.mode === 'task-bound'
161
+ ? `task-bound:${identity.taskId}`
162
+ : identity.mode;
163
+ const requestedDescription = target.workspace.mode === 'task-bound'
164
+ ? `task-bound:${target.workspace.taskId}`
165
+ : target.workspace.mode;
166
+ throw new Error(`SANDBOX_WORKSPACE_IDENTITY_CONFLICT: container '${container}' is ${existingDescription}, `
167
+ + `but this request is ${requestedDescription}`);
168
+ }
169
+ current.set(container, observation);
170
+ }
171
+ return current;
172
+ }
173
+ function controlRootContainer(config, root) {
174
+ const relative = path.relative(path.join(config.controlBase, config.project), root);
175
+ const [container, identity, ...extra] = relative.split(path.sep);
176
+ if (!container || !identity || extra.length > 0) {
177
+ throw new Error(`SANDBOX_CONTROL_TARGET_EVIDENCE_INVALID: ${root}`);
178
+ }
179
+ return container;
180
+ }
79
181
  function assertControlRootMatchesTarget(root, effectiveBranch, workspace) {
80
182
  const manifestPath = path.join(root, 'manifest.json');
81
- if (!fs.existsSync(manifestPath))
183
+ if (!fs.existsSync(manifestPath)) {
184
+ if (workspace.mode === 'task-bound') {
185
+ throw new Error(`SANDBOX_CONTROL_TARGET_EVIDENCE_MISSING: ${root}`);
186
+ }
82
187
  return;
188
+ }
83
189
  const manifest = readSandboxControlManifest(manifestPath);
84
190
  const identityMatches = workspace.mode === 'task-bound'
85
191
  ? manifest.mode === 'task-bound' && manifest.taskId === workspace.taskId
86
192
  : manifest.mode === 'branch-only';
87
- const branchOnlyFallback = workspace.mode === 'task-bound' && manifest.mode === 'branch-only';
88
- if (manifest.branch !== effectiveBranch || (!identityMatches && !branchOnlyFallback)) {
193
+ if (manifest.branch !== effectiveBranch || !identityMatches) {
89
194
  throw new Error(`SANDBOX_CONTROL_TARGET_MISMATCH: ${root}`);
90
195
  }
91
196
  }
197
+ function preflightRmTarget(config, target) {
198
+ const current = assertMatchedContainerIdentities(config, target);
199
+ const manifests = [];
200
+ for (const root of target.controlRoots.filter((candidate) => fs.existsSync(candidate))) {
201
+ assertLegacyCandidateEvidence(root, config.controlBase, config, target.effectiveBranch, target.matchedContainers);
202
+ assertControlRootMatchesTarget(root, target.effectiveBranch, target.workspace);
203
+ const manifestPath = path.join(root, 'manifest.json');
204
+ if (!fs.existsSync(manifestPath))
205
+ continue;
206
+ const manifest = readSandboxControlManifest(manifestPath);
207
+ const rootContainer = controlRootContainer(config, root);
208
+ if (manifest.container !== rootContainer) {
209
+ throw new Error(`SANDBOX_CONTROL_TARGET_MISMATCH: ${root}`);
210
+ }
211
+ const observed = current.get(manifest.container);
212
+ if (observed && (observed.id !== manifest.containerIdentity.id
213
+ || Object.entries(manifest.containerIdentity.labels).some(([key, value]) => observed.labels[key] !== value))) {
214
+ throw new Error(`SANDBOX_CONTROL_TARGET_MISMATCH: ${root}`);
215
+ }
216
+ manifests.push(manifest);
217
+ }
218
+ if (target.workspace.mode !== 'task-bound')
219
+ return;
220
+ const matched = new Set(target.matchedContainers);
221
+ const manifestContainers = manifests.map((manifest) => manifest.container);
222
+ if (matched.size > 0 && manifestContainers.some((container) => !matched.has(container))) {
223
+ throw new Error('SANDBOX_CONTROL_TARGET_MISMATCH: manifest container is outside the cleanup target');
224
+ }
225
+ for (const container of matched) {
226
+ const matches = manifests.filter((manifest) => manifest.container === container);
227
+ if (matches.length !== 1) {
228
+ throw new Error(`SANDBOX_CONTROL_TARGET_EVIDENCE_MISSING: container '${container}'`);
229
+ }
230
+ }
231
+ if (matched.size === 0 && manifests.length > 0) {
232
+ const supported = new Set(containerNameCandidates(config, target.effectiveBranch));
233
+ if (manifests.some((manifest) => !supported.has(manifest.container))) {
234
+ throw new Error('SANDBOX_CONTROL_TARGET_MISMATCH: manifest container is outside the cleanup target');
235
+ }
236
+ }
237
+ }
238
+ function assertCleanupGroupPathsDoNotOverlap(groups) {
239
+ const owners = new Map();
240
+ for (const group of groups) {
241
+ for (const candidate of group.target.managedPathCandidates ?? []) {
242
+ const key = sandboxManagedPathKey(candidate);
243
+ const existingBranch = owners.get(key);
244
+ if (existingBranch && existingBranch !== group.cleanupTarget.branch) {
245
+ throw new Error(`SANDBOX_CLEANUP_BATCH_PREFLIGHT_FAILED: managed path '${candidate}' is shared by branches `
246
+ + `${JSON.stringify(existingBranch)} and ${JSON.stringify(group.cleanupTarget.branch)}`);
247
+ }
248
+ owners.set(key, group.cleanupTarget.branch);
249
+ }
250
+ }
251
+ }
252
+ function assertWorktreeBranchesMatchGroups(groups, inspections) {
253
+ const byPath = new Map(inspections.flatMap((inspection) => inspection.status === 'failed'
254
+ ? []
255
+ : [[sandboxManagedPathKey(inspection.snapshot.worktree), inspection.snapshot.branch]]));
256
+ for (const group of groups) {
257
+ for (const worktree of group.target.existingWorktrees) {
258
+ const registeredBranch = byPath.get(sandboxManagedPathKey(worktree));
259
+ if (registeredBranch && registeredBranch !== group.cleanupTarget.branch) {
260
+ throw new Error(`SANDBOX_CLEANUP_BATCH_PREFLIGHT_FAILED: worktree '${worktree}' is registered to branch `
261
+ + `${JSON.stringify(registeredBranch)}, but cleanup targets ${JSON.stringify(group.cleanupTarget.branch)}`);
262
+ }
263
+ }
264
+ }
265
+ }
92
266
  function assertLegacyCandidateEvidence(candidate, base, config, effectiveBranch, matchedContainers) {
93
267
  const relative = path.relative(path.join(base, config.project), candidate);
94
268
  const [container, identity] = relative.split(path.sep);
@@ -128,7 +302,7 @@ async function removeExactSandboxContainer(engine, manifest, timeoutMs) {
128
302
  throw new Error(`SANDBOX_CONTROL_CONTAINER_UNKNOWN: ${before.reason}`);
129
303
  if (before.state === 'absent')
130
304
  return;
131
- if (before.running && !runOkEngine(engine, 'docker', ['stop', manifest.containerIdentity.id], { timeout: remaining() })) {
305
+ if (before.running && !runOkEngine(engine, 'docker', ['stop', '--timeout', '1', manifest.containerIdentity.id], { timeout: remaining() })) {
132
306
  const afterStop = await inspect();
133
307
  if (afterStop.state !== 'absent' && afterStop.state !== 'found') {
134
308
  throw new Error(`SANDBOX_CONTROL_CONTAINER_UNKNOWN: ${afterStop.reason}`);
@@ -195,8 +369,14 @@ function cleanPermits(inspections) {
195
369
  }
196
370
  return permits;
197
371
  }
198
- async function authorizeWorktrees(worktrees, { allowDirtyDiscard, assumeYes }, { interactive = Boolean(process.stdin.isTTY), confirm = p.confirm, isCancel = p.isCancel } = {}) {
199
- const inspections = inspectWorktrees(worktrees);
372
+ async function authorizeWorktrees(worktrees, { allowDirtyDiscard, assumeYes }, { interactive = Boolean(process.stdin.isTTY), confirm = p.confirm, isCancel = p.isCancel, recovery = new Map() } = {}) {
373
+ const inspections = worktrees.map((worktree) => {
374
+ const inspection = inspectWorktrees([worktree])[0];
375
+ const context = recovery.get(path.resolve(worktree));
376
+ return inspection.status === 'failed' && context
377
+ ? inspectRecoveredWorktree(worktree, context)
378
+ : inspection;
379
+ });
200
380
  const failures = inspections.filter((inspection) => inspection.status === 'failed');
201
381
  if (failures.length > 0)
202
382
  throw new Error(`Unable to inspect worktree(s):\n${blockerMessage(failures)}`);
@@ -219,20 +399,28 @@ async function authorizeWorktrees(worktrees, { allowDirtyDiscard, assumeYes }, {
219
399
  return permits;
220
400
  }
221
401
  async function rmOne(config, tools, branch, options = {}) {
222
- const target = options.target ?? resolveRmTarget(config, tools, branch);
402
+ const target = options.target ?? resolveRmTarget(config, tools, options.cleanupTarget ?? resolveSandboxCleanupTarget(branch, config.repoRoot));
223
403
  const { effectiveBranch, engine, matchedContainers, existingWorktrees, toolCandidates } = target;
224
404
  const { workspace, controlRoots, workspaceViewRoots } = target;
405
+ preflightRmTarget(config, target);
225
406
  const confirm = options.prompt?.confirm ?? p.confirm;
226
407
  const isCancel = options.prompt?.isCancel ?? p.isCancel;
227
408
  if (!options.quiet) {
228
409
  p.intro(pc.cyan(`Removing sandbox for ${branch}`));
229
410
  }
411
+ const recovery = options.permits
412
+ ? new Map()
413
+ : recoveryContexts(config, target, existingWorktrees);
230
414
  const permits = options.permits
231
415
  ? new Map(options.permits)
232
416
  : await authorizeWorktrees(existingWorktrees, {
233
417
  allowDirtyDiscard: options.allowDirtyDiscard ?? true,
234
418
  assumeYes: Boolean(options.assumeYes)
235
- }, options.prompt);
419
+ }, {
420
+ ...options.prompt,
421
+ interactive: options.interactive ?? Boolean(process.stdin.isTTY),
422
+ recovery
423
+ });
236
424
  for (const worktree of existingWorktrees) {
237
425
  const permit = permits.get(path.resolve(worktree));
238
426
  if (!permit)
@@ -251,19 +439,27 @@ async function rmOne(config, tools, branch, options = {}) {
251
439
  p.outro('Cancelled');
252
440
  return;
253
441
  }
442
+ preflightRmTarget(config, target);
254
443
  const coordinatedContainers = new Set();
255
444
  for (const root of controlRoots.filter((candidate) => fs.existsSync(candidate))) {
256
445
  assertLegacyCandidateEvidence(root, config.controlBase, config, effectiveBranch, matchedContainers);
257
446
  assertControlRootMatchesTarget(root, effectiveBranch, workspace);
258
- const manifest = readSandboxControlManifest(path.join(root, 'manifest.json'));
447
+ const manifestPath = path.join(root, 'manifest.json');
448
+ if (!fs.existsSync(manifestPath))
449
+ continue;
450
+ const manifest = readSandboxControlManifest(manifestPath);
259
451
  coordinatedContainers.add(manifest.container);
260
452
  await removeSandboxControlRoot(root, {
261
453
  inspectContainer: (timeoutMs) => inspectSandboxControlContainer(manifest, { timeoutMs }),
262
454
  removeContainer: (timeoutMs) => removeExactSandboxContainer(engine, manifest, timeoutMs)
263
455
  });
456
+ removeEmptyManagedParent(path.join(config.controlBase, config.project), root);
264
457
  }
265
458
  const uncoordinatedContainers = matchedContainers.filter((name) => !coordinatedContainers.has(name));
266
459
  if (uncoordinatedContainers.length > 0) {
460
+ if (workspace.mode === 'task-bound') {
461
+ throw new Error(`SANDBOX_CONTROL_TARGET_EVIDENCE_MISSING: container '${uncoordinatedContainers[0]}'`);
462
+ }
267
463
  const spinner = p.spinner();
268
464
  spinner.start(`Stopping container(s): ${uncoordinatedContainers.join(', ')}`);
269
465
  for (const name of uncoordinatedContainers) {
@@ -500,18 +696,80 @@ async function rmPurge(config, tools, prompt = {}) {
500
696
  }
501
697
  async function rmUnbound(config, tools, options) {
502
698
  const engine = detectEngine(config);
503
- const { running, nonRunning } = fetchSandboxRows(engine, sandboxLabel(config), sandboxBranchLabel(config));
504
- const removable = [...running, ...nonRunning].filter((row) => row.branch && lookupShortIdByBranch(row.branch, config.repoRoot) === null);
699
+ const { running, nonRunning } = fetchSandboxRows(engine, sandboxLabel(config), sandboxBranchLabel(config), { mode: sandboxWorkspaceModeLabel(config), taskId: sandboxTaskIdLabel(config) });
700
+ const rows = [...running, ...nonRunning];
505
701
  p.intro(pc.cyan(`Removing sandboxes not bound to an active task for ${config.project}`));
506
- if (removable.length === 0) {
702
+ if (rows.length === 0) {
507
703
  p.outro('No removable sandboxes: every container is bound to an active task (or none exist)');
508
704
  return;
509
705
  }
510
- const targets = removable.map((row) => resolveRmTarget(config, tools, row.branch));
511
- const inspections = inspectWorktrees(targets.flatMap((target) => target.existingWorktrees));
706
+ const candidates = rows.map((row) => {
707
+ if (!row.branch || !row.workspaceMode || row.workspaceMode === 'legacy-invalid') {
708
+ throw new Error(`SANDBOX_CLEANUP_BATCH_PREFLIGHT_FAILED: container '${row.name}' has incomplete workspace identity`);
709
+ }
710
+ if (row.workspaceMode === 'task-bound') {
711
+ if (!row.taskId) {
712
+ throw new Error(`SANDBOX_CLEANUP_BATCH_PREFLIGHT_FAILED: container '${row.name}' has no task id`);
713
+ }
714
+ const cleanupTarget = resolveSandboxCleanupTarget(row.taskId, config.repoRoot, { allowProtected: true });
715
+ if (cleanupTarget.branch !== row.branch
716
+ || cleanupTarget.workspace.mode !== 'task-bound'
717
+ || cleanupTarget.workspace.taskId !== row.taskId) {
718
+ throw new Error(`SANDBOX_CLEANUP_BATCH_PREFLIGHT_FAILED: container '${row.name}' task identity conflicts with task.md`);
719
+ }
720
+ return { row, cleanupTarget };
721
+ }
722
+ return {
723
+ row,
724
+ cleanupTarget: {
725
+ requestedRef: row.branch,
726
+ branch: row.branch,
727
+ workspace: { mode: 'branch-only' },
728
+ taskState: 'branch-only'
729
+ }
730
+ };
731
+ });
732
+ const branchIdentities = new Map();
733
+ const groupedCandidates = new Map();
734
+ for (const candidate of candidates) {
735
+ const identity = candidate.cleanupTarget.workspace.mode === 'task-bound'
736
+ ? `task-bound:${candidate.cleanupTarget.workspace.taskId}`
737
+ : 'branch-only';
738
+ const existingIdentity = branchIdentities.get(candidate.cleanupTarget.branch);
739
+ if (existingIdentity && existingIdentity !== identity) {
740
+ throw new Error(`SANDBOX_CLEANUP_BATCH_PREFLIGHT_FAILED: branch '${candidate.cleanupTarget.branch}' has conflicting workspace identities`);
741
+ }
742
+ branchIdentities.set(candidate.cleanupTarget.branch, identity);
743
+ const groupKey = `${candidate.cleanupTarget.branch}\0${identity}`;
744
+ const group = groupedCandidates.get(groupKey) ?? [];
745
+ group.push(candidate);
746
+ groupedCandidates.set(groupKey, group);
747
+ }
748
+ const groups = [...groupedCandidates.values()].map((groupCandidates) => {
749
+ const cleanupTarget = groupCandidates[0].cleanupTarget;
750
+ const target = resolveRmTarget(config, tools, cleanupTarget, {
751
+ discoveredContainers: groupCandidates.map(({ row }) => row.name)
752
+ });
753
+ preflightRmTarget(config, target);
754
+ return { candidates: groupCandidates, cleanupTarget, target };
755
+ });
756
+ assertCleanupGroupPathsDoNotOverlap(groups);
757
+ const removableGroups = groups.filter(({ cleanupTarget }) => cleanupTarget.taskState === 'completed' || cleanupTarget.taskState === 'branch-only');
758
+ const removable = removableGroups.flatMap(({ candidates: groupCandidates }) => groupCandidates);
759
+ for (const { candidates: groupCandidates, cleanupTarget } of groups.filter(({ cleanupTarget }) => cleanupTarget.taskState !== 'completed' && cleanupTarget.taskState !== 'branch-only')) {
760
+ for (const { row } of groupCandidates) {
761
+ p.log.message(`Skipped protected sandbox ${row.name} (${cleanupTarget.taskState})`);
762
+ }
763
+ }
764
+ if (removableGroups.length === 0) {
765
+ p.outro('No removable sandboxes: every container is bound to a protected task (or none exist)');
766
+ return;
767
+ }
768
+ const inspections = inspectWorktrees(removableGroups.flatMap(({ target }) => target.existingWorktrees));
769
+ assertWorktreeBranchesMatchGroups(removableGroups, inspections);
512
770
  const blockers = inspectionBlockers(inspections);
513
771
  const permits = cleanPermits(inspections);
514
- for (const row of removable) {
772
+ for (const { row } of removable) {
515
773
  p.log.message(`${row.name} ${row.branch}`);
516
774
  }
517
775
  if (blockers.length > 0)
@@ -527,25 +785,28 @@ async function rmUnbound(config, tools, options) {
527
785
  throw new Error('Refusing to remove sandboxes without confirmation in a non-interactive shell; pass --yes to proceed.');
528
786
  }
529
787
  const failures = [];
530
- for (const [index, row] of removable.entries()) {
788
+ let failedRows = 0;
789
+ for (const group of removableGroups) {
531
790
  try {
532
- await rmOne(config, tools, row.branch, {
791
+ await rmOne(config, tools, group.cleanupTarget.branch, {
533
792
  assumeYes: options.assumeYes,
534
793
  quiet: true,
535
- target: targets[index],
794
+ target: group.target,
795
+ cleanupTarget: group.cleanupTarget,
536
796
  permits,
537
797
  allowDirtyDiscard: false
538
798
  });
539
799
  }
540
800
  catch (error) {
541
- failures.push({ branch: row.branch, message: error instanceof Error ? error.message : String(error) });
801
+ failedRows += group.candidates.length;
802
+ failures.push({ branch: group.cleanupTarget.branch, message: error instanceof Error ? error.message : String(error) });
542
803
  }
543
804
  }
544
805
  if (failures.length > 0) {
545
806
  for (const failure of failures) {
546
807
  p.log.error(`Failed to remove '${failure.branch}': ${failure.message}`);
547
808
  }
548
- throw new Error(`Removed ${removable.length - failures.length}/${removable.length} sandbox(es); ${failures.length} failed`);
809
+ throw new Error(`Removed ${removable.length - failedRows}/${removable.length} sandbox(es); ${failures.length} failed`);
549
810
  }
550
811
  p.outro(pc.green(`Removed ${removable.length} sandbox(es)`));
551
812
  }
@@ -595,8 +856,8 @@ export async function rm(args) {
595
856
  });
596
857
  return;
597
858
  }
598
- const target = resolveSandboxTarget(positionals[0] ?? '', config.repoRoot);
599
- await rmOne(config, tools, target.branch);
859
+ const cleanupTarget = resolveSandboxCleanupTarget(positionals[0] ?? '', config.repoRoot);
860
+ await rmOne(config, tools, cleanupTarget.branch, { cleanupTarget });
600
861
  }
601
862
  export { authorizeWorktrees, rmOne, rmPurge };
602
863
  //# sourceMappingURL=rm.js.map