@fitlab-ai/agent-infra 0.9.8 → 0.9.10
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.
- package/README.md +1 -1
- package/README.zh-CN.md +1 -1
- package/dist/bin/internal-cli.js +11 -1
- package/dist/lib/agent-clients/adapters/codex-lifecycle/app-server.js +58 -21
- package/dist/lib/agent-clients/adapters/codex-lifecycle/build-identity.js +25 -11
- package/dist/lib/agent-clients/adapters/codex-lifecycle/capability-store.js +173 -10
- package/dist/lib/agent-clients/adapters/codex-lifecycle/controller-context.js +127 -0
- package/dist/lib/agent-clients/adapters/codex-lifecycle/evidence.js +14 -0
- package/dist/lib/agent-clients/adapters/codex-lifecycle/sandbox-controller.js +63 -92
- package/dist/lib/agent-clients/adapters/codex-sandbox.js +72 -11
- package/dist/lib/agent-clients/adapters/index.js +3 -1
- package/dist/lib/agent-clients/adapters/traecli.js +66 -0
- package/dist/lib/agent-clients/tokens.js +2 -2
- package/dist/lib/agent-clients/types.js +2 -1
- package/dist/lib/defaults.json +5 -0
- package/dist/lib/git/workflow.js +10 -1
- package/dist/lib/internal/codex-lifecycle.js +20 -6
- package/dist/lib/internal/codex-sandbox-controller.js +9 -15
- package/dist/lib/internal/git-workflow.js +5 -7
- package/dist/lib/internal/platform-pr.js +20 -7
- package/dist/lib/internal/release-workflow.js +2 -1
- package/dist/lib/internal/sandbox-control.js +79 -2
- package/dist/lib/internal/task-finalization.js +54 -0
- package/dist/lib/internal/task-orchestration.js +156 -291
- package/dist/lib/{task/commands/validate.js → internal/task-validate.js} +75 -42
- package/dist/lib/platform/pr-summary.js +48 -10
- package/dist/lib/platform/pull-requests.js +205 -41
- package/dist/lib/run/tui.js +5 -2
- package/dist/lib/sandbox/commands/rm.js +316 -55
- package/dist/lib/sandbox/control/client.js +190 -6
- package/dist/lib/sandbox/control/controller-registration.js +275 -0
- package/dist/lib/sandbox/control/executor.js +140 -5
- package/dist/lib/sandbox/control/lifecycle.js +3 -1
- package/dist/lib/sandbox/control/protocol.js +67 -6
- package/dist/lib/sandbox/index.js +5 -3
- package/dist/lib/sandbox/managed-fs.js +5 -0
- package/dist/lib/sandbox/task-resolver.js +50 -6
- package/dist/lib/sandbox/workspace-identity.js +27 -0
- package/dist/lib/sandbox/worktree-safety.js +215 -2
- package/dist/lib/task/codex-orchestration.js +161 -39
- package/dist/lib/task/commands/status.js +59 -1
- package/dist/lib/task/commit-operation.js +335 -0
- package/dist/lib/task/commit-policy.js +18 -0
- package/dist/lib/task/delegation-receipts.js +248 -6
- package/dist/lib/task/finalization.js +593 -0
- package/dist/lib/task/index.js +0 -9
- package/dist/lib/task/operation-outcome.js +33 -0
- package/dist/lib/task/orchestration.js +187 -761
- package/dist/lib/task/short-id.js +59 -1
- package/dist/lib/task/task-execution-lock.js +6 -1
- package/dist/lib/task/verification-engine.js +110 -60
- package/dist/lib/task/verification.js +2 -1
- package/dist/lib/task/workflow-warning-intents.js +50 -1
- package/dist/lifecycle-build-manifest.json +3 -3
- package/lib/agent-clients/adapters/codex-lifecycle/app-server.ts +61 -19
- package/lib/agent-clients/adapters/codex-lifecycle/build-identity.ts +37 -10
- package/lib/agent-clients/adapters/codex-lifecycle/capability-store.ts +276 -12
- package/lib/agent-clients/adapters/codex-lifecycle/controller-context.ts +163 -0
- package/lib/agent-clients/adapters/codex-lifecycle/evidence.ts +19 -0
- package/lib/agent-clients/adapters/codex-lifecycle/manifest-files.json +9 -1
- package/lib/agent-clients/adapters/codex-lifecycle/sandbox-controller.ts +91 -118
- package/lib/agent-clients/adapters/codex-sandbox.ts +89 -14
- package/lib/agent-clients/adapters/index.ts +3 -1
- package/lib/agent-clients/adapters/traecli.ts +67 -0
- package/lib/agent-clients/tokens.ts +2 -2
- package/lib/agent-clients/types.ts +2 -1
- package/lib/defaults.json +5 -0
- package/lib/git/workflow.ts +11 -2
- package/lib/internal/codex-lifecycle.ts +19 -6
- package/lib/internal/codex-sandbox-controller.ts +9 -14
- package/lib/internal/git-workflow.ts +5 -6
- package/lib/internal/platform-pr.ts +14 -7
- package/lib/internal/release-workflow.ts +2 -1
- package/lib/internal/sandbox-control.ts +92 -2
- package/lib/internal/task-finalization.ts +68 -0
- package/lib/internal/task-orchestration.ts +20 -103
- package/lib/{task/commands/validate.ts → internal/task-validate.ts} +75 -40
- package/lib/platform/pr-summary.ts +55 -11
- package/lib/platform/pull-requests.ts +211 -41
- package/lib/run/tui.ts +5 -3
- package/lib/sandbox/commands/rm.ts +413 -61
- package/lib/sandbox/control/client.ts +255 -6
- package/lib/sandbox/control/controller-registration.ts +352 -0
- package/lib/sandbox/control/executor.ts +169 -5
- package/lib/sandbox/control/lifecycle.ts +2 -1
- package/lib/sandbox/control/protocol.ts +80 -7
- package/lib/sandbox/index.ts +5 -3
- package/lib/sandbox/managed-fs.ts +5 -0
- package/lib/sandbox/task-resolver.ts +58 -7
- package/lib/sandbox/workspace-identity.ts +50 -2
- package/lib/sandbox/workspace-view.ts +3 -3
- package/lib/sandbox/worktree-safety.ts +236 -2
- package/lib/task/codex-orchestration.ts +168 -40
- package/lib/task/commands/status.ts +78 -1
- package/lib/task/commit-operation.ts +430 -0
- package/lib/task/commit-policy.ts +30 -0
- package/lib/task/delegation-receipts.ts +263 -4
- package/lib/task/finalization.ts +761 -0
- package/lib/task/index.ts +0 -8
- package/lib/task/operation-outcome.ts +48 -0
- package/lib/task/orchestration.ts +218 -982
- package/lib/task/short-id.ts +65 -1
- package/lib/task/task-execution-lock.ts +10 -1
- package/lib/task/verification-engine.ts +115 -63
- package/lib/task/verification.ts +3 -2
- package/lib/task/workflow-warning-intents.ts +57 -2
- package/package.json +2 -2
- package/templates/.agents/QUICKSTART.en.md +3 -4
- package/templates/.agents/QUICKSTART.zh-CN.md +3 -4
- package/templates/.agents/hooks/lifecycle-delegation.js +1 -21
- package/templates/.agents/rules/README.en.md +1 -0
- package/templates/.agents/rules/README.zh-CN.md +1 -0
- package/templates/.agents/rules/compatibility-policy.en.md +39 -0
- package/templates/.agents/rules/compatibility-policy.zh-CN.md +39 -0
- package/templates/.agents/rules/lifecycle-orchestration.en.md +4 -4
- package/templates/.agents/rules/lifecycle-orchestration.zh-CN.md +4 -4
- package/templates/.agents/rules/pr-sync.en.md +1 -1
- package/templates/.agents/rules/pr-sync.github.en.md +2 -1
- package/templates/.agents/rules/pr-sync.github.zh-CN.md +2 -0
- package/templates/.agents/rules/pr-sync.zh-CN.md +1 -0
- package/templates/.agents/rules/review-method.en.md +1 -0
- package/templates/.agents/rules/review-method.zh-CN.md +1 -0
- package/templates/.agents/rules/task-management.en.md +2 -2
- package/templates/.agents/rules/task-management.zh-CN.md +2 -2
- package/templates/.agents/skills/analyze-task/SKILL.en.md +1 -0
- package/templates/.agents/skills/analyze-task/SKILL.zh-CN.md +1 -0
- package/templates/.agents/skills/block-task/SKILL.en.md +2 -4
- package/templates/.agents/skills/block-task/SKILL.zh-CN.md +2 -4
- package/templates/.agents/skills/cancel-task/SKILL.en.md +3 -3
- package/templates/.agents/skills/cancel-task/SKILL.zh-CN.md +3 -3
- package/templates/.agents/skills/close-codescan/SKILL.en.md +6 -2
- package/templates/.agents/skills/close-codescan/SKILL.zh-CN.md +6 -2
- package/templates/.agents/skills/close-dependabot/SKILL.en.md +6 -2
- package/templates/.agents/skills/close-dependabot/SKILL.zh-CN.md +6 -2
- package/templates/.agents/skills/code-task/SKILL.en.md +1 -0
- package/templates/.agents/skills/code-task/SKILL.zh-CN.md +2 -0
- package/templates/.agents/skills/commit/SKILL.en.md +41 -125
- package/templates/.agents/skills/commit/SKILL.zh-CN.md +52 -123
- package/templates/.agents/skills/commit/reference/commit-message.en.md +1 -1
- package/templates/.agents/skills/commit/reference/commit-message.zh-CN.md +1 -1
- package/templates/.agents/skills/commit/reference/commit-orchestration.en.md +14 -43
- package/templates/.agents/skills/commit/reference/commit-orchestration.zh-CN.md +14 -49
- package/templates/.agents/skills/commit/reference/pr-summary-sync.en.md +1 -1
- package/templates/.agents/skills/commit/reference/pr-summary-sync.zh-CN.md +1 -1
- package/templates/.agents/skills/commit/reference/task-status-update.en.md +2 -35
- package/templates/.agents/skills/commit/reference/task-status-update.zh-CN.md +2 -35
- package/templates/.agents/skills/complete-task/SKILL.en.md +31 -38
- package/templates/.agents/skills/complete-task/SKILL.zh-CN.md +31 -38
- package/templates/.agents/skills/complete-task/config/verify.en.json +1 -0
- package/templates/.agents/skills/complete-task/config/verify.zh-CN.json +1 -0
- package/templates/.agents/skills/complete-task/reference/external-delivery.en.md +1 -1
- package/templates/.agents/skills/complete-task/reference/external-delivery.zh-CN.md +1 -1
- package/templates/.agents/skills/create-pr/SKILL.en.md +17 -9
- package/templates/.agents/skills/create-pr/SKILL.zh-CN.md +17 -9
- package/templates/.agents/skills/create-pr/reference/change-report.en.md +41 -0
- package/templates/.agents/skills/create-pr/reference/change-report.zh-CN.md +41 -0
- package/templates/.agents/skills/create-pr/reference/comment-publish.en.md +2 -2
- package/templates/.agents/skills/create-pr/reference/comment-publish.zh-CN.md +2 -2
- package/templates/.agents/skills/plan-task/SKILL.en.md +2 -1
- package/templates/.agents/skills/plan-task/SKILL.zh-CN.md +2 -1
- package/templates/.agents/skills/review-code/reference/migration-risks.en.md +8 -6
- package/templates/.agents/skills/review-code/reference/migration-risks.zh-CN.md +8 -6
- package/templates/.agents/skills/run-manual-validation/SKILL.en.md +3 -3
- package/templates/.agents/skills/run-manual-validation/SKILL.zh-CN.md +3 -3
- package/templates/.agents/skills/run-manual-validation/reference/discovery-and-execution.en.md +1 -1
- package/templates/.agents/skills/run-manual-validation/reference/discovery-and-execution.zh-CN.md +1 -1
- package/templates/.agents/skills/run-manual-validation/reference/report-template.en.md +2 -2
- package/templates/.agents/skills/run-manual-validation/reference/report-template.zh-CN.md +2 -2
- package/templates/.agents/skills/run-task/SKILL.en.md +6 -6
- package/templates/.agents/skills/run-task/SKILL.zh-CN.md +6 -6
- package/templates/.agents/skills/run-task/reference/host-validation.en.md +2 -2
- package/templates/.agents/skills/run-task/reference/host-validation.zh-CN.md +2 -2
- package/templates/.agents/skills/update-agent-infra/scripts/sync-templates.js +24 -0
- package/templates/.agents/skills/watch-pr/reference/monitor-and-heal.en.md +1 -1
- package/templates/.agents/skills/watch-pr/reference/monitor-and-heal.zh-CN.md +1 -1
- package/templates/.codex/hooks.json +4 -4
- package/templates/.trae/skills/update-agent-infra.en.md +8 -0
- package/templates/.trae/skills/update-agent-infra.zh-CN.md +8 -0
- package/dist/lib/task/commit-finalization.js +0 -305
- package/dist/lib/task/commit-intent.js +0 -165
- package/lib/task/commit-finalization.ts +0 -419
- package/lib/task/commit-intent.ts +0 -230
|
@@ -10,6 +10,8 @@ import {
|
|
|
10
10
|
containerNameCandidates,
|
|
11
11
|
sandboxBranchLabel,
|
|
12
12
|
sandboxLabel,
|
|
13
|
+
sandboxTaskIdLabel,
|
|
14
|
+
sandboxWorkspaceModeLabel,
|
|
13
15
|
shareBranchDir,
|
|
14
16
|
shellConfigDirCandidates,
|
|
15
17
|
worktreeDirCandidates
|
|
@@ -18,27 +20,37 @@ import { ENGINES, detectEngine, engineDisplayName, isManagedEngine, stopManagedV
|
|
|
18
20
|
import { pruneSandboxDanglingImages } from '../image-prune.ts';
|
|
19
21
|
import { assertManagedPath, removeManagedDir, removeWorktreeDir } from '../managed-fs.ts';
|
|
20
22
|
import { runEngine, runOk, runOkEngine, runSafe, runSafeEngine } from '../shell.ts';
|
|
21
|
-
import {
|
|
23
|
+
import {
|
|
24
|
+
parseSandboxWorkspaceIdentity,
|
|
25
|
+
resolveSandboxCleanupTarget,
|
|
26
|
+
sameSandboxWorkspaceIdentity,
|
|
27
|
+
type SandboxCleanupTarget,
|
|
28
|
+
type SandboxWorkspaceKey
|
|
29
|
+
} from '../workspace-identity.ts';
|
|
22
30
|
import { sandboxControlPaths, sandboxWorkspaceViewPaths } from '../workspace-view.ts';
|
|
23
31
|
import { removeSandboxControlRoot, readSandboxControlManifest } from '../control/lifecycle.ts';
|
|
24
32
|
import { inspectSandboxControlContainer } from '../control/container-identity.ts';
|
|
25
33
|
import { toolConfigDirCandidates, toolProjectDirCandidates } from '../tools.ts';
|
|
26
34
|
import { createSandboxCapabilityPlan } from '../agent-client-reconciler.ts';
|
|
27
35
|
import type { SandboxTool } from '../tools.ts';
|
|
28
|
-
import { fetchSandboxRows } from './list-running.ts';
|
|
29
|
-
import { lookupShortIdByBranch } from '../../task/short-id.ts';
|
|
36
|
+
import { fetchSandboxRows, type SandboxRow } from './list-running.ts';
|
|
30
37
|
import {
|
|
31
38
|
createCleanPermit,
|
|
32
39
|
createDiscardPermit,
|
|
33
40
|
formatWorktreeSnapshot,
|
|
41
|
+
inspectRecoveredWorktree,
|
|
34
42
|
inspectWorktrees,
|
|
35
43
|
verifyWorktreePermit
|
|
36
44
|
} from '../worktree-safety.ts';
|
|
37
|
-
import type {
|
|
45
|
+
import type {
|
|
46
|
+
WorktreeInspection,
|
|
47
|
+
WorktreeRecoveryContext,
|
|
48
|
+
WorktreeRemovalPermit
|
|
49
|
+
} from '../worktree-safety.ts';
|
|
38
50
|
|
|
39
51
|
const USAGE = `Usage:
|
|
40
|
-
ai sandbox rm <branch>
|
|
41
|
-
ai sandbox rm --unbound [--dry-run] [--yes] Remove
|
|
52
|
+
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
|
|
53
|
+
ai sandbox rm --unbound [--dry-run] [--yes] Remove completed task-bound and branch-only sandboxes; active, blocked, and archive tasks are protected
|
|
42
54
|
ai sandbox rm --purge Tear down ALL sandboxes for the project (containers, worktrees, image, VM)`;
|
|
43
55
|
export { assertManagedPath } from '../managed-fs.ts';
|
|
44
56
|
|
|
@@ -46,6 +58,30 @@ function projectToolDirs(config: SandboxConfig, tools: SandboxTool[]): string[]
|
|
|
46
58
|
return tools.flatMap((tool) => toolProjectDirCandidates(tool, config.project));
|
|
47
59
|
}
|
|
48
60
|
|
|
61
|
+
function isMissingPathError(error: unknown): boolean {
|
|
62
|
+
return Boolean(error && typeof error === 'object' && 'code' in error
|
|
63
|
+
&& (error as { code?: unknown }).code === 'ENOENT');
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function sandboxManagedPathKey(
|
|
67
|
+
candidate: string,
|
|
68
|
+
platform: NodeJS.Platform = process.platform,
|
|
69
|
+
resolveExistingPath: (resolved: string) => string = (resolved) => fs.realpathSync.native(resolved)
|
|
70
|
+
): string {
|
|
71
|
+
const resolved = path.resolve(candidate);
|
|
72
|
+
let identity = resolved;
|
|
73
|
+
try {
|
|
74
|
+
identity = resolveExistingPath(resolved);
|
|
75
|
+
} catch (error) {
|
|
76
|
+
if (!isMissingPathError(error)) {
|
|
77
|
+
throw new Error(
|
|
78
|
+
`SANDBOX_CLEANUP_BATCH_PREFLIGHT_FAILED: unable to determine managed path identity for '${resolved}'`
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
return platform === 'win32' ? identity.toLowerCase() : identity;
|
|
83
|
+
}
|
|
84
|
+
|
|
49
85
|
type RmTarget = {
|
|
50
86
|
branch: string;
|
|
51
87
|
effectiveBranch: string;
|
|
@@ -53,15 +89,29 @@ type RmTarget = {
|
|
|
53
89
|
matchedContainers: string[];
|
|
54
90
|
existingWorktrees: string[];
|
|
55
91
|
toolCandidates: Array<{ tool: SandboxTool; candidates: string[] }>;
|
|
56
|
-
|
|
92
|
+
managedPathCandidates?: string[];
|
|
93
|
+
workspace: SandboxWorkspaceKey;
|
|
57
94
|
controlRoots: string[];
|
|
58
95
|
workspaceViewRoots: string[];
|
|
59
96
|
};
|
|
60
97
|
|
|
98
|
+
type CleanupCandidate = Readonly<{
|
|
99
|
+
row: SandboxRow;
|
|
100
|
+
cleanupTarget: SandboxCleanupTarget;
|
|
101
|
+
}>;
|
|
102
|
+
|
|
103
|
+
type CleanupGroup = Readonly<{
|
|
104
|
+
candidates: readonly CleanupCandidate[];
|
|
105
|
+
cleanupTarget: SandboxCleanupTarget;
|
|
106
|
+
target: RmTarget;
|
|
107
|
+
}>;
|
|
108
|
+
|
|
61
109
|
type RmOneOptions = {
|
|
62
110
|
assumeYes?: boolean;
|
|
111
|
+
interactive?: boolean;
|
|
63
112
|
quiet?: boolean;
|
|
64
113
|
target?: RmTarget;
|
|
114
|
+
cleanupTarget?: SandboxCleanupTarget;
|
|
65
115
|
permits?: ReadonlyMap<string, WorktreeRemovalPermit>;
|
|
66
116
|
allowDirtyDiscard?: boolean;
|
|
67
117
|
prompt?: PromptDependencies;
|
|
@@ -72,46 +122,81 @@ type PromptDependencies = {
|
|
|
72
122
|
isCancel?: typeof p.isCancel;
|
|
73
123
|
};
|
|
74
124
|
|
|
75
|
-
function
|
|
76
|
-
|
|
125
|
+
function recoveryContexts(
|
|
126
|
+
config: SandboxConfig,
|
|
127
|
+
target: RmTarget,
|
|
128
|
+
worktrees: readonly string[]
|
|
129
|
+
): Map<string, WorktreeRecoveryContext> {
|
|
130
|
+
const candidates = worktreeDirCandidates(config, target.effectiveBranch).map((candidate) => path.resolve(candidate));
|
|
131
|
+
const existingCandidates = candidates.filter((candidate) => fs.existsSync(candidate));
|
|
132
|
+
if (existingCandidates.length !== 1) return new Map();
|
|
133
|
+
|
|
134
|
+
const contexts = new Map<string, WorktreeRecoveryContext>();
|
|
135
|
+
for (const worktree of worktrees) {
|
|
136
|
+
const resolvedWorktree = path.resolve(worktree);
|
|
137
|
+
if (resolvedWorktree !== existingCandidates[0]) continue;
|
|
138
|
+
for (const root of target.controlRoots.filter((candidate) => fs.existsSync(candidate))) {
|
|
139
|
+
assertLegacyCandidateEvidence(root, config.controlBase, config, target.effectiveBranch, target.matchedContainers);
|
|
140
|
+
assertControlRootMatchesTarget(root, target.effectiveBranch, target.workspace);
|
|
141
|
+
}
|
|
142
|
+
contexts.set(resolvedWorktree, {
|
|
143
|
+
repoRoot: config.repoRoot,
|
|
144
|
+
worktreeBase: config.worktreeBase,
|
|
145
|
+
branch: target.effectiveBranch,
|
|
146
|
+
identitySource: target.workspace.mode,
|
|
147
|
+
taskId: target.workspace.mode === 'task-bound' ? target.workspace.taskId : null
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
return contexts;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function resolveRmTarget(
|
|
154
|
+
config: SandboxConfig,
|
|
155
|
+
tools: SandboxTool[],
|
|
156
|
+
cleanupTarget: SandboxCleanupTarget,
|
|
157
|
+
options: Readonly<{ discoveredContainers?: readonly string[] }> = {}
|
|
158
|
+
): RmTarget {
|
|
159
|
+
assertValidBranchName(cleanupTarget.branch);
|
|
77
160
|
const engine = detectEngine(config);
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
161
|
+
const branch = cleanupTarget.branch;
|
|
162
|
+
const effectiveBranch = branch;
|
|
163
|
+
const worktreeCandidates = worktreeDirCandidates(config, branch);
|
|
164
|
+
const toolCandidates = tools.map((tool) => ({
|
|
81
165
|
tool,
|
|
82
166
|
candidates: toolConfigDirCandidates(tool, config.project, branch)
|
|
83
167
|
}));
|
|
168
|
+
const shellCandidates = shellConfigDirCandidates(config, branch);
|
|
84
169
|
const existing = runEngine(engine, 'docker', ['ps', '-a', '--format', '{{.Names}}']).split('\n').filter(Boolean);
|
|
85
|
-
const matchedContainers =
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
}
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
const workspace = resolveSandboxTarget(effectiveBranch, config.repoRoot).workspace;
|
|
105
|
-
const identities: SandboxWorkspaceIdentity[] = workspace.mode === 'task-bound'
|
|
106
|
-
? [workspace, { mode: 'branch-only' }]
|
|
107
|
-
: [workspace];
|
|
108
|
-
const containers = [...new Set([...containerNameCandidates(config, effectiveBranch), ...matchedContainers])];
|
|
170
|
+
const matchedContainers = options.discoveredContainers
|
|
171
|
+
? options.discoveredContainers.map((container) => {
|
|
172
|
+
if (!existing.includes(container)) {
|
|
173
|
+
throw new Error(
|
|
174
|
+
`SANDBOX_CLEANUP_BATCH_PREFLIGHT_FAILED: discovered container '${container}' is no longer present`
|
|
175
|
+
);
|
|
176
|
+
}
|
|
177
|
+
return container;
|
|
178
|
+
})
|
|
179
|
+
: containerNameCandidates(config, branch).filter((name) => existing.includes(name));
|
|
180
|
+
|
|
181
|
+
const workspace = cleanupTarget.workspace;
|
|
182
|
+
const identities: SandboxWorkspaceKey[] = [workspace];
|
|
183
|
+
const containers = options.discoveredContainers
|
|
184
|
+
? matchedContainers
|
|
185
|
+
: [...new Set([...containerNameCandidates(config, effectiveBranch), ...matchedContainers])];
|
|
109
186
|
const controlRoots = containers.flatMap((container) => identities.map((identity) => sandboxControlPaths({
|
|
110
187
|
base: config.controlBase, project: config.project, container, identity
|
|
111
188
|
}).root));
|
|
112
189
|
const workspaceViewRoots = containers.flatMap((container) => identities.map((identity) => sandboxWorkspaceViewPaths({
|
|
113
190
|
base: config.workspaceViewBase, project: config.project, container, identity
|
|
114
191
|
}).root));
|
|
192
|
+
const managedPathCandidates = [...new Set([
|
|
193
|
+
...worktreeCandidates,
|
|
194
|
+
...toolCandidates.flatMap(({ candidates }) => candidates),
|
|
195
|
+
...shellCandidates,
|
|
196
|
+
shareBranchDir(config, branch),
|
|
197
|
+
...controlRoots,
|
|
198
|
+
...workspaceViewRoots
|
|
199
|
+
].map((candidate) => path.resolve(candidate)))];
|
|
115
200
|
|
|
116
201
|
return {
|
|
117
202
|
branch,
|
|
@@ -120,25 +205,184 @@ function resolveRmTarget(config: SandboxConfig, tools: SandboxTool[], branch: st
|
|
|
120
205
|
matchedContainers,
|
|
121
206
|
existingWorktrees: worktreeCandidates.filter((candidate) => fs.existsSync(candidate)),
|
|
122
207
|
toolCandidates,
|
|
208
|
+
managedPathCandidates,
|
|
123
209
|
workspace,
|
|
124
210
|
controlRoots: [...new Set(controlRoots)],
|
|
125
211
|
workspaceViewRoots: [...new Set(workspaceViewRoots)]
|
|
126
212
|
};
|
|
127
213
|
}
|
|
128
214
|
|
|
129
|
-
|
|
215
|
+
type CurrentContainerIdentity = Readonly<{ id: string; labels: Record<string, string> }>;
|
|
216
|
+
|
|
217
|
+
function inspectNamedContainerIdentity(target: RmTarget, container: string): CurrentContainerIdentity {
|
|
218
|
+
const raw = runSafeEngine(target.engine, 'docker', [
|
|
219
|
+
'inspect', '--format', '{{json .}}', container
|
|
220
|
+
]).trim();
|
|
221
|
+
let parsed: unknown;
|
|
222
|
+
try {
|
|
223
|
+
parsed = JSON.parse(raw);
|
|
224
|
+
} catch {
|
|
225
|
+
throw new Error(`SANDBOX_WORKSPACE_IDENTITY_UNKNOWN: container '${container}' inspection is invalid`);
|
|
226
|
+
}
|
|
227
|
+
const value = Array.isArray(parsed) && parsed.length === 1 ? parsed[0] : parsed;
|
|
228
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
|
229
|
+
throw new Error(`SANDBOX_WORKSPACE_IDENTITY_UNKNOWN: container '${container}' inspection is incomplete`);
|
|
230
|
+
}
|
|
231
|
+
const candidate = value as {
|
|
232
|
+
Id?: unknown;
|
|
233
|
+
Config?: { Labels?: unknown };
|
|
234
|
+
};
|
|
235
|
+
if (typeof candidate.Id !== 'string' || !candidate.Id
|
|
236
|
+
|| !candidate.Config?.Labels || typeof candidate.Config.Labels !== 'object'
|
|
237
|
+
|| Array.isArray(candidate.Config.Labels)
|
|
238
|
+
|| Object.values(candidate.Config.Labels).some((value) => typeof value !== 'string')) {
|
|
239
|
+
throw new Error(`SANDBOX_WORKSPACE_IDENTITY_UNKNOWN: container '${container}' inspection is incomplete`);
|
|
240
|
+
}
|
|
241
|
+
return { id: candidate.Id, labels: candidate.Config.Labels as Record<string, string> };
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function assertMatchedContainerIdentities(config: SandboxConfig, target: RmTarget): Map<string, CurrentContainerIdentity> {
|
|
245
|
+
const current = new Map<string, CurrentContainerIdentity>();
|
|
246
|
+
for (const container of target.matchedContainers) {
|
|
247
|
+
const observation = inspectNamedContainerIdentity(target, container);
|
|
248
|
+
const stringLabels = observation.labels;
|
|
249
|
+
const branch = stringLabels[sandboxBranchLabel(config)];
|
|
250
|
+
if (branch !== target.branch) {
|
|
251
|
+
throw new Error(
|
|
252
|
+
`SANDBOX_WORKSPACE_IDENTITY_CONFLICT: container '${container}' branch is ${JSON.stringify(branch ?? null)}, `
|
|
253
|
+
+ `but this request is ${JSON.stringify(target.branch)}`
|
|
254
|
+
);
|
|
255
|
+
}
|
|
256
|
+
const identity = parseSandboxWorkspaceIdentity(stringLabels, {
|
|
257
|
+
mode: sandboxWorkspaceModeLabel(config),
|
|
258
|
+
taskId: sandboxTaskIdLabel(config)
|
|
259
|
+
});
|
|
260
|
+
if (identity.mode === 'legacy-invalid' || !sameSandboxWorkspaceIdentity(identity, target.workspace)) {
|
|
261
|
+
const existingDescription = identity.mode === 'task-bound'
|
|
262
|
+
? `task-bound:${identity.taskId}`
|
|
263
|
+
: identity.mode;
|
|
264
|
+
const requestedDescription = target.workspace.mode === 'task-bound'
|
|
265
|
+
? `task-bound:${target.workspace.taskId}`
|
|
266
|
+
: target.workspace.mode;
|
|
267
|
+
throw new Error(
|
|
268
|
+
`SANDBOX_WORKSPACE_IDENTITY_CONFLICT: container '${container}' is ${existingDescription}, `
|
|
269
|
+
+ `but this request is ${requestedDescription}`
|
|
270
|
+
);
|
|
271
|
+
}
|
|
272
|
+
current.set(container, observation);
|
|
273
|
+
}
|
|
274
|
+
return current;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function controlRootContainer(config: SandboxConfig, root: string): string {
|
|
278
|
+
const relative = path.relative(path.join(config.controlBase, config.project), root);
|
|
279
|
+
const [container, identity, ...extra] = relative.split(path.sep);
|
|
280
|
+
if (!container || !identity || extra.length > 0) {
|
|
281
|
+
throw new Error(`SANDBOX_CONTROL_TARGET_EVIDENCE_INVALID: ${root}`);
|
|
282
|
+
}
|
|
283
|
+
return container;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
function assertControlRootMatchesTarget(root: string, effectiveBranch: string, workspace: SandboxWorkspaceKey): void {
|
|
130
287
|
const manifestPath = path.join(root, 'manifest.json');
|
|
131
|
-
if (!fs.existsSync(manifestPath))
|
|
288
|
+
if (!fs.existsSync(manifestPath)) {
|
|
289
|
+
if (workspace.mode === 'task-bound') {
|
|
290
|
+
throw new Error(`SANDBOX_CONTROL_TARGET_EVIDENCE_MISSING: ${root}`);
|
|
291
|
+
}
|
|
292
|
+
return;
|
|
293
|
+
}
|
|
132
294
|
const manifest = readSandboxControlManifest(manifestPath);
|
|
133
295
|
const identityMatches = workspace.mode === 'task-bound'
|
|
134
296
|
? manifest.mode === 'task-bound' && manifest.taskId === workspace.taskId
|
|
135
297
|
: manifest.mode === 'branch-only';
|
|
136
|
-
|
|
137
|
-
if (manifest.branch !== effectiveBranch || (!identityMatches && !branchOnlyFallback)) {
|
|
298
|
+
if (manifest.branch !== effectiveBranch || !identityMatches) {
|
|
138
299
|
throw new Error(`SANDBOX_CONTROL_TARGET_MISMATCH: ${root}`);
|
|
139
300
|
}
|
|
140
301
|
}
|
|
141
302
|
|
|
303
|
+
function preflightRmTarget(
|
|
304
|
+
config: SandboxConfig,
|
|
305
|
+
target: RmTarget
|
|
306
|
+
): void {
|
|
307
|
+
const current = assertMatchedContainerIdentities(config, target);
|
|
308
|
+
const manifests: ReturnType<typeof readSandboxControlManifest>[] = [];
|
|
309
|
+
for (const root of target.controlRoots.filter((candidate) => fs.existsSync(candidate))) {
|
|
310
|
+
assertLegacyCandidateEvidence(root, config.controlBase, config, target.effectiveBranch, target.matchedContainers);
|
|
311
|
+
assertControlRootMatchesTarget(root, target.effectiveBranch, target.workspace);
|
|
312
|
+
const manifestPath = path.join(root, 'manifest.json');
|
|
313
|
+
if (!fs.existsSync(manifestPath)) continue;
|
|
314
|
+
const manifest = readSandboxControlManifest(manifestPath);
|
|
315
|
+
const rootContainer = controlRootContainer(config, root);
|
|
316
|
+
if (manifest.container !== rootContainer) {
|
|
317
|
+
throw new Error(`SANDBOX_CONTROL_TARGET_MISMATCH: ${root}`);
|
|
318
|
+
}
|
|
319
|
+
const observed = current.get(manifest.container);
|
|
320
|
+
if (observed && (observed.id !== manifest.containerIdentity.id
|
|
321
|
+
|| Object.entries(manifest.containerIdentity.labels).some(([key, value]) => observed.labels[key] !== value))) {
|
|
322
|
+
throw new Error(`SANDBOX_CONTROL_TARGET_MISMATCH: ${root}`);
|
|
323
|
+
}
|
|
324
|
+
manifests.push(manifest);
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
if (target.workspace.mode !== 'task-bound') return;
|
|
328
|
+
const matched = new Set(target.matchedContainers);
|
|
329
|
+
const manifestContainers = manifests.map((manifest) => manifest.container);
|
|
330
|
+
if (matched.size > 0 && manifestContainers.some((container) => !matched.has(container))) {
|
|
331
|
+
throw new Error('SANDBOX_CONTROL_TARGET_MISMATCH: manifest container is outside the cleanup target');
|
|
332
|
+
}
|
|
333
|
+
for (const container of matched) {
|
|
334
|
+
const matches = manifests.filter((manifest) => manifest.container === container);
|
|
335
|
+
if (matches.length !== 1) {
|
|
336
|
+
throw new Error(`SANDBOX_CONTROL_TARGET_EVIDENCE_MISSING: container '${container}'`);
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
if (matched.size === 0 && manifests.length > 0) {
|
|
340
|
+
const supported = new Set(containerNameCandidates(config, target.effectiveBranch));
|
|
341
|
+
if (manifests.some((manifest) => !supported.has(manifest.container))) {
|
|
342
|
+
throw new Error('SANDBOX_CONTROL_TARGET_MISMATCH: manifest container is outside the cleanup target');
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
function assertCleanupGroupPathsDoNotOverlap(groups: readonly CleanupGroup[]): void {
|
|
348
|
+
const owners = new Map<string, string>();
|
|
349
|
+
for (const group of groups) {
|
|
350
|
+
for (const candidate of group.target.managedPathCandidates ?? []) {
|
|
351
|
+
const key = sandboxManagedPathKey(candidate);
|
|
352
|
+
const existingBranch = owners.get(key);
|
|
353
|
+
if (existingBranch && existingBranch !== group.cleanupTarget.branch) {
|
|
354
|
+
throw new Error(
|
|
355
|
+
`SANDBOX_CLEANUP_BATCH_PREFLIGHT_FAILED: managed path '${candidate}' is shared by branches `
|
|
356
|
+
+ `${JSON.stringify(existingBranch)} and ${JSON.stringify(group.cleanupTarget.branch)}`
|
|
357
|
+
);
|
|
358
|
+
}
|
|
359
|
+
owners.set(key, group.cleanupTarget.branch);
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
function assertWorktreeBranchesMatchGroups(
|
|
365
|
+
groups: readonly CleanupGroup[],
|
|
366
|
+
inspections: readonly WorktreeInspection[]
|
|
367
|
+
): void {
|
|
368
|
+
const byPath = new Map(
|
|
369
|
+
inspections.flatMap((inspection) => inspection.status === 'failed'
|
|
370
|
+
? []
|
|
371
|
+
: [[sandboxManagedPathKey(inspection.snapshot.worktree), inspection.snapshot.branch] as const])
|
|
372
|
+
);
|
|
373
|
+
for (const group of groups) {
|
|
374
|
+
for (const worktree of group.target.existingWorktrees) {
|
|
375
|
+
const registeredBranch = byPath.get(sandboxManagedPathKey(worktree));
|
|
376
|
+
if (registeredBranch && registeredBranch !== group.cleanupTarget.branch) {
|
|
377
|
+
throw new Error(
|
|
378
|
+
`SANDBOX_CLEANUP_BATCH_PREFLIGHT_FAILED: worktree '${worktree}' is registered to branch `
|
|
379
|
+
+ `${JSON.stringify(registeredBranch)}, but cleanup targets ${JSON.stringify(group.cleanupTarget.branch)}`
|
|
380
|
+
);
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
|
|
142
386
|
function assertLegacyCandidateEvidence(
|
|
143
387
|
candidate: string,
|
|
144
388
|
base: string,
|
|
@@ -184,7 +428,12 @@ async function removeExactSandboxContainer(
|
|
|
184
428
|
const before = await inspect();
|
|
185
429
|
if (before.state === 'unknown') throw new Error(`SANDBOX_CONTROL_CONTAINER_UNKNOWN: ${before.reason}`);
|
|
186
430
|
if (before.state === 'absent') return;
|
|
187
|
-
if (before.running && !runOkEngine(
|
|
431
|
+
if (before.running && !runOkEngine(
|
|
432
|
+
engine,
|
|
433
|
+
'docker',
|
|
434
|
+
['stop', '--timeout', '1', manifest.containerIdentity.id],
|
|
435
|
+
{ timeout: remaining() }
|
|
436
|
+
)) {
|
|
188
437
|
const afterStop = await inspect();
|
|
189
438
|
if (afterStop.state !== 'absent' && afterStop.state !== 'found') {
|
|
190
439
|
throw new Error(`SANDBOX_CONTROL_CONTAINER_UNKNOWN: ${afterStop.reason}`);
|
|
@@ -254,10 +503,17 @@ async function authorizeWorktrees(
|
|
|
254
503
|
{
|
|
255
504
|
interactive = Boolean(process.stdin.isTTY),
|
|
256
505
|
confirm = p.confirm,
|
|
257
|
-
isCancel = p.isCancel
|
|
258
|
-
|
|
506
|
+
isCancel = p.isCancel,
|
|
507
|
+
recovery = new Map<string, WorktreeRecoveryContext>()
|
|
508
|
+
}: PromptDependencies & { interactive?: boolean; recovery?: ReadonlyMap<string, WorktreeRecoveryContext> } = {}
|
|
259
509
|
): Promise<Map<string, WorktreeRemovalPermit>> {
|
|
260
|
-
const inspections =
|
|
510
|
+
const inspections = worktrees.map((worktree) => {
|
|
511
|
+
const inspection = inspectWorktrees([worktree])[0]!;
|
|
512
|
+
const context = recovery.get(path.resolve(worktree));
|
|
513
|
+
return inspection.status === 'failed' && context
|
|
514
|
+
? inspectRecoveredWorktree(worktree, context)
|
|
515
|
+
: inspection;
|
|
516
|
+
});
|
|
261
517
|
const failures = inspections.filter((inspection) => inspection.status === 'failed');
|
|
262
518
|
if (failures.length > 0) throw new Error(`Unable to inspect worktree(s):\n${blockerMessage(failures)}`);
|
|
263
519
|
const permits = cleanPermits(inspections);
|
|
@@ -283,9 +539,14 @@ async function rmOne(
|
|
|
283
539
|
branch: string,
|
|
284
540
|
options: RmOneOptions = {}
|
|
285
541
|
): Promise<void> {
|
|
286
|
-
const target = options.target ?? resolveRmTarget(
|
|
542
|
+
const target = options.target ?? resolveRmTarget(
|
|
543
|
+
config,
|
|
544
|
+
tools,
|
|
545
|
+
options.cleanupTarget ?? resolveSandboxCleanupTarget(branch, config.repoRoot)
|
|
546
|
+
);
|
|
287
547
|
const { effectiveBranch, engine, matchedContainers, existingWorktrees, toolCandidates } = target;
|
|
288
548
|
const { workspace, controlRoots, workspaceViewRoots } = target;
|
|
549
|
+
preflightRmTarget(config, target);
|
|
289
550
|
const confirm = options.prompt?.confirm ?? p.confirm;
|
|
290
551
|
const isCancel = options.prompt?.isCancel ?? p.isCancel;
|
|
291
552
|
|
|
@@ -293,12 +554,19 @@ async function rmOne(
|
|
|
293
554
|
p.intro(pc.cyan(`Removing sandbox for ${branch}`));
|
|
294
555
|
}
|
|
295
556
|
|
|
557
|
+
const recovery = options.permits
|
|
558
|
+
? new Map<string, WorktreeRecoveryContext>()
|
|
559
|
+
: recoveryContexts(config, target, existingWorktrees);
|
|
296
560
|
const permits = options.permits
|
|
297
561
|
? new Map(options.permits)
|
|
298
562
|
: await authorizeWorktrees(existingWorktrees, {
|
|
299
563
|
allowDirtyDiscard: options.allowDirtyDiscard ?? true,
|
|
300
564
|
assumeYes: Boolean(options.assumeYes)
|
|
301
|
-
},
|
|
565
|
+
}, {
|
|
566
|
+
...options.prompt,
|
|
567
|
+
interactive: options.interactive ?? Boolean(process.stdin.isTTY),
|
|
568
|
+
recovery
|
|
569
|
+
});
|
|
302
570
|
for (const worktree of existingWorktrees) {
|
|
303
571
|
const permit = permits.get(path.resolve(worktree));
|
|
304
572
|
if (!permit) throw new Error(`Missing worktree removal permit: ${worktree}`);
|
|
@@ -318,20 +586,27 @@ async function rmOne(
|
|
|
318
586
|
return;
|
|
319
587
|
}
|
|
320
588
|
|
|
589
|
+
preflightRmTarget(config, target);
|
|
321
590
|
const coordinatedContainers = new Set<string>();
|
|
322
591
|
for (const root of controlRoots.filter((candidate) => fs.existsSync(candidate))) {
|
|
323
592
|
assertLegacyCandidateEvidence(root, config.controlBase, config, effectiveBranch, matchedContainers);
|
|
324
593
|
assertControlRootMatchesTarget(root, effectiveBranch, workspace);
|
|
325
|
-
const
|
|
594
|
+
const manifestPath = path.join(root, 'manifest.json');
|
|
595
|
+
if (!fs.existsSync(manifestPath)) continue;
|
|
596
|
+
const manifest = readSandboxControlManifest(manifestPath);
|
|
326
597
|
coordinatedContainers.add(manifest.container);
|
|
327
598
|
await removeSandboxControlRoot(root, {
|
|
328
599
|
inspectContainer: (timeoutMs) => inspectSandboxControlContainer(manifest, { timeoutMs }),
|
|
329
600
|
removeContainer: (timeoutMs) => removeExactSandboxContainer(engine, manifest, timeoutMs)
|
|
330
601
|
});
|
|
602
|
+
removeEmptyManagedParent(path.join(config.controlBase, config.project), root);
|
|
331
603
|
}
|
|
332
604
|
|
|
333
605
|
const uncoordinatedContainers = matchedContainers.filter((name) => !coordinatedContainers.has(name));
|
|
334
606
|
if (uncoordinatedContainers.length > 0) {
|
|
607
|
+
if (workspace.mode === 'task-bound') {
|
|
608
|
+
throw new Error(`SANDBOX_CONTROL_TARGET_EVIDENCE_MISSING: container '${uncoordinatedContainers[0]}'`);
|
|
609
|
+
}
|
|
335
610
|
const spinner = p.spinner();
|
|
336
611
|
spinner.start(`Stopping container(s): ${uncoordinatedContainers.join(', ')}`);
|
|
337
612
|
for (const name of uncoordinatedContainers) {
|
|
@@ -583,24 +858,98 @@ async function rmUnbound(
|
|
|
583
858
|
options: { dryRun: boolean; assumeYes: boolean }
|
|
584
859
|
): Promise<void> {
|
|
585
860
|
const engine = detectEngine(config);
|
|
586
|
-
const { running, nonRunning } = fetchSandboxRows(
|
|
587
|
-
|
|
588
|
-
(
|
|
861
|
+
const { running, nonRunning } = fetchSandboxRows(
|
|
862
|
+
engine,
|
|
863
|
+
sandboxLabel(config),
|
|
864
|
+
sandboxBranchLabel(config),
|
|
865
|
+
{ mode: sandboxWorkspaceModeLabel(config), taskId: sandboxTaskIdLabel(config) }
|
|
589
866
|
);
|
|
867
|
+
const rows = [...running, ...nonRunning];
|
|
590
868
|
|
|
591
869
|
p.intro(pc.cyan(`Removing sandboxes not bound to an active task for ${config.project}`));
|
|
592
870
|
|
|
593
|
-
if (
|
|
871
|
+
if (rows.length === 0) {
|
|
594
872
|
p.outro('No removable sandboxes: every container is bound to an active task (or none exist)');
|
|
595
873
|
return;
|
|
596
874
|
}
|
|
597
875
|
|
|
598
|
-
const
|
|
599
|
-
|
|
876
|
+
const candidates: CleanupCandidate[] = rows.map((row) => {
|
|
877
|
+
if (!row.branch || !row.workspaceMode || row.workspaceMode === 'legacy-invalid') {
|
|
878
|
+
throw new Error(`SANDBOX_CLEANUP_BATCH_PREFLIGHT_FAILED: container '${row.name}' has incomplete workspace identity`);
|
|
879
|
+
}
|
|
880
|
+
if (row.workspaceMode === 'task-bound') {
|
|
881
|
+
if (!row.taskId) {
|
|
882
|
+
throw new Error(`SANDBOX_CLEANUP_BATCH_PREFLIGHT_FAILED: container '${row.name}' has no task id`);
|
|
883
|
+
}
|
|
884
|
+
const cleanupTarget = resolveSandboxCleanupTarget(row.taskId, config.repoRoot, { allowProtected: true });
|
|
885
|
+
if (cleanupTarget.branch !== row.branch
|
|
886
|
+
|| cleanupTarget.workspace.mode !== 'task-bound'
|
|
887
|
+
|| cleanupTarget.workspace.taskId !== row.taskId) {
|
|
888
|
+
throw new Error(`SANDBOX_CLEANUP_BATCH_PREFLIGHT_FAILED: container '${row.name}' task identity conflicts with task.md`);
|
|
889
|
+
}
|
|
890
|
+
return { row, cleanupTarget };
|
|
891
|
+
}
|
|
892
|
+
return {
|
|
893
|
+
row,
|
|
894
|
+
cleanupTarget: {
|
|
895
|
+
requestedRef: row.branch,
|
|
896
|
+
branch: row.branch,
|
|
897
|
+
workspace: { mode: 'branch-only' },
|
|
898
|
+
taskState: 'branch-only'
|
|
899
|
+
} satisfies SandboxCleanupTarget
|
|
900
|
+
};
|
|
901
|
+
});
|
|
902
|
+
const branchIdentities = new Map<string, string>();
|
|
903
|
+
const groupedCandidates = new Map<string, CleanupCandidate[]>();
|
|
904
|
+
for (const candidate of candidates) {
|
|
905
|
+
const identity = candidate.cleanupTarget.workspace.mode === 'task-bound'
|
|
906
|
+
? `task-bound:${candidate.cleanupTarget.workspace.taskId}`
|
|
907
|
+
: 'branch-only';
|
|
908
|
+
const existingIdentity = branchIdentities.get(candidate.cleanupTarget.branch);
|
|
909
|
+
if (existingIdentity && existingIdentity !== identity) {
|
|
910
|
+
throw new Error(
|
|
911
|
+
`SANDBOX_CLEANUP_BATCH_PREFLIGHT_FAILED: branch '${candidate.cleanupTarget.branch}' has conflicting workspace identities`
|
|
912
|
+
);
|
|
913
|
+
}
|
|
914
|
+
branchIdentities.set(candidate.cleanupTarget.branch, identity);
|
|
915
|
+
const groupKey = `${candidate.cleanupTarget.branch}\0${identity}`;
|
|
916
|
+
const group = groupedCandidates.get(groupKey) ?? [];
|
|
917
|
+
group.push(candidate);
|
|
918
|
+
groupedCandidates.set(groupKey, group);
|
|
919
|
+
}
|
|
920
|
+
const groups: CleanupGroup[] = [...groupedCandidates.values()].map((groupCandidates) => {
|
|
921
|
+
const cleanupTarget = groupCandidates[0]!.cleanupTarget;
|
|
922
|
+
const target = resolveRmTarget(config, tools, cleanupTarget, {
|
|
923
|
+
discoveredContainers: groupCandidates.map(({ row }) => row.name)
|
|
924
|
+
});
|
|
925
|
+
preflightRmTarget(config, target);
|
|
926
|
+
return { candidates: groupCandidates, cleanupTarget, target };
|
|
927
|
+
});
|
|
928
|
+
assertCleanupGroupPathsDoNotOverlap(groups);
|
|
929
|
+
|
|
930
|
+
const removableGroups = groups.filter(({ cleanupTarget }) =>
|
|
931
|
+
cleanupTarget.taskState === 'completed' || cleanupTarget.taskState === 'branch-only'
|
|
932
|
+
);
|
|
933
|
+
const removable = removableGroups.flatMap(({ candidates: groupCandidates }) => groupCandidates);
|
|
934
|
+
for (const { candidates: groupCandidates, cleanupTarget } of groups.filter(({ cleanupTarget }) =>
|
|
935
|
+
cleanupTarget.taskState !== 'completed' && cleanupTarget.taskState !== 'branch-only'
|
|
936
|
+
)) {
|
|
937
|
+
for (const { row } of groupCandidates) {
|
|
938
|
+
p.log.message(`Skipped protected sandbox ${row.name} (${cleanupTarget.taskState})`);
|
|
939
|
+
}
|
|
940
|
+
}
|
|
941
|
+
|
|
942
|
+
if (removableGroups.length === 0) {
|
|
943
|
+
p.outro('No removable sandboxes: every container is bound to a protected task (or none exist)');
|
|
944
|
+
return;
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
const inspections = inspectWorktrees(removableGroups.flatMap(({ target }) => target.existingWorktrees));
|
|
948
|
+
assertWorktreeBranchesMatchGroups(removableGroups, inspections);
|
|
600
949
|
const blockers = inspectionBlockers(inspections);
|
|
601
950
|
const permits = cleanPermits(inspections);
|
|
602
951
|
|
|
603
|
-
for (const row of removable) {
|
|
952
|
+
for (const { row } of removable) {
|
|
604
953
|
p.log.message(`${row.name} ${row.branch}`);
|
|
605
954
|
}
|
|
606
955
|
if (blockers.length > 0) p.log.error(blockerMessage(blockers));
|
|
@@ -621,17 +970,20 @@ async function rmUnbound(
|
|
|
621
970
|
}
|
|
622
971
|
|
|
623
972
|
const failures: { branch: string; message: string }[] = [];
|
|
624
|
-
|
|
973
|
+
let failedRows = 0;
|
|
974
|
+
for (const group of removableGroups) {
|
|
625
975
|
try {
|
|
626
|
-
await rmOne(config, tools,
|
|
976
|
+
await rmOne(config, tools, group.cleanupTarget.branch, {
|
|
627
977
|
assumeYes: options.assumeYes,
|
|
628
978
|
quiet: true,
|
|
629
|
-
target:
|
|
979
|
+
target: group.target,
|
|
980
|
+
cleanupTarget: group.cleanupTarget,
|
|
630
981
|
permits,
|
|
631
982
|
allowDirtyDiscard: false
|
|
632
983
|
});
|
|
633
984
|
} catch (error) {
|
|
634
|
-
|
|
985
|
+
failedRows += group.candidates.length;
|
|
986
|
+
failures.push({ branch: group.cleanupTarget.branch, message: error instanceof Error ? error.message : String(error) });
|
|
635
987
|
}
|
|
636
988
|
}
|
|
637
989
|
|
|
@@ -640,7 +992,7 @@ async function rmUnbound(
|
|
|
640
992
|
p.log.error(`Failed to remove '${failure.branch}': ${failure.message}`);
|
|
641
993
|
}
|
|
642
994
|
throw new Error(
|
|
643
|
-
`Removed ${removable.length -
|
|
995
|
+
`Removed ${removable.length - failedRows}/${removable.length} sandbox(es); ${failures.length} failed`
|
|
644
996
|
);
|
|
645
997
|
}
|
|
646
998
|
|
|
@@ -703,8 +1055,8 @@ export async function rm(args: string[]): Promise<void> {
|
|
|
703
1055
|
return;
|
|
704
1056
|
}
|
|
705
1057
|
|
|
706
|
-
const
|
|
707
|
-
await rmOne(config, tools,
|
|
1058
|
+
const cleanupTarget = resolveSandboxCleanupTarget(positionals[0] ?? '', config.repoRoot);
|
|
1059
|
+
await rmOne(config, tools, cleanupTarget.branch, { cleanupTarget });
|
|
708
1060
|
}
|
|
709
1061
|
|
|
710
1062
|
export { authorizeWorktrees, rmOne, rmPurge };
|