mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-29 06:28:23 +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.
81 lines
1.9 KiB
Go
81 lines
1.9 KiB
Go
package daemon
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"os"
|
|
"regexp"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
func envOrDefault(key, fallback string) string {
|
|
value := strings.TrimSpace(os.Getenv(key))
|
|
if value == "" {
|
|
return fallback
|
|
}
|
|
return value
|
|
}
|
|
|
|
func durationFromEnv(key string, fallback time.Duration) (time.Duration, error) {
|
|
value := strings.TrimSpace(os.Getenv(key))
|
|
if value == "" {
|
|
return fallback, nil
|
|
}
|
|
d, err := parseFlexDuration(value)
|
|
if err != nil {
|
|
return 0, fmt.Errorf("%s: invalid duration %q: %w", key, value, err)
|
|
}
|
|
return d, nil
|
|
}
|
|
|
|
// dayUnit matches a decimal number (with optional leading digits) followed by
|
|
// `d` (days), so both "5d" and "1.5d" are captured whole and expanded to hours.
|
|
var dayUnit = regexp.MustCompile(`(\d*\.\d+|\d+)d`)
|
|
|
|
// parseFlexDuration accepts the standard Go time.ParseDuration syntax plus a
|
|
// `d` (day) suffix, which the stdlib rejects. "5d" → 120h, "1d12h" → 36h,
|
|
// "0.5d" → 12h. Overflow or malformed numbers propagate as errors.
|
|
func parseFlexDuration(value string) (time.Duration, error) {
|
|
var convErr error
|
|
expanded := dayUnit.ReplaceAllStringFunc(value, func(match string) string {
|
|
days, err := strconv.ParseFloat(match[:len(match)-1], 64)
|
|
if err != nil {
|
|
convErr = err
|
|
return match
|
|
}
|
|
// time.ParseDuration handles fractional hours natively, and rejects
|
|
// overflow on its own.
|
|
return strconv.FormatFloat(days*24, 'f', -1, 64) + "h"
|
|
})
|
|
if convErr != nil {
|
|
return 0, convErr
|
|
}
|
|
return time.ParseDuration(expanded)
|
|
}
|
|
|
|
func intFromEnv(key string, fallback int) (int, error) {
|
|
value := strings.TrimSpace(os.Getenv(key))
|
|
if value == "" {
|
|
return fallback, nil
|
|
}
|
|
n, err := strconv.Atoi(value)
|
|
if err != nil {
|
|
return 0, fmt.Errorf("%s: invalid integer %q: %w", key, value, err)
|
|
}
|
|
return n, nil
|
|
}
|
|
|
|
func sleepWithContext(ctx context.Context, d time.Duration) error {
|
|
timer := time.NewTimer(d)
|
|
defer timer.Stop()
|
|
|
|
select {
|
|
case <-ctx.Done():
|
|
return ctx.Err()
|
|
case <-timer.C:
|
|
return nil
|
|
}
|
|
}
|