Merge pull request #38 from boshu2/codex/stabilize-rpi-loop-landing

stabilize rpi loop landing and control docs
This commit is contained in:
Bo
2026-02-21 21:21:59 -05:00
committed by GitHub
5 changed files with 136 additions and 5 deletions
+2
View File
@@ -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)")
+56 -3
View File
@@ -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 {
+41 -1
View File
@@ -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
}
+20
View File
@@ -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)
+17 -1
View File
@@ -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.