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:
@@ -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"))
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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>`" + `
|
||||||
|
|||||||
@@ -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
|
||||||
` + "```" + `
|
` + "```" + `
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
183
internal/mrqueue/mrqueue.go
Normal 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
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user