mirror of
https://github.com/boshu2/agentops.git
synced 2026-09-14 15:08:13 +08:00
836 lines
24 KiB
Go
836 lines
24 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
"syscall"
|
|
"time"
|
|
|
|
"github.com/spf13/cobra"
|
|
|
|
cliRPI "github.com/boshu2/agentops/cli/internal/rpi"
|
|
)
|
|
|
|
const (
|
|
loopFailurePolicyStop = "stop"
|
|
loopFailurePolicyContinue = "continue"
|
|
|
|
loopGatePolicyOff = "off"
|
|
loopGatePolicyBestEffort = "best-effort"
|
|
loopGatePolicyRequired = "required"
|
|
|
|
loopLandingPolicyOff = "off"
|
|
loopLandingPolicyCommit = "commit"
|
|
loopLandingPolicySyncPush = "sync-push"
|
|
|
|
loopBDSyncPolicyAuto = "auto"
|
|
loopBDSyncPolicyAlways = "always"
|
|
loopBDSyncPolicyNever = "never"
|
|
)
|
|
|
|
const defaultLoopCommandTimeout = 20 * time.Minute
|
|
|
|
var (
|
|
loopExecCommandContext = exec.CommandContext
|
|
loopLookPath = exec.LookPath
|
|
loopCommandRunner = runLoopCommandWithTimeout
|
|
loopCommandOutputRunner = runLoopCommandOutputWithTimeout
|
|
)
|
|
|
|
type rpiLoopSupervisorConfig struct {
|
|
FailurePolicy string
|
|
CycleRetries int
|
|
RetryBackoff time.Duration
|
|
CycleDelay time.Duration
|
|
LeaseEnabled bool
|
|
LeasePath string
|
|
LeaseTTL time.Duration
|
|
DetachedHeal bool
|
|
DetachedBranchPrefix string
|
|
AutoClean bool
|
|
AutoCleanStaleAfter time.Duration
|
|
EnsureCleanup bool
|
|
CleanupPruneWorktrees bool
|
|
GatePolicy string
|
|
ValidateFastScript string
|
|
SecurityGateScript string
|
|
LandingPolicy string
|
|
LandingBranch string
|
|
LandingCommitMessage string
|
|
LandingLockPath string
|
|
BDSyncPolicy string
|
|
CommandTimeout time.Duration
|
|
RuntimeMode string
|
|
RuntimeCommand string
|
|
AOCommand string
|
|
BDCommand string
|
|
TmuxCommand string
|
|
}
|
|
|
|
func resolveLoopSupervisorConfig(cmd *cobra.Command, cwd string) (rpiLoopSupervisorConfig, error) {
|
|
cfg := rpiLoopSupervisorConfig{
|
|
FailurePolicy: strings.ToLower(strings.TrimSpace(rpiFailurePolicy)),
|
|
CycleRetries: rpiCycleRetries,
|
|
RetryBackoff: rpiRetryBackoff,
|
|
CycleDelay: rpiCycleDelay,
|
|
LeaseEnabled: rpiLease,
|
|
LeasePath: rpiLeasePath,
|
|
LeaseTTL: rpiLeaseTTL,
|
|
DetachedHeal: rpiDetachedHeal,
|
|
DetachedBranchPrefix: rpiDetachedBranchPrefix,
|
|
AutoClean: rpiAutoClean,
|
|
AutoCleanStaleAfter: rpiAutoCleanStaleAfter,
|
|
EnsureCleanup: rpiEnsureCleanup,
|
|
CleanupPruneWorktrees: rpiCleanupPruneWorktrees,
|
|
GatePolicy: strings.ToLower(strings.TrimSpace(rpiGatePolicy)),
|
|
ValidateFastScript: rpiValidateFastScript,
|
|
SecurityGateScript: rpiSecurityGateScript,
|
|
LandingPolicy: strings.ToLower(strings.TrimSpace(rpiLandingPolicy)),
|
|
LandingBranch: strings.TrimSpace(rpiLandingBranch),
|
|
LandingCommitMessage: rpiLandingCommitMessage,
|
|
LandingLockPath: rpiLandingLockPath,
|
|
BDSyncPolicy: strings.ToLower(strings.TrimSpace(rpiBDSyncPolicy)),
|
|
CommandTimeout: rpiCommandTimeout,
|
|
}
|
|
|
|
if rpiSupervisor {
|
|
if !cmd.Flags().Changed("failure-policy") {
|
|
cfg.FailurePolicy = loopFailurePolicyContinue
|
|
}
|
|
if !cmd.Flags().Changed("cycle-retries") {
|
|
cfg.CycleRetries = 1
|
|
}
|
|
if !cmd.Flags().Changed("cycle-delay") {
|
|
cfg.CycleDelay = 5 * time.Minute
|
|
}
|
|
if !cmd.Flags().Changed("lease") {
|
|
cfg.LeaseEnabled = true
|
|
}
|
|
if !cmd.Flags().Changed("detached-heal") {
|
|
cfg.DetachedHeal = false
|
|
}
|
|
if !cmd.Flags().Changed("auto-clean") {
|
|
cfg.AutoClean = true
|
|
}
|
|
if !cmd.Flags().Changed("ensure-cleanup") {
|
|
cfg.EnsureCleanup = true
|
|
}
|
|
if !cmd.Flags().Changed("gate-policy") {
|
|
cfg.GatePolicy = loopGatePolicyRequired
|
|
}
|
|
}
|
|
|
|
if cfg.CycleRetries < 0 {
|
|
return cfg, fmt.Errorf("cycle-retries must be >= 0")
|
|
}
|
|
if cfg.RetryBackoff < 0 {
|
|
return cfg, fmt.Errorf("retry-backoff must be >= 0")
|
|
}
|
|
if cfg.CycleDelay < 0 {
|
|
return cfg, fmt.Errorf("cycle-delay must be >= 0")
|
|
}
|
|
if cfg.CommandTimeout < 0 {
|
|
return cfg, fmt.Errorf("command-timeout must be >= 0")
|
|
}
|
|
if cfg.LeaseTTL <= 0 {
|
|
cfg.LeaseTTL = 2 * time.Minute
|
|
}
|
|
if cfg.CommandTimeout == 0 {
|
|
cfg.CommandTimeout = defaultLoopCommandTimeout
|
|
}
|
|
if cfg.AutoCleanStaleAfter <= 0 {
|
|
cfg.AutoCleanStaleAfter = 24 * time.Hour
|
|
}
|
|
if rpiSupervisor && !cmd.Flags().Changed("auto-clean-stale-after") {
|
|
cfg.AutoCleanStaleAfter = 0
|
|
}
|
|
if cfg.LeasePath == "" {
|
|
cfg.LeasePath = filepath.Join(".agents", "rpi", "supervisor.lock")
|
|
}
|
|
if cfg.LandingLockPath == "" {
|
|
cfg.LandingLockPath = filepath.Join(".agents", "rpi", "landing.lock")
|
|
}
|
|
|
|
if cfg.FailurePolicy != loopFailurePolicyStop && cfg.FailurePolicy != loopFailurePolicyContinue {
|
|
return cfg, fmt.Errorf("invalid failure-policy %q (valid: stop|continue)", cfg.FailurePolicy)
|
|
}
|
|
switch cfg.GatePolicy {
|
|
case loopGatePolicyOff, loopGatePolicyBestEffort, loopGatePolicyRequired:
|
|
default:
|
|
return cfg, fmt.Errorf("invalid gate-policy %q (valid: off|best-effort|required)", cfg.GatePolicy)
|
|
}
|
|
switch cfg.LandingPolicy {
|
|
case loopLandingPolicyOff, loopLandingPolicyCommit, loopLandingPolicySyncPush:
|
|
default:
|
|
return cfg, fmt.Errorf("invalid landing-policy %q (valid: off|commit|sync-push)", cfg.LandingPolicy)
|
|
}
|
|
switch cfg.BDSyncPolicy {
|
|
case loopBDSyncPolicyAuto, loopBDSyncPolicyAlways, loopBDSyncPolicyNever:
|
|
default:
|
|
return cfg, fmt.Errorf("invalid bd-sync-policy %q (valid: auto|always|never)", cfg.BDSyncPolicy)
|
|
}
|
|
|
|
if !filepath.IsAbs(cfg.LeasePath) {
|
|
cfg.LeasePath = filepath.Join(cwd, cfg.LeasePath)
|
|
}
|
|
if !filepath.IsAbs(cfg.LandingLockPath) {
|
|
cfg.LandingLockPath = filepath.Join(cwd, cfg.LandingLockPath)
|
|
}
|
|
|
|
toolchain, err := resolveRPIToolchainDefaults()
|
|
if err != nil {
|
|
return cfg, err
|
|
}
|
|
cfg.RuntimeMode = toolchain.RuntimeMode
|
|
cfg.RuntimeCommand = toolchain.RuntimeCommand
|
|
cfg.AOCommand = toolchain.AOCommand
|
|
cfg.BDCommand = toolchain.BDCommand
|
|
cfg.TmuxCommand = toolchain.TmuxCommand
|
|
|
|
return cfg, nil
|
|
}
|
|
|
|
func (c rpiLoopSupervisorConfig) MaxCycleAttempts() int {
|
|
return c.CycleRetries + 1
|
|
}
|
|
|
|
func (c rpiLoopSupervisorConfig) ShouldContinueAfterFailure() bool {
|
|
return c.FailurePolicy == loopFailurePolicyContinue
|
|
}
|
|
|
|
type cycleFailureKind string
|
|
|
|
const (
|
|
cycleFailureTask cycleFailureKind = "task"
|
|
cycleFailureInfrastructure cycleFailureKind = "infrastructure"
|
|
)
|
|
|
|
type cycleFailureError struct {
|
|
kind cycleFailureKind
|
|
err error
|
|
}
|
|
|
|
func (e *cycleFailureError) Error() string {
|
|
return e.err.Error()
|
|
}
|
|
|
|
func (e *cycleFailureError) Unwrap() error {
|
|
return e.err
|
|
}
|
|
|
|
func wrapCycleFailure(kind cycleFailureKind, stage string, err error) error {
|
|
if err == nil {
|
|
return nil
|
|
}
|
|
var existing *cycleFailureError
|
|
if errors.As(err, &existing) {
|
|
return err
|
|
}
|
|
if stage != "" {
|
|
err = fmt.Errorf("%s: %w", stage, err)
|
|
}
|
|
return &cycleFailureError{kind: kind, err: err}
|
|
}
|
|
|
|
func shouldMarkQueueEntryFailed(err error) bool {
|
|
return !isInfrastructureCycleFailure(err)
|
|
}
|
|
|
|
func isInfrastructureCycleFailure(err error) bool {
|
|
var failure *cycleFailureError
|
|
if !errors.As(err, &failure) {
|
|
return false
|
|
}
|
|
return failure.kind == cycleFailureInfrastructure
|
|
}
|
|
|
|
type landingScope struct {
|
|
baselineDirtyPaths map[string]struct{}
|
|
}
|
|
|
|
func runRPISupervisedCycle(cwd, goal string, cycle, attempt int, cfg rpiLoopSupervisorConfig) (retErr error) {
|
|
if cfg.DetachedHeal {
|
|
branch, healed, err := ensureLoopAttachedBranch(cwd, cfg.DetachedBranchPrefix)
|
|
if err != nil {
|
|
return wrapCycleFailure(cycleFailureInfrastructure, "detached-head self-heal", err)
|
|
}
|
|
if healed {
|
|
fmt.Printf("Detached HEAD detected. Switched to branch: %s\n", branch)
|
|
}
|
|
}
|
|
var scope *landingScope
|
|
if cfg.LandingPolicy != loopLandingPolicyOff {
|
|
scope, retErr = captureLandingScope(cwd, cfg.CommandTimeout)
|
|
if retErr != nil {
|
|
return wrapCycleFailure(cycleFailureInfrastructure, "capture landing scope", retErr)
|
|
}
|
|
}
|
|
|
|
if cfg.EnsureCleanup {
|
|
defer func() {
|
|
cleanupErr := runSupervisorCleanup(cwd, cfg.AutoCleanStaleAfter, cfg.CleanupPruneWorktrees)
|
|
if cleanupErr == nil {
|
|
return
|
|
}
|
|
if retErr == nil {
|
|
retErr = wrapCycleFailure(cycleFailureInfrastructure, "supervisor cleanup", cleanupErr)
|
|
return
|
|
}
|
|
VerbosePrintf("Warning: supervisor cleanup after failure: %v\n", cleanupErr)
|
|
}()
|
|
}
|
|
|
|
opts := defaultPhasedEngineOptions()
|
|
opts.AutoCleanStale = cfg.AutoClean
|
|
opts.AutoCleanStaleAfter = cfg.AutoCleanStaleAfter
|
|
opts.RuntimeMode = cfg.RuntimeMode
|
|
opts.RuntimeCommand = cfg.RuntimeCommand
|
|
opts.AOCommand = cfg.AOCommand
|
|
opts.BDCommand = cfg.BDCommand
|
|
opts.TmuxCommand = cfg.TmuxCommand
|
|
|
|
if err := runPhasedEngine(cwd, goal, opts); err != nil {
|
|
return wrapCycleFailure(cycleFailureTask, "phased engine", err)
|
|
}
|
|
if err := runSupervisorGates(cwd, cfg); err != nil {
|
|
return wrapCycleFailure(cycleFailureTask, "quality gates", err)
|
|
}
|
|
if err := runSupervisorLanding(cwd, cfg, cycle, attempt, goal, scope); err != nil {
|
|
return wrapCycleFailure(cycleFailureInfrastructure, "landing", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func ensureLoopAttachedBranch(cwd, branchPrefix string) (string, bool, error) {
|
|
repoRoot, err := cliRPI.GetRepoRoot(cwd, worktreeTimeout)
|
|
if err != nil {
|
|
return "", false, err
|
|
}
|
|
return cliRPI.EnsureAttachedBranch(repoRoot, worktreeTimeout, branchPrefix)
|
|
}
|
|
|
|
func runSupervisorCleanup(cwd string, staleAfter time.Duration, prune bool) error {
|
|
return executeRPICleanup(cwd, "", true, prune, false, GetDryRun(), staleAfter)
|
|
}
|
|
|
|
func runSupervisorGates(cwd string, cfg rpiLoopSupervisorConfig) error {
|
|
if cfg.GatePolicy == loopGatePolicyOff {
|
|
return nil
|
|
}
|
|
|
|
runRequired := cfg.GatePolicy == loopGatePolicyRequired
|
|
var failures []string
|
|
for _, gatePath := range []string{cfg.ValidateFastScript, cfg.SecurityGateScript} {
|
|
if strings.TrimSpace(gatePath) == "" {
|
|
continue
|
|
}
|
|
if err := runGateScript(cwd, gatePath, runRequired, cfg.CommandTimeout); err != nil {
|
|
if cfg.GatePolicy == loopGatePolicyBestEffort {
|
|
VerbosePrintf("Warning: gate %s failed: %v\n", gatePath, err)
|
|
continue
|
|
}
|
|
failures = append(failures, err.Error())
|
|
}
|
|
}
|
|
if len(failures) > 0 {
|
|
return fmt.Errorf("gate failures: %s", strings.Join(failures, "; "))
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func runGateScript(cwd, scriptPath string, required bool, timeout time.Duration) error {
|
|
path := scriptPath
|
|
if !filepath.IsAbs(path) {
|
|
path = filepath.Join(cwd, path)
|
|
}
|
|
|
|
info, err := os.Stat(path)
|
|
if err != nil {
|
|
if required {
|
|
return fmt.Errorf("required gate script missing: %s", path)
|
|
}
|
|
fmt.Printf("Skipping optional gate (missing): %s\n", path)
|
|
return nil
|
|
}
|
|
if info.IsDir() {
|
|
if required {
|
|
return fmt.Errorf("required gate path is a directory: %s", path)
|
|
}
|
|
fmt.Printf("Skipping optional gate (path is directory): %s\n", path)
|
|
return nil
|
|
}
|
|
|
|
fmt.Printf("Running gate: %s\n", path)
|
|
if err := loopCommandRunner(cwd, timeout, "bash", path); err != nil {
|
|
return fmt.Errorf("gate script %s failed: %w", path, err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func runSupervisorLanding(cwd string, cfg rpiLoopSupervisorConfig, cycle, attempt int, goal string, scope *landingScope) error {
|
|
switch cfg.LandingPolicy {
|
|
case loopLandingPolicyOff:
|
|
return nil
|
|
case loopLandingPolicyCommit:
|
|
landingLock, err := acquireLandingLock(cwd, cfg)
|
|
if err != nil {
|
|
return fmt.Errorf("landing lock acquisition failed: %w", err)
|
|
}
|
|
if landingLock != nil {
|
|
defer func() {
|
|
if releaseErr := landingLock.Release(); releaseErr != nil {
|
|
VerbosePrintf("Warning: could not release landing lock: %v\n", releaseErr)
|
|
}
|
|
}()
|
|
}
|
|
|
|
_, err := commitIfDirty(cwd, renderLandingCommitMessage(cfg.LandingCommitMessage, cycle, attempt, goal), cfg.CommandTimeout, scope)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return nil
|
|
case loopLandingPolicySyncPush:
|
|
landingLock, err := acquireLandingLock(cwd, cfg)
|
|
if err != nil {
|
|
return fmt.Errorf("landing lock acquisition failed: %w", err)
|
|
}
|
|
if landingLock != nil {
|
|
defer func() {
|
|
if releaseErr := landingLock.Release(); releaseErr != nil {
|
|
VerbosePrintf("Warning: could not release landing lock: %v\n", releaseErr)
|
|
}
|
|
}()
|
|
}
|
|
|
|
committed, err := commitIfDirty(cwd, renderLandingCommitMessage(cfg.LandingCommitMessage, cycle, attempt, goal), cfg.CommandTimeout, scope)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if !committed {
|
|
fmt.Println("Landing: no commit performed.")
|
|
return nil
|
|
}
|
|
targetBranch, err := resolveLandingBranch(cwd, cfg.LandingBranch, cfg.CommandTimeout)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := loopCommandRunner(cwd, cfg.CommandTimeout, "git", "fetch", "origin", targetBranch); err != nil {
|
|
return wrapSyncPushLandingFailure(cwd, cfg.CommandTimeout, "fetch", err)
|
|
}
|
|
if err := loopCommandRunner(cwd, cfg.CommandTimeout, "git", "rebase", "origin/"+targetBranch); err != nil {
|
|
return wrapSyncPushLandingFailure(cwd, cfg.CommandTimeout, "rebase", err)
|
|
}
|
|
|
|
runSync, err := shouldRunBDSync(cwd, cfg.BDSyncPolicy, cfg.BDCommand)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if runSync {
|
|
if err := loopCommandRunner(cwd, cfg.CommandTimeout, cfg.BDCommand, "sync"); err != nil {
|
|
return fmt.Errorf("bd sync failed: %w", err)
|
|
}
|
|
}
|
|
|
|
if err := loopCommandRunner(cwd, cfg.CommandTimeout, "git", "push", "origin", "HEAD:"+targetBranch); err != nil {
|
|
return fmt.Errorf("landing push failed: %w", err)
|
|
}
|
|
_ = loopCommandRunner(cwd, cfg.CommandTimeout, "git", "status", "-sb")
|
|
return nil
|
|
default:
|
|
return fmt.Errorf("unsupported landing policy: %s", cfg.LandingPolicy)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func acquireLandingLock(cwd string, cfg rpiLoopSupervisorConfig) (*supervisorLease, error) {
|
|
if cfg.LandingPolicy == loopLandingPolicyOff {
|
|
return nil, nil
|
|
}
|
|
if strings.TrimSpace(cfg.LandingLockPath) == "" {
|
|
return nil, nil
|
|
}
|
|
|
|
runID := cfg.LandingPolicy + "-run-" + cliRPI.GenerateRunID()
|
|
return acquireSupervisorLease(cwd, cfg.LandingLockPath, cfg.LeaseTTL, runID)
|
|
}
|
|
|
|
func wrapSyncPushLandingFailure(cwd string, timeout time.Duration, stage string, err error) error {
|
|
if err == nil {
|
|
return nil
|
|
}
|
|
if recoveryErr := recoverSyncPushLandingState(cwd, timeout); recoveryErr != nil {
|
|
return fmt.Errorf("landing %s failed: %w (state recovery failed: %v)", stage, err, recoveryErr)
|
|
}
|
|
return fmt.Errorf("landing %s failed: %w (state recovered)", stage, err)
|
|
}
|
|
|
|
func recoverSyncPushLandingState(cwd string, timeout time.Duration) error {
|
|
abortOut, abortErr := loopCommandOutputRunner(cwd, timeout, "git", "rebase", "--abort")
|
|
if abortErr != nil && !isNoRebaseInProgressMessage(abortOut) {
|
|
return fmt.Errorf("git rebase --abort failed: %w", abortErr)
|
|
}
|
|
if err := loopCommandRunner(cwd, timeout, "git", "status", "-sb"); err != nil {
|
|
return fmt.Errorf("git status during state recovery failed: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func isNoRebaseInProgressMessage(out string) bool {
|
|
msg := strings.ToLower(strings.TrimSpace(out))
|
|
if msg == "" {
|
|
return false
|
|
}
|
|
return strings.Contains(msg, "no rebase in progress") || strings.Contains(msg, "no rebase to abort")
|
|
}
|
|
|
|
func renderLandingCommitMessage(template string, cycle, attempt int, goal string) string {
|
|
msg := strings.TrimSpace(template)
|
|
if msg == "" {
|
|
msg = "chore(rpi): autonomous cycle {{cycle}}"
|
|
}
|
|
msg = strings.ReplaceAll(msg, "{{cycle}}", strconv.Itoa(cycle))
|
|
msg = strings.ReplaceAll(msg, "{{attempt}}", strconv.Itoa(attempt))
|
|
msg = strings.ReplaceAll(msg, "{{goal}}", goal)
|
|
return msg
|
|
}
|
|
|
|
func commitIfDirty(cwd, message string, timeout time.Duration, scope *landingScope) (bool, error) {
|
|
dirty, err := repoHasChanges(cwd, timeout)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
if !dirty {
|
|
fmt.Println("Landing: no changes to commit.")
|
|
return false, nil
|
|
}
|
|
if scope == nil {
|
|
return false, fmt.Errorf("landing scope missing for autonomous commit")
|
|
}
|
|
ownedPaths, err := computeOwnedDirtyPaths(cwd, timeout, scope)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
if len(ownedPaths) == 0 {
|
|
fmt.Println("Landing: only pre-existing dirty paths detected; skipping autonomous commit.")
|
|
return false, nil
|
|
}
|
|
addArgs := append([]string{"add", "--"}, ownedPaths...)
|
|
if err := loopCommandRunner(cwd, timeout, "git", addArgs...); err != nil {
|
|
return false, fmt.Errorf("git add owned paths failed: %w", err)
|
|
}
|
|
if err := loopCommandRunner(cwd, timeout, "git", "commit", "-m", message); err != nil {
|
|
return false, fmt.Errorf("git commit failed: %w", err)
|
|
}
|
|
return true, nil
|
|
}
|
|
|
|
func repoHasChanges(cwd string, timeout time.Duration) (bool, error) {
|
|
out, err := loopCommandOutputRunner(cwd, timeout, "git", "status", "--porcelain")
|
|
if err != nil {
|
|
return false, fmt.Errorf("git status failed: %w", err)
|
|
}
|
|
return strings.TrimSpace(out) != "", nil
|
|
}
|
|
|
|
func resolveLandingBranch(cwd, explicit string, timeout time.Duration) (string, error) {
|
|
if strings.TrimSpace(explicit) != "" {
|
|
return strings.TrimSpace(explicit), nil
|
|
}
|
|
out, err := loopCommandOutputRunner(cwd, timeout, "git", "symbolic-ref", "--quiet", "--short", "refs/remotes/origin/HEAD")
|
|
if err == nil {
|
|
branch := strings.TrimSpace(strings.TrimPrefix(out, "origin/"))
|
|
if branch != "" {
|
|
return branch, nil
|
|
}
|
|
}
|
|
out, err = loopCommandOutputRunner(cwd, timeout, "git", "rev-parse", "--abbrev-ref", "HEAD")
|
|
if err == nil {
|
|
branch := strings.TrimSpace(out)
|
|
if branch != "" && branch != "HEAD" {
|
|
return branch, nil
|
|
}
|
|
}
|
|
return "main", nil
|
|
}
|
|
|
|
func shouldRunBDSync(cwd, policy, bdCommand string) (bool, error) {
|
|
command := strings.TrimSpace(bdCommand)
|
|
if command == "" {
|
|
command = "bd"
|
|
}
|
|
switch policy {
|
|
case loopBDSyncPolicyNever:
|
|
return false, nil
|
|
case loopBDSyncPolicyAlways:
|
|
if _, err := loopLookPath(command); err != nil {
|
|
return false, fmt.Errorf("bd-sync-policy=always but %s CLI not found on PATH", command)
|
|
}
|
|
return true, nil
|
|
case loopBDSyncPolicyAuto:
|
|
if _, err := loopLookPath(command); err != nil {
|
|
return false, nil
|
|
}
|
|
if _, err := os.Stat(filepath.Join(cwd, ".beads")); err != nil {
|
|
return false, nil
|
|
}
|
|
return true, nil
|
|
default:
|
|
return false, fmt.Errorf("unsupported bd-sync-policy: %s", policy)
|
|
}
|
|
}
|
|
|
|
func runLoopCommandWithTimeout(cwd string, timeout time.Duration, name string, args ...string) error {
|
|
timeout = normalizeLoopCommandTimeout(timeout)
|
|
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
|
defer cancel()
|
|
|
|
cmd := loopExecCommandContext(ctx, name, args...)
|
|
cmd.Dir = cwd
|
|
cmd.Stdout = os.Stdout
|
|
cmd.Stderr = os.Stderr
|
|
err := cmd.Run()
|
|
if err != nil && ctx.Err() == context.DeadlineExceeded {
|
|
return fmt.Errorf("%s %s timed out after %s", name, strings.Join(args, " "), timeout)
|
|
}
|
|
return err
|
|
}
|
|
|
|
func runLoopCommandOutputWithTimeout(cwd string, timeout time.Duration, name string, args ...string) (string, error) {
|
|
timeout = normalizeLoopCommandTimeout(timeout)
|
|
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
|
defer cancel()
|
|
|
|
cmd := loopExecCommandContext(ctx, name, args...)
|
|
cmd.Dir = cwd
|
|
out, err := cmd.CombinedOutput()
|
|
if err != nil && ctx.Err() == context.DeadlineExceeded {
|
|
return strings.TrimSpace(string(out)), fmt.Errorf("%s %s timed out after %s", name, strings.Join(args, " "), timeout)
|
|
}
|
|
return strings.TrimSpace(string(out)), err
|
|
}
|
|
|
|
func normalizeLoopCommandTimeout(timeout time.Duration) time.Duration {
|
|
if timeout <= 0 {
|
|
return defaultLoopCommandTimeout
|
|
}
|
|
return timeout
|
|
}
|
|
|
|
func captureLandingScope(cwd string, timeout time.Duration) (*landingScope, error) {
|
|
paths, err := collectDirtyPaths(cwd, timeout)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return &landingScope{baselineDirtyPaths: paths}, nil
|
|
}
|
|
|
|
func computeOwnedDirtyPaths(cwd string, timeout time.Duration, scope *landingScope) ([]string, error) {
|
|
current, err := collectDirtyPaths(cwd, timeout)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var owned []string
|
|
for path := range current {
|
|
if _, existed := scope.baselineDirtyPaths[path]; existed {
|
|
continue
|
|
}
|
|
owned = append(owned, path)
|
|
}
|
|
sort.Strings(owned)
|
|
return owned, nil
|
|
}
|
|
|
|
func collectDirtyPaths(cwd string, timeout time.Duration) (map[string]struct{}, error) {
|
|
paths := make(map[string]struct{})
|
|
|
|
trackedOut, err := loopCommandOutputRunner(cwd, timeout, "git", "diff", "--name-only", "HEAD", "--")
|
|
if err != nil {
|
|
return nil, fmt.Errorf("git diff failed while collecting landing scope: %w", err)
|
|
}
|
|
appendDirtyPaths(paths, trackedOut)
|
|
|
|
untrackedOut, err := loopCommandOutputRunner(cwd, timeout, "git", "ls-files", "--others", "--exclude-standard")
|
|
if err != nil {
|
|
return nil, fmt.Errorf("git ls-files failed while collecting landing scope: %w", err)
|
|
}
|
|
appendDirtyPaths(paths, untrackedOut)
|
|
|
|
return paths, nil
|
|
}
|
|
|
|
func appendDirtyPaths(dest map[string]struct{}, output string) {
|
|
for _, line := range strings.Split(output, "\n") {
|
|
path := strings.TrimSpace(line)
|
|
if path == "" {
|
|
continue
|
|
}
|
|
dest[path] = struct{}{}
|
|
}
|
|
}
|
|
|
|
type supervisorLease struct {
|
|
path string
|
|
file *os.File
|
|
ttl time.Duration
|
|
stopCh chan struct{}
|
|
doneCh chan struct{}
|
|
meta supervisorLeaseMetadata
|
|
}
|
|
|
|
type supervisorLeaseMetadata struct {
|
|
RunID string `json:"run_id"`
|
|
PID int `json:"pid"`
|
|
Host string `json:"host"`
|
|
CWD string `json:"cwd"`
|
|
AcquiredAt string `json:"acquired_at"`
|
|
RenewedAt string `json:"renewed_at"`
|
|
ExpiresAt string `json:"expires_at"`
|
|
}
|
|
|
|
func acquireSupervisorLease(cwd, leasePath string, ttl time.Duration, runID string) (*supervisorLease, error) {
|
|
if runID == "" {
|
|
runID = generateRunID()
|
|
}
|
|
if ttl <= 0 {
|
|
ttl = 2 * time.Minute
|
|
}
|
|
|
|
path := leasePath
|
|
if !filepath.IsAbs(path) {
|
|
path = filepath.Join(cwd, path)
|
|
}
|
|
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
|
|
return nil, fmt.Errorf("create lease directory: %w", err)
|
|
}
|
|
|
|
file, err := os.OpenFile(path, os.O_CREATE|os.O_RDWR, 0644)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("open lease file: %w", err)
|
|
}
|
|
|
|
if err := syscall.Flock(int(file.Fd()), syscall.LOCK_EX|syscall.LOCK_NB); err != nil {
|
|
_ = file.Close()
|
|
if errors.Is(err, syscall.EWOULDBLOCK) || errors.Is(err, syscall.EAGAIN) {
|
|
return nil, fmt.Errorf("single-flight lease already held: %s", readLeaseHolderHint(path))
|
|
}
|
|
return nil, fmt.Errorf("acquire lease lock: %w", err)
|
|
}
|
|
|
|
host, _ := os.Hostname()
|
|
now := time.Now().UTC()
|
|
meta := supervisorLeaseMetadata{
|
|
RunID: runID,
|
|
PID: os.Getpid(),
|
|
Host: host,
|
|
CWD: cwd,
|
|
AcquiredAt: now.Format(time.RFC3339),
|
|
RenewedAt: now.Format(time.RFC3339),
|
|
ExpiresAt: now.Add(ttl).Format(time.RFC3339),
|
|
}
|
|
lease := &supervisorLease{
|
|
path: path,
|
|
file: file,
|
|
ttl: ttl,
|
|
stopCh: make(chan struct{}),
|
|
doneCh: make(chan struct{}),
|
|
meta: meta,
|
|
}
|
|
if err := lease.writeMetadata(now); err != nil {
|
|
_ = syscall.Flock(int(file.Fd()), syscall.LOCK_UN)
|
|
_ = file.Close()
|
|
return nil, err
|
|
}
|
|
lease.startHeartbeat()
|
|
return lease, nil
|
|
}
|
|
|
|
func (l *supervisorLease) Path() string {
|
|
return l.path
|
|
}
|
|
|
|
func (l *supervisorLease) Release() error {
|
|
close(l.stopCh)
|
|
<-l.doneCh
|
|
|
|
unlockErr := syscall.Flock(int(l.file.Fd()), syscall.LOCK_UN)
|
|
closeErr := l.file.Close()
|
|
|
|
if unlockErr != nil {
|
|
return fmt.Errorf("unlock lease: %w", unlockErr)
|
|
}
|
|
if closeErr != nil {
|
|
return fmt.Errorf("close lease file: %w", closeErr)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (l *supervisorLease) startHeartbeat() {
|
|
interval := l.ttl / 2
|
|
if interval < 15*time.Second {
|
|
interval = 15 * time.Second
|
|
}
|
|
ticker := time.NewTicker(interval)
|
|
go func() {
|
|
defer close(l.doneCh)
|
|
defer ticker.Stop()
|
|
for {
|
|
select {
|
|
case <-l.stopCh:
|
|
return
|
|
case now := <-ticker.C:
|
|
if err := l.writeMetadata(now.UTC()); err != nil {
|
|
VerbosePrintf("Warning: lease heartbeat update failed: %v\n", err)
|
|
}
|
|
}
|
|
}
|
|
}()
|
|
}
|
|
|
|
func (l *supervisorLease) writeMetadata(now time.Time) error {
|
|
l.meta.RenewedAt = now.Format(time.RFC3339)
|
|
l.meta.ExpiresAt = now.Add(l.ttl).Format(time.RFC3339)
|
|
|
|
data, err := json.MarshalIndent(l.meta, "", " ")
|
|
if err != nil {
|
|
return fmt.Errorf("marshal lease metadata: %w", err)
|
|
}
|
|
data = append(data, '\n')
|
|
|
|
if err := l.file.Truncate(0); err != nil {
|
|
return fmt.Errorf("truncate lease file: %w", err)
|
|
}
|
|
if _, err := l.file.Seek(0, 0); err != nil {
|
|
return fmt.Errorf("seek lease file: %w", err)
|
|
}
|
|
if _, err := l.file.Write(data); err != nil {
|
|
return fmt.Errorf("write lease metadata: %w", err)
|
|
}
|
|
if err := l.file.Sync(); err != nil {
|
|
return fmt.Errorf("sync lease metadata: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func readLeaseHolderHint(path string) string {
|
|
data, err := os.ReadFile(path)
|
|
if err != nil {
|
|
return fmt.Sprintf("lock=%s", path)
|
|
}
|
|
var meta supervisorLeaseMetadata
|
|
if err := json.Unmarshal(data, &meta); err != nil {
|
|
return fmt.Sprintf("lock=%s", path)
|
|
}
|
|
if meta.RunID == "" {
|
|
return fmt.Sprintf("lock=%s", path)
|
|
}
|
|
return fmt.Sprintf("run=%s pid=%d host=%s renewed_at=%s", meta.RunID, meta.PID, meta.Host, meta.RenewedAt)
|
|
}
|