Refinery as worktree: local MR integration (gt-4u5z)

Architecture changes:
- Refinery created as worktree of mayor clone (shares .git)
- Polecat branches stay local (never pushed to origin)
- MRs stored as wisps in .beads-wisp/mq/ (ephemeral)
- Only main gets pushed to origin after merge

New mrqueue package for wisp-based MR storage.
Updated spawn, done, mq_submit, refinery, molecule templates.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Steve Yegge
2025-12-23 21:24:42 -08:00
parent e895e51ee4
commit b685879b63
11 changed files with 378 additions and 142 deletions

View File

@@ -1,8 +1,39 @@
# Merge Queue Design # Merge Queue Design
The merge queue is the coordination mechanism for landing completed work. It's implemented entirely in Beads - merge requests are just another issue type with dependencies. The merge queue coordinates landing completed work. MRs are ephemeral wisps (not synced beads), and polecat branches stay local (never pushed to origin).
**Key insight**: Git is already a ledger. Beads is already federated. The merge queue is just a query pattern over beads issues. **Key insight**: Git is already a ledger. Beads track durable state (issues). MRs are transient operational state - perfect for wisps.
## Architecture (Current)
```
┌─────────────────────────────────────────────────────────────────┐
│ LOCAL MERGE QUEUE │
│ │
│ Polecat worktree ──commit──► local branch (polecat/nux) │
│ │ │ │
│ │ │ (same .git) │
│ ▼ ▼ │
│ .beads-wisp/mq/mr-xxx.json Refinery worktree │
│ │ │ │
│ └──────────────────────────┘ │
│ │ │
│ Refinery reads MR, merges branch │
│ │ │
│ ▼ │
│ git push origin main │
│ │ │
│ Delete local branch │
│ Delete MR wisp file │
└─────────────────────────────────────────────────────────────────┘
```
**Key points:**
- Refinery is a worktree of the same git repo as polecats (shared .git)
- Polecat branches are local only - never pushed to origin
- MRs stored in `.beads-wisp/mq/` (ephemeral, not synced)
- Only `main` branch gets pushed to origin after merge
- Source issues tracked in beads (durable), closed after merge
## Overview ## Overview
@@ -30,61 +61,47 @@ The merge queue is the coordination mechanism for landing completed work. It's i
## Merge Request Schema ## Merge Request Schema
A merge request is a beads issue with `type: merge-request`: A merge request is a JSON file in `.beads-wisp/mq/`:
```yaml ```json
id: gt-mr-abc123 // .beads-wisp/mq/mr-1703372400-a1b2c3d4.json
type: merge-request {
status: open # open, in_progress, closed "id": "mr-1703372400-a1b2c3d4",
priority: P1 # Inherited from source issue "branch": "polecat/nux",
title: "Merge: Fix login timeout (gt-xyz)" "target": "main",
"source_issue": "gt-xyz",
# MR-specific fields (in description or structured) "worker": "nux",
branch: polecat/Nux/gt-xyz # Source branch "rig": "gastown",
target: main # Target branch (or integration/epic-id) "title": "Merge: gt-xyz",
source_issue: gt-xyz # The work being merged "priority": 1,
worker: Nux # Who did the work "created_at": "2025-12-23T20:00:00Z"
rig: gastown # Which rig }
# Set on completion
merge_commit: abc123def # SHA of merge commit (on success)
close_reason: merged # merged, rejected, conflict, superseded
# Standard beads fields
created: 2025-12-17T10:00:00Z
updated: 2025-12-17T10:30:00Z
assignee: engineer # The Engineer processing it
depends_on: [gt-mr-earlier] # Ordering dependencies
``` ```
### ID Convention ### ID Convention
Merge request IDs follow the pattern: `<prefix>-mr-<hash>` MR IDs follow the pattern: `mr-<timestamp>-<random>`
Example: `gt-mr-abc123` for a gastown merge request. Example: `mr-1703372400-a1b2c3d4`
This distinguishes them from regular issues while keeping them in the same namespace. These are ephemeral - deleted after merge. Source issues in beads provide the durable record.
### Creating Merge Requests ### Creating Merge Requests
Workers submit to the queue via: Workers submit to the queue via:
```bash ```bash
# Worker signals work is ready # Worker signals work is ready (preferred)
gt done # Auto-detects branch, issue, creates MR
# Or explicit submission
gt mq submit # Auto-detects branch, issue, worker gt mq submit # Auto-detects branch, issue, worker
gt mq submit --issue gt-xyz # Explicit issue
# Explicit submission # Under the hood, this writes to .beads-wisp/mq/:
gt mq submit --branch polecat/Nux/gt-xyz --issue gt-xyz # - Creates mr-<timestamp>-<random>.json
# - No beads issue created (MRs are ephemeral wisps)
# Under the hood, this creates: # - Branch stays local (never pushed to origin)
bd create --type=merge-request \
--title="Merge: Fix login timeout (gt-xyz)" \
--priority=P1 \
--body="branch: polecat/Nux/gt-xyz
target: main
source_issue: gt-xyz
worker: Nux
rig: gastown"
``` ```
## Queue Ordering ## Queue Ordering
@@ -175,8 +192,8 @@ The Engineer (formerly Refinery) processes the merge queue continuously:
```python ```python
def process_merge(mr): def process_merge(mr):
# 1. Fetch the branch # 1. Branch is already local (shared .git with polecats)
git fetch origin {mr.branch} # No fetch needed - refinery worktree sees polecat branches directly
# 2. Check for conflicts with target # 2. Check for conflicts with target
conflicts = git_check_conflicts(mr.branch, mr.target) conflicts = git_check_conflicts(mr.branch, mr.target)
@@ -194,12 +211,15 @@ def process_merge(mr):
git reset --hard HEAD~1 # Undo merge git reset --hard HEAD~1 # Undo merge
return Failure(reason="tests_failed", output=result.output) return Failure(reason="tests_failed", output=result.output)
# 5. Push to origin # 5. Push to origin (only main goes to origin)
git push origin {mr.target} git push origin {mr.target}
# 6. Clean up source branch (optional) # 6. Clean up source branch (local delete only)
if config.delete_merged_branches: if config.delete_merged_branches:
git push origin --delete {mr.branch} git branch -D {mr.branch} # Local delete, not remote
# 7. Remove MR wisp file
os.remove(.beads-wisp/mq/{mr.id}.json)
return Success(merge_commit=git_rev_parse("HEAD")) return Success(merge_commit=git_rev_parse("HEAD"))
``` ```

View File

@@ -601,7 +601,7 @@ git checkout main
git merge --ff-only temp git merge --ff-only temp
git push origin main git push origin main
git branch -d temp git branch -d temp
git push origin --delete <polecat-branch> git branch -D <polecat-branch> # Local delete (branches never go to origin)
` + "```" + ` ` + "```" + `
Main has moved. Any remaining branches need rebasing on new baseline. Main has moved. Any remaining branches need rebasing on new baseline.

View File

@@ -409,9 +409,9 @@ End session with proper handoff.
1. Sync all state: 1. Sync all state:
` + "```" + `bash ` + "```" + `bash
git add -A && git commit -m "WIP: <summary>" || true git add -A && git commit -m "WIP: <summary>" || true
git push origin HEAD
bd sync bd sync
` + "```" + ` ` + "```" + `
Note: Branch stays local (commits saved in shared .git).
2. Write handoff to successor (yourself): 2. Write handoff to successor (yourself):
` + "```" + `bash ` + "```" + `bash
@@ -522,8 +522,8 @@ Finalize session and request termination.
1. Sync all state: 1. Sync all state:
` + "```" + `bash ` + "```" + `bash
bd sync bd sync
git push origin HEAD
` + "```" + ` ` + "```" + `
Note: Branch stays local (commits saved in shared .git).
2. Update work mol based on exit type: 2. Update work mol based on exit type:
- COMPLETED: ` + "`bd close <work-mol-root>`" + ` - COMPLETED: ` + "`bd close <work-mol-root>`" + `

View File

@@ -163,13 +163,12 @@ Submit to merge queue via beads.
**IMPORTANT**: Do NOT use gh pr create or GitHub PRs. **IMPORTANT**: Do NOT use gh pr create or GitHub PRs.
The Refinery processes merges via beads merge-request issues. The Refinery processes merges via beads merge-request issues.
Branch stays local (refinery sees it via shared worktree).
1. Push your branch to origin 1. Create a beads merge-request: bd create --type=merge-request --title="Merge: <summary>"
2. Create a beads merge-request: bd create --type=merge-request --title="Merge: <summary>" 2. Signal ready: gt done
3. Signal ready: gt done
` + "```" + `bash ` + "```" + `bash
git push origin HEAD
bd create --type=merge-request --title="Merge: <issue-summary>" bd create --type=merge-request --title="Merge: <issue-summary>"
gt done # Signal work ready for merge queue gt done # Signal work ready for merge queue
` + "```" + ` ` + "```" + `

View File

@@ -3,12 +3,14 @@ package cmd
import ( import (
"fmt" "fmt"
"os" "os"
"path/filepath"
"strings" "strings"
"github.com/spf13/cobra" "github.com/spf13/cobra"
"github.com/steveyegge/gastown/internal/beads" "github.com/steveyegge/gastown/internal/beads"
"github.com/steveyegge/gastown/internal/git" "github.com/steveyegge/gastown/internal/git"
"github.com/steveyegge/gastown/internal/mail" "github.com/steveyegge/gastown/internal/mail"
"github.com/steveyegge/gastown/internal/mrqueue"
"github.com/steveyegge/gastown/internal/style" "github.com/steveyegge/gastown/internal/style"
"github.com/steveyegge/gastown/internal/workspace" "github.com/steveyegge/gastown/internal/workspace"
) )
@@ -144,41 +146,31 @@ func runDone(cmd *cobra.Command, args []string) error {
// Build title // Build title
title := fmt.Sprintf("Merge: %s", issueID) title := fmt.Sprintf("Merge: %s", issueID)
// CRITICAL: Push branch to origin BEFORE creating MR // Note: Branch stays local. Refinery sees it via shared .git (worktree).
// Without this, the worktree can be deleted and the branch lost forever // Only main gets pushed to origin after merge.
fmt.Printf("Pushing branch to origin...\n")
if err := g.Push("origin", branch, false); err != nil {
return fmt.Errorf("pushing branch to origin: %w", err)
}
fmt.Printf("%s Branch pushed to origin/%s\n", style.Bold.Render("✓"), branch)
// Build description with MR fields // Submit to MR queue (wisp storage - ephemeral, not synced)
mrFields := &beads.MRFields{ rigPath := filepath.Join(townRoot, rigName)
queue := mrqueue.New(rigPath)
mr := &mrqueue.MR{
Branch: branch, Branch: branch,
Target: target, Target: target,
SourceIssue: issueID, SourceIssue: issueID,
Worker: worker, Worker: worker,
Rig: rigName, Rig: rigName,
}
description := beads.FormatMRFields(mrFields)
// Create the merge-request issue
createOpts := beads.CreateOptions{
Title: title, Title: title,
Type: "merge-request",
Priority: priority, Priority: priority,
Description: description,
} }
issue, err := bd.Create(createOpts) if err := queue.Submit(mr); err != nil {
if err != nil { return fmt.Errorf("submitting to merge queue: %w", err)
return fmt.Errorf("creating merge request: %w", err)
} }
mrID = issue.ID mrID = mr.ID
// Success output // Success output
fmt.Printf("%s Work submitted to merge queue\n", style.Bold.Render("✓")) fmt.Printf("%s Work submitted to merge queue\n", style.Bold.Render("✓"))
fmt.Printf(" MR ID: %s\n", style.Bold.Render(issue.ID)) fmt.Printf(" MR ID: %s\n", style.Bold.Render(mr.ID))
fmt.Printf(" Source: %s\n", branch) fmt.Printf(" Source: %s\n", branch)
fmt.Printf(" Target: %s\n", target) fmt.Printf(" Target: %s\n", target)
fmt.Printf(" Issue: %s\n", issueID) fmt.Printf(" Issue: %s\n", issueID)

View File

@@ -4,6 +4,7 @@ import (
"fmt" "fmt"
"os" "os"
"os/exec" "os/exec"
"path/filepath"
"regexp" "regexp"
"strings" "strings"
"time" "time"
@@ -11,6 +12,7 @@ import (
"github.com/spf13/cobra" "github.com/spf13/cobra"
"github.com/steveyegge/gastown/internal/beads" "github.com/steveyegge/gastown/internal/beads"
"github.com/steveyegge/gastown/internal/git" "github.com/steveyegge/gastown/internal/git"
"github.com/steveyegge/gastown/internal/mrqueue"
"github.com/steveyegge/gastown/internal/style" "github.com/steveyegge/gastown/internal/style"
"github.com/steveyegge/gastown/internal/workspace" "github.com/steveyegge/gastown/internal/workspace"
) )
@@ -133,40 +135,30 @@ func runMqSubmit(cmd *cobra.Command, args []string) error {
// Build title // Build title
title := fmt.Sprintf("Merge: %s", issueID) title := fmt.Sprintf("Merge: %s", issueID)
// CRITICAL: Push branch to origin BEFORE creating MR // Note: Branch stays local. Refinery sees it via shared .git (worktree).
// Without this, the worktree can be deleted and the branch lost forever // Only main gets pushed to origin after merge.
fmt.Printf("Pushing branch to origin...\n")
if err := g.Push("origin", branch, false); err != nil {
return fmt.Errorf("pushing branch to origin: %w", err)
}
fmt.Printf("%s Branch pushed to origin/%s\n", style.Bold.Render("✓"), branch)
// Build description with MR fields // Submit to MR queue (wisp storage - ephemeral, not synced)
mrFields := &beads.MRFields{ rigPath := filepath.Join(townRoot, rigName)
queue := mrqueue.New(rigPath)
mr := &mrqueue.MR{
Branch: branch, Branch: branch,
Target: target, Target: target,
SourceIssue: issueID, SourceIssue: issueID,
Worker: worker, Worker: worker,
Rig: rigName, Rig: rigName,
}
description := beads.FormatMRFields(mrFields)
// Create the merge-request issue
createOpts := beads.CreateOptions{
Title: title, Title: title,
Type: "merge-request",
Priority: priority, Priority: priority,
Description: description,
} }
issue, err := bd.Create(createOpts) if err := queue.Submit(mr); err != nil {
if err != nil { return fmt.Errorf("submitting to merge queue: %w", err)
return fmt.Errorf("creating merge request: %w", err)
} }
// Success output // Success output
fmt.Printf("%s Created merge request\n", style.Bold.Render("✓")) fmt.Printf("%s Submitted to merge queue\n", style.Bold.Render("✓"))
fmt.Printf(" MR ID: %s\n", style.Bold.Render(issue.ID)) fmt.Printf(" MR ID: %s\n", style.Bold.Render(mr.ID))
fmt.Printf(" Source: %s\n", branch) fmt.Printf(" Source: %s\n", branch)
fmt.Printf(" Target: %s\n", target) fmt.Printf(" Target: %s\n", target)
fmt.Printf(" Issue: %s\n", issueID) fmt.Printf(" Issue: %s\n", issueID)

View File

@@ -608,8 +608,7 @@ func buildSpawnContext(issue *BeadsIssue, message string) string {
sb.WriteString("2. Work on your task, commit changes regularly\n") sb.WriteString("2. Work on your task, commit changes regularly\n")
sb.WriteString("3. Run `bd close <issue-id>` when done\n") sb.WriteString("3. Run `bd close <issue-id>` when done\n")
sb.WriteString("4. Run `bd sync` to push beads changes\n") sb.WriteString("4. Run `bd sync` to push beads changes\n")
sb.WriteString("5. Push code: `git push origin HEAD`\n") sb.WriteString("5. Run `gt done` to signal completion (branch stays local)\n")
sb.WriteString("6. Run `gt done` to signal completion\n")
return sb.String() return sb.String()
} }
@@ -673,18 +672,16 @@ func buildWorkAssignmentMail(issue *BeadsIssue, message, polecatAddress string,
if moleculeCtx != nil { if moleculeCtx != nil {
body.WriteString("4. Check `bd ready --parent " + moleculeCtx.RootIssueID + "` for more steps\n") body.WriteString("4. Check `bd ready --parent " + moleculeCtx.RootIssueID + "` for more steps\n")
body.WriteString("5. Repeat steps 2-4 for each ready step\n") body.WriteString("5. Repeat steps 2-4 for each ready step\n")
body.WriteString("6. When all steps done: run `bd sync`, push code, run `gt done`\n") body.WriteString("6. When all steps done: run `bd sync`, then `gt done`\n")
} else { } else {
body.WriteString("4. Run `bd sync` to push beads changes\n") body.WriteString("4. Run `bd sync` to push beads changes\n")
body.WriteString("5. Push code: `git push origin HEAD`\n") body.WriteString("5. Run `gt done` to signal completion (branch stays local)\n")
body.WriteString("6. Run `gt done` to signal completion\n")
} }
body.WriteString("\n## Handoff Protocol\n") body.WriteString("\n## Handoff Protocol\n")
body.WriteString("Before signaling done, ensure:\n") body.WriteString("Before signaling done, ensure:\n")
body.WriteString("- Git status is clean (no uncommitted changes)\n") body.WriteString("- Git status is clean (no uncommitted changes)\n")
body.WriteString("- Issue is closed with `bd close`\n") body.WriteString("- Issue is closed with `bd close`\n")
body.WriteString("- Beads are synced with `bd sync`\n") body.WriteString("- Beads are synced with `bd sync`\n")
body.WriteString("- Code is pushed to origin\n")
body.WriteString("\nThe `gt done` command verifies these and signals the Witness.\n") body.WriteString("\nThe `gt done` command verifies these and signals the Witness.\n")
return &mail.Message{ return &mail.Message{

183
internal/mrqueue/mrqueue.go Normal file
View File

@@ -0,0 +1,183 @@
// Package mrqueue provides wisp-based merge request queue storage.
// MRs are ephemeral - stored locally in .beads-wisp/mq/ and deleted after merge.
// This avoids sync overhead for transient MR state.
package mrqueue
import (
"crypto/rand"
"encoding/hex"
"encoding/json"
"fmt"
"os"
"path/filepath"
"sort"
"strings"
"time"
)
// MR represents a merge request in the queue.
type MR struct {
ID string `json:"id"`
Branch string `json:"branch"` // Source branch (e.g., "polecat/nux")
Target string `json:"target"` // Target branch (e.g., "main")
SourceIssue string `json:"source_issue"` // The work item being merged
Worker string `json:"worker"` // Who did the work
Rig string `json:"rig"` // Which rig
Title string `json:"title"` // MR title
Priority int `json:"priority"` // Priority (lower = higher priority)
CreatedAt time.Time `json:"created_at"`
}
// Queue manages the MR wisp storage.
type Queue struct {
dir string // .beads-wisp/mq/ directory
}
// New creates a new MR queue for the given rig path.
func New(rigPath string) *Queue {
return &Queue{
dir: filepath.Join(rigPath, ".beads-wisp", "mq"),
}
}
// NewFromWorkdir creates a queue by finding the rig root from a working directory.
func NewFromWorkdir(workdir string) (*Queue, error) {
// Walk up to find .beads-wisp or rig root
dir := workdir
for {
wispDir := filepath.Join(dir, ".beads-wisp")
if info, err := os.Stat(wispDir); err == nil && info.IsDir() {
return &Queue{dir: filepath.Join(wispDir, "mq")}, nil
}
parent := filepath.Dir(dir)
if parent == dir {
return nil, fmt.Errorf("could not find .beads-wisp directory from %s", workdir)
}
dir = parent
}
}
// EnsureDir creates the MQ directory if it doesn't exist.
func (q *Queue) EnsureDir() error {
return os.MkdirAll(q.dir, 0755)
}
// generateID creates a unique MR ID.
func generateID() string {
b := make([]byte, 4)
rand.Read(b)
return fmt.Sprintf("mr-%d-%s", time.Now().Unix(), hex.EncodeToString(b))
}
// Submit adds a new MR to the queue.
func (q *Queue) Submit(mr *MR) error {
if err := q.EnsureDir(); err != nil {
return fmt.Errorf("creating mq directory: %w", err)
}
if mr.ID == "" {
mr.ID = generateID()
}
if mr.CreatedAt.IsZero() {
mr.CreatedAt = time.Now()
}
data, err := json.MarshalIndent(mr, "", " ")
if err != nil {
return fmt.Errorf("marshaling MR: %w", err)
}
path := filepath.Join(q.dir, mr.ID+".json")
if err := os.WriteFile(path, data, 0644); err != nil {
return fmt.Errorf("writing MR file: %w", err)
}
return nil
}
// List returns all pending MRs, sorted by priority then creation time.
func (q *Queue) List() ([]*MR, error) {
entries, err := os.ReadDir(q.dir)
if err != nil {
if os.IsNotExist(err) {
return nil, nil // Empty queue
}
return nil, fmt.Errorf("reading mq directory: %w", err)
}
var mrs []*MR
for _, entry := range entries {
if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".json") {
continue
}
mr, err := q.load(filepath.Join(q.dir, entry.Name()))
if err != nil {
continue // Skip malformed files
}
mrs = append(mrs, mr)
}
// Sort by priority (lower first), then by creation time (older first)
sort.Slice(mrs, func(i, j int) bool {
if mrs[i].Priority != mrs[j].Priority {
return mrs[i].Priority < mrs[j].Priority
}
return mrs[i].CreatedAt.Before(mrs[j].CreatedAt)
})
return mrs, nil
}
// Get retrieves a specific MR by ID.
func (q *Queue) Get(id string) (*MR, error) {
path := filepath.Join(q.dir, id+".json")
return q.load(path)
}
// load reads an MR from a file path.
func (q *Queue) load(path string) (*MR, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, err
}
var mr MR
if err := json.Unmarshal(data, &mr); err != nil {
return nil, err
}
return &mr, nil
}
// Remove deletes an MR from the queue (after successful merge).
func (q *Queue) Remove(id string) error {
path := filepath.Join(q.dir, id+".json")
err := os.Remove(path)
if os.IsNotExist(err) {
return nil // Already removed
}
return err
}
// Count returns the number of pending MRs.
func (q *Queue) Count() int {
entries, err := os.ReadDir(q.dir)
if err != nil {
return 0
}
count := 0
for _, entry := range entries {
if !entry.IsDir() && strings.HasSuffix(entry.Name(), ".json") {
count++
}
}
return count
}
// Dir returns the queue directory path.
func (q *Queue) Dir() string {
return q.dir
}

View File

@@ -12,6 +12,7 @@ import (
"github.com/steveyegge/gastown/internal/beads" "github.com/steveyegge/gastown/internal/beads"
"github.com/steveyegge/gastown/internal/git" "github.com/steveyegge/gastown/internal/git"
"github.com/steveyegge/gastown/internal/mrqueue"
"github.com/steveyegge/gastown/internal/rig" "github.com/steveyegge/gastown/internal/rig"
) )
@@ -69,6 +70,7 @@ func DefaultMergeQueueConfig() *MergeQueueConfig {
type Engineer struct { type Engineer struct {
rig *rig.Rig rig *rig.Rig
beads *beads.Beads beads *beads.Beads
mrQueue *mrqueue.Queue
git *git.Git git *git.Git
config *MergeQueueConfig config *MergeQueueConfig
workDir string workDir string
@@ -83,6 +85,7 @@ func NewEngineer(r *rig.Rig) *Engineer {
return &Engineer{ return &Engineer{
rig: r, rig: r,
beads: beads.New(r.Path), beads: beads.New(r.Path),
mrQueue: mrqueue.New(r.Path),
git: git.NewGit(r.Path), git: git.NewGit(r.Path),
config: DefaultMergeQueueConfig(), config: DefaultMergeQueueConfig(),
workDir: r.Path, workDir: r.Path,
@@ -229,7 +232,7 @@ func (e *Engineer) Stop() {
} }
// processOnce performs one iteration of the Engineer loop: // processOnce performs one iteration of the Engineer loop:
// 1. Query for ready merge-requests // 1. Query for ready merge-requests from wisp storage
// 2. If none, return (will try again on next tick) // 2. If none, return (will try again on next tick)
// 3. Process the highest priority, oldest MR // 3. Process the highest priority, oldest MR
func (e *Engineer) processOnce(ctx context.Context) error { func (e *Engineer) processOnce(ctx context.Context) error {
@@ -240,38 +243,30 @@ func (e *Engineer) processOnce(ctx context.Context) error {
default: default:
} }
// 1. Query: bd ready --type=merge-request (filtered client-side) // 1. Query MR queue (wisp storage - already sorted by priority/age)
readyMRs, err := e.beads.ReadyWithType("merge-request") pendingMRs, err := e.mrQueue.List()
if err != nil { if err != nil {
return fmt.Errorf("querying ready merge-requests: %w", err) return fmt.Errorf("querying merge queue: %w", err)
} }
// 2. If empty, return // 2. If empty, return
if len(readyMRs) == 0 { if len(pendingMRs) == 0 {
return nil return nil
} }
// 3. Select highest priority, oldest MR // 3. Select highest priority, oldest MR (List already returns sorted)
// bd ready already returns sorted by priority then age, so first is best mr := pendingMRs[0]
mr := readyMRs[0]
fmt.Fprintf(e.output, "[Engineer] Processing: %s (%s)\n", mr.ID, mr.Title) fmt.Fprintf(e.output, "[Engineer] Processing: %s (%s)\n", mr.ID, mr.Title)
// 4. Claim: bd update <id> --status=in_progress // 4. Process MR
inProgress := "in_progress" result := e.ProcessMRFromQueue(ctx, mr)
if err := e.beads.Update(mr.ID, beads.UpdateOptions{Status: &inProgress}); err != nil {
return fmt.Errorf("claiming MR %s: %w", mr.ID, err)
}
// 5. Process (delegate to ProcessMR - implementation in separate issue gt-3x1.2) // 5. Handle result
result := e.ProcessMR(ctx, mr)
// 6. Handle result
if result.Success { if result.Success {
e.handleSuccess(mr, result) e.handleSuccessFromQueue(mr, result)
} else { } else {
// Failure handling (detailed implementation in gt-3x1.4) e.handleFailureFromQueue(mr, result)
e.handleFailure(mr, result)
} }
return nil return nil
@@ -349,12 +344,12 @@ func (e *Engineer) handleSuccess(mr *beads.Issue, result ProcessResult) {
} }
} }
// 4. Delete source branch if configured // 4. Delete source branch if configured (local only - branches never go to origin)
if e.config.DeleteMergedBranches && mrFields.Branch != "" { if e.config.DeleteMergedBranches && mrFields.Branch != "" {
if err := e.git.DeleteRemoteBranch("origin", mrFields.Branch); err != nil { if err := e.git.DeleteBranch(mrFields.Branch, true); err != nil {
fmt.Fprintf(e.output, "[Engineer] Warning: failed to delete branch %s: %v\n", mrFields.Branch, err) fmt.Fprintf(e.output, "[Engineer] Warning: failed to delete branch %s: %v\n", mrFields.Branch, err)
} else { } else {
fmt.Fprintf(e.output, "[Engineer] Deleted branch: %s\n", mrFields.Branch) fmt.Fprintf(e.output, "[Engineer] Deleted local branch: %s\n", mrFields.Branch)
} }
} }
@@ -376,3 +371,58 @@ func (e *Engineer) handleFailure(mr *beads.Issue, result ProcessResult) {
// Full failure handling (assign back to worker, labels) in gt-3x1.4 // Full failure handling (assign back to worker, labels) in gt-3x1.4
} }
// ProcessMRFromQueue processes a merge request from wisp queue.
func (e *Engineer) ProcessMRFromQueue(ctx context.Context, mr *mrqueue.MR) ProcessResult {
// MR fields are directly on the struct (no parsing needed)
fmt.Fprintln(e.output, "[Engineer] Processing MR from queue:")
fmt.Fprintf(e.output, " Branch: %s\n", mr.Branch)
fmt.Fprintf(e.output, " Target: %s\n", mr.Target)
fmt.Fprintf(e.output, " Worker: %s\n", mr.Worker)
fmt.Fprintf(e.output, " Source: %s\n", mr.SourceIssue)
// TODO: Actual merge implementation
// For now, return failure - actual implementation in gt-3x1.2
return ProcessResult{
Success: false,
Error: "ProcessMRFromQueue not fully implemented (see gt-3x1.2)",
}
}
// handleSuccessFromQueue handles a successful merge from wisp queue.
func (e *Engineer) handleSuccessFromQueue(mr *mrqueue.MR, result ProcessResult) {
// 1. Close source issue with reference to MR
if mr.SourceIssue != "" {
closeReason := fmt.Sprintf("Merged in %s", mr.ID)
if err := e.beads.CloseWithReason(closeReason, mr.SourceIssue); err != nil {
fmt.Fprintf(e.output, "[Engineer] Warning: failed to close source issue %s: %v\n", mr.SourceIssue, err)
} else {
fmt.Fprintf(e.output, "[Engineer] Closed source issue: %s\n", mr.SourceIssue)
}
}
// 2. Delete source branch if configured (local only)
if e.config.DeleteMergedBranches && mr.Branch != "" {
if err := e.git.DeleteBranch(mr.Branch, true); err != nil {
fmt.Fprintf(e.output, "[Engineer] Warning: failed to delete branch %s: %v\n", mr.Branch, err)
} else {
fmt.Fprintf(e.output, "[Engineer] Deleted local branch: %s\n", mr.Branch)
}
}
// 3. Remove MR from queue (ephemeral - just delete the file)
if err := e.mrQueue.Remove(mr.ID); err != nil {
fmt.Fprintf(e.output, "[Engineer] Warning: failed to remove MR from queue: %v\n", err)
}
// 4. Log success
fmt.Fprintf(e.output, "[Engineer] ✓ Merged: %s (commit: %s)\n", mr.ID, result.MergeCommit)
}
// handleFailureFromQueue handles a failed merge from wisp queue.
func (e *Engineer) handleFailureFromQueue(mr *mrqueue.MR, result ProcessResult) {
// MR stays in queue for retry - no action needed on the file
// Log the failure
fmt.Fprintf(e.output, "[Engineer] ✗ Failed: %s - %s\n", mr.ID, result.Error)
fmt.Fprintln(e.output, "[Engineer] MR remains in queue for retry")
}

View File

@@ -183,7 +183,7 @@ func (m *Manager) Start(foreground bool) error {
// Background mode: spawn a Claude agent in a tmux session // Background mode: spawn a Claude agent in a tmux session
// The Claude agent handles MR processing using git commands and beads // The Claude agent handles MR processing using git commands and beads
// Working directory is the refinery's rig clone (canonical main branch view) // Working directory is the refinery worktree (shares .git with mayor/polecats)
refineryRigDir := filepath.Join(m.rig.Path, "refinery", "rig") refineryRigDir := filepath.Join(m.rig.Path, "refinery", "rig")
if _, err := os.Stat(refineryRigDir); os.IsNotExist(err) { if _, err := os.Stat(refineryRigDir); os.IsNotExist(err) {
// Fall back to rig path if refinery/rig doesn't exist // Fall back to rig path if refinery/rig doesn't exist
@@ -506,9 +506,9 @@ func (m *Manager) ProcessMR(mr *MergeRequest) MergeResult {
// Notify worker of success // Notify worker of success
m.notifyWorkerMerged(mr) m.notifyWorkerMerged(mr)
// Optionally delete the merged branch // Optionally delete the merged branch (local only - branches never go to origin)
if config.DeleteMergedBranches { if config.DeleteMergedBranches {
_ = m.gitRun("push", "origin", "--delete", mr.Branch) _ = m.gitRun("branch", "-D", mr.Branch)
} }
return result return result

View File

@@ -217,20 +217,7 @@ func (m *Manager) AddRig(opts AddRigOptions) (*Rig, error) {
return nil, fmt.Errorf("saving rig config: %w", err) return nil, fmt.Errorf("saving rig config: %w", err)
} }
// Clone repository for refinery (canonical main) // Clone repository for mayor (must be first - serves as base for worktrees)
refineryRigPath := filepath.Join(rigPath, "refinery", "rig")
if err := os.MkdirAll(filepath.Dir(refineryRigPath), 0755); err != nil {
return nil, fmt.Errorf("creating refinery dir: %w", err)
}
if err := m.git.Clone(opts.GitURL, refineryRigPath); err != nil {
return nil, fmt.Errorf("cloning for refinery: %w", err)
}
// Create refinery CLAUDE.md (overrides any from cloned repo)
if err := m.createRoleCLAUDEmd(refineryRigPath, "refinery", opts.Name, ""); err != nil {
return nil, fmt.Errorf("creating refinery CLAUDE.md: %w", err)
}
// Clone repository for mayor
mayorRigPath := filepath.Join(rigPath, "mayor", "rig") mayorRigPath := filepath.Join(rigPath, "mayor", "rig")
if err := os.MkdirAll(filepath.Dir(mayorRigPath), 0755); err != nil { if err := os.MkdirAll(filepath.Dir(mayorRigPath), 0755); err != nil {
return nil, fmt.Errorf("creating mayor dir: %w", err) return nil, fmt.Errorf("creating mayor dir: %w", err)
@@ -243,6 +230,22 @@ func (m *Manager) AddRig(opts AddRigOptions) (*Rig, error) {
return nil, fmt.Errorf("creating mayor CLAUDE.md: %w", err) return nil, fmt.Errorf("creating mayor CLAUDE.md: %w", err)
} }
// Create refinery as a worktree of mayor's clone.
// This allows refinery to see polecat branches locally (shared .git).
// Refinery uses the "refinery" branch which tracks main.
refineryRigPath := filepath.Join(rigPath, "refinery", "rig")
if err := os.MkdirAll(filepath.Dir(refineryRigPath), 0755); err != nil {
return nil, fmt.Errorf("creating refinery dir: %w", err)
}
mayorGit := git.NewGit(mayorRigPath)
if err := mayorGit.WorktreeAdd(refineryRigPath, "refinery"); err != nil {
return nil, fmt.Errorf("creating refinery worktree: %w", err)
}
// Create refinery CLAUDE.md (overrides any from cloned repo)
if err := m.createRoleCLAUDEmd(refineryRigPath, "refinery", opts.Name, ""); err != nil {
return nil, fmt.Errorf("creating refinery CLAUDE.md: %w", err)
}
// Clone repository for default crew workspace // Clone repository for default crew workspace
crewPath := filepath.Join(rigPath, "crew", opts.CrewName) crewPath := filepath.Join(rigPath, "crew", opts.CrewName)
if err := os.MkdirAll(filepath.Dir(crewPath), 0755); err != nil { if err := os.MkdirAll(filepath.Dir(crewPath), 0755); err != nil {