Files
multica/server/internal/daemon/diskusage_test.go
Bohan Jiang a735e4808d feat(daemon): account for and evict the bare repo cache (#6297)
The .repos bare-clone cache was excluded from disk-usage and never
reclaimed, so it grew monotonically and was invisible while doing it. On
the machine in #6265 it was 3.78 GB — 29% of the workspaces root, and
exactly the difference between what disk-usage reported (9.1 GiB) and
what the user's file manager showed (12.88 GiB).

Accounting: .repos is now measured and reported on its own line rather
than skipped. It stays out of the task totals on purpose — every task in
a workspace checks out from this one shared cache, so folding it into
per-task numbers would attribute it to directories that do not contain
it. Other daemon-internal dot-directories (.skill-cache) are no longer
counted as workspaces, which is what produced bogus rows like '.skillca'.

Eviction: a bare repo is removed only when all four hold — GCRepoTTL > 0,
no watched workspace still claims it, no worktrees remain, and no task
has created a worktree from it within the TTL (default 30d).

Two decisions worth calling out:

- The workspace check is a RETAIN predicate, not a delete predicate.
  Sync re-clones every listed repo that is missing whenever a workspace
  registers, which happens on every daemon start, so evicting a repo the
  workspace still claims just buys a full re-clone on the next restart —
  that moves disk cost, it does not reclaim it. Because the set only
  prevents deletion, a stale or empty one cannot widen what we delete.

- Idleness is an explicit stamp written by CreateWorktree, not directory
  mtime. Restarts re-fetch every cached repo, refreshing the mtime of
  repos no task has checked out in months; atime is unavailable in
  practice (noatime on Linux, off by default on Windows). A cache with no
  stamp reports unknown and gets its clock started, never treated as
  ancient — otherwise the first cycle after an upgrade would wipe every
  cache on the machine.

Evicting wrongly costs a re-clone, not a failure: the next task that
needs the repo takes the cache-miss path in ensureRepoReady.

Verified on a live runtime: both table views now show the .repos line
(242.8 MiB across 3 repos) and no longer list .skill-cache as a
workspace.

Co-authored-by: Bohan-J <bohan@devv.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-08-03 15:16:16 +08:00

724 lines
25 KiB
Go

package daemon
import (
"context"
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"runtime"
"strings"
"testing"
"time"
"github.com/multica-ai/multica/server/internal/daemon/execenv"
)
func writeFile(t *testing.T, path string, size int) {
t.Helper()
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatal(err)
}
buf := make([]byte, size)
for i := range buf {
buf[i] = 'x'
}
if err := os.WriteFile(path, buf, 0o644); err != nil {
t.Fatal(err)
}
}
// TestScanDiskUsage_AggregatesAndCategorizes verifies the happy-path: each
// task directory is sized, categorized by GC meta kind, and aggregated into
// per-workspace totals matching the per-task totals.
func TestScanDiskUsage_AggregatesAndCategorizes(t *testing.T) {
t.Parallel()
root := t.TempDir()
wsA := "11111111-1111-1111-1111-111111111111"
wsB := "22222222-2222-2222-2222-222222222222"
taskA1 := filepath.Join(root, wsA, "aaaaaaaa")
writeFile(t, filepath.Join(taskA1, "workdir/main.go"), 1000)
writeFile(t, filepath.Join(taskA1, "workdir/node_modules/dep/index.js"), 4000)
mustWriteMeta(t, taskA1, execenv.GCMeta{
Kind: execenv.GCKindIssue,
IssueID: "issue-1",
WorkspaceID: wsA,
CompletedAt: time.Now().Add(-3 * time.Hour),
})
taskA2 := filepath.Join(root, wsA, "bbbbbbbb")
writeFile(t, filepath.Join(taskA2, "workdir/notes.md"), 500)
mustWriteMeta(t, taskA2, execenv.GCMeta{
Kind: execenv.GCKindChat,
ChatSessionID: "chat-1",
WorkspaceID: wsA,
CompletedAt: time.Now().Add(-1 * time.Hour),
})
taskB1 := filepath.Join(root, wsB, "cccccccc")
writeFile(t, filepath.Join(taskB1, "workdir/result.txt"), 2000)
// No meta — exercises the unknown-kind / mtime-fallback path. Backdate
// the dir mtime so the fallback produces a measurable age (a freshly
// created dir has mtime=now, which would round to 0 seconds).
backdate := time.Now().Add(-2 * time.Hour)
if err := os.Chtimes(taskB1, backdate, backdate); err != nil {
t.Fatal(err)
}
report, err := ScanDiskUsage(root, []string{"node_modules", ".next", ".turbo"})
if err != nil {
t.Fatalf("ScanDiskUsage: %v", err)
}
if len(report.Tasks) != 3 {
t.Fatalf("expected 3 tasks, got %d", len(report.Tasks))
}
byShort := map[string]TaskDiskUsage{}
for _, task := range report.Tasks {
byShort[task.TaskShort] = task
}
a1 := byShort["aaaaaaaa"]
if a1.Kind != string(execenv.GCKindIssue) {
t.Errorf("task a1 kind = %q, want %q", a1.Kind, execenv.GCKindIssue)
}
// Size includes main.go (1000) + node_modules subtree (4000) + the
// .gc_meta.json control file we wrote. Bound the meta overhead so we
// don't drift if the meta JSON shape changes.
if a1.SizeBytes < 5000 || a1.SizeBytes > 5000+1024 {
t.Errorf("task a1 size = %d, want in [5000, 6024]", a1.SizeBytes)
}
if a1.ArtifactSizeBytes != 4000 {
t.Errorf("task a1 artifact size = %d, want 4000", a1.ArtifactSizeBytes)
}
if a1.AgeSeconds < 60 {
t.Errorf("task a1 age_seconds = %d, want >= 60 (CompletedAt -3h)", a1.AgeSeconds)
}
if a1.WorkspaceShort != ShortID(wsA) {
t.Errorf("task a1 workspace_short = %q, want %q", a1.WorkspaceShort, ShortID(wsA))
}
a2 := byShort["bbbbbbbb"]
if a2.Kind != string(execenv.GCKindChat) {
t.Errorf("task a2 kind = %q, want chat", a2.Kind)
}
if a2.SizeBytes < 500 || a2.SizeBytes > 500+1024 {
t.Errorf("task a2 size = %d, want in [500, 1524]", a2.SizeBytes)
}
if a2.ArtifactSizeBytes != 0 {
t.Errorf("task a2 artifact size = %d, want 0", a2.ArtifactSizeBytes)
}
b1 := byShort["cccccccc"]
if b1.Kind != DiskUsageKindUnknown {
t.Errorf("task b1 kind = %q, want %q", b1.Kind, DiskUsageKindUnknown)
}
if b1.SizeBytes != 2000 {
t.Errorf("task b1 size = %d, want 2000 (no meta file)", b1.SizeBytes)
}
if b1.AgeSeconds < 60 {
t.Errorf("task b1 age_seconds = %d, want >= 60 (mtime backdated 2h)", b1.AgeSeconds)
}
if report.TotalSizeBytes != a1.SizeBytes+a2.SizeBytes+b1.SizeBytes {
t.Errorf("total size = %d, want sum of per-task sizes (%d)",
report.TotalSizeBytes, a1.SizeBytes+a2.SizeBytes+b1.SizeBytes)
}
if report.TotalArtifactSizeBytes != 4000 {
t.Errorf("total artifact size = %d, want 4000", report.TotalArtifactSizeBytes)
}
wsByID := map[string]WorkspaceDiskUsage{}
for _, ws := range report.Workspaces {
wsByID[ws.WorkspaceID] = ws
}
if wsByID[wsA].SizeBytes != a1.SizeBytes+a2.SizeBytes {
t.Errorf("workspace A size = %d, want %d (a1+a2)",
wsByID[wsA].SizeBytes, a1.SizeBytes+a2.SizeBytes)
}
if wsByID[wsA].ArtifactSizeBytes != 4000 {
t.Errorf("workspace A artifact size = %d, want 4000", wsByID[wsA].ArtifactSizeBytes)
}
if wsByID[wsA].TaskCount != 2 {
t.Errorf("workspace A task count = %d, want 2", wsByID[wsA].TaskCount)
}
if wsByID[wsB].SizeBytes != 2000 {
t.Errorf("workspace B size = %d, want 2000", wsByID[wsB].SizeBytes)
}
// Workspace A's artifact ratio: 4000 reclaimable / a1+a2 size. Match
// within float tolerance so a small meta-file delta doesn't break it.
wantARatio := 4000.0 / float64(a1.SizeBytes+a2.SizeBytes)
if got := wsByID[wsA].ArtifactRatio; got < wantARatio-0.005 || got > wantARatio+0.005 {
t.Errorf("workspace A artifact_ratio = %f, want ~%f", got, wantARatio)
}
// Workspace B has no artifact subtree at all → ratio must be 0, not NaN.
if got := wsByID[wsB].ArtifactRatio; got != 0 {
t.Errorf("workspace B artifact_ratio = %f, want 0", got)
}
// Scan-wide counts must reflect the full scan, not the (un-truncated
// here) slice — they're the contract callers rely on once --top kicks in.
if report.TotalTaskCount != 3 {
t.Errorf("total_task_count = %d, want 3", report.TotalTaskCount)
}
if report.TotalWorkspaceCount != 2 {
t.Errorf("total_workspace_count = %d, want 2", report.TotalWorkspaceCount)
}
if report.TotalArtifactRatio <= 0 || report.TotalArtifactRatio > 1 {
t.Errorf("total_artifact_ratio = %f, want in (0, 1]", report.TotalArtifactRatio)
}
// Tasks must be sorted by size descending — the consumer treats this as
// a stable contract for `--top N` slicing.
for i := 1; i < len(report.Tasks); i++ {
if report.Tasks[i-1].SizeBytes < report.Tasks[i].SizeBytes {
t.Errorf("tasks not sorted by size desc: %d < %d at idx %d",
report.Tasks[i-1].SizeBytes, report.Tasks[i].SizeBytes, i)
}
}
// JSON round-trip — guards the field names the issue spec calls out.
raw, err := json.Marshal(report)
if err != nil {
t.Fatalf("marshal report: %v", err)
}
for _, want := range []string{
`"kind"`,
`"parent_status"`,
`"age_seconds"`,
`"size_bytes"`,
`"artifact_size_bytes"`,
`"workspace_id"`,
`"task_short"`,
`"artifact_ratio"`,
`"managed_artifact_subpaths"`,
`"total_task_count"`,
`"total_workspace_count"`,
`"total_artifact_ratio"`,
} {
if !strings.Contains(string(raw), want) {
t.Errorf("JSON missing required field %s: %s", want, raw)
}
}
}
func TestScanDiskUsage_ManagedCodexSandboxIsExactAndDeduplicated(t *testing.T) {
t.Parallel()
root := t.TempDir()
wsID := "mmmmmmmm-mmmm-mmmm-mmmm-mmmmmmmmmmmm"
taskDir := filepath.Join(root, wsID, "tttttttt")
writeFile(t, filepath.Join(taskDir, "codex-home/.sandbox-bin/codex.exe"), 300)
writeFile(t, filepath.Join(taskDir, "workdir/repo/.sandbox-bin/cache"), 400)
report, err := ScanDiskUsage(root, nil)
if err != nil {
t.Fatalf("ScanDiskUsage managed-only: %v", err)
}
if got := report.Tasks[0].SizeBytes; got != 700 {
t.Fatalf("size_bytes=%d, want 700", got)
}
if got := report.Tasks[0].ArtifactSizeBytes; got != 300 {
t.Fatalf("artifact_size_bytes=%d, want exact managed 300", got)
}
if got := strings.Join(report.ManagedArtifactSubpaths, ","); got != "codex-home/.sandbox-bin" {
t.Fatalf("managed artifact paths=%q, want codex-home/.sandbox-bin", got)
}
report, err = ScanDiskUsage(root, []string{".sandbox-bin"})
if err != nil {
t.Fatalf("ScanDiskUsage broad basename: %v", err)
}
if got := report.Tasks[0].ArtifactSizeBytes; got != 700 {
t.Fatalf("artifact_size_bytes=%d, want 700 without double counting", got)
}
}
func TestScanDiskUsage_LegacyMetaAgeUsesMetaFileMTime(t *testing.T) {
t.Parallel()
root := t.TempDir()
wsID := "llllllll-llll-llll-llll-llllllllllll"
taskDir := filepath.Join(root, wsID, "tttttttt")
writeFile(t, filepath.Join(taskDir, "workdir/main.go"), 10)
mustWriteMeta(t, taskDir, execenv.GCMeta{
Kind: execenv.GCKindIssue, IssueID: "issue-legacy", WorkspaceID: wsID,
})
oldRoot := time.Now().Add(-30 * 24 * time.Hour)
if err := os.Chtimes(taskDir, oldRoot, oldRoot); err != nil {
t.Fatal(err)
}
recentMeta := time.Now().Add(-2 * time.Hour)
if err := os.Chtimes(filepath.Join(taskDir, ".gc_meta.json"), recentMeta, recentMeta); err != nil {
t.Fatal(err)
}
report, err := ScanDiskUsage(root, nil)
if err != nil {
t.Fatalf("ScanDiskUsage: %v", err)
}
if got := report.Tasks[0].AgeSeconds; got < int64(time.Hour.Seconds()) || got > int64((3*time.Hour).Seconds()) {
t.Fatalf("age_seconds=%d, want metadata age near 2h instead of stale task root age", got)
}
}
// TestScanDiskUsage_EmptyWorkspaceArtifactRatio guards the total=0 edge:
// a workspace whose tasks have no measurable bytes (or no files at all) must
// still report ArtifactRatio=0, never NaN. The CLI table renders this column,
// and `NaN%` would surface in the user's terminal otherwise.
func TestScanDiskUsage_EmptyWorkspaceArtifactRatio(t *testing.T) {
t.Parallel()
root := t.TempDir()
wsID := "00000000-0000-0000-0000-000000000000"
taskDir := filepath.Join(root, wsID, "tttttttt")
if err := os.MkdirAll(filepath.Join(taskDir, "workdir"), 0o755); err != nil {
t.Fatal(err)
}
report, err := ScanDiskUsage(root, []string{"node_modules"})
if err != nil {
t.Fatalf("ScanDiskUsage: %v", err)
}
if len(report.Workspaces) != 1 {
t.Fatalf("expected 1 workspace, got %d", len(report.Workspaces))
}
if got := report.Workspaces[0].ArtifactRatio; got != 0 {
t.Errorf("empty workspace artifact_ratio = %f, want 0 (no NaN)", got)
}
if got := report.TotalArtifactRatio; got != 0 {
t.Errorf("empty scan total_artifact_ratio = %f, want 0 (no NaN)", got)
}
}
// TestScanDiskUsage_CountsGitButNeverAsArtifact pins the two halves of the
// .git rule. A .git subtree IS real footprint, so it counts toward size_bytes
// (a full task-dir reclaim frees it, and dirSize reports it that way). But it
// must never count as an artifact, even when something inside it matches an
// artifact basename, because cleanTaskArtifacts refuses to reclaim in there.
func TestScanDiskUsage_CountsGitButNeverAsArtifact(t *testing.T) {
t.Parallel()
root := t.TempDir()
wsID := "wwwwwwww-wwww-wwww-wwww-wwwwwwwwwwww"
taskDir := filepath.Join(root, wsID, "tttttttt")
writeFile(t, filepath.Join(taskDir, "workdir/.git/objects/pack"), 9999)
writeFile(t, filepath.Join(taskDir, "workdir/.git/node_modules/x"), 5555)
writeFile(t, filepath.Join(taskDir, "workdir/main.go"), 100)
report, err := ScanDiskUsage(root, []string{"node_modules"})
if err != nil {
t.Fatalf("ScanDiskUsage: %v", err)
}
if len(report.Tasks) != 1 {
t.Fatalf("expected 1 task, got %d", len(report.Tasks))
}
got := report.Tasks[0]
const wantSize = 100 + 9999 + 5555
if got.SizeBytes != wantSize {
t.Errorf("size_bytes = %d, want %d (main.go plus the whole .git tree)", got.SizeBytes, wantSize)
}
if got.ArtifactSizeBytes != 0 {
t.Errorf("artifact_size_bytes = %d, want 0 (node_modules under .git is not reclaimable)", got.ArtifactSizeBytes)
}
}
// TestScanDiskUsage_DoesNotFollowSymlinks guards the second safety
// constraint. A symlinked artifact directory must not be sized — neither
// the link itself nor its target — because cleanTaskArtifacts won't reclaim
// it either.
func TestScanDiskUsage_DoesNotFollowSymlinks(t *testing.T) {
t.Parallel()
if runtime.GOOS == "windows" {
t.Skip("symlink semantics differ on windows")
}
root := t.TempDir()
outside := t.TempDir()
writeFile(t, filepath.Join(outside, "huge.bin"), 10000)
wsID := "ssssssss-ssss-ssss-ssss-ssssssssssss"
taskDir := filepath.Join(root, wsID, "tttttttt")
writeFile(t, filepath.Join(taskDir, "workdir/main.go"), 100)
if err := os.Symlink(outside, filepath.Join(taskDir, "workdir/node_modules")); err != nil {
t.Skipf("symlink not supported: %v", err)
}
// Symlinked regular file too — the link's target lives outside taskDir
// and must not be summed.
if err := os.Symlink(filepath.Join(outside, "huge.bin"), filepath.Join(taskDir, "workdir/big-link")); err != nil {
t.Skipf("symlink not supported: %v", err)
}
report, err := ScanDiskUsage(root, []string{"node_modules"})
if err != nil {
t.Fatalf("ScanDiskUsage: %v", err)
}
if len(report.Tasks) != 1 {
t.Fatalf("expected 1 task, got %d", len(report.Tasks))
}
got := report.Tasks[0]
if got.SizeBytes != 100 {
t.Errorf("size_bytes = %d, want 100 (only main.go; symlinks ignored)", got.SizeBytes)
}
if got.ArtifactSizeBytes != 0 {
t.Errorf("artifact_size_bytes = %d, want 0 (symlinked node_modules ignored)", got.ArtifactSizeBytes)
}
}
// TestScanDiskUsage_MissingRoot ensures a daemon that has never run yet
// (workspaces dir doesn't exist) returns an empty report, not an error.
func TestScanDiskUsage_MissingRoot(t *testing.T) {
t.Parallel()
report, err := ScanDiskUsage(filepath.Join(t.TempDir(), "does-not-exist"), nil)
if err != nil {
t.Fatalf("ScanDiskUsage on missing root returned error: %v", err)
}
if len(report.Tasks) != 0 || len(report.Workspaces) != 0 {
t.Errorf("expected empty report, got %+v", report)
}
}
// TestScanDiskUsage_RejectsPatternsWithSeparators mirrors the GC safety check:
// a pattern containing "/" or "\\" is meaningless for basename matching and
// must be silently dropped, not interpreted as a path.
func TestScanDiskUsage_RejectsPatternsWithSeparators(t *testing.T) {
t.Parallel()
root := t.TempDir()
wsID := "rrrrrrrr-rrrr-rrrr-rrrr-rrrrrrrrrrrr"
taskDir := filepath.Join(root, wsID, "tttttttt")
writeFile(t, filepath.Join(taskDir, "workdir/node_modules/x"), 1000)
report, err := ScanDiskUsage(root, []string{"workdir/node_modules", "../etc"})
if err != nil {
t.Fatalf("ScanDiskUsage: %v", err)
}
if got := report.Tasks[0].ArtifactSizeBytes; got != 0 {
t.Errorf("artifact_size_bytes = %d, want 0 (separator-bearing patterns dropped)", got)
}
if got := report.ArtifactPatterns; len(got) != 0 {
t.Errorf("ArtifactPatterns = %v, want empty (all dropped)", got)
}
}
// TestScanDiskUsageRoots_SumsAcrossRoots verifies the cross-root aggregate:
// each root keeps its own labeled report and the grand totals are the sum of
// every root's full scan. A missing root contributes an empty report, not an
// error, so a never-used profile root doesn't break the aggregate.
func TestScanDiskUsageRoots_SumsAcrossRoots(t *testing.T) {
t.Parallel()
rootA := t.TempDir()
writeFile(t, filepath.Join(rootA, "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", "t1", "workdir/main.go"), 100)
rootB := t.TempDir()
writeFile(t, filepath.Join(rootB, "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb", "t1", "workdir/big"), 300)
writeFile(t, filepath.Join(rootB, "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb", "t2", "workdir/main.go"), 50)
missing := filepath.Join(t.TempDir(), "never-ran")
agg, err := ScanDiskUsageRoots([]DiskUsageRoot{
{Profile: "", Root: rootA},
{Profile: "desktop-host", Root: rootB},
{Profile: "never-ran", Root: missing},
}, []string{"node_modules"})
if err != nil {
t.Fatalf("ScanDiskUsageRoots: %v", err)
}
if len(agg.Roots) != 3 {
t.Fatalf("Roots len = %d, want 3 (missing root still listed, empty)", len(agg.Roots))
}
if agg.Roots[0].Profile != "" || agg.Roots[1].Profile != "desktop-host" {
t.Fatalf("root profiles not preserved in order: %+v", agg.Roots)
}
if agg.Roots[2].Report.TotalTaskCount != 0 {
t.Fatalf("missing root TotalTaskCount = %d, want 0", agg.Roots[2].Report.TotalTaskCount)
}
if agg.TotalTaskCount != 3 {
t.Fatalf("TotalTaskCount = %d, want 3 across roots", agg.TotalTaskCount)
}
if agg.TotalSizeBytes != 450 {
t.Fatalf("TotalSizeBytes = %d, want 450 (100 + 300 + 50)", agg.TotalSizeBytes)
}
if agg.TotalWorkspaceCount != 2 {
t.Fatalf("TotalWorkspaceCount = %d, want 2", agg.TotalWorkspaceCount)
}
if got := strings.Join(agg.ManagedArtifactSubpaths, ","); got != "codex-home/.sandbox-bin" {
t.Fatalf("managed artifact paths=%q, want codex-home/.sandbox-bin", got)
}
}
func mustWriteMeta(t *testing.T, taskDir string, meta execenv.GCMeta) {
t.Helper()
data, err := json.Marshal(meta)
if err != nil {
t.Fatal(err)
}
if err := os.MkdirAll(taskDir, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(taskDir, ".gc_meta.json"), data, 0o644); err != nil {
t.Fatal(err)
}
}
// issueTask builds a minimal issue-kind task row for ResolveParentStatuses.
func issueTask(wsID, taskShort, issueID string) TaskDiskUsage {
return TaskDiskUsage{
WorkspaceID: wsID,
TaskShort: taskShort,
Kind: string(execenv.GCKindIssue),
ParentID: issueID,
}
}
// TestResolveParentStatuses_FillsIssueTasks covers the main path: statuses land
// on the right rows, ids shared by several task dirs are asked for once, each
// workspace is queried separately, and non-issue kinds are left alone.
func TestResolveParentStatuses_FillsIssueTasks(t *testing.T) {
t.Parallel()
wsA := "11111111-1111-1111-1111-111111111111"
wsB := "22222222-2222-2222-2222-222222222222"
report := &DiskUsageReport{Tasks: []TaskDiskUsage{
issueTask(wsA, "aaaa1111", "issue-1"),
// Same issue, second workdir — must resolve without a second ask.
issueTask(wsA, "aaaa2222", "issue-1"),
issueTask(wsA, "aaaa3333", "issue-2"),
issueTask(wsB, "bbbb1111", "issue-3"),
{WorkspaceID: wsA, TaskShort: "cccc1111", Kind: string(execenv.GCKindChat), ParentID: "chat-1"},
{WorkspaceID: wsA, TaskShort: "dddd1111", Kind: DiskUsageKindUnknown},
}}
asked := map[string][]string{}
fetch := func(_ context.Context, workspaceID string, issueIDs []string) (map[string]string, error) {
asked[workspaceID] = append(asked[workspaceID], issueIDs...)
out := map[string]string{}
for _, id := range issueIDs {
switch id {
case "issue-1":
out[id] = "done"
case "issue-2":
out[id] = "in_progress"
case "issue-3":
out[id] = "cancelled"
}
}
return out, nil
}
if err := ResolveParentStatuses(context.Background(), report, fetch); err != nil {
t.Fatalf("ResolveParentStatuses: %v", err)
}
want := []string{"done", "done", "in_progress", "cancelled", "", ""}
for i, wantStatus := range want {
if got := report.Tasks[i].ParentStatus; got != wantStatus {
t.Errorf("task[%d] (%s) parent_status = %q, want %q",
i, report.Tasks[i].TaskShort, got, wantStatus)
}
}
if len(asked[wsA]) != 2 {
t.Errorf("workspace A asked for %v, want exactly 2 de-duplicated ids", asked[wsA])
}
if len(asked[wsB]) != 1 {
t.Errorf("workspace B asked for %v, want exactly 1 id", asked[wsB])
}
}
// TestResolveParentStatuses_UnresolvedStaysBlank ensures an id the server does
// not return (deleted issue, or one this token cannot see) reads as unknown
// rather than being filled with a placeholder that looks like a real status.
func TestResolveParentStatuses_UnresolvedStaysBlank(t *testing.T) {
t.Parallel()
wsID := "11111111-1111-1111-1111-111111111111"
report := &DiskUsageReport{Tasks: []TaskDiskUsage{
issueTask(wsID, "aaaa1111", "issue-known"),
issueTask(wsID, "aaaa2222", "issue-missing"),
}}
fetch := func(_ context.Context, _ string, _ []string) (map[string]string, error) {
return map[string]string{"issue-known": "todo"}, nil
}
if err := ResolveParentStatuses(context.Background(), report, fetch); err != nil {
t.Fatalf("ResolveParentStatuses: %v", err)
}
if report.Tasks[0].ParentStatus != "todo" {
t.Errorf("known issue status = %q, want todo", report.Tasks[0].ParentStatus)
}
if report.Tasks[1].ParentStatus != "" {
t.Errorf("missing issue status = %q, want empty", report.Tasks[1].ParentStatus)
}
}
// TestResolveParentStatuses_ChunksLargeWorkspaces verifies a root with more
// issues than the server's batch cap is split the same way the GC loop splits
// it, instead of being sent as one oversized request.
func TestResolveParentStatuses_ChunksLargeWorkspaces(t *testing.T) {
t.Parallel()
wsID := "11111111-1111-1111-1111-111111111111"
total := issueGCBatchSize + 1
tasks := make([]TaskDiskUsage, 0, total)
for i := range total {
tasks = append(tasks, issueTask(wsID, fmt.Sprintf("task%04d", i), fmt.Sprintf("issue-%04d", i)))
}
report := &DiskUsageReport{Tasks: tasks}
var chunkSizes []int
fetch := func(_ context.Context, _ string, issueIDs []string) (map[string]string, error) {
chunkSizes = append(chunkSizes, len(issueIDs))
out := make(map[string]string, len(issueIDs))
for _, id := range issueIDs {
out[id] = "done"
}
return out, nil
}
if err := ResolveParentStatuses(context.Background(), report, fetch); err != nil {
t.Fatalf("ResolveParentStatuses: %v", err)
}
if len(chunkSizes) != 2 {
t.Fatalf("chunk sizes = %v, want 2 chunks", chunkSizes)
}
if chunkSizes[0] != issueGCBatchSize || chunkSizes[1] != 1 {
t.Errorf("chunk sizes = %v, want [%d 1]", chunkSizes, issueGCBatchSize)
}
for i := range report.Tasks {
if report.Tasks[i].ParentStatus != "done" {
t.Fatalf("task[%d] parent_status = %q, want done", i, report.Tasks[i].ParentStatus)
}
}
}
// TestResolveParentStatuses_PartialFailureKeepsOtherWorkspaces pins the
// best-effort contract: one workspace failing must not blank out the rest, and
// the error still surfaces so the CLI can warn.
func TestResolveParentStatuses_PartialFailureKeepsOtherWorkspaces(t *testing.T) {
t.Parallel()
wsGood := "11111111-1111-1111-1111-111111111111"
wsBad := "22222222-2222-2222-2222-222222222222"
report := &DiskUsageReport{Tasks: []TaskDiskUsage{
issueTask(wsGood, "aaaa1111", "issue-good"),
issueTask(wsBad, "bbbb1111", "issue-bad"),
}}
fetch := func(_ context.Context, workspaceID string, _ []string) (map[string]string, error) {
if workspaceID == wsBad {
return nil, errors.New("boom")
}
return map[string]string{"issue-good": "done"}, nil
}
err := ResolveParentStatuses(context.Background(), report, fetch)
if err == nil {
t.Fatal("expected the failing workspace's error to surface")
}
if report.Tasks[0].ParentStatus != "done" {
t.Errorf("healthy workspace status = %q, want done", report.Tasks[0].ParentStatus)
}
if report.Tasks[1].ParentStatus != "" {
t.Errorf("failed workspace status = %q, want empty", report.Tasks[1].ParentStatus)
}
}
// TestResolveParentStatuses_NoFetcherIsNoOp keeps `disk-usage` usable offline
// or logged out: with no way to resolve, the scan result passes through
// untouched instead of erroring.
func TestResolveParentStatuses_NoFetcherIsNoOp(t *testing.T) {
t.Parallel()
report := &DiskUsageReport{Tasks: []TaskDiskUsage{
issueTask("11111111-1111-1111-1111-111111111111", "aaaa1111", "issue-1"),
}}
if err := ResolveParentStatuses(context.Background(), report, nil); err != nil {
t.Fatalf("nil fetcher should be a no-op, got %v", err)
}
if report.Tasks[0].ParentStatus != "" {
t.Errorf("parent_status = %q, want empty", report.Tasks[0].ParentStatus)
}
if err := ResolveParentStatuses(context.Background(), nil, func(context.Context, string, []string) (map[string]string, error) {
t.Fatal("fetcher must not run for a nil report")
return nil, nil
}); err != nil {
t.Fatalf("nil report should be a no-op, got %v", err)
}
}
// TestScanDiskUsage_ReportsRepoCacheSeparately pins the accounting split: the
// bare-repo cache is measured (it used to be invisible, which made the reported
// total silently disagree with the user's file manager) but kept out of the
// task totals, since every task checks out from it and none contains it.
func TestScanDiskUsage_ReportsRepoCacheSeparately(t *testing.T) {
t.Parallel()
root := t.TempDir()
wsID := "11111111-1111-1111-1111-111111111111"
writeFile(t, filepath.Join(root, wsID, "aaaaaaaa", "workdir/main.go"), 1000)
// .repos/<workspace>/<repo>/... — the repo dir is the unit the GC evicts.
writeFile(t, filepath.Join(root, ".repos", wsID, "widgets.git", "objects/pack/x"), 4000)
writeFile(t, filepath.Join(root, ".repos", wsID, "gadgets.git", "objects/pack/y"), 2000)
report, err := ScanDiskUsage(root, nil)
if err != nil {
t.Fatalf("ScanDiskUsage: %v", err)
}
if report.RepoCacheSizeBytes != 6000 {
t.Errorf("repo_cache_size_bytes = %d, want 6000", report.RepoCacheSizeBytes)
}
if report.RepoCacheCount != 2 {
t.Errorf("repo_cache_count = %d, want 2", report.RepoCacheCount)
}
if report.TotalSizeBytes != 1000 {
t.Errorf("total_size_bytes = %d, want 1000 (task dirs only, cache excluded)", report.TotalSizeBytes)
}
if report.TotalWorkspaceCount != 1 {
t.Errorf("total_workspace_count = %d, want 1 (.repos is not a workspace)", report.TotalWorkspaceCount)
}
}
// TestScanDiskUsage_SkipsDaemonInternalDotDirs keeps caches like .skill-cache
// out of the per-workspace table, where they used to surface as bogus
// workspace rows alongside the real ones.
func TestScanDiskUsage_SkipsDaemonInternalDotDirs(t *testing.T) {
t.Parallel()
root := t.TempDir()
wsID := "11111111-1111-1111-1111-111111111111"
writeFile(t, filepath.Join(root, wsID, "aaaaaaaa", "workdir/main.go"), 1000)
writeFile(t, filepath.Join(root, ".skill-cache", "v1", "bundle", "skill.md"), 500)
report, err := ScanDiskUsage(root, nil)
if err != nil {
t.Fatalf("ScanDiskUsage: %v", err)
}
if report.TotalWorkspaceCount != 1 {
t.Fatalf("total_workspace_count = %d, want 1", report.TotalWorkspaceCount)
}
for _, ws := range report.Workspaces {
if strings.HasPrefix(ws.WorkspaceID, ".") {
t.Errorf("dot-directory %q reported as a workspace", ws.WorkspaceID)
}
}
if report.TotalSizeBytes != 1000 {
t.Errorf("total_size_bytes = %d, want 1000 (skill cache excluded)", report.TotalSizeBytes)
}
}