Merge remote-tracking branch 'origin/polecat/Nux'

This commit is contained in:
Steve Yegge
2025-12-19 17:14:27 -08:00
5 changed files with 304 additions and 55 deletions
+42 -5
View File
@@ -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)
+77
View File
@@ -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 <issue-id>...",
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
}
+85 -8
View File
@@ -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-<rig>-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-<rig>-witness -> <rig>)
+53
View File
@@ -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
+47 -42
View File
@@ -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)
}
})
}
}