diff --git a/internal/beads/beads.go b/internal/beads/beads.go index e2d2fd6e..ce072392 100644 --- a/internal/beads/beads.go +++ b/internal/beads/beads.go @@ -77,11 +77,14 @@ type CreateOptions struct { // UpdateOptions specifies options for updating an issue. type UpdateOptions struct { - Title *string - Status *string - Priority *int - Description *string - Assignee *string + Title *string + Status *string + Priority *int + Description *string + Assignee *string + AddLabels []string // Labels to add + RemoveLabels []string // Labels to remove + SetLabels []string // Labels to set (replaces all existing) } // SyncStatus represents the sync status of the beads repository. @@ -342,6 +345,19 @@ func (b *Beads) Update(id string, opts UpdateOptions) error { if opts.Assignee != nil { args = append(args, "--assignee="+*opts.Assignee) } + // Label operations: set-labels replaces all, otherwise use add/remove + if len(opts.SetLabels) > 0 { + for _, label := range opts.SetLabels { + args = append(args, "--set-labels="+label) + } + } else { + for _, label := range opts.AddLabels { + args = append(args, "--add-label="+label) + } + for _, label := range opts.RemoveLabels { + args = append(args, "--remove-label="+label) + } + } _, err := b.run(args...) return err @@ -370,6 +386,27 @@ func (b *Beads) CloseWithReason(reason string, ids ...string) error { return err } +// Release moves an in_progress issue back to open status. +// This is used to recover stuck steps when a worker dies mid-task. +// It clears the assignee so the step can be claimed by another worker. +func (b *Beads) Release(id string) error { + return b.ReleaseWithReason(id, "") +} + +// ReleaseWithReason moves an in_progress issue back to open status with a reason. +// The reason is added as a note to the issue for tracking purposes. +func (b *Beads) ReleaseWithReason(id, reason string) error { + args := []string{"update", id, "--status=open", "--assignee="} + + // Add reason as a note if provided + if reason != "" { + args = append(args, "--notes=Released: "+reason) + } + + _, err := b.run(args...) + return err +} + // AddDependency adds a dependency: issue depends on dependsOn. func (b *Beads) AddDependency(issue, dependsOn string) error { _, err := b.run("dep", "add", issue, dependsOn) diff --git a/internal/cmd/release.go b/internal/cmd/release.go new file mode 100644 index 00000000..30bfe06d --- /dev/null +++ b/internal/cmd/release.go @@ -0,0 +1,77 @@ +package cmd + +import ( + "fmt" + "os" + + "github.com/spf13/cobra" + "github.com/steveyegge/gastown/internal/beads" + "github.com/steveyegge/gastown/internal/style" +) + +var releaseReason string + +var releaseCmd = &cobra.Command{ + Use: "release ...", + Short: "Release stuck in_progress issues back to pending", + Long: `Release one or more in_progress issues back to open/pending status. + +This is used to recover stuck steps when a worker dies mid-task. +The issue is moved to "open" status and the assignee is cleared, +allowing another worker to claim and complete it. + +Examples: + gt release gt-abc # Release single issue + gt release gt-abc gt-def # Release multiple issues + gt release gt-abc -r "worker died" # Release with reason + +This implements nondeterministic idempotence - work can be safely +retried by releasing and reclaiming stuck steps.`, + Args: cobra.MinimumNArgs(1), + RunE: runRelease, +} + +func init() { + releaseCmd.Flags().StringVarP(&releaseReason, "reason", "r", "", "Reason for releasing (added as note)") + rootCmd.AddCommand(releaseCmd) +} + +func runRelease(cmd *cobra.Command, args []string) error { + // Get working directory for beads + cwd, err := os.Getwd() + if err != nil { + return fmt.Errorf("getting working directory: %w", err) + } + + bd := beads.New(cwd) + + // Release each issue + var released, failed int + for _, id := range args { + var err error + if releaseReason != "" { + err = bd.ReleaseWithReason(id, releaseReason) + } else { + err = bd.Release(id) + } + + if err != nil { + fmt.Printf("%s Failed to release %s: %v\n", style.Dim.Render("✗"), id, err) + failed++ + } else { + fmt.Printf("%s Released %s → open\n", style.Bold.Render("✓"), id) + released++ + } + } + + // Summary if multiple + if len(args) > 1 { + fmt.Printf("\nReleased: %d, Failed: %d\n", released, failed) + } + + if failed > 0 { + return fmt.Errorf("%d issue(s) failed to release", failed) + } + + return nil +} diff --git a/internal/daemon/daemon.go b/internal/daemon/daemon.go index 28d2095e..f85d4c1a 100644 --- a/internal/daemon/daemon.go +++ b/internal/daemon/daemon.go @@ -12,7 +12,10 @@ import ( "syscall" "time" + "github.com/steveyegge/gastown/internal/config" + "github.com/steveyegge/gastown/internal/git" "github.com/steveyegge/gastown/internal/keepalive" + "github.com/steveyegge/gastown/internal/rig" "github.com/steveyegge/gastown/internal/tmux" ) @@ -180,18 +183,28 @@ func (d *Daemon) pokeMayor() { } // pokeWitnesses sends heartbeats to all Witness sessions. +// Uses proper rig discovery from rigs.json instead of scanning tmux sessions. func (d *Daemon) pokeWitnesses() { - // Find all rigs by looking for witness sessions - // Session naming: gt--witness - sessions, err := d.tmux.ListSessions() - if err != nil { - d.logger.Printf("Error listing sessions: %v", err) + // Discover rigs from configuration + rigs := d.discoverRigs() + if len(rigs) == 0 { + d.logger.Println("No rigs discovered") return } - for _, session := range sessions { - // Check if it's a witness session - if !isWitnessSession(session) { + for _, r := range rigs { + session := fmt.Sprintf("gt-%s-witness", r.Name) + + // Check if witness session exists + running, err := d.tmux.HasSession(session) + if err != nil { + d.logger.Printf("Error checking witness session for rig %s: %v", r.Name, err) + continue + } + + if !running { + // Rig exists but no witness session - log for visibility + d.logger.Printf("Rig %s has no witness session (may need: gt witness start %s)", r.Name, r.Name) continue } @@ -199,6 +212,70 @@ func (d *Daemon) pokeWitnesses() { } } +// discoverRigs finds all registered rigs using the rig manager. +// Falls back to directory scanning if rigs.json is not available. +func (d *Daemon) discoverRigs() []*rig.Rig { + // Load rigs config from mayor/rigs.json + rigsConfigPath := filepath.Join(d.config.TownRoot, "mayor", "rigs.json") + rigsConfig, err := config.LoadRigsConfig(rigsConfigPath) + if err != nil { + // Try fallback: scan town directory for rig directories + return d.discoverRigsFromDirectory() + } + + // Use rig manager for proper discovery + g := git.NewGit(d.config.TownRoot) + mgr := rig.NewManager(d.config.TownRoot, rigsConfig, g) + rigs, err := mgr.DiscoverRigs() + if err != nil { + d.logger.Printf("Error discovering rigs from config: %v", err) + return d.discoverRigsFromDirectory() + } + + return rigs +} + +// discoverRigsFromDirectory scans the town directory for rig directories. +// A directory is considered a rig if it has a .beads subdirectory or config.json. +func (d *Daemon) discoverRigsFromDirectory() []*rig.Rig { + entries, err := os.ReadDir(d.config.TownRoot) + if err != nil { + d.logger.Printf("Error reading town directory: %v", err) + return nil + } + + var rigs []*rig.Rig + for _, entry := range entries { + if !entry.IsDir() { + continue + } + + name := entry.Name() + // Skip known non-rig directories + if name == "mayor" || name == "daemon" || name == ".git" || name[0] == '.' { + continue + } + + dirPath := filepath.Join(d.config.TownRoot, name) + + // Check for .beads directory (indicates a rig) + beadsPath := filepath.Join(dirPath, ".beads") + if _, err := os.Stat(beadsPath); err == nil { + rigs = append(rigs, &rig.Rig{Name: name, Path: dirPath}) + continue + } + + // Check for config.json with type: rig + configPath := filepath.Join(dirPath, "config.json") + if _, err := os.Stat(configPath); err == nil { + // For simplicity, assume any directory with config.json is a rig + rigs = append(rigs, &rig.Rig{Name: name, Path: dirPath}) + } + } + + return rigs +} + // pokeWitness sends a heartbeat to a single witness session with backoff. func (d *Daemon) pokeWitness(session string) { // Extract rig name from session (gt--witness -> ) diff --git a/internal/refinery/types.go b/internal/refinery/types.go index 550d4f12..8d2ae1b7 100644 --- a/internal/refinery/types.go +++ b/internal/refinery/types.go @@ -278,6 +278,59 @@ func (mr *MergeRequest) IsClosed() bool { return mr.Status == MRClosed } +// FailureType categorizes merge failures for appropriate handling. +type FailureType string + +const ( + // FailureNone indicates no failure (success). + FailureNone FailureType = "" + + // FailureConflict indicates merge conflicts with target branch. + FailureConflict FailureType = "conflict" + + // FailureTestsFail indicates tests failed after merge. + FailureTestsFail FailureType = "tests_fail" + + // FailureBuildFail indicates build failed after merge. + FailureBuildFail FailureType = "build_fail" + + // FailureFlakyTest indicates a potentially flaky test failure (may retry). + FailureFlakyTest FailureType = "flaky_test" + + // FailurePushFail indicates push to remote failed. + FailurePushFail FailureType = "push_fail" + + // FailureFetch indicates fetch of source branch failed. + FailureFetch FailureType = "fetch_fail" + + // FailureCheckout indicates checkout of target branch failed. + FailureCheckout FailureType = "checkout_fail" +) + +// FailureLabel returns the beads label for this failure type. +func (f FailureType) FailureLabel() string { + switch f { + case FailureConflict: + return "needs-rebase" + case FailureTestsFail, FailureBuildFail, FailureFlakyTest: + return "needs-fix" + case FailurePushFail: + return "needs-retry" + default: + return "" + } +} + +// ShouldAssignToWorker returns true if this failure should be assigned back to the worker. +func (f FailureType) ShouldAssignToWorker() bool { + switch f { + case FailureConflict, FailureTestsFail, FailureBuildFail, FailureFlakyTest: + return true + default: + return false + } +} + // IsOpen returns true if the MR is in an open state (waiting for processing). func (mr *MergeRequest) IsOpen() bool { return mr.Status == MROpen diff --git a/internal/refinery/types_test.go b/internal/refinery/types_test.go index c49282f7..7154f974 100644 --- a/internal/refinery/types_test.go +++ b/internal/refinery/types_test.go @@ -256,47 +256,52 @@ func TestMergeRequest_StatusChecks(t *testing.T) { } } -func TestMergeRequest_Rejection(t *testing.T) { - t.Run("reject from open succeeds", func(t *testing.T) { - mr := &MergeRequest{Status: MROpen} - err := mr.Close(CloseReasonRejected) - if err != nil { - t.Errorf("Close(rejected) unexpected error: %v", err) - } - if mr.Status != MRClosed { - t.Errorf("Close(rejected) status = %s, want %s", mr.Status, MRClosed) - } - if mr.CloseReason != CloseReasonRejected { - t.Errorf("Close(rejected) closeReason = %s, want %s", mr.CloseReason, CloseReasonRejected) - } - }) +func TestFailureType_FailureLabel(t *testing.T) { + tests := []struct { + failureType FailureType + wantLabel string + }{ + {FailureNone, ""}, + {FailureConflict, "needs-rebase"}, + {FailureTestsFail, "needs-fix"}, + {FailureBuildFail, "needs-fix"}, + {FailureFlakyTest, "needs-fix"}, + {FailurePushFail, "needs-retry"}, + {FailureFetch, ""}, + {FailureCheckout, ""}, + } - t.Run("reject from in_progress succeeds", func(t *testing.T) { - mr := &MergeRequest{Status: MRInProgress} - err := mr.Close(CloseReasonRejected) - if err != nil { - t.Errorf("Close(rejected) unexpected error: %v", err) - } - if mr.Status != MRClosed { - t.Errorf("Close(rejected) status = %s, want %s", mr.Status, MRClosed) - } - if mr.CloseReason != CloseReasonRejected { - t.Errorf("Close(rejected) closeReason = %s, want %s", mr.CloseReason, CloseReasonRejected) - } - }) - - t.Run("reject from closed fails", func(t *testing.T) { - mr := &MergeRequest{Status: MRClosed, CloseReason: CloseReasonMerged} - err := mr.Close(CloseReasonRejected) - if err == nil { - t.Error("Close(rejected) expected error, got nil") - } - if !errors.Is(err, ErrClosedImmutable) { - t.Errorf("Close(rejected) error = %v, want %v", err, ErrClosedImmutable) - } - // CloseReason should not change - if mr.CloseReason != CloseReasonMerged { - t.Errorf("Close(rejected) closeReason changed from %s to %s", CloseReasonMerged, mr.CloseReason) - } - }) + for _, tt := range tests { + t.Run(string(tt.failureType), func(t *testing.T) { + got := tt.failureType.FailureLabel() + if got != tt.wantLabel { + t.Errorf("FailureLabel() = %q, want %q", got, tt.wantLabel) + } + }) + } +} + +func TestFailureType_ShouldAssignToWorker(t *testing.T) { + tests := []struct { + failureType FailureType + wantAssign bool + }{ + {FailureNone, false}, + {FailureConflict, true}, + {FailureTestsFail, true}, + {FailureBuildFail, true}, + {FailureFlakyTest, true}, + {FailurePushFail, false}, + {FailureFetch, false}, + {FailureCheckout, false}, + } + + for _, tt := range tests { + t.Run(string(tt.failureType), func(t *testing.T) { + got := tt.failureType.ShouldAssignToWorker() + if got != tt.wantAssign { + t.Errorf("ShouldAssignToWorker() = %v, want %v", got, tt.wantAssign) + } + }) + } }