mirror of
https://github.com/multica-ai/multica.git
synced 2026-06-17 03:38:32 +02:00
* feat(daemon/gc): tighten GC defaults + flex duration suffix Driven by user feedback in #1539 (40 GB VPS filling within 24h of heavy AI-coding usage): the existing TTLs were sized for desktop/laptop deployments and are too lenient for small-disk, long-running daemons. - GCTTL: 5d → 24h. Done/canceled issues almost never need a multi-day grace period in AI-coding workflows. - GCOrphanTTL: 30d → 72h. Covers crash-leftover and pre-GC directories without a month-long wait. - Issue-deleted orphans (API returns 404) are now cleaned on the next GC cycle regardless of mtime. The issue row is gone; there is nothing left to protect. - parseFlexDuration: accept a `d` (day) suffix in addition to the stdlib time.ParseDuration syntax. MULTICA_GC_TTL=5d now works; previously only 120h was accepted. * fix(daemon/gc): address review — 404 safety + decimal/overflow in duration parser Two issues flagged in PR review: 1. 404-immediate-clean is unsafe. The /gc-check endpoint returns 404 for both "issue deleted" AND "daemon token has no access to the workspace" (anti-enumeration, see requireDaemonWorkspaceAccess). Clean-on-404 would let a scoped-down daemon token wipe taskDirs whose issues are still live. Restore the mtime gate against GCOrphanTTL. With the new 72h default we still shrink the original 30d window dramatically without the cross-workspace hazard. Lock the behavior in with a new test that asserts a recent 404 is skipped. 2. parseFlexDuration mishandled decimals and swallowed Atoi errors: "0.5d" → 7m12s (regex matched only the "5d"), "1.5d" → 1h7m12s, and 20+ digit day values Atoi-errored silently to 0. Match the full decimal number with `\d*\.\d+|\d+` and parse with ParseFloat so fractional days and oversized inputs both go through time.ParseDuration correctly — fractions as sub-hour durations, overflow as a returned error.
329 lines
9.8 KiB
Go
329 lines
9.8 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,
|
|
}
|
|
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_CanceledIssueOverTTL(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": "canceled",
|
|
"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)
|
|
|
|
if _, err := os.Stat(wsDir); !os.IsNotExist(err) {
|
|
t.Fatal("empty workspace dir should be removed after all tasks cleaned")
|
|
}
|
|
}
|
|
|
|
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")
|
|
}
|
|
})
|
|
}
|