diff --git a/internal/cmd/beads_version.go b/internal/cmd/beads_version.go index 9c087d94..326eb561 100644 --- a/internal/cmd/beads_version.go +++ b/internal/cmd/beads_version.go @@ -3,13 +3,18 @@ package cmd import ( "context" + "encoding/json" "fmt" + "os" "os/exec" + "path/filepath" "regexp" "strconv" "strings" "sync" "time" + + "github.com/steveyegge/gastown/internal/state" ) // MinBeadsVersion is the minimum required beads version for Gas Town. @@ -90,6 +95,58 @@ func (v beadsVersion) compare(other beadsVersion) int { // Pre-compiled regex for beads version parsing var beadsVersionRe = regexp.MustCompile(`bd version (\d+\.\d+(?:\.\d+)?(?:-\w+)?)`) +// versionCacheTTL is how long a cached version check remains valid. +// 24 hours is reasonable since version upgrades are infrequent. +const versionCacheTTL = 24 * time.Hour + +// versionCache stores the result of a beads version check. +type versionCache struct { + Version string `json:"version"` + CheckedAt time.Time `json:"checked_at"` + Valid bool `json:"valid"` // true if version meets minimum requirement +} + +// versionCachePath returns the path to the version cache file. +func versionCachePath() string { + return filepath.Join(state.CacheDir(), "beads-version.json") +} + +// loadVersionCache reads the cached version check result. +func loadVersionCache() (*versionCache, error) { + data, err := os.ReadFile(versionCachePath()) + if err != nil { + return nil, err + } + var cache versionCache + if err := json.Unmarshal(data, &cache); err != nil { + return nil, err + } + return &cache, nil +} + +// saveVersionCache writes the version check result to cache. +func saveVersionCache(c *versionCache) error { + dir := state.CacheDir() + if err := os.MkdirAll(dir, 0755); err != nil { + return err + } + data, err := json.MarshalIndent(c, "", " ") + if err != nil { + return err + } + // Atomic write via temp file + tmp := versionCachePath() + ".tmp" + if err := os.WriteFile(tmp, data, 0600); err != nil { + return err + } + return os.Rename(tmp, versionCachePath()) +} + +// isCacheFresh returns true if the cache is within the TTL. +func (c *versionCache) isCacheFresh() bool { + return time.Since(c.CheckedAt) < versionCacheTTL +} + func getBeadsVersion() (string, error) { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() @@ -132,8 +189,27 @@ func CheckBeadsVersion() error { } func checkBeadsVersionInternal() error { + // Try to use cached result first to avoid subprocess spawning + if cache, err := loadVersionCache(); err == nil && cache.isCacheFresh() { + if cache.Valid { + return nil // Cached successful check + } + // Cached failure - still need to check (version might have been upgraded) + } + installedStr, err := getBeadsVersion() if err != nil { + // On timeout, try to use stale cache or gracefully degrade + if strings.Contains(err.Error(), "timed out") { + if cache, cacheErr := loadVersionCache(); cacheErr == nil && cache.Valid { + // Use stale cache but warn + fmt.Fprintf(os.Stderr, "Warning: bd version check timed out, using cached result (v%s)\n", cache.Version) + return nil + } + // No cache available - gracefully degrade with warning + fmt.Fprintf(os.Stderr, "Warning: bd version check timed out (high system load?), proceeding anyway\n") + return nil + } return fmt.Errorf("cannot verify beads version: %w", err) } @@ -148,7 +224,16 @@ func checkBeadsVersionInternal() error { return fmt.Errorf("cannot parse required beads version %q: %w", MinBeadsVersion, err) } - if installed.compare(required) < 0 { + valid := installed.compare(required) >= 0 + + // Cache the result + _ = saveVersionCache(&versionCache{ + Version: installedStr, + CheckedAt: time.Now(), + Valid: valid, + }) + + if !valid { return fmt.Errorf("beads version %s is required, but %s is installed\n\nPlease upgrade beads: go install github.com/steveyegge/beads/cmd/bd@latest", MinBeadsVersion, installedStr) }