diff --git a/internal/beads/beads.go b/internal/beads/beads.go index 89e330cc..675dc3a8 100644 --- a/internal/beads/beads.go +++ b/internal/beads/beads.go @@ -321,6 +321,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 +}