mirror of
https://github.com/boshu2/agentops.git
synced 2026-09-14 15:08:13 +08:00
Merge pull request #38 from boshu2/codex/stabilize-rpi-loop-landing
stabilize rpi loop landing and control docs
This commit is contained in:
@@ -36,6 +36,7 @@ var (
|
||||
rpiLandingPolicy string
|
||||
rpiLandingBranch string
|
||||
rpiLandingCommitMessage string
|
||||
rpiLandingLockPath string
|
||||
rpiBDSyncPolicy string
|
||||
rpiCommandTimeout time.Duration
|
||||
)
|
||||
@@ -92,6 +93,7 @@ Examples:
|
||||
loopCmd.Flags().StringVar(&rpiLandingPolicy, "landing-policy", "off", "Landing policy after successful cycle: off|commit|sync-push")
|
||||
loopCmd.Flags().StringVar(&rpiLandingBranch, "landing-branch", "", "Landing target branch (empty resolves origin/HEAD, then current branch, then main)")
|
||||
loopCmd.Flags().StringVar(&rpiLandingCommitMessage, "landing-commit-message", "chore(rpi): autonomous cycle {{cycle}}", "Commit message template for landing policies that commit")
|
||||
loopCmd.Flags().StringVar(&rpiLandingLockPath, "landing-lock-path", filepath.Join(".agents", "rpi", "landing.lock"), "Landing lock file path for synchronized integration (absolute or repo-relative)")
|
||||
loopCmd.Flags().StringVar(&rpiBDSyncPolicy, "bd-sync-policy", "auto", "bd sync policy for landing: auto|always|never")
|
||||
loopCmd.Flags().DurationVar(&rpiCommandTimeout, "command-timeout", 20*time.Minute, "Timeout for supervisor external commands (git/bd/gate scripts)")
|
||||
|
||||
|
||||
@@ -65,6 +65,7 @@ type rpiLoopSupervisorConfig struct {
|
||||
LandingPolicy string
|
||||
LandingBranch string
|
||||
LandingCommitMessage string
|
||||
LandingLockPath string
|
||||
BDSyncPolicy string
|
||||
CommandTimeout time.Duration
|
||||
RuntimeMode string
|
||||
@@ -95,6 +96,7 @@ func resolveLoopSupervisorConfig(cmd *cobra.Command, cwd string) (rpiLoopSupervi
|
||||
LandingPolicy: strings.ToLower(strings.TrimSpace(rpiLandingPolicy)),
|
||||
LandingBranch: strings.TrimSpace(rpiLandingBranch),
|
||||
LandingCommitMessage: rpiLandingCommitMessage,
|
||||
LandingLockPath: rpiLandingLockPath,
|
||||
BDSyncPolicy: strings.ToLower(strings.TrimSpace(rpiBDSyncPolicy)),
|
||||
CommandTimeout: rpiCommandTimeout,
|
||||
}
|
||||
@@ -150,6 +152,9 @@ func resolveLoopSupervisorConfig(cmd *cobra.Command, cwd string) (rpiLoopSupervi
|
||||
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)
|
||||
@@ -173,6 +178,9 @@ func resolveLoopSupervisorConfig(cmd *cobra.Command, cwd string) (rpiLoopSupervi
|
||||
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 {
|
||||
@@ -369,12 +377,44 @@ func runSupervisorLanding(cwd string, cfg rpiLoopSupervisorConfig, cycle, attemp
|
||||
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)
|
||||
return err
|
||||
case loopLandingPolicySyncPush:
|
||||
if _, err := commitIfDirty(cwd, renderLandingCommitMessage(cfg.LandingCommitMessage, cycle, attempt, goal), cfg.CommandTimeout, scope); err != nil {
|
||||
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
|
||||
@@ -404,6 +444,19 @@ func runSupervisorLanding(cwd string, cfg rpiLoopSupervisorConfig, cycle, attemp
|
||||
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 {
|
||||
|
||||
@@ -31,14 +31,16 @@ func TestResolveLoopSupervisorConfig_AppliesSupervisorDefaults(t *testing.T) {
|
||||
rpiEnsureCleanup = false
|
||||
rpiGatePolicy = "off"
|
||||
rpiLandingPolicy = "off"
|
||||
rpiLandingLockPath = ""
|
||||
rpiBDSyncPolicy = "auto"
|
||||
rpiLeaseTTL = 2 * time.Minute
|
||||
rpiAutoCleanStaleAfter = 24 * time.Hour
|
||||
rpiLeasePath = ".agents/rpi/supervisor.lock"
|
||||
|
||||
cmd := newLoopSupervisorTestCommand()
|
||||
tmpDir := t.TempDir()
|
||||
|
||||
cfg, err := resolveLoopSupervisorConfig(cmd, t.TempDir())
|
||||
cfg, err := resolveLoopSupervisorConfig(cmd, tmpDir)
|
||||
if err != nil {
|
||||
t.Fatalf("resolveLoopSupervisorConfig: %v", err)
|
||||
}
|
||||
@@ -66,6 +68,9 @@ func TestResolveLoopSupervisorConfig_AppliesSupervisorDefaults(t *testing.T) {
|
||||
if cfg.GatePolicy != loopGatePolicyRequired {
|
||||
t.Fatalf("gate policy: got %q, want %q", cfg.GatePolicy, loopGatePolicyRequired)
|
||||
}
|
||||
if cfg.LandingLockPath != filepath.Join(tmpDir, ".agents", "rpi", "landing.lock") {
|
||||
t.Fatalf("landing lock path: got %q, want %q", cfg.LandingLockPath, filepath.Join(tmpDir, ".agents", "rpi", "landing.lock"))
|
||||
}
|
||||
}
|
||||
|
||||
func TestAcquireSupervisorLease_SingleFlight(t *testing.T) {
|
||||
@@ -314,6 +319,37 @@ func TestRunSupervisorLanding_SyncPush_FetchFailure_RecoversState(t *testing.T)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunSupervisorLanding_CommitPolicy_RespectsLandingLock(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
lockPath := filepath.Join(tmpDir, "landing.lock")
|
||||
|
||||
landingLease, err := acquireSupervisorLease(tmpDir, lockPath, 2*time.Minute, "landing-run-locked")
|
||||
if err != nil {
|
||||
t.Fatalf("acquire landing lease: %v", err)
|
||||
}
|
||||
defer func() {
|
||||
if err := landingLease.Release(); err != nil {
|
||||
t.Fatalf("release landing lease: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
cfg := rpiLoopSupervisorConfig{
|
||||
LandingPolicy: loopLandingPolicyCommit,
|
||||
LandingLockPath: lockPath,
|
||||
LandingCommitMessage: "chore(rpi): autonomous cycle {{cycle}}",
|
||||
CommandTimeout: time.Minute,
|
||||
}
|
||||
err = runSupervisorLanding(tmpDir, cfg, 1, 1, "ship", &landingScope{
|
||||
baselineDirtyPaths: map[string]struct{}{},
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected landing lock contention error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "landing lock acquisition failed") {
|
||||
t.Fatalf("expected landing lock acquisition error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsNoRebaseInProgressMessage(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
@@ -372,6 +408,7 @@ type loopSupervisorGlobals struct {
|
||||
rpiLandingPolicy string
|
||||
rpiLandingBranch string
|
||||
rpiLandingCommitMessage string
|
||||
rpiLandingLockPath string
|
||||
rpiBDSyncPolicy string
|
||||
rpiCommandTimeout time.Duration
|
||||
}
|
||||
@@ -398,6 +435,7 @@ func snapshotLoopSupervisorGlobals() loopSupervisorGlobals {
|
||||
rpiLandingPolicy: rpiLandingPolicy,
|
||||
rpiLandingBranch: rpiLandingBranch,
|
||||
rpiLandingCommitMessage: rpiLandingCommitMessage,
|
||||
rpiLandingLockPath: rpiLandingLockPath,
|
||||
rpiBDSyncPolicy: rpiBDSyncPolicy,
|
||||
rpiCommandTimeout: rpiCommandTimeout,
|
||||
}
|
||||
@@ -424,6 +462,7 @@ func restoreLoopSupervisorGlobals(prev loopSupervisorGlobals) {
|
||||
rpiLandingPolicy = prev.rpiLandingPolicy
|
||||
rpiLandingBranch = prev.rpiLandingBranch
|
||||
rpiLandingCommitMessage = prev.rpiLandingCommitMessage
|
||||
rpiLandingLockPath = prev.rpiLandingLockPath
|
||||
rpiBDSyncPolicy = prev.rpiBDSyncPolicy
|
||||
rpiCommandTimeout = prev.rpiCommandTimeout
|
||||
}
|
||||
@@ -438,6 +477,7 @@ func newLoopSupervisorTestCommand() *cobra.Command {
|
||||
cmd.Flags().Bool("auto-clean", false, "")
|
||||
cmd.Flags().Bool("ensure-cleanup", false, "")
|
||||
cmd.Flags().String("gate-policy", "off", "")
|
||||
cmd.Flags().String("landing-lock-path", "", "")
|
||||
cmd.Flags().Duration("command-timeout", 20*time.Minute, "")
|
||||
return cmd
|
||||
}
|
||||
|
||||
@@ -1252,6 +1252,24 @@ ao rpi cleanup [flags]
|
||||
--stale-after duration Only clean runs older than this age (0 disables age filtering)
|
||||
```
|
||||
|
||||
#### `ao rpi cancel`
|
||||
|
||||
Cancel active in-flight RPI runs.
|
||||
|
||||
```
|
||||
ao rpi cancel [flags]
|
||||
```
|
||||
|
||||
**Flags:**
|
||||
|
||||
```
|
||||
--all Cancel all active runs discovered under current/sibling roots
|
||||
-h, --help help for cancel
|
||||
--run-id string Cancel one active run by run ID
|
||||
--signal string Signal to send: TERM|KILL|INT (default "TERM")
|
||||
--dry-run Show what would be cancelled without sending signals
|
||||
```
|
||||
|
||||
#### `ao rpi loop`
|
||||
|
||||
Execute RPI cycles in a loop, consuming from next-work.jsonl.
|
||||
@@ -1267,6 +1285,7 @@ ao rpi loop [goal] [flags]
|
||||
--auto-clean-stale-after duration Only auto-clean runs older than this age (default 24h0m0s)
|
||||
--bd-sync-policy string bd sync policy for landing: auto|always|never (default "auto")
|
||||
--cleanup-prune-worktrees Run git worktree prune during supervisor cleanup (default true)
|
||||
--command-timeout duration Timeout for supervisor external commands (git/bd/gate scripts) (default 20m0s)
|
||||
--cycle-delay duration Delay between completed cycles
|
||||
--cycle-retries int Automatic retry count per cycle after a failed attempt
|
||||
--detached-branch-prefix string Branch prefix used by detached HEAD self-heal (default "codex/auto-rpi")
|
||||
@@ -1280,6 +1299,7 @@ ao rpi loop [goal] [flags]
|
||||
--landing-branch string Landing target branch (empty resolves origin/HEAD, then current branch, then main)
|
||||
--landing-commit-message string Commit message template for landing policies that commit (default "chore(rpi): autonomous cycle {{cycle}}")
|
||||
--landing-policy string Landing policy after successful cycle: off|commit|sync-push (default "off")
|
||||
--landing-lock-path string Landing lock file path for synchronized integration (absolute or repo-relative) (default ".agents/rpi/landing.lock")
|
||||
--lease Acquire a single-flight supervisor lease lock before running
|
||||
--lease-path string Lease lock file path (absolute or repo-relative) (default ".agents/rpi/supervisor.lock")
|
||||
--lease-ttl duration Lease heartbeat TTL for supervisor lock metadata (default 2m0s)
|
||||
|
||||
@@ -97,7 +97,7 @@ When starting from phase 1 (fresh run), the orchestrator removes stale phase sum
|
||||
|
||||
`ao rpi phased` creates sibling worktrees named `../<repo>-rpi-<run-id>/` (unless `--no-worktree` is set). Cleanup behavior is intentional and asymmetric:
|
||||
|
||||
- Success path: after all phases complete, the orchestrator merges `rpi/<run-id>` into the source branch and removes the worktree + branch.
|
||||
- Success path: after all phases complete, the orchestrator merges the worktree commit (detached checkout) into the source branch and removes the worktree directory.
|
||||
- Failure path: worktree is preserved for debugging (no auto-destroy on failed phase).
|
||||
- Interrupt path (`SIGINT`/`SIGTERM`): worktree is preserved and terminal metadata is written (`terminal_status: interrupted`).
|
||||
|
||||
@@ -139,6 +139,20 @@ Behavior:
|
||||
- Optionally runs `git worktree prune`
|
||||
- Supports age-gated cleanup via `--stale-after` to avoid touching recently interrupted runs
|
||||
|
||||
## Kill-Switch and Cleanup Workflow
|
||||
|
||||
Operators should use the following explicit sequence when suspending or recovering autonomous runs:
|
||||
|
||||
- Stop active runs:
|
||||
- `ao rpi cancel --all`
|
||||
- `ao rpi cancel --run-id <id>`
|
||||
- Mark-and-prune terminal runs:
|
||||
- `ao rpi cleanup --all --dry-run`
|
||||
- `ao rpi cleanup --all --prune-worktrees`
|
||||
- Reclaim stale worktrees and stale tmux sessions:
|
||||
- `ao worktree gc`
|
||||
- `ao worktree gc --prune` (when a wider sweep is needed)
|
||||
|
||||
Safety guards:
|
||||
|
||||
- Refuses to remove non-sibling paths
|
||||
@@ -161,3 +175,5 @@ Behavior:
|
||||
## Current Limitation
|
||||
|
||||
`ao rpi cleanup` operates on run-registry state entries. If a historical/log-only run never wrote `.agents/rpi/runs/<run-id>/phased-state.json`, it may appear in log views but not be selected by stale cleanup. In that case, use standard git worktree hygiene (`git worktree list`, `git worktree remove --force <path>`, `git branch -D rpi/<run-id>`) after verifying the branch has no unique commits.
|
||||
|
||||
`ao rpi phased` no longer uses `rpi/<run-id>` branches in the current implementation; cleanup for historical branch names now applies only to legacy runs that were created before detached-worktree migration.
|
||||
|
||||
Reference in New Issue
Block a user