diff --git a/apps/backend/internal/agent/docker/storage.go b/apps/backend/internal/agent/docker/storage.go
index b6c627f8bb..2e656e8079 100644
--- a/apps/backend/internal/agent/docker/storage.go
+++ b/apps/backend/internal/agent/docker/storage.go
@@ -33,6 +33,7 @@ type ContainerUsage struct {
}
type DiskUsage struct {
+ ImageLayerBytes int64 `json:"image_layer_bytes"`
BuildCacheBytes int64 `json:"build_cache_bytes"`
Images []ImageUsage `json:"images"`
Containers []ContainerUsage `json:"containers"`
@@ -60,8 +61,9 @@ func (c *Client) DiskUsage(ctx context.Context) (DiskUsage, error) {
return DiskUsage{}, fmt.Errorf("read Docker disk usage: %w", err)
}
result := DiskUsage{
- Images: make([]ImageUsage, 0, len(usage.Images)),
- Containers: make([]ContainerUsage, 0, len(usage.Containers)),
+ ImageLayerBytes: usage.LayersSize,
+ Images: make([]ImageUsage, 0, len(usage.Images)),
+ Containers: make([]ContainerUsage, 0, len(usage.Containers)),
}
for _, cache := range usage.BuildCache {
if cache != nil && cache.Size > 0 {
diff --git a/apps/backend/internal/agent/docker/storage_test.go b/apps/backend/internal/agent/docker/storage_test.go
index 216a66b344..9568db0a4c 100644
--- a/apps/backend/internal/agent/docker/storage_test.go
+++ b/apps/backend/internal/agent/docker/storage_test.go
@@ -40,7 +40,7 @@ func TestStorageResultJSONUsesSnakeCase(t *testing.T) {
value: DiskUsage{
BuildCacheBytes: 30, Images: []ImageUsage{}, Containers: []ContainerUsage{},
},
- want: `{"build_cache_bytes":30,"images":[],"containers":[]}`,
+ want: `{"image_layer_bytes":0,"build_cache_bytes":30,"images":[],"containers":[]}`,
},
{
name: "prune result", value: PruneResult{Deleted: 3, BytesReclaimed: 40},
@@ -64,7 +64,8 @@ func TestDiskUsageMapsTypedSDKResponse(t *testing.T) {
created := time.Unix(100, 0)
lastUsed := time.Unix(200, 0)
sdk := &fakeStorageAPI{usage: dockertypes.DiskUsage{
- Images: []*image.Summary{{ID: "image-1", Size: 300, Containers: 0, Created: created.Unix()}},
+ LayersSize: 500,
+ Images: []*image.Summary{{ID: "image-1", Size: 300, Containers: 0, Created: created.Unix()}},
Containers: []*container.Summary{
{ID: "managed", SizeRw: 75, Labels: map[string]string{"kandev.managed": "true"}},
{ID: "unrelated", SizeRw: 125, Labels: map[string]string{"owner": "user"}},
@@ -80,7 +81,8 @@ func TestDiskUsageMapsTypedSDKResponse(t *testing.T) {
if err != nil {
t.Fatalf("DiskUsage: %v", err)
}
- if got.BuildCacheBytes != 300 || len(got.Images) != 1 || !got.Images[0].CreatedAt.Equal(created) ||
+ if got.ImageLayerBytes != 500 || got.BuildCacheBytes != 300 ||
+ len(got.Images) != 1 || !got.Images[0].CreatedAt.Equal(created) ||
len(got.Containers) != 2 || got.Containers[0].WritableBytes != 75 ||
got.Containers[0].Labels["kandev.managed"] != "true" {
t.Fatalf("disk usage = %#v", got)
diff --git a/apps/backend/internal/backendapp/storage_inventory.go b/apps/backend/internal/backendapp/storage_inventory.go
index 79c8b4f806..21cd8e72f6 100644
--- a/apps/backend/internal/backendapp/storage_inventory.go
+++ b/apps/backend/internal/backendapp/storage_inventory.go
@@ -27,7 +27,7 @@ func (i *storageInventory) LoadWorkspaceInventory(ctx context.Context) (workspac
if i.worktrees == nil || i.lifecycle == nil {
return workspaces.Inventory{}, workspaces.ErrInventoryIncomplete
}
- paths, err := i.worktrees.ListActiveWorktreePaths(ctx)
+ paths, err := i.activeWorktreePaths(ctx)
if err != nil {
return workspaces.Inventory{}, err
}
@@ -52,12 +52,30 @@ func (i *storageInventory) LoadWorkspaceInventory(ctx context.Context) (workspac
return inventory, nil
}
+func (i *storageInventory) activeWorktreePaths(ctx context.Context) ([]string, error) {
+ paths := make([]string, 0)
+ query := "SELECT w.worktree_path FROM task_session_worktrees w " +
+ "INNER JOIN task_sessions s ON s.id = w.session_id " +
+ "INNER JOIN tasks t ON t.id = s.task_id " +
+ "WHERE t.archived_at IS NULL AND w.status = 'active' " +
+ "AND w.deleted_at IS NULL AND w.worktree_path <> ''"
+ if err := i.reader.SelectContext(ctx, &paths, query); err != nil {
+ return nil, err
+ }
+ return paths, nil
+}
+
func (i *storageInventory) activeWorkspaceRows(ctx context.Context) ([]activeWorkspaceRow, error) {
rows := make([]activeWorkspaceRow, 0)
query := "SELECT te.task_id AS taskid, COALESCE(t.workspace_id, '') AS workspaceid, " +
"te.workspace_path AS workspacepath FROM task_environments te " +
"LEFT JOIN tasks t ON t.id = te.task_id " +
- "WHERE te.status IN ('creating', 'ready') AND te.workspace_path <> ''"
+ "WHERE te.status IN ('creating', 'ready') AND te.workspace_path <> '' AND (" +
+ "(t.id IS NOT NULL AND t.archived_at IS NULL) OR EXISTS (" +
+ "SELECT 1 FROM task_sessions borrower " +
+ "INNER JOIN tasks borrower_task ON borrower_task.id = borrower.task_id " +
+ "WHERE borrower.task_environment_id = te.id AND borrower_task.archived_at IS NULL " +
+ "AND borrower.state IN ('CREATED', 'STARTING', 'RUNNING', 'WAITING_FOR_INPUT')))"
if err := i.reader.SelectContext(ctx, &rows, query); err != nil {
return nil, err
}
diff --git a/apps/backend/internal/backendapp/storage_inventory_test.go b/apps/backend/internal/backendapp/storage_inventory_test.go
index b7f40a51e3..efc59a5a32 100644
--- a/apps/backend/internal/backendapp/storage_inventory_test.go
+++ b/apps/backend/internal/backendapp/storage_inventory_test.go
@@ -3,6 +3,7 @@ package backendapp
import (
"context"
"path/filepath"
+ "reflect"
"testing"
"time"
@@ -13,6 +14,87 @@ import (
v1 "github.com/kandev/kandev/pkg/api/v1"
)
+func TestWorkspaceInventoryOnlyProtectsActiveTaskEnvironments(t *testing.T) {
+ database := newContainerInventoryDatabase(t)
+ insertContainerInventoryTask(t, database, "active", v1.TaskStateInProgress, false)
+ insertContainerInventoryTask(t, database, "archived", v1.TaskStateInProgress, true)
+ insertContainerInventoryTask(t, database, "borrowed-owner", v1.TaskStateInProgress, true)
+ insertContainerInventoryTask(t, database, "borrower", v1.TaskStateInProgress, false)
+ for _, item := range []struct {
+ taskID string
+ path string
+ }{
+ {taskID: "active", path: "/tasks/active_abc"},
+ {taskID: "archived", path: "/tasks/archived_def"},
+ {taskID: "borrowed-owner", path: "/tasks/borrowed_jkl"},
+ {taskID: "deleted", path: "/tasks/deleted_ghi"},
+ } {
+ if _, err := database.Exec(
+ "INSERT INTO task_environments (id, task_id, status, workspace_path) VALUES (?, ?, ?, ?)",
+ "env-"+item.taskID, item.taskID, models.TaskEnvironmentStatusReady, item.path,
+ ); err != nil {
+ t.Fatalf("insert task environment for %q: %v", item.taskID, err)
+ }
+ }
+ if _, err := database.Exec(
+ "INSERT INTO task_sessions (id, task_id, state, task_environment_id) VALUES (?, ?, ?, ?)",
+ "session-borrower", "borrower", models.TaskSessionStateRunning, "env-borrowed-owner",
+ ); err != nil {
+ t.Fatalf("insert borrowed environment session: %v", err)
+ }
+
+ rows, err := (&storageInventory{reader: database}).activeWorkspaceRows(context.Background())
+ if err != nil {
+ t.Fatalf("activeWorkspaceRows: %v", err)
+ }
+ want := []activeWorkspaceRow{
+ {TaskID: "active", WorkspaceID: "workspace-active", WorkspacePath: "/tasks/active_abc"},
+ {
+ TaskID: "borrowed-owner", WorkspaceID: "workspace-borrowed-owner",
+ WorkspacePath: "/tasks/borrowed_jkl",
+ },
+ }
+ if !reflect.DeepEqual(rows, want) {
+ t.Fatalf("active workspace rows = %#v, want %#v", rows, want)
+ }
+}
+
+func TestWorkspaceInventoryOnlyProtectsActiveTaskWorktrees(t *testing.T) {
+ database := newContainerInventoryDatabase(t)
+ insertContainerInventoryTask(t, database, "active", v1.TaskStateInProgress, false)
+ insertContainerInventoryTask(t, database, "archived", v1.TaskStateInProgress, true)
+ for _, item := range []struct {
+ taskID string
+ path string
+ }{
+ {taskID: "active", path: "/tasks/active_abc/repo"},
+ {taskID: "archived", path: "/tasks/archived_def/repo"},
+ {taskID: "deleted", path: "/tasks/deleted_ghi/repo"},
+ } {
+ sessionID := "session-" + item.taskID
+ if _, err := database.Exec(
+ "INSERT INTO task_sessions (id, task_id) VALUES (?, ?)", sessionID, item.taskID,
+ ); err != nil {
+ t.Fatalf("insert task session for %q: %v", item.taskID, err)
+ }
+ if _, err := database.Exec(
+ "INSERT INTO task_session_worktrees (id, session_id, status, worktree_path) VALUES (?, ?, 'active', ?)",
+ "worktree-"+item.taskID, sessionID, item.path,
+ ); err != nil {
+ t.Fatalf("insert task worktree for %q: %v", item.taskID, err)
+ }
+ }
+
+ paths, err := (&storageInventory{reader: database}).activeWorktreePaths(context.Background())
+ if err != nil {
+ t.Fatalf("activeWorktreePaths: %v", err)
+ }
+ want := []string{"/tasks/active_abc/repo"}
+ if !reflect.DeepEqual(paths, want) {
+ t.Fatalf("active worktree paths = %#v, want %#v", paths, want)
+ }
+}
+
func TestContainerInventoryRemovability(t *testing.T) {
tests := []struct {
name string
@@ -134,12 +216,27 @@ func newContainerInventoryDatabase(t *testing.T) *sqlx.DB {
CREATE TABLE tasks (
id TEXT PRIMARY KEY,
state TEXT NOT NULL,
+ workspace_id TEXT NOT NULL DEFAULT '',
archived_at TIMESTAMP
);
CREATE TABLE task_environments (
id TEXT PRIMARY KEY,
task_id TEXT NOT NULL,
- status TEXT NOT NULL
+ status TEXT NOT NULL,
+ workspace_path TEXT NOT NULL DEFAULT ''
+ );
+ CREATE TABLE task_sessions (
+ id TEXT PRIMARY KEY,
+ task_id TEXT NOT NULL,
+ state TEXT NOT NULL DEFAULT 'CREATED',
+ task_environment_id TEXT NOT NULL DEFAULT ''
+ );
+ CREATE TABLE task_session_worktrees (
+ id TEXT PRIMARY KEY,
+ session_id TEXT NOT NULL,
+ status TEXT NOT NULL,
+ worktree_path TEXT NOT NULL DEFAULT '',
+ deleted_at TIMESTAMP
);
CREATE TABLE executors_running (
id TEXT PRIMARY KEY,
@@ -166,8 +263,8 @@ func insertContainerInventoryTask(
archivedAt = time.Now().UTC()
}
if _, err := database.Exec(
- "INSERT INTO tasks (id, state, archived_at) VALUES (?, ?, ?)",
- taskID, state, archivedAt,
+ "INSERT INTO tasks (id, state, workspace_id, archived_at) VALUES (?, ?, ?, ?)",
+ taskID, state, "workspace-"+taskID, archivedAt,
); err != nil {
t.Fatalf("insert task %q: %v", taskID, err)
}
diff --git a/apps/backend/internal/backendapp/storage_maintenance.go b/apps/backend/internal/backendapp/storage_maintenance.go
index 818cf5af0a..20e2818367 100644
--- a/apps/backend/internal/backendapp/storage_maintenance.go
+++ b/apps/backend/internal/backendapp/storage_maintenance.go
@@ -154,6 +154,7 @@ func (o *storageOverview) Summary(ctx context.Context) (storagepkg.Summary, erro
Quarantine: summaryValue(quarantineSummary, quarantineErr),
Docker: map[string]any{
"available": dockerSummary.Available, "build_cache_bytes": dockerSummary.BuildCacheBytes,
+ "image_layer_bytes": dockerSummary.ImageLayerBytes,
"unused_image_bytes": dockerSummary.UnusedImageBytes, "warnings": dockerSummary.Warnings,
"managed_container_count": dockerSummary.ManagedContainerCount,
"managed_container_bytes": dockerSummary.ManagedContainerBytes,
diff --git a/apps/backend/internal/backendapp/storage_maintenance_test.go b/apps/backend/internal/backendapp/storage_maintenance_test.go
index 7e965c7a24..8a7740ed15 100644
--- a/apps/backend/internal/backendapp/storage_maintenance_test.go
+++ b/apps/backend/internal/backendapp/storage_maintenance_test.go
@@ -42,7 +42,7 @@ func TestStorageOverviewIncludesQuarantineAndManagedContainers(t *testing.T) {
t.Fatalf("CreateQuarantineEntry: %v", err)
}
docker := dockerstore.NewProvider(
- &overviewDockerClient{usage: agentdocker.DiskUsage{Containers: []agentdocker.ContainerUsage{
+ &overviewDockerClient{usage: agentdocker.DiskUsage{ImageLayerBytes: 128, Containers: []agentdocker.ContainerUsage{
{ID: "managed", WritableBytes: 64, Labels: map[string]string{"kandev.managed": "true"}},
}}},
overviewContainerInventory{}, settings,
@@ -72,7 +72,9 @@ func TestStorageOverviewIncludesQuarantineAndManagedContainers(t *testing.T) {
t.Fatalf("quarantine summary = %#v", summary.Quarantine)
}
dockerSummary, ok := summary.Docker.(map[string]any)
- if !ok || dockerSummary["managed_container_count"] != 1 || dockerSummary["managed_container_bytes"] != int64(64) {
+ if !ok || dockerSummary["managed_container_count"] != 1 ||
+ dockerSummary["managed_container_bytes"] != int64(64) ||
+ dockerSummary["image_layer_bytes"] != int64(128) {
t.Fatalf("docker summary = %#v", summary.Docker)
}
diff --git a/apps/backend/internal/system/storage/dockerstore/provider.go b/apps/backend/internal/system/storage/dockerstore/provider.go
index 2b5bb8a30f..f1182e0fa9 100644
--- a/apps/backend/internal/system/storage/dockerstore/provider.go
+++ b/apps/backend/internal/system/storage/dockerstore/provider.go
@@ -43,6 +43,7 @@ type Analysis struct {
Available bool `json:"available"`
ManagedContainerCount int `json:"managed_container_count"`
ManagedContainerBytes int64 `json:"managed_container_bytes"`
+ ImageLayerBytes int64 `json:"image_layer_bytes"`
BuildCacheBytes int64 `json:"build_cache_bytes"`
UnusedImageBytes int64 `json:"unused_image_bytes"`
Warnings []string `json:"warnings"`
@@ -96,6 +97,7 @@ func (p *Provider) Analyze(ctx context.Context) Analysis {
Available: true,
ManagedContainerCount: managedCount,
ManagedContainerBytes: managedBytes,
+ ImageLayerBytes: usage.ImageLayerBytes,
BuildCacheBytes: usage.BuildCacheBytes,
UnusedImageBytes: unusedImageBytes(usage.Images, cutoff),
}
diff --git a/apps/backend/internal/system/storage/dockerstore/provider_test.go b/apps/backend/internal/system/storage/dockerstore/provider_test.go
index 40a09fb29e..efec65b1ea 100644
--- a/apps/backend/internal/system/storage/dockerstore/provider_test.go
+++ b/apps/backend/internal/system/storage/dockerstore/provider_test.go
@@ -32,7 +32,7 @@ func TestAnalysisJSONUsesStorageAPISnakeCase(t *testing.T) {
if err != nil {
t.Fatalf("Marshal Analysis: %v", err)
}
- want := `{"available":true,"managed_container_count":1,"managed_container_bytes":2,"build_cache_bytes":3,"unused_image_bytes":4,"warnings":["warn"]}`
+ want := `{"available":true,"managed_container_count":1,"managed_container_bytes":2,"image_layer_bytes":0,"build_cache_bytes":3,"unused_image_bytes":4,"warnings":["warn"]}`
if string(encoded) != want {
t.Fatalf("Analysis JSON = %s, want %s", encoded, want)
}
@@ -76,6 +76,7 @@ func TestCleanupContainersFailsClosedOnInventoryError(t *testing.T) {
func TestAnalyzeIsReadOnlyAndReportsUnavailableWithoutError(t *testing.T) {
t.Run("read only", func(t *testing.T) {
docker := &fakeDockerClient{usage: agentdocker.DiskUsage{
+ ImageLayerBytes: 150,
BuildCacheBytes: 200,
Containers: []agentdocker.ContainerUsage{
{ID: "managed", WritableBytes: 25, Labels: map[string]string{"kandev.managed": "true"}},
@@ -91,7 +92,7 @@ func TestAnalyzeIsReadOnlyAndReportsUnavailableWithoutError(t *testing.T) {
provider.now = func() time.Time { return time.Unix(2000000, 0) }
got := provider.Analyze(context.Background())
- if !got.Available || got.BuildCacheBytes != 200 || got.UnusedImageBytes != 300 ||
+ if !got.Available || got.ImageLayerBytes != 150 || got.BuildCacheBytes != 200 || got.UnusedImageBytes != 300 ||
got.ManagedContainerCount != 2 || got.ManagedContainerBytes != 25 {
t.Fatalf("analysis = %#v", got)
}
diff --git a/apps/backend/internal/system/storage/gocache/provider.go b/apps/backend/internal/system/storage/gocache/provider.go
index 8c9841f064..cc2eaecaeb 100644
--- a/apps/backend/internal/system/storage/gocache/provider.go
+++ b/apps/backend/internal/system/storage/gocache/provider.go
@@ -53,10 +53,12 @@ type Provider struct {
// Analysis describes the configured cache without changing it.
type Analysis struct {
- Path string `json:"path"`
- SizeBytes int64 `json:"size_bytes"`
- Owned bool `json:"owned"`
- Enabled bool `json:"enabled"`
+ Path string `json:"path"`
+ SizeBytes int64 `json:"size_bytes"`
+ Owned bool `json:"owned"`
+ Enabled bool `json:"enabled"`
+ UnmanagedPath string `json:"unmanaged_path,omitempty"`
+ UnmanagedSizeBytes int64 `json:"unmanaged_size_bytes,omitempty"`
}
// CleanupResult describes one cache rotation.
@@ -118,7 +120,32 @@ func (p *Provider) Analyze(ctx context.Context) (Analysis, error) {
if err != nil {
return Analysis{}, err
}
- return Analysis{Path: cachePath, SizeBytes: size, Owned: owned, Enabled: settings.GoCache.Enabled}, nil
+ analysis := Analysis{Path: cachePath, SizeBytes: size, Owned: owned, Enabled: settings.GoCache.Enabled}
+ unmanagedPath, ok := defaultGoCachePath()
+ if !ok || unmanagedPath == cachePath {
+ return analysis, nil
+ }
+ unmanagedSize, err := directorySizeNoFollow(unmanagedPath)
+ if err != nil {
+ return Analysis{}, err
+ }
+ analysis.UnmanagedPath = unmanagedPath
+ analysis.UnmanagedSizeBytes = unmanagedSize
+ return analysis, nil
+}
+
+func defaultGoCachePath() (string, bool) {
+ if configured := os.Getenv("GOCACHE"); configured != "" {
+ if configured == "off" || !filepath.IsAbs(configured) {
+ return "", false
+ }
+ return filepath.Clean(configured), true
+ }
+ cacheDir, err := os.UserCacheDir()
+ if err != nil || !filepath.IsAbs(cacheDir) {
+ return "", false
+ }
+ return filepath.Join(cacheDir, "go-build"), true
}
// Cleanup rotates an above-threshold cache into Kandev trash.
@@ -400,6 +427,14 @@ func rejectSymlink(path string) error {
}
func directorySize(root string) (int64, error) {
+ return directorySizeWithSymlinkPolicy(root, false)
+}
+
+func directorySizeNoFollow(root string) (int64, error) {
+ return directorySizeWithSymlinkPolicy(root, true)
+}
+
+func directorySizeWithSymlinkPolicy(root string, skipSymlinks bool) (int64, error) {
if _, err := os.Lstat(root); errors.Is(err, os.ErrNotExist) {
return 0, nil
} else if err != nil {
@@ -411,6 +446,9 @@ func directorySize(root string) (int64, error) {
return walkErr
}
if entry.Type()&os.ModeSymlink != 0 {
+ if skipSymlinks {
+ return nil
+ }
return fmt.Errorf("symlink found in Go cache: %s", entry.Name())
}
if path == markerPath(root) {
diff --git a/apps/backend/internal/system/storage/gocache/provider_test.go b/apps/backend/internal/system/storage/gocache/provider_test.go
index 4644403dbe..b8c6427f1a 100644
--- a/apps/backend/internal/system/storage/gocache/provider_test.go
+++ b/apps/backend/internal/system/storage/gocache/provider_test.go
@@ -13,11 +13,14 @@ import (
)
func TestAnalysisJSONUsesStorageAPISnakeCase(t *testing.T) {
- encoded, err := json.Marshal(Analysis{Path: "/cache", SizeBytes: 42, Owned: true, Enabled: false})
+ encoded, err := json.Marshal(Analysis{
+ Path: "/cache", SizeBytes: 42, Owned: true, Enabled: false,
+ UnmanagedPath: "/user-cache", UnmanagedSizeBytes: 24,
+ })
if err != nil {
t.Fatalf("Marshal Analysis: %v", err)
}
- want := `{"path":"/cache","size_bytes":42,"owned":true,"enabled":false}`
+ want := `{"path":"/cache","size_bytes":42,"owned":true,"enabled":false,"unmanaged_path":"/user-cache","unmanaged_size_bytes":24}`
if string(encoded) != want {
t.Fatalf("Analysis JSON = %s, want %s", encoded, want)
}
@@ -136,6 +139,45 @@ func TestExecutionEnvironmentCreatesOwnedManagedCache(t *testing.T) {
}
}
+func TestAnalyzeReportsUnmanagedDefaultGoCacheReadOnly(t *testing.T) {
+ home := t.TempDir()
+ userCache := filepath.Join(t.TempDir(), "go-build")
+ if err := os.MkdirAll(userCache, 0o755); err != nil {
+ t.Fatal(err)
+ }
+ artifact := filepath.Join(userCache, "artifact")
+ if err := os.WriteFile(artifact, []byte("user cache bytes"), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ externalArtifact := filepath.Join(t.TempDir(), "external")
+ if err := os.WriteFile(externalArtifact, []byte("external bytes"), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.Symlink(externalArtifact, filepath.Join(userCache, "external-link")); err != nil {
+ t.Fatal(err)
+ }
+ t.Setenv("GOCACHE", userCache)
+ provider := New(Config{
+ HomeDir: home, TrashDir: filepath.Join(home, "trash"),
+ Settings: staticSettings{settings: storage.DefaultSettings()},
+ })
+
+ analysis, err := provider.Analyze(context.Background())
+ if err != nil {
+ t.Fatalf("Analyze: %v", err)
+ }
+ if analysis.UnmanagedPath != userCache ||
+ analysis.UnmanagedSizeBytes != int64(len("user cache bytes")) {
+ t.Fatalf("analysis = %#v, want unmanaged cache %s", analysis, userCache)
+ }
+ if _, err := os.Stat(artifact); err != nil {
+ t.Fatalf("Analyze modified unmanaged cache: %v", err)
+ }
+ if _, err := os.Stat(externalArtifact); err != nil {
+ t.Fatalf("Analyze followed or modified unmanaged cache symlink: %v", err)
+ }
+}
+
func TestCleanupRotatesOwnedCacheAboveThreshold(t *testing.T) {
home := t.TempDir()
settings := storage.DefaultSettings()
diff --git a/apps/backend/internal/system/storage/workspaces/provider.go b/apps/backend/internal/system/storage/workspaces/provider.go
index 6eaa39a00c..99a5d7362b 100644
--- a/apps/backend/internal/system/storage/workspaces/provider.go
+++ b/apps/backend/internal/system/storage/workspaces/provider.go
@@ -42,25 +42,41 @@ func New(config Config) *Provider {
}
type candidate struct {
- path string
- owner OwnershipMarker
- size int64
+ path string
+ owner OwnershipMarker
+ size int64
+ measured bool
}
func (p *Provider) Analyze(ctx context.Context) (Analysis, error) {
- candidates, protected, warnings, err := p.classify(ctx)
+ _, trashTasks, protected, roots, warnings, err := p.classificationInputs(ctx)
if err != nil {
return Analysis{}, err
}
analysis := Analysis{Warnings: warnings}
- for _, item := range candidates {
- analysis.CandidateBytes += item.size
- }
- for path := range protected {
- if size, sizeErr := directorySizeNoSymlinks(path); sizeErr == nil {
+ for index := range roots {
+ size, sizeErr := directorySizeNoFollow(roots[index].path)
+ if sizeErr != nil {
+ analysis.Warnings = append(
+ analysis.Warnings,
+ fmt.Sprintf("measure workspace %s: %v", roots[index].path, sizeErr),
+ )
+ continue
+ }
+ roots[index].size = size
+ roots[index].measured = true
+ analysis.TotalBytes += size
+ if _, active := protected[roots[index].path]; active {
analysis.ActiveBytes += size
}
}
+ candidates, err := p.eligibleCandidates(roots, protected, trashTasks)
+ if err != nil {
+ return Analysis{}, err
+ }
+ for _, item := range candidates {
+ analysis.CandidateBytes += item.size
+ }
return analysis, nil
}
@@ -145,11 +161,14 @@ func (p *Provider) eligibleCandidates(
if info.ModTime().After(cutoff) {
continue
}
- size, err := directorySizeNoSymlinks(root.path)
- if err != nil {
- return nil, fmt.Errorf("measure workspace candidate %s: %w", root.path, err)
+ if !root.measured {
+ size, err := directorySizeNoFollow(root.path)
+ if err != nil {
+ return nil, fmt.Errorf("measure workspace candidate %s: %w", root.path, err)
+ }
+ root.size = size
+ root.measured = true
}
- root.size = size
candidates = append(candidates, root)
}
return candidates, nil
@@ -337,7 +356,7 @@ func (p *Provider) PermanentDelete(
if p.config.Now().Before(entry.DeleteAfter) {
return entry, fmt.Errorf("%w: quarantine retention deadline has not elapsed", storage.ErrConflict)
}
- if _, err := directorySizeNoSymlinks(entry.QuarantinePath); err != nil && !errors.Is(err, os.ErrNotExist) {
+ if _, err := directorySizeNoFollow(entry.QuarantinePath); err != nil && !errors.Is(err, os.ErrNotExist) {
return entry, fmt.Errorf("validate quarantined workspace: %w", err)
}
if p.config.Pruner != nil && !p.config.Now().Before(entry.DeleteAfter) {
@@ -632,14 +651,15 @@ func looksSemanticTaskDir(name string) bool {
return index > 0 && len(name[index+1:]) == 3
}
-func directorySizeNoSymlinks(root string) (int64, error) {
+// directorySizeNoFollow counts files in root while treating nested symlinks as opaque entries.
+func directorySizeNoFollow(root string) (int64, error) {
var size int64
err := filepath.WalkDir(root, func(path string, entry fs.DirEntry, walkErr error) error {
if walkErr != nil {
return walkErr
}
if entry.Type()&os.ModeSymlink != 0 {
- return fmt.Errorf("symlink found at %s", path)
+ return nil
}
if entry.Type().IsRegular() {
info, err := entry.Info()
@@ -705,7 +725,34 @@ func writeJSONFile(path string, value any) error {
if err != nil {
return err
}
- return os.WriteFile(path, encoded, 0o600)
+ if info, err := os.Lstat(path); err == nil {
+ if info.Mode()&os.ModeSymlink != 0 || !info.Mode().IsRegular() {
+ return fmt.Errorf("unsafe JSON control file: %s", path)
+ }
+ } else if !errors.Is(err, os.ErrNotExist) {
+ return fmt.Errorf("inspect JSON control file %s: %w", path, err)
+ }
+ tmp, err := os.CreateTemp(filepath.Dir(path), ".kandev-control-*")
+ if err != nil {
+ return err
+ }
+ tmpPath := tmp.Name()
+ defer func() { _ = os.Remove(tmpPath) }()
+ if err := tmp.Chmod(0o600); err != nil {
+ _ = tmp.Close()
+ return err
+ }
+ if _, err := tmp.Write(encoded); err != nil {
+ _ = tmp.Close()
+ return err
+ }
+ if err := tmp.Close(); err != nil {
+ return err
+ }
+ if err := os.Rename(tmpPath, path); err != nil {
+ return fmt.Errorf("install JSON control file %s: %w", path, err)
+ }
+ return nil
}
func readQuarantineManifest(root string) (quarantineManifest, error) {
diff --git a/apps/backend/internal/system/storage/workspaces/provider_test.go b/apps/backend/internal/system/storage/workspaces/provider_test.go
index a01f2daaa4..655ba02edd 100644
--- a/apps/backend/internal/system/storage/workspaces/provider_test.go
+++ b/apps/backend/internal/system/storage/workspaces/provider_test.go
@@ -114,6 +114,49 @@ func TestCleanupFailsClosedWhenInventoryIsIncompleteOrErrors(t *testing.T) {
}
}
+func TestAnalyzeReportsAllWorkspaceBytesIncludingYoungOrphans(t *testing.T) {
+ provider, root, _ := newProviderFixture(t, Inventory{Complete: true}, nil)
+ oldOrphan := createOwnedCandidate(t, root, "old-orphan_abc", OwnershipMarker{
+ TaskID: "old", TaskDirName: "old-orphan_abc", LayoutVersion: LayoutVersionSemantic,
+ })
+ youngOrphan := createOwnedCandidate(t, root, "young-orphan_def", OwnershipMarker{
+ TaskID: "young", TaskDirName: "young-orphan_def", LayoutVersion: LayoutVersionSemantic,
+ })
+ active := createOwnedCandidate(t, root, "active-task_ghi", OwnershipMarker{
+ TaskID: "active", TaskDirName: "active-task_ghi", LayoutVersion: LayoutVersionSemantic,
+ })
+ for path, contents := range map[string]string{
+ oldOrphan: "old orphan bytes", youngOrphan: "young orphan bytes", active: "active bytes",
+ } {
+ if err := os.WriteFile(filepath.Join(path, "artifact"), []byte(contents), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ }
+ old := provider.config.Now().Add(-8 * 24 * time.Hour)
+ if err := os.Chtimes(oldOrphan, old, old); err != nil {
+ t.Fatal(err)
+ }
+ provider.config.Inventory = fakeInventorySource{inventory: Inventory{
+ Complete: true, EnvironmentPaths: []string{active},
+ }}
+
+ analysis, err := provider.Analyze(context.Background())
+ if err != nil {
+ t.Fatalf("Analyze: %v", err)
+ }
+ wantTotal := int64(0)
+ for _, path := range []string{oldOrphan, youngOrphan, active} {
+ size, sizeErr := directorySizeNoFollow(path)
+ if sizeErr != nil {
+ t.Fatal(sizeErr)
+ }
+ wantTotal += size
+ }
+ if analysis.TotalBytes != wantTotal {
+ t.Fatalf("TotalBytes = %d, want %d", analysis.TotalBytes, wantTotal)
+ }
+}
+
func TestCleanupQuarantinesOldOwnedOrphanAndRestoreIsConflictSafe(t *testing.T) {
provider, root, store := newProviderFixture(t, Inventory{Complete: true}, nil)
candidate := createOwnedCandidate(t, root, "orphan-task_abc", OwnershipMarker{
@@ -282,33 +325,77 @@ func TestCleanupProtectsEveryInventorySourceAndScratchSiblingsIndependently(t *t
}
}
-func TestCleanupPathUncertaintyAbortsBeforeAnyMove(t *testing.T) {
+func TestCleanupQuarantinesWorkspaceWithoutFollowingNestedSymlink(t *testing.T) {
provider, root, store := newProviderFixture(t, Inventory{Complete: true}, nil)
- first := createOwnedCandidate(t, root, "first-task_abc", OwnershipMarker{TaskID: "first", TaskDirName: "first-task_abc", LayoutVersion: LayoutVersionSemantic})
- unsafe := createOwnedCandidate(t, root, "unsafe-task_def", OwnershipMarker{TaskID: "unsafe", TaskDirName: "unsafe-task_def", LayoutVersion: LayoutVersionSemantic})
- for _, path := range []string{first, unsafe} {
- old := time.Date(2026, time.July, 1, 0, 0, 0, 0, time.UTC)
- if err := os.Chtimes(path, old, old); err != nil {
- t.Fatalf("Chtimes: %v", err)
- }
+ candidate := createOwnedCandidate(t, root, "linked-task_abc", OwnershipMarker{
+ TaskID: "linked", TaskDirName: "linked-task_abc", LayoutVersion: LayoutVersionSemantic,
+ })
+ external := t.TempDir()
+ externalArtifact := filepath.Join(external, "keep")
+ if err := os.WriteFile(externalArtifact, []byte("external"), 0o600); err != nil {
+ t.Fatal(err)
}
- if err := os.Symlink(t.TempDir(), filepath.Join(unsafe, "escape")); err != nil {
+ if err := os.Symlink(external, filepath.Join(candidate, "external-link")); err != nil {
t.Fatalf("Symlink: %v", err)
}
old := time.Date(2026, time.July, 1, 0, 0, 0, 0, time.UTC)
- if err := os.Chtimes(unsafe, old, old); err != nil {
- t.Fatalf("Chtimes unsafe after symlink: %v", err)
+ if err := os.Chtimes(candidate, old, old); err != nil {
+ t.Fatalf("Chtimes candidate: %v", err)
}
+
+ result, err := provider.Cleanup(context.Background())
+ if err != nil {
+ t.Fatalf("Cleanup: %v", err)
+ }
+ if result.Quarantined != 1 || len(store.entries) != 1 {
+ t.Fatalf("cleanup result=%#v entries=%#v", result, store.entries)
+ }
+ if _, err := os.Stat(externalArtifact); err != nil {
+ t.Fatalf("cleanup followed nested symlink target: %v", err)
+ }
+ entry := store.entries["entry-1"]
+ if _, err := os.Lstat(filepath.Join(entry.QuarantinePath, "external-link")); err != nil {
+ t.Fatalf("symlink entry was not quarantined: %v", err)
+ }
+ provider.config.Now = func() time.Time { return entry.DeleteAfter }
+ if _, err := provider.PermanentDelete(context.Background(), entry.ID, "DELETE"); err != nil {
+ t.Fatalf("PermanentDelete: %v", err)
+ }
+ if _, err := os.Stat(externalArtifact); err != nil {
+ t.Fatalf("permanent deletion followed nested symlink target: %v", err)
+ }
+}
+
+func TestCleanupRejectsSymlinkedQuarantineManifestWithoutFollowingTarget(t *testing.T) {
+ provider, root, _ := newProviderFixture(t, Inventory{Complete: true}, nil)
+ candidate := createOwnedCandidate(t, root, "linked-manifest_abc", OwnershipMarker{
+ TaskID: "linked-manifest", TaskDirName: "linked-manifest_abc", LayoutVersion: LayoutVersionSemantic,
+ })
+ externalArtifact := filepath.Join(t.TempDir(), "keep")
+ const original = "external"
+ if err := os.WriteFile(externalArtifact, []byte(original), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.Symlink(externalArtifact, filepath.Join(candidate, quarantineManifestName)); err != nil {
+ t.Fatalf("Symlink quarantine manifest: %v", err)
+ }
+ old := time.Date(2026, time.July, 1, 0, 0, 0, 0, time.UTC)
+ if err := os.Chtimes(candidate, old, old); err != nil {
+ t.Fatalf("Chtimes candidate: %v", err)
+ }
+
if _, err := provider.Cleanup(context.Background()); err == nil {
- t.Fatal("Cleanup succeeded with symlink uncertainty")
+ t.Fatal("Cleanup succeeded with a symlinked quarantine manifest")
}
- if len(store.entries) != 0 {
- t.Fatalf("persisted %d entries before validating every candidate", len(store.entries))
+ if _, err := os.Stat(candidate); err != nil {
+ t.Fatalf("candidate moved after manifest validation failed: %v", err)
}
- for _, path := range []string{first, unsafe} {
- if _, err := os.Stat(path); err != nil {
- t.Fatalf("candidate moved despite path uncertainty: %v", err)
- }
+ contents, err := os.ReadFile(externalArtifact)
+ if err != nil {
+ t.Fatalf("read external target: %v", err)
+ }
+ if string(contents) != original {
+ t.Fatalf("quarantine manifest write followed symlink target: %q", contents)
}
}
diff --git a/apps/backend/internal/system/storage/workspaces/types.go b/apps/backend/internal/system/storage/workspaces/types.go
index 8291a709ef..e1b9b9e912 100644
--- a/apps/backend/internal/system/storage/workspaces/types.go
+++ b/apps/backend/internal/system/storage/workspaces/types.go
@@ -77,6 +77,7 @@ type CleanupResult struct {
}
type Analysis struct {
+ TotalBytes int64 `json:"total_bytes"`
ActiveBytes int64 `json:"active_bytes"`
CandidateBytes int64 `json:"candidate_bytes"`
Warnings []string `json:"warnings,omitempty"`
diff --git a/apps/web/components/settings/system/storage/storage-overview-card.test.tsx b/apps/web/components/settings/system/storage/storage-overview-card.test.tsx
index 921f3d4a0b..5d6b2b6c92 100644
--- a/apps/web/components/settings/system/storage/storage-overview-card.test.tsx
+++ b/apps/web/components/settings/system/storage/storage-overview-card.test.tsx
@@ -60,11 +60,50 @@ describe("StorageOverviewCard", () => {
it("renders unavailable Docker resources without inventing zero usage", () => {
render();
- const dockerResourceIds = ["managed-containers", "docker-build-cache", "docker-unused-images"];
+ const dockerResourceIds = [
+ "managed-containers",
+ "docker-image-layers",
+ "docker-build-cache",
+ "docker-unused-images",
+ ];
for (const resourceId of dockerResourceIds) {
const trigger = screen.getByTestId(`storage-resource-${resourceId}-trigger`);
expect(trigger.textContent).toContain("Unavailable");
expect(trigger.textContent).not.toContain("0 B");
}
});
+
+ it("renders total workspace, unmanaged Go cache, and Docker layer usage", () => {
+ const overview = {
+ ...degradedOverview,
+ summary: {
+ ...degradedOverview.summary,
+ workspaces: {
+ total_bytes: 8 * 1024 ** 3,
+ active_bytes: 2 * 1024 ** 3,
+ candidate_bytes: 5 * 1024 ** 3,
+ },
+ go_cache: {
+ ...degradedOverview.summary.go_cache,
+ unmanaged_path: "/root/.cache/go-build",
+ unmanaged_size_bytes: 25 * 1024 ** 3,
+ },
+ docker: {
+ ...degradedOverview.summary.docker,
+ available: true,
+ image_layer_bytes: 14 * 1024 ** 3,
+ },
+ },
+ } satisfies StorageOverviewResponse;
+
+ render();
+
+ expect(screen.getByTestId("storage-resource-workspaces-trigger").textContent).toContain("8 GB");
+ expect(screen.getByTestId("storage-resource-unmanaged-go-cache-trigger").textContent).toContain(
+ "25 GB",
+ );
+ expect(
+ screen.getByTestId("storage-resource-docker-image-layers-trigger").textContent,
+ ).toContain("14 GB");
+ });
});
diff --git a/apps/web/components/settings/system/storage/storage-overview-card.tsx b/apps/web/components/settings/system/storage/storage-overview-card.tsx
index 95a2baa03d..077ecfd64c 100644
--- a/apps/web/components/settings/system/storage/storage-overview-card.tsx
+++ b/apps/web/components/settings/system/storage/storage-overview-card.tsx
@@ -67,8 +67,8 @@ function storageResources(overview: StorageOverviewResponse): StorageResource[]
{
id: "workspaces",
label: "Task workspaces",
- value: formatGigabytes(summary.workspaces.candidate_bytes ?? 0),
- detail: `Active workspaces use ${formatGigabytes(summary.workspaces.active_bytes ?? 0)}`,
+ value: formatGigabytes(summary.workspaces.total_bytes ?? 0),
+ detail: `${formatGigabytes(summary.workspaces.candidate_bytes ?? 0)} reclaimable after the grace period ยท ${formatGigabytes(summary.workspaces.active_bytes ?? 0)} active`,
warning: summary.workspaces.warning,
},
quarantineResource(summary.quarantine),
@@ -89,6 +89,26 @@ function storageResources(overview: StorageOverviewResponse): StorageResource[]
detail: summary.go_cache.path ?? overview.capabilities.managed_go_cache_path,
warning: summary.go_cache.warning,
},
+ ...(summary.go_cache.unmanaged_path
+ ? [
+ {
+ id: "unmanaged-go-cache",
+ label: "User Go build cache",
+ value: formatGigabytes(summary.go_cache.unmanaged_size_bytes ?? 0),
+ detail: summary.go_cache.unmanaged_path,
+ },
+ ]
+ : []),
+ {
+ id: "docker-image-layers",
+ label: "Docker image layers",
+ ...dockerMeasurement(
+ summary.docker.available,
+ formatGigabytes(summary.docker.image_layer_bytes ?? 0),
+ overview.capabilities.docker_host || "Default Docker host",
+ ),
+ warning: dockerWarning,
+ },
{
id: "docker-build-cache",
label: "Docker build cache",
diff --git a/apps/web/e2e/tests/system/mobile-storage-maintenance.spec.ts b/apps/web/e2e/tests/system/mobile-storage-maintenance.spec.ts
index 72fad46850..7cc3fa5355 100644
--- a/apps/web/e2e/tests/system/mobile-storage-maintenance.spec.ts
+++ b/apps/web/e2e/tests/system/mobile-storage-maintenance.spec.ts
@@ -43,6 +43,10 @@ test.describe("Mobile storage maintenance", () => {
await expect(testPage.getByTestId("storage-analyze")).toHaveText("Analysis complete");
await testPage.getByTestId("storage-resource-workspaces-trigger").click();
await expect(testPage.getByTestId("storage-resource-workspaces")).toBeVisible();
+ await testPage.getByTestId("storage-resource-unmanaged-go-cache-trigger").click();
+ await expect(testPage.getByTestId("storage-resource-unmanaged-go-cache")).toBeVisible();
+ await testPage.getByTestId("storage-resource-docker-image-layers-trigger").click();
+ await expect(testPage.getByTestId("storage-resource-docker-image-layers")).toBeVisible();
await testPage.getByTestId("storage-resource-go-cache-trigger").click();
const explicitRequest = testPage.waitForRequest(
(request) =>
diff --git a/apps/web/lib/types/system.ts b/apps/web/lib/types/system.ts
index ad3ff7a324..0283496eb7 100644
--- a/apps/web/lib/types/system.ts
+++ b/apps/web/lib/types/system.ts
@@ -201,6 +201,7 @@ export interface StorageCapabilities {
}
export interface StorageWorkspaceSummary {
+ total_bytes?: number;
active_bytes?: number;
candidate_bytes?: number;
warnings?: string[];
@@ -213,12 +214,15 @@ export interface StorageGoCacheSummary {
size_bytes?: number;
owned?: boolean;
enabled?: boolean;
+ unmanaged_path?: string;
+ unmanaged_size_bytes?: number;
available?: boolean;
warning?: string;
}
export interface StorageDockerSummary {
available: boolean;
+ image_layer_bytes?: number;
build_cache_bytes: number;
unused_image_bytes: number;
managed_container_count: number;
diff --git a/docs/decisions/2026-07-19-workspace-symlink-entries.md b/docs/decisions/2026-07-19-workspace-symlink-entries.md
new file mode 100644
index 0000000000..e78bbae471
--- /dev/null
+++ b/docs/decisions/2026-07-19-workspace-symlink-entries.md
@@ -0,0 +1,43 @@
+# ADR-2026-07-19-workspace-symlink-entries: Treat Nested Workspace Symlinks as Entries
+
+**Status:** accepted
+**Date:** 2026-07-19
+**Area:** backend, infra
+
+## Context
+
+Kandev workspaces routinely contain repository-owned symlinks, including checked-in links such as
+`CLAUDE.md -> AGENTS.md` and package-manager links beneath `node_modules`. Storage analysis and
+orphan cleanup rejected any nested symlink, which made normal task roots unmeasurable and
+permanently ineligible for quarantine even when authoritative inventory classified them as orphaned.
+
+## Decision
+
+Workspace storage operations treat a nested symlink as an opaque directory entry and never follow
+its target. `internal/system/storage/workspaces` measures roots with `filepath.WalkDir`, skips
+symlink entries, quarantines the complete root with an atomic rename, and permanently deletes the
+quarantined tree with `os.RemoveAll`; these operations do not traverse nested symlink targets.
+
+The fail-closed boundary remains unchanged for owned control paths. A symlinked task root, tasks or
+trash ancestor, quarantine path, ownership marker, or quarantine manifest is rejected before any
+mutation. Authoritative liveness inventory, containment validation, grace periods, and quarantine
+retention remain mandatory.
+
+## Consequences
+
+- Normal Git and package-manager workspaces can be measured, quarantined, and deleted.
+- Files reached only through a nested symlink target are not counted and are never changed.
+- Reviewers must preserve no-follow filesystem primitives throughout workspace maintenance; a
+ future traversal API that follows symlinks must supersede this decision or restore rejection.
+- Size totals describe bytes stored inside the workspace tree, excluding target data referenced by
+ symlinks.
+
+## Alternatives Considered
+
+- **Reject every nested symlink.** Rejected because ordinary Kandev repositories then cannot be
+ analyzed or reclaimed.
+- **Resolve each target and allow only links contained by the task root.** Rejected because cleanup
+ does not need target contents, relative and temporarily broken links are legitimate repository
+ state, and resolution adds race-prone complexity.
+- **Follow symlinks while measuring or deleting.** Rejected because it can count or destroy data
+ outside Kandev-owned task roots.
diff --git a/docs/decisions/INDEX.md b/docs/decisions/INDEX.md
index f37f6f0ef6..3c4b79c9d4 100644
--- a/docs/decisions/INDEX.md
+++ b/docs/decisions/INDEX.md
@@ -56,3 +56,4 @@ Read individual ADRs for full context. Create new ones via `/record decision` or
| 0046 | [Settings route save coordinator](0046-settings-route-save-coordinator.md) | accepted | frontend | 2026-07-14 |
| 2026-07-18-turn-configuration-snapshots | [Attribute runtime configuration to turns](2026-07-18-turn-configuration-snapshots.md) | accepted | backend, frontend | 2026-07-18 |
| 2026-07-19-reject-mcp-actions-on-raw-websocket | [Reject MCP Actions on the Raw WebSocket](2026-07-19-reject-mcp-actions-on-raw-websocket.md) | accepted | backend, protocol | 2026-07-19 |
+| 2026-07-19-workspace-symlink-entries | [Treat Nested Workspace Symlinks as Entries](2026-07-19-workspace-symlink-entries.md) | accepted | backend, infra | 2026-07-19 |
diff --git a/docs/specs/system-page/storage-maintenance.md b/docs/specs/system-page/storage-maintenance.md
index e33fecbfb4..206c61392e 100644
--- a/docs/specs/system-page/storage-maintenance.md
+++ b/docs/specs/system-page/storage-maintenance.md
@@ -29,10 +29,11 @@ reclaim that space without maintaining cron or systemd configuration outside Kan
pointer-accessible help that explains what it can change, when it runs, and which safety checks
apply. Threshold and path fields are disabled while their parent cleanup option is disabled;
quarantine retention remains independently editable because it also governs existing entries.
-- Read-only analysis is available even when scheduled maintenance is disabled. It reports
- active task workspace bytes, orphan-candidate bytes, active quarantined count and bytes, the
- managed Go cache, Kandev-managed container count and writable-layer bytes, Docker build cache,
- and unused Docker images.
+- Read-only analysis is available even when scheduled maintenance is disabled. It reports total
+ task workspace bytes alongside active and orphan-candidate bytes, active quarantined count and
+ bytes, the managed Go cache, the service user's default Go cache when it is a distinct path,
+ Kandev-managed container count and writable-layer bytes, Docker image-layer bytes, Docker build
+ cache, and unused Docker images.
- Scheduled maintenance is install-wide, persists in Kandev's database, and is disabled by
default. Enabling it does not require editing the VM, a systemd unit, or environment
variables.
@@ -66,6 +67,8 @@ reclaim that space without maintaining cron or systemd configuration outside Kan
### Task cleanup and orphan workspaces
+Decision: [ADR-2026-07-19-workspace-symlink-entries](../../decisions/2026-07-19-workspace-symlink-entries.md)
+
- Archive, delete, cascade, workspace-delete, and quick-chat expiration persist a task resource
cleanup intent before mutating or removing the task row. Cleanup inventory needed after a
task deletion is captured in that intent and is not dependent on foreign-keyed rows surviving.
@@ -81,6 +84,10 @@ reclaim that space without maintaining cron or systemd configuration outside Kan
configured orphan grace period.
- The authoritative inventory covers both task layouts:
`tasks//` and `tasks//`.
+- Ready environment rows and active worktree rows protect files while their owning task exists and
+ is not archived. A ready environment owned by an archived or deleted task remains protected while
+ a live session of an unarchived task borrows it. Other rows retained for archived-task branch
+ recovery are historical metadata, not live workspace references.
- New task roots contain a Kandev ownership marker with the task ID, workspace ID, task directory
name, layout version, and creation time. Legacy unmarked directories remain eligible only when
the authoritative inventory and grace-period checks positively classify them as unreferenced.
@@ -121,10 +128,12 @@ reclaim that space without maintaining cron or systemd configuration outside Kan
- Kandev creates an ownership marker beside the managed cache. It never deletes the default user
cache such as `/root/.cache/go-build` unless that exact path was explicitly adopted through the
Storage page with a destructive confirmation.
-- Analysis reports the managed cache's current bytes. Cleanup rotates the owned cache into
- Kandev trash and recreates an empty cache when its size is greater than the configured maximum.
- The limit is a cleanup trigger, not a hard quota; the cache can temporarily grow beyond it while
- tasks are active.
+- Analysis reports the managed cache's current bytes and read-only usage for the service user's
+ distinct default Go cache (`$GOCACHE` when absolute, otherwise the platform user-cache path).
+ Reporting the default cache does not adopt it or grant cleanup ownership. Cleanup rotates the
+ owned cache into Kandev trash and recreates an empty cache when its size is greater than the
+ configured maximum. The limit is a cleanup trigger, not a hard quota; the cache can temporarily
+ grow beyond it while tasks are active.
- Disabling managed Go cache stops injecting `GOCACHE` into new executions. It does not delete the
previously managed cache. Scheduled cleanup and a global manual run with no resource selection
leave it untouched; only a manual run whose non-empty selection includes `go_cache` may rotate it.
@@ -134,8 +143,9 @@ reclaim that space without maintaining cron or systemd configuration outside Kan
- Kandev-owned container cleanup lists only containers labeled `kandev.managed=true`. It removes
a stopped container only after the task/runtime inventory positively shows it is orphaned or no
longer needed. Running containers are never removed by storage maintenance.
-- Analysis reports the count and writable-layer bytes of exactly labeled Kandev-managed containers;
- an unavailable usage API degrades Docker analysis without failing the other resource summaries.
+- Analysis reports the daemon's image-layer bytes and the count and writable-layer bytes of exactly
+ labeled Kandev-managed containers; an unavailable usage API degrades Docker analysis without
+ failing the other resource summaries.
- Docker build-cache and unused-image analysis may inspect the configured Docker daemon without
changing it.
- Docker build-cache cleanup uses the Docker API's age/storage filters and does not invoke
@@ -349,8 +359,9 @@ enabling host-global Docker cleanup require explicit UI confirmation and server-
- Any authoritative workspace inventory query failure aborts workspace classification and performs
no workspace move or deletion.
-- Any uncertainty about path ownership, containment, active descendants, symlink traversal, or
- task activity keeps the directory and records a warning.
+- Any uncertainty about path ownership, containment, active descendants, owned control-path
+ symlinks, or task activity keeps the directory and records a warning. Nested workspace symlinks
+ are opaque entries: analysis, quarantine, and deletion never follow their targets.
- A quarantine rename failure leaves the original directory untouched and records a failed entry
only when the failure can be associated with a durable candidate ID.
- A backend crash after rename but before the database update is reconciled at startup by scanning
@@ -395,6 +406,13 @@ enabling host-global Docker cleanup require explicit UI confirmation and server-
- **GIVEN** an unreferenced task directory older than the orphan grace period contains
`node_modules`, **WHEN** workspace cleanup runs with a successful authoritative inventory,
**THEN** the whole task root moves to quarantine and its measured bytes appear in the run result.
+- **GIVEN** task roots include active, recent orphan, and grace-eligible orphan directories,
+ **WHEN** storage analysis runs, **THEN** total workspace bytes include every classified task root
+ while active and reclaimable bytes remain separate subsets.
+- **GIVEN** archived or deleted tasks retain ready environment or active worktree rows for recovery,
+ **WHEN** storage analysis or cleanup classifies their old directories, **THEN** those historical
+ rows do not protect the directories from normal orphan grace and quarantine rules unless a live
+ session of an unarchived task still borrows the environment.
- **GIVEN** the worktree inventory query fails, **WHEN** workspace cleanup runs, **THEN** no task
directory moves and the run reports the inventory error.
- **GIVEN** a multi-repository task has one active descendant worktree, **WHEN** workspace cleanup
@@ -416,6 +434,11 @@ enabling host-global Docker cleanup require explicit UI confirmation and server-
reports the reclaimed bytes.
- **GIVEN** `/root/.cache/go-build` was not explicitly adopted, **WHEN** storage cleanup runs,
**THEN** Kandev does not modify it.
+- **GIVEN** `/root/.cache/go-build` is the service user's default Go cache and is not adopted,
+ **WHEN** storage analysis runs, **THEN** its path and bytes are reported read-only while cleanup
+ remains unavailable for that path.
+- **GIVEN** the Docker daemon reports image-layer usage, **WHEN** storage analysis runs, **THEN**
+ image-layer bytes are shown separately from build-cache and managed-container writable bytes.
- **GIVEN** an exited container has `kandev.managed=true` and its task is positively absent,
**WHEN** container cleanup runs, **THEN** the container and its attached Kandev volumes are removed.
- **GIVEN** an unrelated exited container exists, **WHEN** Kandev container cleanup runs, **THEN**