Files
gastown/internal/cmd/beads_version.go
furiosa 67467d886f
Some checks failed
CI / Check for .beads changes (push) Has been skipped
CI / Check embedded formulas (push) Failing after 23s
CI / Test (push) Failing after 1m47s
CI / Lint (push) Failing after 25s
CI / Integration Tests (push) Successful in 1m19s
CI / Coverage Report (push) Has been skipped
Windows CI / Windows Build and Unit Tests (push) Has been cancelled
fix(version): add file-based caching to prevent bd version contention
Under high concurrency (17+ agents), the bd version check spawns
multiple git subprocesses per invocation, causing timeouts when
85-120+ git processes compete for resources.

This fix:
- Caches successful version checks to ~/.cache/gastown/beads-version.json
- Uses cached results for 24 hours to avoid subprocess spawning
- On timeout, uses stale cache if available or gracefully degrades
- Prints warning when using cached/degraded path

Fixes: https://github.com/steveyegge/gastown/issues/503

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-24 09:39:15 -08:00

242 lines
6.6 KiB
Go

// Package cmd provides CLI commands for the gt tool.
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.
// This version must include custom type support (bd-i54l).
const MinBeadsVersion = "0.44.0"
// beadsVersion represents a parsed semantic version.
type beadsVersion struct {
major int
minor int
patch int
}
// parseBeadsVersion parses a version string like "0.44.0" into components.
func parseBeadsVersion(v string) (beadsVersion, error) {
// Strip leading 'v' if present
v = strings.TrimPrefix(v, "v")
// Split on dots
parts := strings.Split(v, ".")
if len(parts) < 2 {
return beadsVersion{}, fmt.Errorf("invalid version format: %s", v)
}
major, err := strconv.Atoi(parts[0])
if err != nil {
return beadsVersion{}, fmt.Errorf("invalid major version: %s", parts[0])
}
minor, err := strconv.Atoi(parts[1])
if err != nil {
return beadsVersion{}, fmt.Errorf("invalid minor version: %s", parts[1])
}
patch := 0
if len(parts) >= 3 {
// Handle versions like "0.44.0-dev" - take only numeric prefix
patchStr := parts[2]
if idx := strings.IndexFunc(patchStr, func(r rune) bool {
return r < '0' || r > '9'
}); idx != -1 {
patchStr = patchStr[:idx]
}
if patchStr != "" {
patch, err = strconv.Atoi(patchStr)
if err != nil {
return beadsVersion{}, fmt.Errorf("invalid patch version: %s", parts[2])
}
}
}
return beadsVersion{major: major, minor: minor, patch: patch}, nil
}
// compare returns -1 if v < other, 0 if equal, 1 if v > other.
func (v beadsVersion) compare(other beadsVersion) int {
if v.major != other.major {
if v.major < other.major {
return -1
}
return 1
}
if v.minor != other.minor {
if v.minor < other.minor {
return -1
}
return 1
}
if v.patch != other.patch {
if v.patch < other.patch {
return -1
}
return 1
}
return 0
}
// 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()
cmd := exec.CommandContext(ctx, "bd", "version")
output, err := cmd.Output()
if err != nil {
if ctx.Err() == context.DeadlineExceeded {
return "", fmt.Errorf("bd version check timed out")
}
if exitErr, ok := err.(*exec.ExitError); ok {
return "", fmt.Errorf("bd version failed: %s", string(exitErr.Stderr))
}
return "", fmt.Errorf("failed to run bd: %w (is beads installed?)", err)
}
// Parse output like "bd version 0.44.0 (dev)"
// or "bd version 0.44.0"
matches := beadsVersionRe.FindStringSubmatch(string(output))
if len(matches) < 2 {
return "", fmt.Errorf("could not parse beads version from: %s", strings.TrimSpace(string(output)))
}
return matches[1], nil
}
var (
cachedVersionCheckResult error
versionCheckOnce sync.Once
)
// CheckBeadsVersion verifies that the installed beads version meets the minimum requirement.
// Returns nil if the version is sufficient, or an error with details if not.
// The check is performed only once per process execution.
func CheckBeadsVersion() error {
versionCheckOnce.Do(func() {
cachedVersionCheckResult = checkBeadsVersionInternal()
})
return cachedVersionCheckResult
}
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)
}
installed, err := parseBeadsVersion(installedStr)
if err != nil {
return fmt.Errorf("cannot parse installed beads version %q: %w", installedStr, err)
}
required, err := parseBeadsVersion(MinBeadsVersion)
if err != nil {
// This would be a bug in our code
return fmt.Errorf("cannot parse required beads version %q: %w", MinBeadsVersion, err)
}
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)
}
return nil
}