mirror of
https://github.com/multica-ai/multica.git
synced 2026-08-12 00:45:55 +02:00
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: multica-agent <github@multica.ai>
1792 lines
60 KiB
Go
1792 lines
60 KiB
Go
package daemon
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"log/slog"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/multica-ai/multica/server/internal/daemon/execenv"
|
|
"github.com/multica-ai/multica/server/internal/daemon/repocache"
|
|
)
|
|
|
|
// 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 TestGCWorkspace_BatchesAndDeduplicatesIssueChecks(t *testing.T) {
|
|
doneID := "77777777-7777-7777-7777-777777777771"
|
|
openID := "77777777-7777-7777-7777-777777777772"
|
|
var batchRequests, legacyRequests int
|
|
|
|
mux := http.NewServeMux()
|
|
mux.HandleFunc("/api/daemon/workspaces/ws-batch/issues/gc-check", func(w http.ResponseWriter, r *http.Request) {
|
|
batchRequests++
|
|
if r.Method != http.MethodPost {
|
|
t.Fatalf("batch method = %s, want POST", r.Method)
|
|
}
|
|
var body struct {
|
|
IssueIDs []string `json:"issue_ids"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
|
t.Fatalf("decode batch request: %v", err)
|
|
}
|
|
if got, want := strings.Join(body.IssueIDs, ","), doneID+","+openID; got != want {
|
|
t.Fatalf("batch issue_ids = %q, want %q", got, want)
|
|
}
|
|
json.NewEncoder(w).Encode(map[string]any{"issues": []map[string]any{
|
|
{"id": doneID, "found": true, "status": "done", "updated_at": time.Now().Add(-10 * 24 * time.Hour)},
|
|
{"id": openID, "found": true, "status": "in_progress", "updated_at": time.Now()},
|
|
}})
|
|
})
|
|
mux.HandleFunc("/api/daemon/issues/", func(w http.ResponseWriter, r *http.Request) {
|
|
legacyRequests++
|
|
http.Error(w, "legacy endpoint must not be called", http.StatusInternalServerError)
|
|
})
|
|
|
|
d := newGCTestDaemon(t, mux)
|
|
wsDir := filepath.Join(d.cfg.WorkspacesRoot, "ws-batch")
|
|
doneA := createTaskDir(t, d.cfg.WorkspacesRoot, "ws-batch", "done-a", &execenv.GCMeta{
|
|
IssueID: doneID, WorkspaceID: "ws-batch", CompletedAt: time.Now().Add(-10 * 24 * time.Hour),
|
|
})
|
|
doneB := createTaskDir(t, d.cfg.WorkspacesRoot, "ws-batch", "done-b", &execenv.GCMeta{
|
|
IssueID: doneID, WorkspaceID: "ws-batch", CompletedAt: time.Now().Add(-10 * 24 * time.Hour),
|
|
})
|
|
open := createTaskDir(t, d.cfg.WorkspacesRoot, "ws-batch", "open", &execenv.GCMeta{
|
|
IssueID: openID, WorkspaceID: "ws-batch", CompletedAt: time.Now(),
|
|
})
|
|
|
|
stats := &gcStats{byPattern: map[string]int{}}
|
|
d.gcWorkspace(context.Background(), wsDir, stats)
|
|
|
|
if batchRequests != 1 || legacyRequests != 0 {
|
|
t.Fatalf("requests: batch=%d legacy=%d, want batch=1 legacy=0", batchRequests, legacyRequests)
|
|
}
|
|
for _, dir := range []string{doneA, doneB} {
|
|
if _, err := os.Stat(dir); !os.IsNotExist(err) {
|
|
t.Fatalf("done task dir %s was not removed", dir)
|
|
}
|
|
}
|
|
if _, err := os.Stat(open); err != nil {
|
|
t.Fatalf("open task dir should remain: %v", err)
|
|
}
|
|
if stats.cleaned != 2 || stats.skipped != 1 {
|
|
t.Fatalf("stats = cleaned:%d skipped:%d, want 2/1", stats.cleaned, stats.skipped)
|
|
}
|
|
}
|
|
|
|
func TestGCWorkspace_OldServerFallbackIsCached(t *testing.T) {
|
|
issueA := "77777777-7777-7777-7777-777777777773"
|
|
issueB := "77777777-7777-7777-7777-777777777774"
|
|
var batchRequests, legacyRequests int
|
|
|
|
mux := http.NewServeMux()
|
|
mux.HandleFunc("/api/daemon/workspaces/", func(w http.ResponseWriter, r *http.Request) {
|
|
batchRequests++
|
|
http.NotFound(w, r)
|
|
})
|
|
mux.HandleFunc("/api/daemon/issues/", func(w http.ResponseWriter, r *http.Request) {
|
|
legacyRequests++
|
|
json.NewEncoder(w).Encode(map[string]any{
|
|
"status": "done", "updated_at": time.Now().Add(-10 * 24 * time.Hour),
|
|
})
|
|
})
|
|
|
|
d := newGCTestDaemon(t, mux)
|
|
for i, tc := range []struct {
|
|
workspace string
|
|
issueID string
|
|
}{{"ws-legacy-a", issueA}, {"ws-legacy-b", issueB}} {
|
|
wsDir := filepath.Join(d.cfg.WorkspacesRoot, tc.workspace)
|
|
taskDir := createTaskDir(t, d.cfg.WorkspacesRoot, tc.workspace, fmt.Sprintf("task-%d", i), &execenv.GCMeta{
|
|
IssueID: tc.issueID, WorkspaceID: tc.workspace, CompletedAt: time.Now().Add(-10 * 24 * time.Hour),
|
|
})
|
|
d.gcWorkspace(context.Background(), wsDir, &gcStats{byPattern: map[string]int{}})
|
|
if _, err := os.Stat(taskDir); !os.IsNotExist(err) {
|
|
t.Fatalf("legacy fallback did not clean %s", taskDir)
|
|
}
|
|
}
|
|
|
|
if batchRequests != 1 {
|
|
t.Fatalf("batch requests = %d, want 1 capability probe", batchRequests)
|
|
}
|
|
if legacyRequests != 2 {
|
|
t.Fatalf("legacy requests = %d, want 2", legacyRequests)
|
|
}
|
|
}
|
|
|
|
func TestGCWorkspace_BatchFailureDoesNotFanOutOrClean(t *testing.T) {
|
|
issueID := "77777777-7777-7777-7777-777777777775"
|
|
var batchRequests, legacyRequests int
|
|
|
|
mux := http.NewServeMux()
|
|
mux.HandleFunc("/api/daemon/workspaces/ws-fail/issues/gc-check", func(w http.ResponseWriter, r *http.Request) {
|
|
batchRequests++
|
|
http.Error(w, "temporary failure", http.StatusInternalServerError)
|
|
})
|
|
mux.HandleFunc("/api/daemon/issues/", func(w http.ResponseWriter, r *http.Request) {
|
|
legacyRequests++
|
|
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-fail")
|
|
taskDir := createTaskDir(t, d.cfg.WorkspacesRoot, "ws-fail", "task", &execenv.GCMeta{
|
|
IssueID: issueID, WorkspaceID: "ws-fail", CompletedAt: time.Now().Add(-10 * 24 * time.Hour),
|
|
})
|
|
stats := &gcStats{byPattern: map[string]int{}}
|
|
d.gcWorkspace(context.Background(), wsDir, stats)
|
|
|
|
if batchRequests != 1 || legacyRequests != 0 {
|
|
t.Fatalf("requests: batch=%d legacy=%d, want batch=1 legacy=0", batchRequests, legacyRequests)
|
|
}
|
|
if _, err := os.Stat(taskDir); err != nil {
|
|
t.Fatalf("task dir must remain on batch failure: %v", err)
|
|
}
|
|
if stats.cleaned != 0 || stats.orphaned != 0 || stats.skipped != 1 {
|
|
t.Fatalf("unexpected stats after failure: %+v", stats)
|
|
}
|
|
}
|
|
|
|
func TestGCWorkspace_ReclaimsLegacyCodexSandboxWithoutConfiguredPatterns(t *testing.T) {
|
|
issueID := "77777777-7777-7777-7777-777777777776"
|
|
|
|
mux := http.NewServeMux()
|
|
mux.HandleFunc("/api/daemon/workspaces/ws-legacy-codex/issues/gc-check", func(w http.ResponseWriter, r *http.Request) {
|
|
json.NewEncoder(w).Encode(map[string]any{"issues": []map[string]any{
|
|
{"id": issueID, "found": true, "status": "in_progress", "updated_at": time.Now()},
|
|
}})
|
|
})
|
|
|
|
d := newGCTestDaemon(t, mux)
|
|
d.cfg.GCArtifactPatterns = nil
|
|
wsDir := filepath.Join(d.cfg.WorkspacesRoot, "ws-legacy-codex")
|
|
taskDir := createTaskDir(t, d.cfg.WorkspacesRoot, "ws-legacy-codex", "v0.4.0-task", nil)
|
|
// Raw v0.4.0 metadata shape: no migration or newly introduced marker is
|
|
// needed for an already-existing task to become eligible after upgrade.
|
|
legacyMeta := fmt.Sprintf(
|
|
`{"kind":"issue","issue_id":%q,"workspace_id":"ws-legacy-codex","completed_at":%q,"local_directory":true}`,
|
|
issueID,
|
|
time.Now().Add(-24*time.Hour).UTC().Format(time.RFC3339Nano),
|
|
)
|
|
if err := os.WriteFile(filepath.Join(taskDir, ".gc_meta.json"), []byte(legacyMeta), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
// A tiny representative file keeps the regression fast; the reported
|
|
// Windows executable is about 325 MiB but has identical GC semantics.
|
|
writeFile(t, filepath.Join(taskDir, "codex-home/.sandbox-bin/codex.exe"), 325)
|
|
for _, rel := range []string{
|
|
"codex-home/auth.json",
|
|
"codex-home/config.toml",
|
|
"codex-home/sessions/session.jsonl",
|
|
"output/result.txt",
|
|
"logs/task.log",
|
|
"workdir/repo/.sandbox-bin/user-owned",
|
|
} {
|
|
writeFile(t, filepath.Join(taskDir, filepath.FromSlash(rel)), 10)
|
|
}
|
|
|
|
stats := &gcStats{byPattern: map[string]int{}}
|
|
d.gcWorkspace(context.Background(), wsDir, stats)
|
|
|
|
if _, err := os.Stat(filepath.Join(taskDir, "codex-home/.sandbox-bin")); !os.IsNotExist(err) {
|
|
t.Fatalf("managed Codex sandbox should be removed, stat err=%v", err)
|
|
}
|
|
for _, rel := range []string{
|
|
"codex-home/auth.json",
|
|
"codex-home/config.toml",
|
|
"codex-home/sessions/session.jsonl",
|
|
"output/result.txt",
|
|
"logs/task.log",
|
|
"workdir/repo/.sandbox-bin/user-owned",
|
|
".gc_meta.json",
|
|
} {
|
|
if _, err := os.Stat(filepath.Join(taskDir, filepath.FromSlash(rel))); err != nil {
|
|
t.Errorf("expected %s to be preserved: %v", rel, err)
|
|
}
|
|
}
|
|
managedPattern := managedArtifactPatternPrefix + "codex-home/.sandbox-bin"
|
|
if stats.artifactDirs != 1 || stats.artifactRemoved != 1 || stats.bytesReclaimed != 325 || stats.skipped != 1 {
|
|
t.Fatalf("unexpected managed cleanup stats: %+v", stats)
|
|
}
|
|
if stats.byPattern[managedPattern] != 1 {
|
|
t.Fatalf("managed pattern stats = %+v, want %q=1", stats.byPattern, managedPattern)
|
|
}
|
|
}
|
|
|
|
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_LegacyMetaUsesManagedOnlyFallback(t *testing.T) {
|
|
t.Parallel()
|
|
issueID := "88888888-8888-8888-8888-88888888888c"
|
|
|
|
mux := http.NewServeMux()
|
|
mux.HandleFunc(fmt.Sprintf("/api/daemon/issues/%s/gc-check", issueID), func(w http.ResponseWriter, r *http.Request) {
|
|
json.NewEncoder(w).Encode(map[string]any{
|
|
"status": "in_progress", "updated_at": time.Now(),
|
|
})
|
|
})
|
|
|
|
d := newGCTestDaemon(t, mux)
|
|
d.cfg.GCOrphanTTL = 72 * time.Hour
|
|
taskDir := createTaskDir(t, d.cfg.WorkspacesRoot, "ws1", "legacy-meta", &execenv.GCMeta{
|
|
Kind: execenv.GCKindIssue, IssueID: issueID, WorkspaceID: "ws1",
|
|
})
|
|
old := time.Now().Add(-73 * time.Hour)
|
|
if err := os.Chtimes(filepath.Join(taskDir, ".gc_meta.json"), old, old); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
if action := d.shouldCleanTaskDir(context.Background(), taskDir); action != gcActionCleanManagedArtifacts {
|
|
t.Fatalf("expected managed-only fallback for stale legacy meta, got %d", action)
|
|
}
|
|
|
|
recentDir := createTaskDir(t, d.cfg.WorkspacesRoot, "ws1", "recent-legacy-meta", &execenv.GCMeta{
|
|
Kind: execenv.GCKindIssue, IssueID: issueID, WorkspaceID: "ws1",
|
|
})
|
|
// Nested activity may leave taskDir's own mtime stale. A recently rewritten
|
|
// metadata file is the authoritative fallback and must defer cleanup.
|
|
if err := os.Chtimes(recentDir, old, old); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if action := d.shouldCleanTaskDir(context.Background(), recentDir); action != gcActionSkip {
|
|
t.Fatalf("expected recent legacy meta to remain untouched, 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. a re-dispatched task
|
|
// reuses the prior workdir of an already-done issue whose updated_at is
|
|
// still stale, since a task re-claim doesn't advance it). 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 TestShouldCleanTaskDir_ArtifactTTLDisabledSkipsLocalOrphanManagedCleanup(t *testing.T) {
|
|
t.Parallel()
|
|
issueID := "88888888-8888-8888-8888-88888888888d"
|
|
|
|
mux := http.NewServeMux()
|
|
mux.HandleFunc(fmt.Sprintf("/api/daemon/issues/%s/gc-check", issueID), func(w http.ResponseWriter, r *http.Request) {
|
|
http.NotFound(w, r)
|
|
})
|
|
|
|
d := newGCTestDaemon(t, mux)
|
|
d.cfg.GCArtifactTTL = 0
|
|
d.cfg.GCOrphanTTL = 0
|
|
taskDir := createTaskDir(t, d.cfg.WorkspacesRoot, "ws1", "disabled-local-orphan", &execenv.GCMeta{
|
|
Kind: execenv.GCKindIssue, IssueID: issueID, WorkspaceID: "ws1", LocalDirectory: true,
|
|
})
|
|
|
|
if action := d.shouldCleanTaskDir(context.Background(), taskDir); action != gcActionSkip {
|
|
t.Fatalf("expected artifact TTL zero to disable managed local orphan cleanup, 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 TestCleanTaskArtifacts_ManagedPathIsExactAndDeduplicated(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
d := newGCTestDaemon(t, http.NewServeMux())
|
|
|
|
t.Run("managed only", func(t *testing.T) {
|
|
taskDir := t.TempDir()
|
|
writeFile(t, filepath.Join(taskDir, "codex-home/.sandbox-bin/codex.exe"), 300)
|
|
writeFile(t, filepath.Join(taskDir, "workdir/repo/.sandbox-bin/keep"), 400)
|
|
writeFile(t, filepath.Join(taskDir, "codex-home/auth.json"), 10)
|
|
|
|
removed, bytes, perPattern := d.cleanTaskArtifacts(taskDir, nil)
|
|
if removed != 1 || bytes != 300 {
|
|
t.Fatalf("removed=%d bytes=%d, want 1/300", removed, bytes)
|
|
}
|
|
if perPattern[managedArtifactPatternPrefix+"codex-home/.sandbox-bin"] != 1 {
|
|
t.Fatalf("unexpected per-pattern stats: %+v", perPattern)
|
|
}
|
|
for _, rel := range []string{"workdir/repo/.sandbox-bin/keep", "codex-home/auth.json"} {
|
|
if _, err := os.Stat(filepath.Join(taskDir, filepath.FromSlash(rel))); err != nil {
|
|
t.Errorf("expected %s to be preserved: %v", rel, err)
|
|
}
|
|
}
|
|
})
|
|
|
|
t.Run("explicit broad basename", func(t *testing.T) {
|
|
taskDir := t.TempDir()
|
|
writeFile(t, filepath.Join(taskDir, "codex-home/.sandbox-bin/codex.exe"), 300)
|
|
writeFile(t, filepath.Join(taskDir, "workdir/repo/.sandbox-bin/cache"), 400)
|
|
|
|
removed, bytes, perPattern := d.cleanTaskArtifacts(taskDir, []string{".sandbox-bin"})
|
|
if removed != 2 || bytes != 700 {
|
|
t.Fatalf("removed=%d bytes=%d, want 2/700 without double counting", removed, bytes)
|
|
}
|
|
if perPattern[managedArtifactPatternPrefix+"codex-home/.sandbox-bin"] != 1 || perPattern[".sandbox-bin"] != 1 {
|
|
t.Fatalf("unexpected per-pattern stats: %+v", perPattern)
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestCleanTaskArtifacts_ManagedPathDoesNotFollowSymlinks(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
d := newGCTestDaemon(t, http.NewServeMux())
|
|
|
|
for _, tc := range []struct {
|
|
name string
|
|
linkPath string
|
|
}{
|
|
{name: "leaf", linkPath: "codex-home/.sandbox-bin"},
|
|
{name: "parent", linkPath: "codex-home"},
|
|
} {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
taskDir := t.TempDir()
|
|
outside := t.TempDir()
|
|
keepFile := filepath.Join(outside, "keep")
|
|
writeFile(t, keepFile, 10)
|
|
linkPath := filepath.Join(taskDir, filepath.FromSlash(tc.linkPath))
|
|
if err := os.MkdirAll(filepath.Dir(linkPath), 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.Symlink(outside, linkPath); err != nil {
|
|
t.Skipf("symlink not supported: %v", err)
|
|
}
|
|
|
|
removed, _, _ := d.cleanTaskArtifacts(taskDir, nil)
|
|
if removed != 0 {
|
|
t.Fatalf("removed=%d, want 0 for symlinked managed path", removed)
|
|
}
|
|
if _, err := os.Stat(keepFile); err != nil {
|
|
t.Fatalf("symlink target was touched: %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 TestReserveEnvRootForGCIsExclusive(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
d := newGCTestDaemon(t, http.NewServeMux())
|
|
root := "/tmp/fake/gc-reservation"
|
|
|
|
d.markActiveEnvRoot(root)
|
|
if _, ok := d.reserveEnvRootForGC(root); ok {
|
|
t.Fatal("GC reservation must fail while a task is active")
|
|
}
|
|
d.unmarkActiveEnvRoot(root)
|
|
|
|
release, ok := d.reserveEnvRootForGC(root)
|
|
if !ok {
|
|
t.Fatal("expected GC reservation for inactive env root")
|
|
}
|
|
if _, ok := d.reserveEnvRootForGC(root); ok {
|
|
t.Fatal("second GC reservation must fail while the first is held")
|
|
}
|
|
release()
|
|
|
|
release, ok = d.reserveEnvRootForGC(root)
|
|
if !ok {
|
|
t.Fatal("expected GC reservation after release")
|
|
}
|
|
release()
|
|
}
|
|
|
|
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")
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestPruneWorktree_RemovesOnlyStaleAgentBranches(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
d := newGCTestDaemon(t, http.NewServeMux())
|
|
sourceRepo := createGCGitRepo(t)
|
|
barePath := filepath.Join(t.TempDir(), "cache.git")
|
|
|
|
runGitForGC(t, "", "clone", "--bare", sourceRepo, barePath)
|
|
|
|
activeWorktree := filepath.Join(t.TempDir(), "active")
|
|
activeBranch := "agent/live/12345678"
|
|
staleBranch := "agent/stale/87654321"
|
|
keepBranch := "main"
|
|
|
|
runGitForGC(t, "", "-C", barePath, "worktree", "add", "-b", activeBranch, activeWorktree, "HEAD")
|
|
runGitForGC(t, "", "-C", barePath, "branch", staleBranch, "HEAD")
|
|
|
|
d.pruneWorktree(barePath)
|
|
|
|
if gitRefExists(t, barePath, "refs/heads/"+staleBranch) {
|
|
t.Fatalf("expected stale branch %q to be deleted", staleBranch)
|
|
}
|
|
if !gitRefExists(t, barePath, "refs/heads/"+activeBranch) {
|
|
t.Fatalf("expected active branch %q to be preserved", activeBranch)
|
|
}
|
|
if !gitRefExists(t, barePath, "refs/heads/"+keepBranch) {
|
|
t.Fatalf("expected non-agent branch %q to be preserved", keepBranch)
|
|
}
|
|
}
|
|
|
|
// TestPruneWorktree_IgnoresLiteralAgentBranch ensures the GC pattern is scoped
|
|
// to the `agent/` namespace. A repo whose only `agent`-shaped ref is the
|
|
// literal `refs/heads/agent` (no slash) must be left untouched — the
|
|
// `for-each-ref` query is narrowed to `refs/heads/agent/` for that reason.
|
|
func TestPruneWorktree_IgnoresLiteralAgentBranch(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
d := newGCTestDaemon(t, http.NewServeMux())
|
|
sourceRepo := createGCGitRepo(t)
|
|
barePath := filepath.Join(t.TempDir(), "cache.git")
|
|
|
|
runGitForGC(t, "", "clone", "--bare", sourceRepo, barePath)
|
|
runGitForGC(t, "", "-C", barePath, "branch", "agent", "HEAD")
|
|
|
|
d.pruneWorktree(barePath)
|
|
|
|
if !gitRefExists(t, barePath, "refs/heads/agent") {
|
|
t.Fatal("expected literal `agent` branch outside the daemon namespace to be preserved")
|
|
}
|
|
}
|
|
|
|
// TestPruneWorktree_SkipsMaintenanceWhenNothingDeleted pins the gate that
|
|
// keeps the heavy `gc --prune` step from running on every GC tick. Uses an
|
|
// unreachable loose blob backdated past the prune horizon as a sentinel: it
|
|
// survives when no agent branch was deleted (no maintenance), and disappears
|
|
// once a stale agent branch is reaped (maintenance ran).
|
|
func TestPruneWorktree_SkipsMaintenanceWhenNothingDeleted(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
d := newGCTestDaemon(t, http.NewServeMux())
|
|
sourceRepo := createGCGitRepo(t)
|
|
barePath := filepath.Join(t.TempDir(), "cache.git")
|
|
|
|
runGitForGC(t, "", "clone", "--bare", sourceRepo, barePath)
|
|
|
|
// Park an active agent worktree so the scan has something to filter, and
|
|
// to make sure pruneWorktree exercises the full code path.
|
|
activeWorktree := filepath.Join(t.TempDir(), "active")
|
|
runGitForGC(t, "", "-C", barePath, "worktree", "add", "-b", "agent/live/12345678", activeWorktree, "HEAD")
|
|
|
|
sentinelPath := writeOldLooseBlob(t, barePath, "sentinel-content", 60*24*time.Hour)
|
|
|
|
// No stale agent branch → no deletion → no `gc --prune`. The sentinel
|
|
// blob must survive.
|
|
d.pruneWorktree(barePath)
|
|
if _, err := os.Stat(sentinelPath); err != nil {
|
|
t.Fatalf("expected sentinel blob to survive when nothing was deleted: %v", err)
|
|
}
|
|
|
|
// Introduce a stale agent branch → deletion happens → maintenance runs →
|
|
// `gc --prune=30.days` reaps the sentinel blob.
|
|
runGitForGC(t, "", "-C", barePath, "branch", "agent/stale/87654321", "HEAD")
|
|
d.pruneWorktree(barePath)
|
|
if _, err := os.Stat(sentinelPath); !os.IsNotExist(err) {
|
|
t.Fatalf("expected sentinel blob to be pruned after maintenance ran, stat err=%v", err)
|
|
}
|
|
}
|
|
|
|
// writeOldLooseBlob writes a dangling loose-object blob to the bare repo and
|
|
// backdates its mtime so `git gc --prune=30.days` will consider it prunable.
|
|
// Returns the absolute path to the loose object on disk.
|
|
func writeOldLooseBlob(t *testing.T, barePath, content string, age time.Duration) string {
|
|
t.Helper()
|
|
cmd := exec.Command("git", "-C", barePath, "hash-object", "-w", "--stdin")
|
|
cmd.Stdin = strings.NewReader(content)
|
|
out, err := cmd.CombinedOutput()
|
|
if err != nil {
|
|
t.Fatalf("hash-object failed: %v: %s", err, out)
|
|
}
|
|
sha := strings.TrimSpace(string(out))
|
|
if len(sha) < 4 {
|
|
t.Fatalf("unexpected sha output: %q", sha)
|
|
}
|
|
loose := filepath.Join(barePath, "objects", sha[:2], sha[2:])
|
|
if _, err := os.Stat(loose); err != nil {
|
|
t.Fatalf("expected loose object at %s: %v", loose, err)
|
|
}
|
|
old := time.Now().Add(-age)
|
|
if err := os.Chtimes(loose, old, old); err != nil {
|
|
t.Fatalf("chtimes failed: %v", err)
|
|
}
|
|
return loose
|
|
}
|
|
|
|
func TestPruneWorktree_SerializesWithCreateWorktree(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
d := newGCTestDaemon(t, http.NewServeMux())
|
|
sourceRepo := createGCGitRepo(t)
|
|
cache := repocache.New(filepath.Join(d.cfg.WorkspacesRoot, ".repos"), slog.Default())
|
|
if err := cache.Sync("ws1", []repocache.RepoInfo{{URL: sourceRepo}}); err != nil {
|
|
t.Fatalf("cache sync failed: %v", err)
|
|
}
|
|
|
|
barePath := cache.Lookup("ws1", sourceRepo)
|
|
if barePath == "" {
|
|
t.Fatal("expected bare repo to be cached")
|
|
}
|
|
|
|
runGitForGC(t, "", "-C", barePath, "branch", "agent/stale/87654321", "HEAD")
|
|
|
|
blockingCache := &blockingRepoCache{
|
|
inner: cache,
|
|
entered: make(chan struct{}),
|
|
release: make(chan struct{}),
|
|
}
|
|
d.repoCache = blockingCache
|
|
|
|
pruneDone := make(chan struct{})
|
|
go func() {
|
|
d.pruneWorktree(barePath)
|
|
close(pruneDone)
|
|
}()
|
|
|
|
select {
|
|
case <-blockingCache.entered:
|
|
case <-time.After(5 * time.Second):
|
|
t.Fatal("timed out waiting for pruneWorktree to acquire repo lock")
|
|
}
|
|
|
|
createDone := make(chan error, 1)
|
|
go func() {
|
|
_, err := blockingCache.CreateWorktree(repocache.WorktreeParams{
|
|
WorkspaceID: "ws1",
|
|
RepoURL: sourceRepo,
|
|
WorkDir: t.TempDir(),
|
|
AgentName: "tester",
|
|
TaskID: "11111111-1111-1111-1111-111111111111",
|
|
})
|
|
createDone <- err
|
|
}()
|
|
|
|
select {
|
|
case err := <-createDone:
|
|
t.Fatalf("CreateWorktree should wait for GC lock, returned early with err=%v", err)
|
|
case <-time.After(200 * time.Millisecond):
|
|
}
|
|
|
|
close(blockingCache.release)
|
|
|
|
select {
|
|
case err := <-createDone:
|
|
if err != nil {
|
|
t.Fatalf("CreateWorktree failed after GC lock released: %v", err)
|
|
}
|
|
case <-time.After(5 * time.Second):
|
|
t.Fatal("timed out waiting for CreateWorktree after releasing GC lock")
|
|
}
|
|
|
|
select {
|
|
case <-pruneDone:
|
|
case <-time.After(5 * time.Second):
|
|
t.Fatal("timed out waiting for pruneWorktree to finish")
|
|
}
|
|
}
|
|
|
|
type blockingRepoCache struct {
|
|
inner *repocache.Cache
|
|
entered chan struct{}
|
|
release chan struct{}
|
|
}
|
|
|
|
func (c *blockingRepoCache) Lookup(workspaceID, url string) string {
|
|
return c.inner.Lookup(workspaceID, url)
|
|
}
|
|
|
|
func (c *blockingRepoCache) BarePath(workspaceID, url string) string {
|
|
return c.inner.BarePath(workspaceID, url)
|
|
}
|
|
|
|
func (c *blockingRepoCache) Sync(workspaceID string, repos []repocache.RepoInfo) error {
|
|
return c.inner.Sync(workspaceID, repos)
|
|
}
|
|
|
|
func (c *blockingRepoCache) WithRepoLock(barePath string, fn func() error) error {
|
|
return c.inner.WithRepoLock(barePath, func() error {
|
|
close(c.entered)
|
|
<-c.release
|
|
return fn()
|
|
})
|
|
}
|
|
|
|
func (c *blockingRepoCache) CreateWorktree(params repocache.WorktreeParams) (*repocache.WorktreeResult, error) {
|
|
return c.inner.CreateWorktree(params)
|
|
}
|
|
|
|
// TestShouldCleanTaskDir_KindDispatch covers the four GCMeta kinds across
|
|
// active / terminal / 404 / non-terminal axes. Each entry stands up a mock
|
|
// server returning the expected payload (or 404) and asserts the action.
|
|
func TestShouldCleanTaskDir_KindDispatch(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
const (
|
|
issueID = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaa01"
|
|
chatID = "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbb01"
|
|
runID = "cccccccc-cccc-cccc-cccc-cccccccccc01"
|
|
quickTask = "dddddddd-dddd-dddd-dddd-dddddddddd01"
|
|
legacyMeta = "eeeeeeee-eeee-eeee-eeee-eeeeeeeeee01"
|
|
)
|
|
|
|
now := time.Now()
|
|
overTTL := now.Add(-10 * 24 * time.Hour)
|
|
withinTTL := now.Add(-1 * time.Hour)
|
|
|
|
type serverResp struct {
|
|
// Path to register on the mux. Empty entries are skipped (used for
|
|
// 404 cases where the mux returns the default not-found handler).
|
|
path string
|
|
status int
|
|
body map[string]any
|
|
}
|
|
|
|
cases := []struct {
|
|
name string
|
|
meta *execenv.GCMeta
|
|
servers []serverResp
|
|
want gcAction
|
|
}{
|
|
// ---- chat ---------------------------------------------------------
|
|
{
|
|
name: "chat active session — never reclaimed",
|
|
meta: &execenv.GCMeta{Kind: execenv.GCKindChat, ChatSessionID: chatID, WorkspaceID: "ws"},
|
|
servers: []serverResp{{
|
|
path: "/api/daemon/chat-sessions/" + chatID + "/gc-check",
|
|
body: map[string]any{"status": "active", "updated_at": overTTL},
|
|
}},
|
|
want: gcActionSkip,
|
|
},
|
|
{
|
|
name: "chat archived over TTL — clean",
|
|
meta: &execenv.GCMeta{Kind: execenv.GCKindChat, ChatSessionID: chatID, WorkspaceID: "ws"},
|
|
servers: []serverResp{{
|
|
path: "/api/daemon/chat-sessions/" + chatID + "/gc-check",
|
|
body: map[string]any{"status": "archived", "updated_at": overTTL},
|
|
}},
|
|
want: gcActionClean,
|
|
},
|
|
{
|
|
name: "chat archived within TTL — skip",
|
|
meta: &execenv.GCMeta{Kind: execenv.GCKindChat, ChatSessionID: chatID, WorkspaceID: "ws"},
|
|
servers: []serverResp{{
|
|
path: "/api/daemon/chat-sessions/" + chatID + "/gc-check",
|
|
body: map[string]any{"status": "archived", "updated_at": withinTTL},
|
|
}},
|
|
want: gcActionSkip,
|
|
},
|
|
{
|
|
name: "chat 404 — hard-deleted, clean immediately (no mtime gate)",
|
|
meta: &execenv.GCMeta{Kind: execenv.GCKindChat, ChatSessionID: chatID, WorkspaceID: "ws"},
|
|
servers: []serverResp{{
|
|
path: "/api/daemon/chat-sessions/" + chatID + "/gc-check",
|
|
status: http.StatusNotFound,
|
|
}},
|
|
want: gcActionClean,
|
|
},
|
|
|
|
// ---- autopilot run -----------------------------------------------
|
|
{
|
|
name: "autopilot completed over TTL — clean",
|
|
meta: &execenv.GCMeta{Kind: execenv.GCKindAutopilotRun, AutopilotRunID: runID, WorkspaceID: "ws"},
|
|
servers: []serverResp{{
|
|
path: "/api/daemon/autopilot-runs/" + runID + "/gc-check",
|
|
body: map[string]any{"status": "completed", "completed_at": overTTL},
|
|
}},
|
|
want: gcActionClean,
|
|
},
|
|
{
|
|
name: "autopilot issue_created counts as terminal",
|
|
meta: &execenv.GCMeta{Kind: execenv.GCKindAutopilotRun, AutopilotRunID: runID, WorkspaceID: "ws"},
|
|
servers: []serverResp{{
|
|
path: "/api/daemon/autopilot-runs/" + runID + "/gc-check",
|
|
body: map[string]any{"status": "issue_created", "completed_at": overTTL},
|
|
}},
|
|
want: gcActionClean,
|
|
},
|
|
{
|
|
name: "autopilot running — skip",
|
|
meta: &execenv.GCMeta{Kind: execenv.GCKindAutopilotRun, AutopilotRunID: runID, WorkspaceID: "ws"},
|
|
servers: []serverResp{{
|
|
path: "/api/daemon/autopilot-runs/" + runID + "/gc-check",
|
|
body: map[string]any{"status": "running"},
|
|
}},
|
|
want: gcActionSkip,
|
|
},
|
|
{
|
|
name: "autopilot pending — skip",
|
|
meta: &execenv.GCMeta{Kind: execenv.GCKindAutopilotRun, AutopilotRunID: runID, WorkspaceID: "ws"},
|
|
servers: []serverResp{{
|
|
path: "/api/daemon/autopilot-runs/" + runID + "/gc-check",
|
|
body: map[string]any{"status": "pending"},
|
|
}},
|
|
want: gcActionSkip,
|
|
},
|
|
{
|
|
// The directory is never reused, so a terminal run is reclaimed on
|
|
// sight — the recent completed_at no longer buys it a 24h reprieve.
|
|
name: "autopilot completed within TTL — clean immediately (no 24h gate)",
|
|
meta: &execenv.GCMeta{Kind: execenv.GCKindAutopilotRun, AutopilotRunID: runID, WorkspaceID: "ws"},
|
|
servers: []serverResp{{
|
|
path: "/api/daemon/autopilot-runs/" + runID + "/gc-check",
|
|
body: map[string]any{"status": "completed", "completed_at": withinTTL},
|
|
}},
|
|
want: gcActionClean,
|
|
},
|
|
{
|
|
// Terminal status with no completed_at stamp at all still cleans —
|
|
// GC keys purely on the terminal status, not on any timestamp.
|
|
name: "autopilot skipped with no completed_at — clean",
|
|
meta: &execenv.GCMeta{Kind: execenv.GCKindAutopilotRun, AutopilotRunID: runID, WorkspaceID: "ws"},
|
|
servers: []serverResp{{
|
|
path: "/api/daemon/autopilot-runs/" + runID + "/gc-check",
|
|
body: map[string]any{"status": "skipped"},
|
|
}},
|
|
want: gcActionClean,
|
|
},
|
|
{
|
|
name: "autopilot failed — clean",
|
|
meta: &execenv.GCMeta{Kind: execenv.GCKindAutopilotRun, AutopilotRunID: runID, WorkspaceID: "ws"},
|
|
servers: []serverResp{{
|
|
path: "/api/daemon/autopilot-runs/" + runID + "/gc-check",
|
|
body: map[string]any{"status": "failed"},
|
|
}},
|
|
want: gcActionClean,
|
|
},
|
|
|
|
// ---- quick-create -------------------------------------------------
|
|
{
|
|
name: "quick_create completed task — clean immediately",
|
|
meta: &execenv.GCMeta{Kind: execenv.GCKindQuickCreate, TaskID: quickTask, WorkspaceID: "ws"},
|
|
servers: []serverResp{{
|
|
path: "/api/daemon/tasks/" + quickTask + "/gc-check",
|
|
body: map[string]any{"status": "completed", "completed_at": withinTTL},
|
|
}},
|
|
want: gcActionClean,
|
|
},
|
|
{
|
|
name: "quick_create cancelled — clean",
|
|
meta: &execenv.GCMeta{Kind: execenv.GCKindQuickCreate, TaskID: quickTask, WorkspaceID: "ws"},
|
|
servers: []serverResp{{
|
|
path: "/api/daemon/tasks/" + quickTask + "/gc-check",
|
|
body: map[string]any{"status": "cancelled"},
|
|
}},
|
|
want: gcActionClean,
|
|
},
|
|
{
|
|
name: "quick_create still running — skip",
|
|
meta: &execenv.GCMeta{Kind: execenv.GCKindQuickCreate, TaskID: quickTask, WorkspaceID: "ws"},
|
|
servers: []serverResp{{
|
|
path: "/api/daemon/tasks/" + quickTask + "/gc-check",
|
|
body: map[string]any{"status": "running"},
|
|
}},
|
|
want: gcActionSkip,
|
|
},
|
|
|
|
// ---- legacy meta (no kind) → issue path ---------------------------
|
|
{
|
|
name: "legacy meta with no kind defaults to issue path — done over TTL = clean",
|
|
meta: &execenv.GCMeta{IssueID: legacyMeta, WorkspaceID: "ws"},
|
|
servers: []serverResp{{
|
|
path: "/api/daemon/issues/" + legacyMeta + "/gc-check",
|
|
body: map[string]any{"status": "done", "updated_at": overTTL},
|
|
}},
|
|
want: gcActionClean,
|
|
},
|
|
}
|
|
|
|
for _, tc := range cases {
|
|
tc := tc
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
mux := http.NewServeMux()
|
|
for _, s := range tc.servers {
|
|
if s.path == "" {
|
|
continue
|
|
}
|
|
resp := s
|
|
mux.HandleFunc(resp.path, func(w http.ResponseWriter, r *http.Request) {
|
|
if resp.status != 0 {
|
|
w.WriteHeader(resp.status)
|
|
return
|
|
}
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_ = json.NewEncoder(w).Encode(resp.body)
|
|
})
|
|
}
|
|
d := newGCTestDaemon(t, mux)
|
|
taskDir := createTaskDir(t, d.cfg.WorkspacesRoot, "ws", tc.name, tc.meta)
|
|
got := d.shouldCleanTaskDir(context.Background(), taskDir)
|
|
if got != tc.want {
|
|
t.Fatalf("kind dispatch %q: want %d, got %d", tc.name, tc.want, got)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestShouldCleanTaskDir_EmptyParentIDFallsBackToOrphanMTime(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
cases := []struct {
|
|
name string
|
|
meta *execenv.GCMeta
|
|
}{
|
|
{
|
|
name: "legacy issue meta",
|
|
meta: &execenv.GCMeta{WorkspaceID: "ws"},
|
|
},
|
|
{
|
|
name: "issue meta",
|
|
meta: &execenv.GCMeta{Kind: execenv.GCKindIssue, WorkspaceID: "ws"},
|
|
},
|
|
{
|
|
name: "chat meta",
|
|
meta: &execenv.GCMeta{Kind: execenv.GCKindChat, WorkspaceID: "ws"},
|
|
},
|
|
{
|
|
name: "autopilot run meta",
|
|
meta: &execenv.GCMeta{Kind: execenv.GCKindAutopilotRun, WorkspaceID: "ws"},
|
|
},
|
|
{
|
|
name: "quick create meta",
|
|
meta: &execenv.GCMeta{Kind: execenv.GCKindQuickCreate, WorkspaceID: "ws"},
|
|
},
|
|
}
|
|
|
|
for _, tc := range cases {
|
|
tc := tc
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
requests := 0
|
|
mux := http.NewServeMux()
|
|
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
|
|
requests++
|
|
http.Error(w, "unexpected request", http.StatusBadRequest)
|
|
})
|
|
|
|
d := newGCTestDaemon(t, mux)
|
|
d.cfg.GCOrphanTTL = 365 * 24 * time.Hour
|
|
taskDir := createTaskDir(t, d.cfg.WorkspacesRoot, "ws", tc.name, tc.meta)
|
|
|
|
got := d.shouldCleanTaskDir(context.Background(), taskDir)
|
|
if got != gcActionSkip {
|
|
t.Fatalf("empty parent id should skip while under orphan TTL, got %d", got)
|
|
}
|
|
if requests != 0 {
|
|
t.Fatalf("empty parent id should not call gc-check endpoint, got %d requests", requests)
|
|
}
|
|
|
|
old := time.Now().Add(-400 * 24 * time.Hour)
|
|
if err := os.Chtimes(taskDir, old, old); err != nil {
|
|
t.Fatalf("chtimes: %v", err)
|
|
}
|
|
got = d.shouldCleanTaskDir(context.Background(), taskDir)
|
|
if got != gcActionOrphan {
|
|
t.Fatalf("empty parent id over orphan TTL should orphan, got %d", got)
|
|
}
|
|
if requests != 0 {
|
|
t.Fatalf("empty parent id should not call gc-check endpoint after mtime fallback, got %d requests", requests)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func createGCGitRepo(t *testing.T) string {
|
|
t.Helper()
|
|
|
|
repoDir := t.TempDir()
|
|
runGitForGC(t, repoDir, "init", "-b", "main")
|
|
if err := os.WriteFile(filepath.Join(repoDir, "README.md"), []byte("hello\n"), 0o644); err != nil {
|
|
t.Fatalf("write README: %v", err)
|
|
}
|
|
runGitForGC(t, repoDir, "add", "README.md")
|
|
runGitForGC(t, repoDir, "commit", "-m", "initial commit")
|
|
return repoDir
|
|
}
|
|
|
|
func runGitForGC(t *testing.T, dir string, args ...string) string {
|
|
t.Helper()
|
|
|
|
fullArgs := args
|
|
if dir != "" {
|
|
fullArgs = append([]string{"-C", dir}, args...)
|
|
}
|
|
cmd := exec.Command("git", fullArgs...)
|
|
cmd.Env = append(os.Environ(),
|
|
"GIT_AUTHOR_NAME=test", "GIT_AUTHOR_EMAIL=test@test.com",
|
|
"GIT_COMMITTER_NAME=test", "GIT_COMMITTER_EMAIL=test@test.com",
|
|
)
|
|
out, err := cmd.CombinedOutput()
|
|
if err != nil {
|
|
t.Fatalf("git %s failed: %s: %v", strings.Join(fullArgs, " "), out, err)
|
|
}
|
|
return strings.TrimSpace(string(out))
|
|
}
|
|
|
|
func gitRefExists(t *testing.T, repoPath, ref string) bool {
|
|
t.Helper()
|
|
|
|
cmd := exec.Command("git", "-C", repoPath, "show-ref", "--verify", "--quiet", ref)
|
|
if err := cmd.Run(); err != nil {
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
// TestShouldCleanTaskDir_ChatHardDeletedFreshMtime locks acceptance #3:
|
|
// when a user hard-deletes a chat session, the workdir must be reclaimed
|
|
// on the next GC cycle (≤ GCInterval), not deferred to GCOrphanTTL. A
|
|
// directory that was just created (mtime well within GCOrphanTTL) but
|
|
// whose chat session now 404s must therefore return gcActionClean.
|
|
func TestShouldCleanTaskDir_ChatHardDeletedFreshMtime(t *testing.T) {
|
|
t.Parallel()
|
|
chatID := "ffffffff-ffff-ffff-ffff-ffffffffff02"
|
|
|
|
mux := http.NewServeMux()
|
|
mux.HandleFunc(fmt.Sprintf("/api/daemon/chat-sessions/%s/gc-check", chatID), func(w http.ResponseWriter, r *http.Request) {
|
|
// Simulate hard-deleted session (DeleteChatSession ran).
|
|
w.WriteHeader(http.StatusNotFound)
|
|
})
|
|
|
|
d := newGCTestDaemon(t, mux)
|
|
// Crank GCOrphanTTL up so the mtime path is unmistakably not in play —
|
|
// the only way the directory gets reclaimed is the chat-404 fast path.
|
|
d.cfg.GCOrphanTTL = 365 * 24 * time.Hour
|
|
meta := &execenv.GCMeta{
|
|
Kind: execenv.GCKindChat,
|
|
ChatSessionID: chatID,
|
|
WorkspaceID: "ws",
|
|
CompletedAt: time.Now(),
|
|
}
|
|
taskDir := createTaskDir(t, d.cfg.WorkspacesRoot, "ws", "hard-deleted-chat", meta)
|
|
// taskDir mtime is now-ish — well within any sane GCOrphanTTL.
|
|
|
|
if got := d.shouldCleanTaskDir(context.Background(), taskDir); got != gcActionClean {
|
|
t.Fatalf("hard-deleted chat with fresh mtime must clean immediately, got %d", got)
|
|
}
|
|
}
|
|
|
|
// TestShouldCleanTaskDir_ChatActiveResistsOldMtime is the explicit acceptance
|
|
// criterion #2: an active chat session whose workdir is older than
|
|
// GCOrphanTTL must NOT be reclaimed. The only path to clean an active
|
|
// session's workdir is for the user to archive or hard-delete the session.
|
|
func TestShouldCleanTaskDir_ChatActiveResistsOldMtime(t *testing.T) {
|
|
t.Parallel()
|
|
chatID := "ffffffff-ffff-ffff-ffff-ffffffffff01"
|
|
|
|
mux := http.NewServeMux()
|
|
mux.HandleFunc(fmt.Sprintf("/api/daemon/chat-sessions/%s/gc-check", chatID), func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_ = json.NewEncoder(w).Encode(map[string]any{
|
|
"status": "active",
|
|
"updated_at": time.Now().Add(-100 * 24 * time.Hour),
|
|
})
|
|
})
|
|
|
|
d := newGCTestDaemon(t, mux)
|
|
d.cfg.GCOrphanTTL = 0 // every directory is "older than orphan TTL"
|
|
meta := &execenv.GCMeta{
|
|
Kind: execenv.GCKindChat,
|
|
ChatSessionID: chatID,
|
|
WorkspaceID: "ws",
|
|
CompletedAt: time.Now().Add(-200 * 24 * time.Hour),
|
|
}
|
|
taskDir := createTaskDir(t, d.cfg.WorkspacesRoot, "ws", "active-chat", meta)
|
|
if err := os.Chtimes(taskDir, time.Now().Add(-200*24*time.Hour), time.Now().Add(-200*24*time.Hour)); err != nil {
|
|
t.Fatalf("chtimes: %v", err)
|
|
}
|
|
|
|
if got := d.shouldCleanTaskDir(context.Background(), taskDir); got != gcActionSkip {
|
|
t.Fatalf("active chat session must not be reclaimed even with stale mtime, got %d", got)
|
|
}
|
|
}
|
|
|
|
// TestGCMetaForTask covers the discriminator priority used by the daemon
|
|
// when selecting which GCMetaKind to write at task completion.
|
|
func TestGCMetaForTask(t *testing.T) {
|
|
t.Parallel()
|
|
cases := []struct {
|
|
name string
|
|
task Task
|
|
want execenv.GCMetaKind
|
|
idOK func(m execenv.GCMeta) bool
|
|
}{
|
|
{
|
|
name: "chat task",
|
|
task: Task{ID: "t1", WorkspaceID: "ws", ChatSessionID: "c1"},
|
|
want: execenv.GCKindChat,
|
|
idOK: func(m execenv.GCMeta) bool { return m.ChatSessionID == "c1" },
|
|
},
|
|
{
|
|
name: "autopilot run task",
|
|
task: Task{ID: "t2", WorkspaceID: "ws", AutopilotRunID: "r1"},
|
|
want: execenv.GCKindAutopilotRun,
|
|
idOK: func(m execenv.GCMeta) bool { return m.AutopilotRunID == "r1" },
|
|
},
|
|
{
|
|
name: "issue task",
|
|
task: Task{ID: "t3", WorkspaceID: "ws", IssueID: "i1"},
|
|
want: execenv.GCKindIssue,
|
|
idOK: func(m execenv.GCMeta) bool { return m.IssueID == "i1" },
|
|
},
|
|
{
|
|
name: "quick-create task — issue_id always empty at WriteGCMeta time",
|
|
task: Task{ID: "t4", WorkspaceID: "ws", QuickCreatePrompt: "do the thing"},
|
|
want: execenv.GCKindQuickCreate,
|
|
idOK: func(m execenv.GCMeta) bool { return m.TaskID == "t4" },
|
|
},
|
|
{
|
|
name: "chat wins over issue when both set (defensive ordering)",
|
|
task: Task{ID: "t5", WorkspaceID: "ws", IssueID: "i1", ChatSessionID: "c1"},
|
|
want: execenv.GCKindChat,
|
|
idOK: func(m execenv.GCMeta) bool { return m.ChatSessionID == "c1" && m.IssueID == "" },
|
|
},
|
|
}
|
|
for _, tc := range cases {
|
|
tc := tc
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
meta, ok := gcMetaForTask(tc.task)
|
|
if !ok {
|
|
t.Fatalf("expected gcMetaForTask to recognize task, got ok=false")
|
|
}
|
|
if meta.Kind != tc.want {
|
|
t.Fatalf("kind: want %q, got %q", tc.want, meta.Kind)
|
|
}
|
|
if !tc.idOK(meta) {
|
|
t.Fatalf("ID field mismatch: %+v", meta)
|
|
}
|
|
if meta.WorkspaceID != "ws" {
|
|
t.Fatalf("workspace_id: want %q, got %q", "ws", meta.WorkspaceID)
|
|
}
|
|
})
|
|
}
|
|
|
|
t.Run("unrecognized task — ok=false", func(t *testing.T) {
|
|
t.Parallel()
|
|
_, ok := gcMetaForTask(Task{ID: "tX", WorkspaceID: "ws"})
|
|
if ok {
|
|
t.Fatal("expected gcMetaForTask to return ok=false for task with no IDs")
|
|
}
|
|
})
|
|
}
|
|
|
|
// TestShouldCleanTaskDir_LocalDirectoryNeverClean confirms the GC loop
|
|
// never removes the envRoot of a local_directory task even when the parent
|
|
// issue is long-since done. Artifact-pattern cleanup is the most that
|
|
// should ever happen, so output/ and logs/ stay around for the user.
|
|
func TestShouldCleanTaskDir_LocalDirectoryNeverClean(t *testing.T) {
|
|
t.Parallel()
|
|
issueID := "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaa1"
|
|
|
|
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(-30 * 24 * time.Hour),
|
|
})
|
|
})
|
|
|
|
d := newGCTestDaemon(t, mux)
|
|
taskDir := createTaskDir(t, d.cfg.WorkspacesRoot, "ws1", "local-task", &execenv.GCMeta{
|
|
Kind: execenv.GCKindIssue,
|
|
IssueID: issueID,
|
|
WorkspaceID: "ws1",
|
|
CompletedAt: time.Now().Add(-30 * 24 * time.Hour),
|
|
LocalDirectory: true,
|
|
})
|
|
|
|
got := d.shouldCleanTaskDir(context.Background(), taskDir)
|
|
if got == gcActionClean {
|
|
t.Fatalf("expected local_directory task to never return gcActionClean, got gcActionClean")
|
|
}
|
|
// Either skip (no patterns configured) or artifact cleanup is OK —
|
|
// what matters is that gcActionClean never fires for local_directory.
|
|
if got != gcActionCleanArtifacts && got != gcActionSkip {
|
|
t.Fatalf("unexpected action for local_directory done issue: %d", got)
|
|
}
|
|
}
|
|
|
|
// TestShouldCleanTaskDir_LocalDirectoryOrphanUsesManagedOnly confirms that
|
|
// when the parent issue 404s, a local_directory task preserves its envRoot and
|
|
// becomes eligible only for exact daemon-managed artifact cleanup.
|
|
func TestShouldCleanTaskDir_LocalDirectoryOrphanUsesManagedOnly(t *testing.T) {
|
|
t.Parallel()
|
|
issueID := "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaa2"
|
|
|
|
mux := http.NewServeMux()
|
|
mux.HandleFunc(fmt.Sprintf("/api/daemon/issues/%s/gc-check", issueID), func(w http.ResponseWriter, r *http.Request) {
|
|
http.NotFound(w, r)
|
|
})
|
|
|
|
d := newGCTestDaemon(t, mux)
|
|
d.cfg.GCOrphanTTL = 0 // any age is "stale" enough to orphan
|
|
taskDir := createTaskDir(t, d.cfg.WorkspacesRoot, "ws1", "local-orphan", &execenv.GCMeta{
|
|
Kind: execenv.GCKindIssue,
|
|
IssueID: issueID,
|
|
WorkspaceID: "ws1",
|
|
LocalDirectory: true,
|
|
})
|
|
|
|
got := d.shouldCleanTaskDir(context.Background(), taskDir)
|
|
if got != gcActionCleanManagedArtifacts {
|
|
t.Fatalf("expected local_directory orphan to use managed-only cleanup, got %d", got)
|
|
}
|
|
}
|
|
|
|
// TestShouldCleanTaskDir_LocalDirectoryFalsePreservesNormalClean is the
|
|
// negative control: a regular (non-local_directory) task whose parent issue
|
|
// is done + over TTL must still be reclaimed via gcActionClean.
|
|
func TestShouldCleanTaskDir_LocalDirectoryFalsePreservesNormalClean(t *testing.T) {
|
|
t.Parallel()
|
|
issueID := "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaa3"
|
|
|
|
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(-30 * 24 * time.Hour),
|
|
})
|
|
})
|
|
|
|
d := newGCTestDaemon(t, mux)
|
|
taskDir := createTaskDir(t, d.cfg.WorkspacesRoot, "ws1", "normal-task", &execenv.GCMeta{
|
|
Kind: execenv.GCKindIssue,
|
|
IssueID: issueID,
|
|
WorkspaceID: "ws1",
|
|
CompletedAt: time.Now().Add(-30 * 24 * time.Hour),
|
|
// LocalDirectory unset (false).
|
|
})
|
|
|
|
if got := d.shouldCleanTaskDir(context.Background(), taskDir); got != gcActionClean {
|
|
t.Fatalf("expected gcActionClean for normal task, got %d", got)
|
|
}
|
|
}
|