Files
multica/server/internal/daemon/gc.go
Bohan Jiang 8f10741a4d feat(daemon/gc): tighten GC defaults + flex duration suffix (#1559)
* 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.
2026-04-23 17:40:09 +08:00

239 lines
6.2 KiB
Go

package daemon
import (
"context"
"errors"
"net/http"
"os"
"os/exec"
"path/filepath"
"strings"
"time"
"github.com/multica-ai/multica/server/internal/daemon/execenv"
)
// gcLoop periodically scans local workspace directories and removes those
// whose issue is done/canceled and hasn't been updated within the configured TTL.
func (d *Daemon) gcLoop(ctx context.Context) {
if !d.cfg.GCEnabled {
d.logger.Info("gc: disabled")
return
}
d.logger.Info("gc: started", "interval", d.cfg.GCInterval, "ttl", d.cfg.GCTTL, "orphan_ttl", d.cfg.GCOrphanTTL)
// Run once at startup after a short delay (let the daemon finish initializing).
if err := sleepWithContext(ctx, 30*time.Second); err != nil {
return
}
d.runGC(ctx)
ticker := time.NewTicker(d.cfg.GCInterval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
d.runGC(ctx)
}
}
}
// runGC performs a single GC scan across all workspace directories.
func (d *Daemon) runGC(ctx context.Context) {
root := d.cfg.WorkspacesRoot
entries, err := os.ReadDir(root)
if err != nil {
if os.IsNotExist(err) {
return
}
d.logger.Warn("gc: read workspaces root failed", "error", err)
return
}
var cleaned, skipped, orphaned int
for _, wsEntry := range entries {
if !wsEntry.IsDir() || wsEntry.Name() == ".repos" {
continue
}
wsDir := filepath.Join(root, wsEntry.Name())
c, s, o := d.gcWorkspace(ctx, wsDir)
cleaned += c
skipped += s
orphaned += o
}
// Prune stale worktree references from all bare repo caches.
d.pruneRepoWorktrees(root)
if cleaned > 0 || orphaned > 0 {
d.logger.Info("gc: cycle complete", "cleaned", cleaned, "orphaned", orphaned, "skipped", skipped)
}
}
// gcWorkspace scans task directories inside a single workspace directory.
func (d *Daemon) gcWorkspace(ctx context.Context, wsDir string) (cleaned, skipped, orphaned int) {
taskEntries, err := os.ReadDir(wsDir)
if err != nil {
d.logger.Warn("gc: read workspace dir failed", "dir", wsDir, "error", err)
return
}
for _, entry := range taskEntries {
if ctx.Err() != nil {
return
}
if !entry.IsDir() {
continue
}
taskDir := filepath.Join(wsDir, entry.Name())
action := d.shouldCleanTaskDir(ctx, taskDir)
switch action {
case gcActionClean:
d.cleanTaskDir(taskDir)
cleaned++
case gcActionOrphan:
d.cleanTaskDir(taskDir)
orphaned++
default:
skipped++
}
}
// Remove the workspace directory itself if it's now empty.
if cleaned+orphaned > 0 {
remaining, _ := os.ReadDir(wsDir)
if len(remaining) == 0 {
os.Remove(wsDir)
}
}
return
}
type gcAction int
const (
gcActionSkip gcAction = iota
gcActionClean // issue is done/canceled and stale
gcActionOrphan // no meta or unknown issue and dir is old
)
// shouldCleanTaskDir decides whether a task directory should be removed.
func (d *Daemon) shouldCleanTaskDir(ctx context.Context, taskDir string) gcAction {
meta, err := execenv.ReadGCMeta(taskDir)
if err != nil {
// No .gc_meta.json — check mtime for orphan cleanup.
info, statErr := os.Stat(taskDir)
if statErr != nil {
return gcActionSkip
}
if time.Since(info.ModTime()) > d.cfg.GCOrphanTTL {
d.logger.Info("gc: orphan directory (no meta)", "dir", taskDir, "age", time.Since(info.ModTime()).Round(time.Hour))
return gcActionOrphan
}
return gcActionSkip
}
status, err := d.client.GetIssueGCCheck(ctx, meta.IssueID)
if err != nil {
var reqErr *requestError
if errors.As(err, &reqErr) && reqErr.StatusCode == http.StatusNotFound {
// 404 is ambiguous: the server returns it for both "issue deleted"
// and "daemon token has no access to the workspace" (anti-enumeration,
// see requireDaemonWorkspaceAccess). Fall back to the mtime-gated
// orphan cleanup so a scoped-down token can't instantly wipe dirs
// whose issues are still live.
info, statErr := os.Stat(taskDir)
if statErr != nil {
return gcActionSkip
}
if time.Since(info.ModTime()) > d.cfg.GCOrphanTTL {
d.logger.Info("gc: orphan directory (issue not accessible)", "dir", taskDir, "issue", meta.IssueID)
return gcActionOrphan
}
}
// API error (network, auth, etc.) — skip and retry next cycle.
return gcActionSkip
}
if (status.Status == "done" || status.Status == "canceled") &&
time.Since(status.UpdatedAt) > d.cfg.GCTTL {
d.logger.Info("gc: eligible for cleanup",
"dir", filepath.Base(taskDir),
"issue", meta.IssueID,
"status", status.Status,
"updated_at", status.UpdatedAt.Format(time.RFC3339),
)
return gcActionClean
}
return gcActionSkip
}
// cleanTaskDir removes a task directory and logs the result.
func (d *Daemon) cleanTaskDir(taskDir string) {
if err := os.RemoveAll(taskDir); err != nil {
d.logger.Warn("gc: remove task dir failed", "dir", taskDir, "error", err)
} else {
d.logger.Info("gc: removed", "dir", taskDir)
}
}
const gitCmdTimeout = 30 * time.Second
// pruneRepoWorktrees runs `git worktree prune` on all bare repos in the cache.
func (d *Daemon) pruneRepoWorktrees(workspacesRoot string) {
reposRoot := filepath.Join(workspacesRoot, ".repos")
wsEntries, err := os.ReadDir(reposRoot)
if err != nil {
return
}
for _, wsEntry := range wsEntries {
if !wsEntry.IsDir() {
continue
}
wsRepoDir := filepath.Join(reposRoot, wsEntry.Name())
repoEntries, err := os.ReadDir(wsRepoDir)
if err != nil {
continue
}
for _, repoEntry := range repoEntries {
if !repoEntry.IsDir() {
continue
}
barePath := filepath.Join(wsRepoDir, repoEntry.Name())
if !isBareRepo(barePath) {
continue
}
d.pruneWorktree(barePath)
}
}
}
func (d *Daemon) pruneWorktree(barePath string) {
ctx, cancel := context.WithTimeout(context.Background(), gitCmdTimeout)
defer cancel()
cmd := exec.CommandContext(ctx, "git", "-C", barePath, "worktree", "prune")
if out, err := cmd.CombinedOutput(); err != nil {
d.logger.Warn("gc: worktree prune failed",
"repo", barePath,
"output", strings.TrimSpace(string(out)),
"error", err,
)
}
}
// isBareRepo checks if a path looks like a bare git repository.
func isBareRepo(path string) bool {
if _, err := os.Stat(filepath.Join(path, "HEAD")); err != nil {
return false
}
if _, err := os.Stat(filepath.Join(path, "objects")); err != nil {
return false
}
return true
}