Files
gastown/internal/beads/beads.go
gastown/crew/joe d126c967a0 fix: handle bd --no-daemon exit code 0 bug on not-found (#339)
When bd --no-daemon show <id> does not find an issue, it incorrectly exits
with code 0 (success) but writes the error to stderr and leaves stdout empty.
This causes JSON parse failures throughout gt when code tries to unmarshal
the empty stdout.

This PR handles the bug defensively in all affected code paths:
- beads.go run(): Detect empty stdout + non-empty stderr as error
- beads.go wrapError(): Add 'no issue found' to ErrNotFound patterns
- sling.go: Check len(out) == 0 in multiple functions
- convoy.go getIssueDetails(): Check stdout.Len() == 0
- prime_molecule.go: Check stdout.Len() == 0

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-11 18:37:01 -08:00

700 lines
21 KiB
Go

// Package beads provides a wrapper for the bd (beads) CLI.
package beads
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"github.com/steveyegge/gastown/internal/runtime"
)
// Common errors
// ZFC: Only define errors that don't require stderr parsing for decisions.
// ErrNotARepo and ErrSyncConflict were removed - agents should handle these directly.
var (
ErrNotInstalled = errors.New("bd not installed: run 'pip install beads-cli' or see https://github.com/anthropics/beads")
ErrNotFound = errors.New("issue not found")
)
// Issue represents a beads issue.
type Issue struct {
ID string `json:"id"`
Title string `json:"title"`
Description string `json:"description"`
Status string `json:"status"`
Priority int `json:"priority"`
Type string `json:"issue_type"`
CreatedAt string `json:"created_at"`
CreatedBy string `json:"created_by,omitempty"`
UpdatedAt string `json:"updated_at"`
ClosedAt string `json:"closed_at,omitempty"`
Parent string `json:"parent,omitempty"`
Assignee string `json:"assignee,omitempty"`
Children []string `json:"children,omitempty"`
DependsOn []string `json:"depends_on,omitempty"`
Blocks []string `json:"blocks,omitempty"`
BlockedBy []string `json:"blocked_by,omitempty"`
Labels []string `json:"labels,omitempty"`
// Agent bead slots (type=agent only)
HookBead string `json:"hook_bead,omitempty"` // Current work attached to agent's hook
RoleBead string `json:"role_bead,omitempty"` // Role definition bead (shared)
AgentState string `json:"agent_state,omitempty"` // Agent lifecycle state (spawning, working, done, stuck)
// Counts from list output
DependencyCount int `json:"dependency_count,omitempty"`
DependentCount int `json:"dependent_count,omitempty"`
BlockedByCount int `json:"blocked_by_count,omitempty"`
// Detailed dependency info from show output
Dependencies []IssueDep `json:"dependencies,omitempty"`
Dependents []IssueDep `json:"dependents,omitempty"`
}
// IssueDep represents a dependency or dependent issue with its relation.
type IssueDep struct {
ID string `json:"id"`
Title string `json:"title"`
Status string `json:"status"`
Priority int `json:"priority"`
Type string `json:"issue_type"`
DependencyType string `json:"dependency_type,omitempty"`
}
// ListOptions specifies filters for listing issues.
type ListOptions struct {
Status string // "open", "closed", "all"
Type string // Deprecated: use Label instead. "task", "bug", "feature", "epic"
Label string // Label filter (e.g., "gt:agent", "gt:merge-request")
Priority int // 0-4, -1 for no filter
Parent string // filter by parent ID
Assignee string // filter by assignee (e.g., "gastown/Toast")
NoAssignee bool // filter for issues with no assignee
}
// CreateOptions specifies options for creating an issue.
type CreateOptions struct {
Title string
Type string // "task", "bug", "feature", "epic"
Priority int // 0-4
Description string
Parent string
Actor string // Who is creating this issue (populates created_by)
}
// UpdateOptions specifies options for updating an issue.
type UpdateOptions struct {
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.
type SyncStatus struct {
Branch string
Ahead int
Behind int
Conflicts []string
}
// Beads wraps bd CLI operations for a working directory.
type Beads struct {
workDir string
beadsDir string // Optional BEADS_DIR override for cross-database access
}
// New creates a new Beads wrapper for the given directory.
func New(workDir string) *Beads {
return &Beads{workDir: workDir}
}
// NewWithBeadsDir creates a Beads wrapper with an explicit BEADS_DIR.
// This is needed when running from a polecat worktree but accessing town-level beads.
func NewWithBeadsDir(workDir, beadsDir string) *Beads {
return &Beads{workDir: workDir, beadsDir: beadsDir}
}
// run executes a bd command and returns stdout.
func (b *Beads) run(args ...string) ([]byte, error) {
// Use --no-daemon for faster read operations (avoids daemon IPC overhead)
// The daemon is primarily useful for write coalescing, not reads
fullArgs := append([]string{"--no-daemon"}, args...)
cmd := exec.Command("bd", fullArgs...) //nolint:gosec // G204: bd is a trusted internal tool
cmd.Dir = b.workDir
// Always explicitly set BEADS_DIR to prevent inherited env vars from
// causing prefix mismatches. Use explicit beadsDir if set, otherwise
// resolve from working directory.
beadsDir := b.beadsDir
if beadsDir == "" {
beadsDir = ResolveBeadsDir(b.workDir)
}
cmd.Env = append(os.Environ(), "BEADS_DIR="+beadsDir)
var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr
err := cmd.Run()
if err != nil {
return nil, b.wrapError(err, stderr.String(), args)
}
// Handle bd --no-daemon exit code 0 bug: when issue not found,
// --no-daemon exits 0 but writes error to stderr with empty stdout.
// Detect this case and treat as error to avoid JSON parse failures.
if stdout.Len() == 0 && stderr.Len() > 0 {
return nil, b.wrapError(fmt.Errorf("command produced no output"), stderr.String(), args)
}
return stdout.Bytes(), nil
}
// Run executes a bd command and returns stdout.
// This is a public wrapper around the internal run method for cases where
// callers need to run arbitrary bd commands.
func (b *Beads) Run(args ...string) ([]byte, error) {
return b.run(args...)
}
// wrapError wraps bd errors with context.
// ZFC: Avoid parsing stderr to make decisions. Transport errors to agents instead.
// Exception: ErrNotInstalled (exec.ErrNotFound) and ErrNotFound (issue lookup) are
// acceptable as they enable basic error handling without decision-making.
func (b *Beads) wrapError(err error, stderr string, args []string) error {
stderr = strings.TrimSpace(stderr)
// Check for bd not installed
if execErr, ok := err.(*exec.Error); ok && errors.Is(execErr.Err, exec.ErrNotFound) {
return ErrNotInstalled
}
// ErrNotFound is widely used for issue lookups - acceptable exception
// Match various "not found" error patterns from bd
if strings.Contains(stderr, "not found") || strings.Contains(stderr, "Issue not found") ||
strings.Contains(stderr, "no issue found") {
return ErrNotFound
}
if stderr != "" {
return fmt.Errorf("bd %s: %s", strings.Join(args, " "), stderr)
}
return fmt.Errorf("bd %s: %w", strings.Join(args, " "), err)
}
// List returns issues matching the given options.
func (b *Beads) List(opts ListOptions) ([]*Issue, error) {
args := []string{"list", "--json"}
if opts.Status != "" {
args = append(args, "--status="+opts.Status)
}
// Prefer Label over Type (Type is deprecated)
if opts.Label != "" {
args = append(args, "--label="+opts.Label)
} else if opts.Type != "" {
// Deprecated: convert type to label for backward compatibility
args = append(args, "--label=gt:"+opts.Type)
}
if opts.Priority >= 0 {
args = append(args, fmt.Sprintf("--priority=%d", opts.Priority))
}
if opts.Parent != "" {
args = append(args, "--parent="+opts.Parent)
}
if opts.Assignee != "" {
args = append(args, "--assignee="+opts.Assignee)
}
if opts.NoAssignee {
args = append(args, "--no-assignee")
}
out, err := b.run(args...)
if err != nil {
return nil, err
}
var issues []*Issue
if err := json.Unmarshal(out, &issues); err != nil {
return nil, fmt.Errorf("parsing bd list output: %w", err)
}
return issues, nil
}
// ListByAssignee returns all issues assigned to a specific assignee.
// The assignee is typically in the format "rig/polecatName" (e.g., "gastown/Toast").
func (b *Beads) ListByAssignee(assignee string) ([]*Issue, error) {
return b.List(ListOptions{
Status: "all", // Include both open and closed for state derivation
Assignee: assignee,
Priority: -1, // No priority filter
})
}
// GetAssignedIssue returns the first open issue assigned to the given assignee.
// Returns nil if no open issue is assigned.
func (b *Beads) GetAssignedIssue(assignee string) (*Issue, error) {
issues, err := b.List(ListOptions{
Status: "open",
Assignee: assignee,
Priority: -1,
})
if err != nil {
return nil, err
}
// Also check in_progress status explicitly
if len(issues) == 0 {
issues, err = b.List(ListOptions{
Status: "in_progress",
Assignee: assignee,
Priority: -1,
})
if err != nil {
return nil, err
}
}
if len(issues) == 0 {
return nil, nil
}
return issues[0], nil
}
// Ready returns issues that are ready to work (not blocked).
func (b *Beads) Ready() ([]*Issue, error) {
out, err := b.run("ready", "--json")
if err != nil {
return nil, err
}
var issues []*Issue
if err := json.Unmarshal(out, &issues); err != nil {
return nil, fmt.Errorf("parsing bd ready output: %w", err)
}
return issues, nil
}
// ReadyWithType returns ready issues filtered by label.
// Uses bd ready --label flag for server-side filtering.
// The issueType is converted to a gt:<type> label (e.g., "molecule" -> "gt:molecule").
func (b *Beads) ReadyWithType(issueType string) ([]*Issue, error) {
out, err := b.run("ready", "--json", "--label", "gt:"+issueType, "-n", "100")
if err != nil {
return nil, err
}
var issues []*Issue
if err := json.Unmarshal(out, &issues); err != nil {
return nil, fmt.Errorf("parsing bd ready output: %w", err)
}
return issues, nil
}
// Show returns detailed information about an issue.
func (b *Beads) Show(id string) (*Issue, error) {
out, err := b.run("show", id, "--json")
if err != nil {
return nil, err
}
// bd show --json returns an array with one element
var issues []*Issue
if err := json.Unmarshal(out, &issues); err != nil {
return nil, fmt.Errorf("parsing bd show output: %w", err)
}
if len(issues) == 0 {
return nil, ErrNotFound
}
return issues[0], nil
}
// ShowMultiple fetches multiple issues by ID in a single bd call.
// Returns a map of ID to Issue. Missing IDs are not included in the map.
func (b *Beads) ShowMultiple(ids []string) (map[string]*Issue, error) {
if len(ids) == 0 {
return make(map[string]*Issue), nil
}
// bd show supports multiple IDs
args := append([]string{"show", "--json"}, ids...)
out, err := b.run(args...)
if err != nil {
// If bd fails, return empty map (some IDs might not exist)
return make(map[string]*Issue), nil
}
var issues []*Issue
if err := json.Unmarshal(out, &issues); err != nil {
return nil, fmt.Errorf("parsing bd show output: %w", err)
}
result := make(map[string]*Issue, len(issues))
for _, issue := range issues {
result[issue.ID] = issue
}
return result, nil
}
// Blocked returns issues that are blocked by dependencies.
func (b *Beads) Blocked() ([]*Issue, error) {
out, err := b.run("blocked", "--json")
if err != nil {
return nil, err
}
var issues []*Issue
if err := json.Unmarshal(out, &issues); err != nil {
return nil, fmt.Errorf("parsing bd blocked output: %w", err)
}
return issues, nil
}
// Create creates a new issue and returns it.
// If opts.Actor is empty, it defaults to the BD_ACTOR environment variable.
// This ensures created_by is populated for issue provenance tracking.
func (b *Beads) Create(opts CreateOptions) (*Issue, error) {
args := []string{"create", "--json"}
if opts.Title != "" {
args = append(args, "--title="+opts.Title)
}
// Type is deprecated: convert to gt:<type> label
if opts.Type != "" {
args = append(args, "--labels=gt:"+opts.Type)
}
if opts.Priority >= 0 {
args = append(args, fmt.Sprintf("--priority=%d", opts.Priority))
}
if opts.Description != "" {
args = append(args, "--description="+opts.Description)
}
if opts.Parent != "" {
args = append(args, "--parent="+opts.Parent)
}
// Default Actor from BD_ACTOR env var if not specified
actor := opts.Actor
if actor == "" {
actor = os.Getenv("BD_ACTOR")
}
if actor != "" {
args = append(args, "--actor="+actor)
}
out, err := b.run(args...)
if err != nil {
return nil, err
}
var issue Issue
if err := json.Unmarshal(out, &issue); err != nil {
return nil, fmt.Errorf("parsing bd create output: %w", err)
}
return &issue, nil
}
// CreateWithID creates an issue with a specific ID.
// This is useful for agent beads, role beads, and other beads that need
// deterministic IDs rather than auto-generated ones.
func (b *Beads) CreateWithID(id string, opts CreateOptions) (*Issue, error) {
args := []string{"create", "--json", "--id=" + id}
if opts.Title != "" {
args = append(args, "--title="+opts.Title)
}
// Type is deprecated: convert to gt:<type> label
if opts.Type != "" {
args = append(args, "--labels=gt:"+opts.Type)
}
if opts.Priority >= 0 {
args = append(args, fmt.Sprintf("--priority=%d", opts.Priority))
}
if opts.Description != "" {
args = append(args, "--description="+opts.Description)
}
if opts.Parent != "" {
args = append(args, "--parent="+opts.Parent)
}
// Default Actor from BD_ACTOR env var if not specified
actor := opts.Actor
if actor == "" {
actor = os.Getenv("BD_ACTOR")
}
if actor != "" {
args = append(args, "--actor="+actor)
}
out, err := b.run(args...)
if err != nil {
return nil, err
}
var issue Issue
if err := json.Unmarshal(out, &issue); err != nil {
return nil, fmt.Errorf("parsing bd create output: %w", err)
}
return &issue, nil
}
// Update updates an existing issue.
func (b *Beads) Update(id string, opts UpdateOptions) error {
args := []string{"update", id}
if opts.Title != nil {
args = append(args, "--title="+*opts.Title)
}
if opts.Status != nil {
args = append(args, "--status="+*opts.Status)
}
if opts.Priority != nil {
args = append(args, fmt.Sprintf("--priority=%d", *opts.Priority))
}
if opts.Description != nil {
args = append(args, "--description="+*opts.Description)
}
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
}
// Close closes one or more issues.
// If a runtime session ID is set in the environment, it is passed to bd close
// for work attribution tracking (see decision 009-session-events-architecture.md).
func (b *Beads) Close(ids ...string) error {
if len(ids) == 0 {
return nil
}
args := append([]string{"close"}, ids...)
// Pass session ID for work attribution if available
if sessionID := runtime.SessionIDFromEnv(); sessionID != "" {
args = append(args, "--session="+sessionID)
}
_, err := b.run(args...)
return err
}
// CloseWithReason closes one or more issues with a reason.
// If a runtime session ID is set in the environment, it is passed to bd close
// for work attribution tracking (see decision 009-session-events-architecture.md).
func (b *Beads) CloseWithReason(reason string, ids ...string) error {
if len(ids) == 0 {
return nil
}
args := append([]string{"close"}, ids...)
args = append(args, "--reason="+reason)
// Pass session ID for work attribution if available
if sessionID := runtime.SessionIDFromEnv(); sessionID != "" {
args = append(args, "--session="+sessionID)
}
_, err := b.run(args...)
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)
return err
}
// RemoveDependency removes a dependency.
func (b *Beads) RemoveDependency(issue, dependsOn string) error {
_, err := b.run("dep", "remove", issue, dependsOn)
return err
}
// Sync syncs beads with remote.
func (b *Beads) Sync() error {
_, err := b.run("sync")
return err
}
// SyncFromMain syncs beads updates from main branch.
func (b *Beads) SyncFromMain() error {
_, err := b.run("sync", "--from-main")
return err
}
// GetSyncStatus returns the sync status without performing a sync.
func (b *Beads) GetSyncStatus() (*SyncStatus, error) {
out, err := b.run("sync", "--status", "--json")
if err != nil {
// If sync branch doesn't exist, return empty status
if strings.Contains(err.Error(), "does not exist") {
return &SyncStatus{}, nil
}
return nil, err
}
var status SyncStatus
if err := json.Unmarshal(out, &status); err != nil {
return nil, fmt.Errorf("parsing bd sync status output: %w", err)
}
return &status, nil
}
// Stats returns repository statistics.
func (b *Beads) Stats() (string, error) {
out, err := b.run("stats")
if err != nil {
return "", err
}
return string(out), nil
}
// IsBeadsRepo checks if the working directory is a beads repository.
// ZFC: Check file existence directly instead of parsing bd errors.
func (b *Beads) IsBeadsRepo() bool {
beadsDir := ResolveBeadsDir(b.workDir)
info, err := os.Stat(beadsDir)
return err == nil && info.IsDir()
}
// primeContent is the Gas Town PRIME.md content that provides essential context
// for crew workers. This is the fallback if the SessionStart hook fails.
const primeContent = `# Gas Town Worker Context
> **Context Recovery**: Run ` + "`gt prime`" + ` for full context after compaction or new session.
## The Propulsion Principle (GUPP)
**If you find work on your hook, YOU RUN IT.**
No confirmation. No waiting. No announcements. The hook having work IS the assignment.
This is physics, not politeness. Gas Town is a steam engine - you are a piston.
**Failure mode we're preventing:**
- Agent starts with work on hook
- Agent announces itself and waits for human to say "ok go"
- Human is AFK / trusting the engine to run
- Work sits idle. The whole system stalls.
## Startup Protocol
1. Check your hook: ` + "`gt mol status`" + `
2. If work is hooked → EXECUTE (no announcement, no waiting)
3. If hook empty → Check mail: ` + "`gt mail inbox`" + `
4. Still nothing? Wait for user instructions
## Key Commands
- ` + "`gt prime`" + ` - Get full role context (run after compaction)
- ` + "`gt mol status`" + ` - Check your hooked work
- ` + "`gt mail inbox`" + ` - Check for messages
- ` + "`bd ready`" + ` - Find available work (no blockers)
- ` + "`bd sync`" + ` - Sync beads changes
## Session Close Protocol
Before saying "done":
1. git status (check what changed)
2. git add <files> (stage code changes)
3. bd sync (commit beads changes)
4. git commit -m "..." (commit code)
5. bd sync (commit any new beads changes)
6. git push (push to remote)
**Work is not done until pushed.**
`
// ProvisionPrimeMD writes the Gas Town PRIME.md file to the specified beads directory.
// This provides essential Gas Town context (GUPP, startup protocol) as a fallback
// if the SessionStart hook fails. The PRIME.md is read by bd prime.
//
// The beadsDir should be the actual beads directory (after following any redirect).
// Returns nil if PRIME.md already exists (idempotent).
func ProvisionPrimeMD(beadsDir string) error {
primePath := filepath.Join(beadsDir, "PRIME.md")
// Check if already exists - don't overwrite customizations
if _, err := os.Stat(primePath); err == nil {
return nil // Already exists, don't overwrite
}
// Create .beads directory if it doesn't exist
if err := os.MkdirAll(beadsDir, 0755); err != nil {
return fmt.Errorf("creating beads dir: %w", err)
}
// Write PRIME.md
if err := os.WriteFile(primePath, []byte(primeContent), 0644); err != nil {
return fmt.Errorf("writing PRIME.md: %w", err)
}
return nil
}
// ProvisionPrimeMDForWorktree provisions PRIME.md for a worktree by following its redirect.
// This is the main entry point for crew/polecat provisioning.
func ProvisionPrimeMDForWorktree(worktreePath string) error {
// Resolve the beads directory (follows redirect chain)
beadsDir := ResolveBeadsDir(worktreePath)
// Provision PRIME.md in the target directory
return ProvisionPrimeMD(beadsDir)
}