Files
multica/server/internal/daemon/gc_test.go
Bohan Jiang 1d1dedbf6e fix(daemon): reclaim disk on long-open issues + correct cancelled-status check (#1931)
* fix(daemon): reclaim disk on long-open issues + correct cancelled-status check

Two related fixes for GitHub #1890 (self-hosted disk space growth):

- The GC's done/cancelled branch compared `status.Status` against `"canceled"`
  (single l), but the issue schema and the rest of the daemon use `"cancelled"`
  (double l). Cancelled issues therefore never matched and only fell out via the
  72h orphan TTL, which itself doesn't fire because cancelled issues are still
  reachable. Aligning the spelling lets cancelled-issue task dirs be reclaimed
  on the normal TTL path.

- Add a third GC mode, artifact-only cleanup, for the common case the report
  flagged: an issue stays open for days while many tasks complete on it, so
  per-task `node_modules`, `.next` and `.turbo` directories accumulate without
  ever becoming GC-eligible. The new branch fires when `.gc_meta.completed_at`
  is older than `MULTICA_GC_ARTIFACT_TTL` (default 12h), the env root is not
  currently in use by an active task, and the issue is still alive. It removes
  only directories whose basename matches `MULTICA_GC_ARTIFACT_PATTERNS`
  (default narrow: `node_modules,.next,.turbo`); source, `.git`, `output/`,
  `logs/` and the meta file are preserved so subsequent tasks can still resume
  the workdir. Patterns containing path separators are dropped, `.git` subtrees
  are never descended into, symlinked matches are not followed, and every
  removal target is verified to live inside the task dir.

Bookkeeping: `Daemon` now tracks active env roots with a refcounted set so the
GC loop never reclaims a directory that is mid-execution; `runTask` claims the
predicted root early plus the prior workdir on reuse paths. The cycle log is
extended with bytes reclaimed and per-pattern counts so self-hosted operators
can see what was freed.

Docs: extend the daemon configuration table in CLI_AND_DAEMON.md with the new
GC env vars and add a Workspace garbage collection section explaining the
three modes and the artifact-pattern contract.

Co-authored-by: multica-agent <github@multica.ai>

* fix(daemon): protect active env root from full GC removal too

Address GPT-Boy's PR #1931 review: the active-root guard only fired in the
artifact-cleanup branch, leaving a real race on the full-removal paths. A
follow-up comment on a long-done issue dispatches a task that reuses the prior
workdir, but `CreateComment` does not bump issue.updated_at — so the issue
still satisfies the done+stale GCTTL window and `gcActionClean` would
`RemoveAll` the directory mid-execution. The orphan-404 path is similarly
exposed when a token's workspace access is in flux.

Move the `isActiveEnvRoot` check to the top of `shouldCleanTaskDir` so all
three delete actions (clean, orphan, artifact) skip an in-use env root in one
place, and drop the now-redundant guard from the artifact branch.

Add tests covering the three at-risk paths: active root + done/stale issue,
active root + 404 issue past orphan TTL, active root + no-meta orphan past
TTL.

Also align two stale comments noted in the same review: cleanTaskArtifacts now
documents that symlinks are skipped entirely (the previous note implied the
link itself was removed), and GCOrphanTTL no longer claims that 404s are
cleaned immediately — the implementation gates them on the same TTL.

Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: multica-agent <github@multica.ai>
2026-04-30 15:34:16 +08:00

656 lines
20 KiB
Go

package daemon
import (
"context"
"encoding/json"
"fmt"
"log/slog"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
"time"
"github.com/multica-ai/multica/server/internal/daemon/execenv"
)
// newGCTestDaemon creates a minimal Daemon for GC testing with a mock HTTP server.
func newGCTestDaemon(t *testing.T, handler http.Handler) *Daemon {
t.Helper()
srv := httptest.NewServer(handler)
t.Cleanup(srv.Close)
root := t.TempDir()
cfg := Config{
WorkspacesRoot: root,
GCEnabled: true,
GCInterval: 1 * time.Hour,
GCTTL: 5 * 24 * time.Hour,
GCOrphanTTL: 30 * 24 * time.Hour,
GCArtifactTTL: 12 * time.Hour,
GCArtifactPatterns: []string{"node_modules", ".next", ".turbo"},
}
d := New(cfg, slog.Default())
d.client = NewClient(srv.URL)
d.client.SetToken("test-token")
return d
}
// createTaskDir creates a task directory with optional GC metadata.
func createTaskDir(t *testing.T, root, wsID, dirName string, meta *execenv.GCMeta) string {
t.Helper()
taskDir := filepath.Join(root, wsID, dirName)
if err := os.MkdirAll(taskDir, 0o755); err != nil {
t.Fatal(err)
}
if meta != nil {
data, _ := json.Marshal(meta)
if err := os.WriteFile(filepath.Join(taskDir, ".gc_meta.json"), data, 0o644); err != nil {
t.Fatal(err)
}
}
return taskDir
}
func TestShouldCleanTaskDir_DoneIssueOverTTL(t *testing.T) {
t.Parallel()
issueID := "11111111-1111-1111-1111-111111111111"
mux := http.NewServeMux()
mux.HandleFunc(fmt.Sprintf("/api/daemon/issues/%s/gc-check", issueID), func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]any{
"status": "done",
"updated_at": time.Now().Add(-10 * 24 * time.Hour), // 10 days ago
})
})
d := newGCTestDaemon(t, mux)
taskDir := createTaskDir(t, d.cfg.WorkspacesRoot, "ws1", "task1", &execenv.GCMeta{
IssueID: issueID,
WorkspaceID: "ws1",
CompletedAt: time.Now().Add(-10 * 24 * time.Hour),
})
action := d.shouldCleanTaskDir(context.Background(), taskDir)
if action != gcActionClean {
t.Fatalf("expected gcActionClean, got %d", action)
}
}
func TestShouldCleanTaskDir_CancelledIssueOverTTL(t *testing.T) {
t.Parallel()
issueID := "22222222-2222-2222-2222-222222222222"
mux := http.NewServeMux()
mux.HandleFunc(fmt.Sprintf("/api/daemon/issues/%s/gc-check", issueID), func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]any{
"status": "cancelled",
"updated_at": time.Now().Add(-6 * 24 * time.Hour),
})
})
d := newGCTestDaemon(t, mux)
taskDir := createTaskDir(t, d.cfg.WorkspacesRoot, "ws1", "task2", &execenv.GCMeta{
IssueID: issueID,
WorkspaceID: "ws1",
CompletedAt: time.Now(),
})
action := d.shouldCleanTaskDir(context.Background(), taskDir)
if action != gcActionClean {
t.Fatalf("expected gcActionClean, got %d", action)
}
}
func TestShouldCleanTaskDir_OpenIssueSkipped(t *testing.T) {
t.Parallel()
issueID := "33333333-3333-3333-3333-333333333333"
mux := http.NewServeMux()
mux.HandleFunc(fmt.Sprintf("/api/daemon/issues/%s/gc-check", issueID), func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]any{
"status": "in_progress",
"updated_at": time.Now().Add(-30 * 24 * time.Hour),
})
})
d := newGCTestDaemon(t, mux)
taskDir := createTaskDir(t, d.cfg.WorkspacesRoot, "ws1", "task3", &execenv.GCMeta{
IssueID: issueID,
WorkspaceID: "ws1",
CompletedAt: time.Now(),
})
action := d.shouldCleanTaskDir(context.Background(), taskDir)
if action != gcActionSkip {
t.Fatalf("expected gcActionSkip for open issue, got %d", action)
}
}
func TestShouldCleanTaskDir_DoneButRecentSkipped(t *testing.T) {
t.Parallel()
issueID := "44444444-4444-4444-4444-444444444444"
mux := http.NewServeMux()
mux.HandleFunc(fmt.Sprintf("/api/daemon/issues/%s/gc-check", issueID), func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]any{
"status": "done",
"updated_at": time.Now().Add(-1 * 24 * time.Hour), // 1 day ago, within TTL
})
})
d := newGCTestDaemon(t, mux)
taskDir := createTaskDir(t, d.cfg.WorkspacesRoot, "ws1", "task4", &execenv.GCMeta{
IssueID: issueID,
WorkspaceID: "ws1",
CompletedAt: time.Now(),
})
action := d.shouldCleanTaskDir(context.Background(), taskDir)
if action != gcActionSkip {
t.Fatalf("expected gcActionSkip for recently-done issue, got %d", action)
}
}
func TestShouldCleanTaskDir_NoMetaRecentSkipped(t *testing.T) {
t.Parallel()
d := newGCTestDaemon(t, http.NewServeMux())
// No meta, fresh directory — should skip.
taskDir := createTaskDir(t, d.cfg.WorkspacesRoot, "ws1", "task5", nil)
action := d.shouldCleanTaskDir(context.Background(), taskDir)
if action != gcActionSkip {
t.Fatalf("expected gcActionSkip for recent orphan, got %d", action)
}
}
func TestShouldCleanTaskDir_NoMetaOldOrphan(t *testing.T) {
t.Parallel()
d := newGCTestDaemon(t, http.NewServeMux())
d.cfg.GCOrphanTTL = 0 // treat all orphans as expired
taskDir := createTaskDir(t, d.cfg.WorkspacesRoot, "ws1", "task6", nil)
action := d.shouldCleanTaskDir(context.Background(), taskDir)
if action != gcActionOrphan {
t.Fatalf("expected gcActionOrphan, got %d", action)
}
}
func TestShouldCleanTaskDir_APIErrorSkipped(t *testing.T) {
t.Parallel()
issueID := "55555555-5555-5555-5555-555555555555"
mux := http.NewServeMux()
mux.HandleFunc(fmt.Sprintf("/api/daemon/issues/%s/gc-check", issueID), func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
})
d := newGCTestDaemon(t, mux)
taskDir := createTaskDir(t, d.cfg.WorkspacesRoot, "ws1", "task7", &execenv.GCMeta{
IssueID: issueID,
WorkspaceID: "ws1",
CompletedAt: time.Now(),
})
action := d.shouldCleanTaskDir(context.Background(), taskDir)
if action != gcActionSkip {
t.Fatalf("expected gcActionSkip on API error, got %d", action)
}
}
func TestShouldCleanTaskDir_Issue404OldOrphan(t *testing.T) {
t.Parallel()
issueID := "66666666-6666-6666-6666-666666666666"
mux := http.NewServeMux()
mux.HandleFunc(fmt.Sprintf("/api/daemon/issues/%s/gc-check", issueID), func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNotFound)
w.Write([]byte(`{"error":"issue not found"}`))
})
d := newGCTestDaemon(t, mux)
d.cfg.GCOrphanTTL = 0 // treat orphans as immediately eligible
taskDir := createTaskDir(t, d.cfg.WorkspacesRoot, "ws1", "task8", &execenv.GCMeta{
IssueID: issueID,
WorkspaceID: "ws1",
CompletedAt: time.Now(),
})
action := d.shouldCleanTaskDir(context.Background(), taskDir)
if action != gcActionOrphan {
t.Fatalf("expected gcActionOrphan for unreachable issue past TTL, got %d", action)
}
}
// TestShouldCleanTaskDir_Issue404RecentSkipped locks in the cross-workspace
// safety: the server returns 404 both for deleted issues and for workspaces
// the daemon token can't see, so a recent 404 must NOT trigger immediate
// cleanup — otherwise a token re-scope could wipe dirs whose issues are live.
func TestShouldCleanTaskDir_Issue404RecentSkipped(t *testing.T) {
t.Parallel()
issueID := "66666666-6666-6666-6666-666666666667"
mux := http.NewServeMux()
mux.HandleFunc(fmt.Sprintf("/api/daemon/issues/%s/gc-check", issueID), func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNotFound)
w.Write([]byte(`{"error":"not found"}`))
})
d := newGCTestDaemon(t, mux)
// Default production OrphanTTL; taskDir mtime is now, so it's fresh.
taskDir := createTaskDir(t, d.cfg.WorkspacesRoot, "ws1", "fresh-404", &execenv.GCMeta{
IssueID: issueID,
WorkspaceID: "ws1",
CompletedAt: time.Now(),
})
action := d.shouldCleanTaskDir(context.Background(), taskDir)
if action != gcActionSkip {
t.Fatalf("expected gcActionSkip for recent 404 (cross-workspace safety), got %d", action)
}
}
func TestCleanTaskDir_RemovesDirectory(t *testing.T) {
t.Parallel()
d := newGCTestDaemon(t, http.NewServeMux())
taskDir := createTaskDir(t, d.cfg.WorkspacesRoot, "ws1", "doomed", nil)
if _, err := os.Stat(taskDir); err != nil {
t.Fatal("task dir should exist before cleanup")
}
d.cleanTaskDir(taskDir)
if _, err := os.Stat(taskDir); !os.IsNotExist(err) {
t.Fatal("task dir should be removed after cleanup")
}
}
func TestGcWorkspace_CleansEmptyWorkspaceDir(t *testing.T) {
t.Parallel()
issueID := "77777777-7777-7777-7777-777777777777"
mux := http.NewServeMux()
mux.HandleFunc(fmt.Sprintf("/api/daemon/issues/%s/gc-check", issueID), func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]any{
"status": "done",
"updated_at": time.Now().Add(-10 * 24 * time.Hour),
})
})
d := newGCTestDaemon(t, mux)
wsDir := filepath.Join(d.cfg.WorkspacesRoot, "ws-empty")
createTaskDir(t, d.cfg.WorkspacesRoot, "ws-empty", "only-task", &execenv.GCMeta{
IssueID: issueID,
WorkspaceID: "ws-empty",
CompletedAt: time.Now(),
})
d.gcWorkspace(context.Background(), wsDir, &gcStats{byPattern: map[string]int{}})
if _, err := os.Stat(wsDir); !os.IsNotExist(err) {
t.Fatal("empty workspace dir should be removed after all tasks cleaned")
}
}
func TestShouldCleanTaskDir_OpenIssueArtifactCleanup(t *testing.T) {
t.Parallel()
issueID := "88888888-8888-8888-8888-888888888888"
mux := http.NewServeMux()
mux.HandleFunc(fmt.Sprintf("/api/daemon/issues/%s/gc-check", issueID), func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]any{
"status": "in_progress",
"updated_at": time.Now(),
})
})
d := newGCTestDaemon(t, mux)
taskDir := createTaskDir(t, d.cfg.WorkspacesRoot, "ws1", "open-task", &execenv.GCMeta{
IssueID: issueID,
WorkspaceID: "ws1",
CompletedAt: time.Now().Add(-24 * time.Hour),
})
action := d.shouldCleanTaskDir(context.Background(), taskDir)
if action != gcActionCleanArtifacts {
t.Fatalf("expected gcActionCleanArtifacts for old completed task on open issue, got %d", action)
}
}
func TestShouldCleanTaskDir_OpenIssueRecentTaskSkipped(t *testing.T) {
t.Parallel()
issueID := "88888888-8888-8888-8888-888888888889"
mux := http.NewServeMux()
mux.HandleFunc(fmt.Sprintf("/api/daemon/issues/%s/gc-check", issueID), func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]any{
"status": "in_progress",
"updated_at": time.Now(),
})
})
d := newGCTestDaemon(t, mux)
taskDir := createTaskDir(t, d.cfg.WorkspacesRoot, "ws1", "fresh-task", &execenv.GCMeta{
IssueID: issueID,
WorkspaceID: "ws1",
CompletedAt: time.Now().Add(-1 * time.Minute),
})
if action := d.shouldCleanTaskDir(context.Background(), taskDir); action != gcActionSkip {
t.Fatalf("expected gcActionSkip for fresh completed_at, got %d", action)
}
}
func TestShouldCleanTaskDir_ActiveEnvRootSkipsArtifactCleanup(t *testing.T) {
t.Parallel()
issueID := "88888888-8888-8888-8888-88888888888a"
mux := http.NewServeMux()
mux.HandleFunc(fmt.Sprintf("/api/daemon/issues/%s/gc-check", issueID), func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]any{
"status": "in_progress",
"updated_at": time.Now(),
})
})
d := newGCTestDaemon(t, mux)
taskDir := createTaskDir(t, d.cfg.WorkspacesRoot, "ws1", "active-task", &execenv.GCMeta{
IssueID: issueID,
WorkspaceID: "ws1",
CompletedAt: time.Now().Add(-24 * time.Hour),
})
d.markActiveEnvRoot(taskDir)
defer d.unmarkActiveEnvRoot(taskDir)
if action := d.shouldCleanTaskDir(context.Background(), taskDir); action != gcActionSkip {
t.Fatalf("expected gcActionSkip while task is active, got %d", action)
}
}
func TestShouldCleanTaskDir_ActiveEnvRootSkipsFullCleanup(t *testing.T) {
t.Parallel()
issueID := "99999999-9999-9999-9999-999999999999"
mux := http.NewServeMux()
mux.HandleFunc(fmt.Sprintf("/api/daemon/issues/%s/gc-check", issueID), func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
// Done long enough ago to satisfy GCTTL — this would normally return
// gcActionClean. But the env root is in use (e.g. follow-up comment
// dispatched a task that reuses the prior workdir), and CreateComment
// does not bump issue.updated_at. Active-root guard must override.
json.NewEncoder(w).Encode(map[string]any{
"status": "done",
"updated_at": time.Now().Add(-30 * 24 * time.Hour),
})
})
d := newGCTestDaemon(t, mux)
taskDir := createTaskDir(t, d.cfg.WorkspacesRoot, "ws1", "active-done", &execenv.GCMeta{
IssueID: issueID,
WorkspaceID: "ws1",
CompletedAt: time.Now().Add(-30 * 24 * time.Hour),
})
d.markActiveEnvRoot(taskDir)
defer d.unmarkActiveEnvRoot(taskDir)
if action := d.shouldCleanTaskDir(context.Background(), taskDir); action != gcActionSkip {
t.Fatalf("expected gcActionSkip on active env root with done+stale issue, got %d", action)
}
}
func TestShouldCleanTaskDir_ActiveEnvRootSkipsOrphan404(t *testing.T) {
t.Parallel()
issueID := "99999999-9999-9999-9999-99999999999a"
mux := http.NewServeMux()
mux.HandleFunc(fmt.Sprintf("/api/daemon/issues/%s/gc-check", issueID), func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNotFound)
w.Write([]byte(`{"error":"not found"}`))
})
d := newGCTestDaemon(t, mux)
d.cfg.GCOrphanTTL = 0 // would normally make this an immediate orphan delete
taskDir := createTaskDir(t, d.cfg.WorkspacesRoot, "ws1", "active-404", &execenv.GCMeta{
IssueID: issueID,
WorkspaceID: "ws1",
CompletedAt: time.Now(),
})
d.markActiveEnvRoot(taskDir)
defer d.unmarkActiveEnvRoot(taskDir)
if action := d.shouldCleanTaskDir(context.Background(), taskDir); action != gcActionSkip {
t.Fatalf("expected gcActionSkip on active env root with 404 issue, got %d", action)
}
}
func TestShouldCleanTaskDir_ActiveEnvRootSkipsNoMetaOrphan(t *testing.T) {
t.Parallel()
d := newGCTestDaemon(t, http.NewServeMux())
d.cfg.GCOrphanTTL = 0
taskDir := createTaskDir(t, d.cfg.WorkspacesRoot, "ws1", "active-no-meta", nil)
d.markActiveEnvRoot(taskDir)
defer d.unmarkActiveEnvRoot(taskDir)
if action := d.shouldCleanTaskDir(context.Background(), taskDir); action != gcActionSkip {
t.Fatalf("expected gcActionSkip on active env root with no-meta orphan, got %d", action)
}
}
func TestShouldCleanTaskDir_ArtifactTTLDisabled(t *testing.T) {
t.Parallel()
issueID := "88888888-8888-8888-8888-88888888888b"
mux := http.NewServeMux()
mux.HandleFunc(fmt.Sprintf("/api/daemon/issues/%s/gc-check", issueID), func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]any{
"status": "in_progress",
"updated_at": time.Now(),
})
})
d := newGCTestDaemon(t, mux)
d.cfg.GCArtifactTTL = 0
taskDir := createTaskDir(t, d.cfg.WorkspacesRoot, "ws1", "no-artifact-gc", &execenv.GCMeta{
IssueID: issueID,
WorkspaceID: "ws1",
CompletedAt: time.Now().Add(-100 * 24 * time.Hour),
})
if action := d.shouldCleanTaskDir(context.Background(), taskDir); action != gcActionSkip {
t.Fatalf("expected gcActionSkip when artifact GC disabled, got %d", action)
}
}
func TestCleanTaskArtifacts_RemovesOnlyMatchedDirs(t *testing.T) {
t.Parallel()
d := newGCTestDaemon(t, http.NewServeMux())
taskDir := t.TempDir()
// Create a synthetic project layout.
mustMkdir := func(rel string) string {
p := filepath.Join(taskDir, rel)
if err := os.MkdirAll(p, 0o755); err != nil {
t.Fatal(err)
}
return p
}
mustWrite := func(rel string, content string) {
p := filepath.Join(taskDir, rel)
if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(p, []byte(content), 0o644); err != nil {
t.Fatal(err)
}
}
mustMkdir("workdir/repo/src")
mustWrite("workdir/repo/src/index.ts", "console.log('hi')")
mustMkdir("workdir/repo/.git/objects")
mustWrite("workdir/repo/.git/objects/pack", "binary")
mustMkdir("workdir/repo/node_modules/lodash")
mustWrite("workdir/repo/node_modules/lodash/index.js", "module.exports = {}")
mustMkdir("workdir/repo/.next/cache")
mustWrite("workdir/repo/.next/cache/page.html", "<html></html>")
mustMkdir("workdir/repo/.turbo")
mustWrite("workdir/repo/.turbo/log", "trace")
mustMkdir("workdir/repo/dist") // not in default patterns — must be preserved
mustWrite("workdir/repo/dist/main.js", "compiled")
mustWrite(".gc_meta.json", `{"issue_id":"x"}`)
mustMkdir("output")
mustWrite("output/result.txt", "done")
removed, bytes, perPattern := d.cleanTaskArtifacts(taskDir, []string{"node_modules", ".next", ".turbo"})
if removed != 3 {
t.Fatalf("expected 3 artifact dirs removed, got %d", removed)
}
if bytes <= 0 {
t.Fatalf("expected non-zero bytes reclaimed, got %d", bytes)
}
if perPattern["node_modules"] != 1 || perPattern[".next"] != 1 || perPattern[".turbo"] != 1 {
t.Fatalf("unexpected per-pattern counts: %+v", perPattern)
}
// Verify protected paths are intact.
for _, rel := range []string{
"workdir/repo/src/index.ts",
"workdir/repo/.git/objects/pack",
"workdir/repo/dist/main.js",
"output/result.txt",
".gc_meta.json",
} {
if _, err := os.Stat(filepath.Join(taskDir, rel)); err != nil {
t.Errorf("expected %s to be preserved, got %v", rel, err)
}
}
// Verify removed paths are gone.
for _, rel := range []string{
"workdir/repo/node_modules",
"workdir/repo/.next",
"workdir/repo/.turbo",
} {
if _, err := os.Stat(filepath.Join(taskDir, rel)); !os.IsNotExist(err) {
t.Errorf("expected %s to be removed, stat err=%v", rel, err)
}
}
}
func TestCleanTaskArtifacts_RejectsPatternsWithSeparators(t *testing.T) {
t.Parallel()
d := newGCTestDaemon(t, http.NewServeMux())
taskDir := t.TempDir()
if err := os.MkdirAll(filepath.Join(taskDir, "workdir", "node_modules"), 0o755); err != nil {
t.Fatal(err)
}
removed, _, _ := d.cleanTaskArtifacts(taskDir, []string{"workdir/node_modules", "../etc"})
if removed != 0 {
t.Fatalf("expected 0 removals from separator-bearing patterns, got %d", removed)
}
if _, err := os.Stat(filepath.Join(taskDir, "workdir", "node_modules")); err != nil {
t.Fatalf("dir should still exist, got %v", err)
}
}
func TestCleanTaskArtifacts_DoesNotFollowSymlinks(t *testing.T) {
t.Parallel()
d := newGCTestDaemon(t, http.NewServeMux())
taskDir := t.TempDir()
outside := t.TempDir()
keepFile := filepath.Join(outside, "keep.txt")
if err := os.WriteFile(keepFile, []byte("safe"), 0o644); err != nil {
t.Fatal(err)
}
if err := os.MkdirAll(filepath.Join(taskDir, "workdir"), 0o755); err != nil {
t.Fatal(err)
}
linkPath := filepath.Join(taskDir, "workdir", "node_modules")
if err := os.Symlink(outside, linkPath); err != nil {
t.Skipf("symlink not supported: %v", err)
}
removed, _, _ := d.cleanTaskArtifacts(taskDir, []string{"node_modules"})
if removed != 0 {
t.Fatalf("expected 0 removals (symlinked node_modules), got %d", removed)
}
if _, err := os.Stat(keepFile); err != nil {
t.Fatalf("symlinked target was deleted: %v", err)
}
}
func TestActiveEnvRootRefcount(t *testing.T) {
t.Parallel()
d := newGCTestDaemon(t, http.NewServeMux())
root := "/tmp/fake/env"
if d.isActiveEnvRoot(root) {
t.Fatal("expected inactive before mark")
}
d.markActiveEnvRoot(root)
d.markActiveEnvRoot(root) // second mark from reuse path
if !d.isActiveEnvRoot(root) {
t.Fatal("expected active after mark")
}
d.unmarkActiveEnvRoot(root)
if !d.isActiveEnvRoot(root) {
t.Fatal("expected still active after one unmark")
}
d.unmarkActiveEnvRoot(root)
if d.isActiveEnvRoot(root) {
t.Fatal("expected inactive after both unmarks")
}
}
func TestIsBareRepo(t *testing.T) {
t.Parallel()
t.Run("valid bare repo", func(t *testing.T) {
dir := t.TempDir()
os.WriteFile(filepath.Join(dir, "HEAD"), []byte("ref: refs/heads/main"), 0o644)
os.MkdirAll(filepath.Join(dir, "objects"), 0o755)
if !isBareRepo(dir) {
t.Fatal("expected isBareRepo=true for dir with HEAD + objects/")
}
})
t.Run("HEAD only", func(t *testing.T) {
dir := t.TempDir()
os.WriteFile(filepath.Join(dir, "HEAD"), []byte("ref: refs/heads/main"), 0o644)
if isBareRepo(dir) {
t.Fatal("expected isBareRepo=false for dir with only HEAD")
}
})
t.Run("empty dir", func(t *testing.T) {
dir := t.TempDir()
if isBareRepo(dir) {
t.Fatal("expected isBareRepo=false for empty dir")
}
})
}