feat(refinery): Add parallel worker support with MR claiming (gt-kgszr)

Implements Option A from the issue: multiple refinery workers with locking.

Changes to mrqueue:
- Add ClaimedBy/ClaimedAt fields to MR struct
- Add Claim(id, worker) method with atomic file operations
- Add Release(id) method to unclaim on failure
- Add ListUnclaimed() to find available work
- Add ListClaimedBy(worker) for worker-specific queries
- Claims expire after 10 minutes for crash recovery

New CLI commands:
- gt refinery claim <mr-id> - Claim MR for processing
- gt refinery release <mr-id> - Release claim back to queue
- gt refinery unclaimed - List available MRs

Formula updates:
- queue-scan now uses gt refinery unclaimed
- process-branch claims MR before processing
- handle-failures releases claim on test failure
- Claims prevent double-processing by parallel workers

Worker ID comes from GT_REFINERY_WORKER env var (default: refinery-1).

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
toast
2026-01-01 18:46:29 -08:00
committed by Steve Yegge
parent e159489edb
commit 3ecaf9d6fe
3 changed files with 307 additions and 5 deletions

View File

@@ -6,6 +6,7 @@ import (
"os"
"github.com/spf13/cobra"
"github.com/steveyegge/gastown/internal/mrqueue"
"github.com/steveyegge/gastown/internal/refinery"
"github.com/steveyegge/gastown/internal/rig"
"github.com/steveyegge/gastown/internal/style"
@@ -115,6 +116,57 @@ Examples:
RunE: runRefineryRestart,
}
var refineryClaimCmd = &cobra.Command{
Use: "claim <mr-id>",
Short: "Claim an MR for processing",
Long: `Claim a merge request for processing by this refinery worker.
When running multiple refinery workers in parallel, each worker must claim
an MR before processing to prevent double-processing. Claims expire after
10 minutes if not processed (for crash recovery).
The worker ID is automatically determined from the GT_REFINERY_WORKER
environment variable, or defaults to "refinery-1".
Examples:
gt refinery claim gt-abc123
GT_REFINERY_WORKER=refinery-2 gt refinery claim gt-abc123`,
Args: cobra.ExactArgs(1),
RunE: runRefineryClaim,
}
var refineryReleaseCmd = &cobra.Command{
Use: "release <mr-id>",
Short: "Release a claimed MR back to the queue",
Long: `Release a claimed merge request back to the queue.
Called when processing fails and the MR should be retried by another worker.
This clears the claim so other workers can pick up the MR.
Examples:
gt refinery release gt-abc123`,
Args: cobra.ExactArgs(1),
RunE: runRefineryRelease,
}
var refineryUnclaimedCmd = &cobra.Command{
Use: "unclaimed [rig]",
Short: "List unclaimed MRs available for processing",
Long: `List merge requests that are available for claiming.
Shows MRs that are not currently claimed by any worker, or have stale
claims (worker may have crashed). Useful for parallel refinery workers
to find work.
Examples:
gt refinery unclaimed
gt refinery unclaimed --json`,
Args: cobra.MaximumNArgs(1),
RunE: runRefineryUnclaimed,
}
var refineryUnclaimedJSON bool
func init() {
// Start flags
refineryStartCmd.Flags().BoolVar(&refineryForeground, "foreground", false, "Run in foreground (default: background)")
@@ -125,6 +177,9 @@ func init() {
// Queue flags
refineryQueueCmd.Flags().BoolVar(&refineryQueueJSON, "json", false, "Output as JSON")
// Unclaimed flags
refineryUnclaimedCmd.Flags().BoolVar(&refineryUnclaimedJSON, "json", false, "Output as JSON")
// Add subcommands
refineryCmd.AddCommand(refineryStartCmd)
refineryCmd.AddCommand(refineryStopCmd)
@@ -132,6 +187,9 @@ func init() {
refineryCmd.AddCommand(refineryStatusCmd)
refineryCmd.AddCommand(refineryQueueCmd)
refineryCmd.AddCommand(refineryAttachCmd)
refineryCmd.AddCommand(refineryClaimCmd)
refineryCmd.AddCommand(refineryReleaseCmd)
refineryCmd.AddCommand(refineryUnclaimedCmd)
rootCmd.AddCommand(refineryCmd)
}
@@ -423,3 +481,92 @@ func runRefineryRestart(cmd *cobra.Command, args []string) error {
fmt.Printf(" %s\n", style.Dim.Render("Use 'gt refinery attach' to connect"))
return nil
}
// getWorkerID returns the refinery worker ID from environment or default.
func getWorkerID() string {
if id := os.Getenv("GT_REFINERY_WORKER"); id != "" {
return id
}
return "refinery-1"
}
func runRefineryClaim(cmd *cobra.Command, args []string) error {
mrID := args[0]
workerID := getWorkerID()
// Find the queue from current working directory
q, err := mrqueue.NewFromWorkdir(".")
if err != nil {
return fmt.Errorf("finding merge queue: %w", err)
}
if err := q.Claim(mrID, workerID); err != nil {
if err == mrqueue.ErrNotFound {
return fmt.Errorf("MR %s not found in queue", mrID)
}
if err == mrqueue.ErrAlreadyClaimed {
return fmt.Errorf("MR %s is already claimed by another worker", mrID)
}
return fmt.Errorf("claiming MR: %w", err)
}
fmt.Printf("%s Claimed %s for %s\n", style.Bold.Render("✓"), mrID, workerID)
return nil
}
func runRefineryRelease(cmd *cobra.Command, args []string) error {
mrID := args[0]
q, err := mrqueue.NewFromWorkdir(".")
if err != nil {
return fmt.Errorf("finding merge queue: %w", err)
}
if err := q.Release(mrID); err != nil {
return fmt.Errorf("releasing MR: %w", err)
}
fmt.Printf("%s Released %s back to queue\n", style.Bold.Render("✓"), mrID)
return nil
}
func runRefineryUnclaimed(cmd *cobra.Command, args []string) error {
rigName := ""
if len(args) > 0 {
rigName = args[0]
}
_, r, rigName, err := getRefineryManager(rigName)
if err != nil {
return err
}
q := mrqueue.New(r.Path)
unclaimed, err := q.ListUnclaimed()
if err != nil {
return fmt.Errorf("listing unclaimed MRs: %w", err)
}
// JSON output
if refineryUnclaimedJSON {
enc := json.NewEncoder(os.Stdout)
enc.SetIndent("", " ")
return enc.Encode(unclaimed)
}
// Human-readable output
fmt.Printf("%s Unclaimed MRs for '%s':\n\n", style.Bold.Render("📋"), rigName)
if len(unclaimed) == 0 {
fmt.Printf(" %s\n", style.Dim.Render("(none available)"))
return nil
}
for i, mr := range unclaimed {
priority := fmt.Sprintf("P%d", mr.Priority)
fmt.Printf(" %d. [%s] %s → %s\n", i+1, priority, mr.Branch, mr.Target)
fmt.Printf(" ID: %s Worker: %s\n", mr.ID, mr.Worker)
}
return nil
}