@fitlab-ai/agent-infra 0.9.15 → 0.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/lib/agent-clients/adapters/codex-lifecycle/sandbox-controller.js +6 -2
- package/dist/lib/agent-clients/adapters/codex-lifecycle/store.js +43 -3
- package/dist/lib/fs/durable-write.js +2 -0
- package/dist/lib/internal/cli-route-inventory.js +1 -1
- package/dist/lib/internal/manual-validation.js +17 -8
- package/dist/lib/internal/task-artifact.js +1 -1
- package/dist/lib/internal/task-lifecycle.js +2 -2
- package/dist/lib/internal/task-operation-registry.js +0 -1
- package/dist/lib/internal/task-review.js +4 -4
- package/dist/lib/platform/issue-comments.js +16 -10
- package/dist/lib/platform/issues.js +1 -1
- package/dist/lib/platform/pr-change-report.js +5 -4
- package/dist/lib/platform/pr-summary.js +64 -32
- package/dist/lib/platform/pull-requests.js +3 -3
- package/dist/lib/platform/task-identities.js +2 -9
- package/dist/lib/platform/verification-sync.js +6 -2
- package/dist/lib/pr-review/evidence-grading.js +4 -8
- package/dist/lib/sandbox/commands/rm.js +21 -2
- package/dist/lib/sandbox/control/server.js +29 -9
- package/dist/lib/sandbox/control/state.js +7 -1
- package/dist/lib/sandbox/control/workflow-executor.js +4 -14
- package/dist/lib/sandbox/index.js +1 -2
- package/dist/lib/sandbox/removal.js +116 -1011
- package/dist/lib/sandbox/workspace-identity.js +17 -1
- package/dist/lib/task/artifact-command.js +6 -28
- package/dist/lib/task/artifact-operations.js +16 -230
- package/dist/lib/task/artifact-recovery.js +430 -0
- package/dist/lib/task/artifact-repair-intent.js +73 -39
- package/dist/lib/task/commit-operation.js +31 -4
- package/dist/lib/task/control-authority.js +38 -7
- package/dist/lib/task/control-recovery.js +14 -2
- package/dist/lib/task/create-service.js +1 -8
- package/dist/lib/task/delegation-receipts.js +64 -3
- package/dist/lib/task/events.js +164 -22
- package/dist/lib/task/human-override.js +5 -8
- package/dist/lib/task/intermediate-cleanup.js +66 -8
- package/dist/lib/task/lifecycle-recovery.js +549 -0
- package/dist/lib/task/lifecycle.js +12 -3
- package/dist/lib/task/local-artifact-finalization.js +148 -123
- package/dist/lib/task/manual-validation-receipt.js +5 -1
- package/dist/lib/task/orchestration.js +52 -2
- package/dist/lib/task/pr-delivery-fact.js +12 -56
- package/dist/lib/task/recovery-warning.js +19 -0
- package/dist/lib/task/review-command.js +1 -0
- package/dist/lib/task/review-finalization.js +104 -77
- package/dist/lib/task/short-id.js +15 -7
- package/dist/lib/task/task-execution-lock.js +1 -1
- package/dist/lib/task/verification-engine.js +3 -14
- package/dist/lib/task/workflow-command.js +0 -1
- package/dist/lib/version.js +1 -11
- package/dist/lifecycle-build-manifest.json +3 -3
- package/lib/agent-clients/adapters/codex-lifecycle/sandbox-controller.ts +7 -2
- package/lib/agent-clients/adapters/codex-lifecycle/store.ts +55 -3
- package/lib/fs/durable-write.ts +1 -0
- package/lib/internal/cli-route-inventory.ts +1 -1
- package/lib/internal/manual-validation.ts +17 -8
- package/lib/internal/task-artifact.ts +1 -1
- package/lib/internal/task-lifecycle.ts +4 -3
- package/lib/internal/task-operation-registry.ts +0 -1
- package/lib/internal/task-review.ts +4 -4
- package/lib/platform/issue-comments.ts +18 -10
- package/lib/platform/issues.ts +1 -1
- package/lib/platform/pr-change-report.ts +7 -5
- package/lib/platform/pr-summary.ts +64 -32
- package/lib/platform/pull-requests.ts +3 -3
- package/lib/platform/task-identities.ts +3 -14
- package/lib/platform/verification-sync.ts +6 -2
- package/lib/pr-review/evidence-grading.ts +4 -9
- package/lib/sandbox/commands/rm.ts +34 -2
- package/lib/sandbox/control/protocol.ts +5 -0
- package/lib/sandbox/control/server.ts +30 -10
- package/lib/sandbox/control/state.ts +9 -1
- package/lib/sandbox/control/workflow-executor.ts +4 -14
- package/lib/sandbox/index.ts +1 -2
- package/lib/sandbox/removal.ts +126 -1128
- package/lib/sandbox/workspace-identity.ts +19 -3
- package/lib/task/artifact-command.ts +8 -27
- package/lib/task/artifact-operations.ts +21 -251
- package/lib/task/artifact-recovery.ts +519 -0
- package/lib/task/artifact-repair-intent.ts +99 -51
- package/lib/task/commit-operation.ts +35 -4
- package/lib/task/control-authority.ts +47 -14
- package/lib/task/control-recovery.ts +13 -1
- package/lib/task/create-service.ts +1 -10
- package/lib/task/delegation-receipts.ts +73 -2
- package/lib/task/events.ts +187 -23
- package/lib/task/human-override.ts +5 -8
- package/lib/task/intermediate-cleanup.ts +50 -12
- package/lib/task/lifecycle-recovery.ts +691 -0
- package/lib/task/lifecycle.ts +13 -4
- package/lib/task/local-artifact-finalization.ts +176 -132
- package/lib/task/manual-validation-receipt.ts +5 -1
- package/lib/task/orchestration.ts +65 -0
- package/lib/task/pr-delivery-fact.ts +15 -62
- package/lib/task/recovery-warning.ts +21 -0
- package/lib/task/review-command.ts +1 -0
- package/lib/task/review-finalization.ts +106 -87
- package/lib/task/short-id.ts +27 -5
- package/lib/task/task-execution-lock.ts +1 -1
- package/lib/task/verification-engine.ts +3 -17
- package/lib/task/verification-types.ts +0 -2
- package/lib/task/workflow-command.ts +0 -1
- package/lib/version.ts +1 -15
- package/package.json +1 -1
- package/templates/.agents/rules/create-issue.en.md +1 -1
- package/templates/.agents/rules/create-issue.zh-CN.md +1 -1
- package/templates/.agents/rules/issue-sync.en.md +4 -2
- package/templates/.agents/rules/issue-sync.zh-CN.md +4 -3
- package/templates/.agents/rules/local-artifact-repair.en.md +46 -39
- package/templates/.agents/rules/local-artifact-repair.zh-CN.md +43 -36
- package/templates/.agents/skills/analyze-task/SKILL.en.md +6 -6
- package/templates/.agents/skills/analyze-task/SKILL.zh-CN.md +6 -6
- package/templates/.agents/skills/analyze-task/config/verify.en.json +1 -1
- package/templates/.agents/skills/analyze-task/config/verify.zh-CN.json +1 -1
- package/templates/.agents/skills/block-task/SKILL.en.md +2 -2
- package/templates/.agents/skills/block-task/SKILL.zh-CN.md +2 -2
- package/templates/.agents/skills/block-task/config/verify.json +1 -1
- 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/cancel-task/config/verify.json +1 -1
- package/templates/.agents/skills/code-task/SKILL.en.md +7 -7
- package/templates/.agents/skills/code-task/SKILL.zh-CN.md +7 -7
- package/templates/.agents/skills/code-task/config/verify.en.json +1 -1
- package/templates/.agents/skills/code-task/config/verify.zh-CN.json +1 -1
- package/templates/.agents/skills/commit/reference/issue-metadata-sync.en.md +1 -1
- package/templates/.agents/skills/commit/reference/issue-metadata-sync.zh-CN.md +1 -1
- package/templates/.agents/skills/complete-manual-validation/SKILL.en.md +1 -1
- package/templates/.agents/skills/complete-manual-validation/SKILL.zh-CN.md +1 -1
- package/templates/.agents/skills/complete-task/SKILL.en.md +2 -2
- package/templates/.agents/skills/complete-task/SKILL.zh-CN.md +2 -2
- package/templates/.agents/skills/complete-task/config/verify.en.json +2 -2
- package/templates/.agents/skills/complete-task/config/verify.zh-CN.json +2 -2
- 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 +2 -2
- package/templates/.agents/skills/create-pr/SKILL.zh-CN.md +2 -2
- package/templates/.agents/skills/create-pr/config/verify.json +1 -1
- package/templates/.agents/skills/create-pr/reference/pr-body-template.en.md +2 -2
- package/templates/.agents/skills/create-pr/reference/pr-body-template.zh-CN.md +2 -2
- package/templates/.agents/skills/create-task/SKILL.en.md +5 -5
- package/templates/.agents/skills/create-task/SKILL.zh-CN.md +5 -5
- package/templates/.agents/skills/create-task/config/verify.json +1 -1
- package/templates/.agents/skills/import-issue/SKILL.en.md +8 -8
- package/templates/.agents/skills/import-issue/SKILL.zh-CN.md +8 -8
- package/templates/.agents/skills/import-issue/config/verify.json +2 -2
- package/templates/.agents/skills/plan-task/SKILL.en.md +4 -4
- package/templates/.agents/skills/plan-task/SKILL.zh-CN.md +4 -4
- package/templates/.agents/skills/plan-task/config/verify.en.json +1 -1
- package/templates/.agents/skills/plan-task/config/verify.zh-CN.json +1 -1
- package/templates/.agents/skills/restore-task/SKILL.en.md +2 -2
- package/templates/.agents/skills/restore-task/SKILL.zh-CN.md +2 -2
- package/templates/.agents/skills/restore-task/config/verify.json +1 -1
- package/templates/.agents/skills/review-analysis/SKILL.en.md +2 -2
- package/templates/.agents/skills/review-analysis/SKILL.zh-CN.md +2 -2
- package/templates/.agents/skills/review-analysis/config/verify.en.json +1 -1
- package/templates/.agents/skills/review-analysis/config/verify.zh-CN.json +1 -1
- package/templates/.agents/skills/review-analysis/reference/report-template.en.md +7 -7
- package/templates/.agents/skills/review-analysis/reference/report-template.zh-CN.md +6 -6
- package/templates/.agents/skills/review-code/SKILL.en.md +2 -2
- package/templates/.agents/skills/review-code/SKILL.zh-CN.md +2 -2
- package/templates/.agents/skills/review-code/config/verify.en.json +1 -1
- package/templates/.agents/skills/review-code/config/verify.zh-CN.json +1 -1
- package/templates/.agents/skills/review-code/reference/report-template.en.md +12 -12
- package/templates/.agents/skills/review-code/reference/report-template.zh-CN.md +12 -12
- package/templates/.agents/skills/review-plan/SKILL.en.md +2 -2
- package/templates/.agents/skills/review-plan/SKILL.zh-CN.md +2 -2
- package/templates/.agents/skills/review-plan/config/verify.en.json +1 -1
- package/templates/.agents/skills/review-plan/config/verify.zh-CN.json +1 -1
- package/templates/.agents/skills/review-plan/reference/report-template.en.md +7 -7
- package/templates/.agents/skills/review-plan/reference/report-template.zh-CN.md +7 -7
- package/templates/.agents/skills/review-pr/config/verify.en.json +1 -1
- package/templates/.agents/skills/review-pr/config/verify.zh-CN.json +1 -1
- package/templates/.agents/skills/review-pr/reference/evidence-grading.en.md +3 -3
- package/templates/.agents/skills/review-pr/reference/evidence-grading.zh-CN.md +3 -3
- package/templates/.agents/skills/run-manual-validation/config/verify.en.json +1 -1
- package/templates/.agents/skills/run-manual-validation/config/verify.zh-CN.json +1 -1
- package/templates/.agents/skills/run-task/SKILL.en.md +1 -1
- package/templates/.agents/skills/run-task/SKILL.zh-CN.md +1 -1
- package/templates/.agents/templates/task.en.md +1 -1
- package/templates/.agents/templates/task.zh-CN.md +1 -1
- package/templates/.claude/commands/import-issue.en.md +1 -1
- package/templates/.claude/commands/import-issue.zh-CN.md +1 -1
- package/templates/.claude/commands/restore-task.en.md +1 -1
- package/templates/.claude/commands/restore-task.zh-CN.md +1 -1
package/lib/sandbox/removal.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
|
962
|
+
await runRmOneUnderRepositoryLock(config, tools, branch, options);
|
|
1329
963
|
}
|
|
1330
964
|
|
|
1331
|
-
async function
|
|
965
|
+
async function removeUncheckedSandbox(
|
|
1332
966
|
config: SandboxConfig,
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
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
|
|
1418
|
-
|
|
1419
|
-
|
|
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 =
|
|
1434
|
-
?
|
|
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 =
|
|
1450
|
-
?
|
|
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
|
|
1465
|
-
|
|
1466
|
-
|
|
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
|
-
|
|
1553
|
-
|
|
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
|
|
1557
|
-
if (
|
|
1558
|
-
|
|
1559
|
-
|
|
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
|
-
|
|
1576
|
-
|
|
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
|
-
|
|
1634
|
-
|
|
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
|
-
|
|
1645
|
-
|
|
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
|
-
|
|
1721
|
-
|
|
1722
|
-
|
|
1723
|
-
|
|
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
|
|
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
|
-
|
|
1771
|
-
|
|
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
|
|
1796
|
-
|
|
1797
|
-
|
|
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
|
-
|
|
1842
|
-
|
|
1843
|
-
|
|
1844
|
-
|
|
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
|
-
|
|
1854
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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: ${
|
|
1117
|
+
p.outro(`Dry run: ${rows.length} sandbox(es) found, nothing deleted`);
|
|
2064
1118
|
return;
|
|
2065
1119
|
}
|
|
2066
|
-
|
|
2067
|
-
|
|
2068
|
-
|
|
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
|
-
|
|
2137
|
-
|
|
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 };
|