From bbff3b2144aa842cbab1584ae4311770be3c294f Mon Sep 17 00:00:00 2001 From: Steve Yegge Date: Fri, 19 Dec 2025 12:07:35 -0800 Subject: [PATCH] refactor(polecat): eliminate state.json, use beads assignee for state MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace polecat state.json with beads assignee field for state management: - Remove state.json read/write from polecat.Manager - Add loadFromBeads() to derive state from issue.assignee field - Update AssignIssue() to set issue.assignee in beads - Update ClearIssue() to clear assignee from beads - Update SetState() to work with beads or gracefully degrade - Add ListByAssignee and GetAssignedIssue to beads package - Update spawn to create beads issues for free-form tasks - Update tests for new beads-based architecture State derivation: - Polecat exists: worktree directory exists - Polecat assigned: issue.assignee = 'rig/polecatName' - Polecat working: issue.status = open/in_progress - Polecat done: issue.status = closed or no assignee Fixes: gt-qp98 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- internal/beads/beads.go | 57 +++++++- internal/cmd/spawn.go | 65 ++++++++- internal/polecat/manager.go | 243 +++++++++++++++++++------------ internal/polecat/manager_test.go | 156 +++++++------------- 4 files changed, 317 insertions(+), 204 deletions(-) diff --git a/internal/beads/beads.go b/internal/beads/beads.go index dca988b5..13a69e1c 100644 --- a/internal/beads/beads.go +++ b/internal/beads/beads.go @@ -58,10 +58,12 @@ type IssueDep struct { // ListOptions specifies filters for listing issues. type ListOptions struct { - Status string // "open", "closed", "all" - Type string // "task", "bug", "feature", "epic" - Priority int // 0-4, -1 for no filter - Parent string // filter by parent ID + Status string // "open", "closed", "all" + Type string // "task", "bug", "feature", "epic" + 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. @@ -161,6 +163,12 @@ func (b *Beads) List(opts ListOptions) ([]*Issue, error) { 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 { @@ -175,6 +183,47 @@ func (b *Beads) List(opts ListOptions) ([]*Issue, error) { 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") diff --git a/internal/cmd/spawn.go b/internal/cmd/spawn.go index 0df63e81..136f2fc5 100644 --- a/internal/cmd/spawn.go +++ b/internal/cmd/spawn.go @@ -184,9 +184,12 @@ func runSpawn(cmd *cobra.Command, args []string) error { return fmt.Errorf("polecat '%s' is already working on %s", polecatName, pc.Issue) } + // Beads operations use mayor/rig directory (rig-level beads) + beadsPath := filepath.Join(r.Path, "mayor", "rig") + // Handle molecule instantiation if specified if spawnMolecule != "" { - b := beads.New(r.Path) + b := beads.New(beadsPath) // Get the molecule mol, err := b.Show(spawnMolecule) @@ -239,20 +242,28 @@ func runSpawn(cmd *cobra.Command, args []string) error { spawnIssue = firstReadyStep.ID } - // Get issue details if specified + // Get or create issue var issue *BeadsIssue + var assignmentID string if spawnIssue != "" { - issue, err = fetchBeadsIssue(r.Path, spawnIssue) + // Use existing issue + issue, err = fetchBeadsIssue(beadsPath, spawnIssue) if err != nil { return fmt.Errorf("fetching issue %s: %w", spawnIssue, err) } + assignmentID = spawnIssue + } else { + // Create a beads issue for free-form task + fmt.Printf("Creating beads issue for task...\n") + issue, err = createBeadsTask(beadsPath, spawnMessage) + if err != nil { + return fmt.Errorf("creating task issue: %w", err) + } + assignmentID = issue.ID + fmt.Printf("Created issue %s\n", assignmentID) } - // Assign issue/task to polecat - assignmentID := spawnIssue - if assignmentID == "" { - assignmentID = "task:" + time.Now().Format("20060102-150405") - } + // Assign issue to polecat (sets issue.assignee in beads) if err := polecatMgr.AssignIssue(polecatName, assignmentID); err != nil { return fmt.Errorf("assigning issue: %w", err) } @@ -412,6 +423,44 @@ func fetchBeadsIssue(rigPath, issueID string) (*BeadsIssue, error) { return &issues[0], nil } +// createBeadsTask creates a new beads task issue for a free-form task message. +func createBeadsTask(rigPath, message string) (*BeadsIssue, error) { + // Truncate message for title if too long + title := message + if len(title) > 60 { + title = title[:57] + "..." + } + + // Use bd create to make a new task issue + cmd := exec.Command("bd", "create", + "--title="+title, + "--type=task", + "--priority=2", + "--description="+message, + "--json") + cmd.Dir = rigPath + + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + + if err := cmd.Run(); err != nil { + errMsg := strings.TrimSpace(stderr.String()) + if errMsg != "" { + return nil, fmt.Errorf("%s", errMsg) + } + return nil, err + } + + // bd create --json returns the created issue + var issue BeadsIssue + if err := json.Unmarshal(stdout.Bytes(), &issue); err != nil { + return nil, fmt.Errorf("parsing created issue: %w", err) + } + + return &issue, nil +} + // buildSpawnContext creates the initial context message for the polecat. func buildSpawnContext(issue *BeadsIssue, message string) string { var sb strings.Builder diff --git a/internal/polecat/manager.go b/internal/polecat/manager.go index c6b108b5..45f6b9b8 100644 --- a/internal/polecat/manager.go +++ b/internal/polecat/manager.go @@ -1,13 +1,13 @@ package polecat import ( - "encoding/json" "errors" "fmt" "os" "path/filepath" "time" + "github.com/steveyegge/gastown/internal/beads" "github.com/steveyegge/gastown/internal/git" "github.com/steveyegge/gastown/internal/rig" ) @@ -21,36 +21,42 @@ var ( // Manager handles polecat lifecycle. type Manager struct { - rig *rig.Rig - git *git.Git + rig *rig.Rig + git *git.Git + beads *beads.Beads } // NewManager creates a new polecat manager. func NewManager(r *rig.Rig, g *git.Git) *Manager { + // Use the mayor's rig directory for beads operations (rig-level beads) + mayorRigPath := filepath.Join(r.Path, "mayor", "rig") return &Manager{ - rig: r, - git: g, + rig: r, + git: g, + beads: beads.New(mayorRigPath), } } +// assigneeID returns the beads assignee identifier for a polecat. +// Format: "rig/polecatName" (e.g., "gastown/Toast") +func (m *Manager) assigneeID(name string) string { + return fmt.Sprintf("%s/%s", m.rig.Name, name) +} + // polecatDir returns the directory for a polecat. func (m *Manager) polecatDir(name string) string { return filepath.Join(m.rig.Path, "polecats", name) } -// stateFile returns the state file path for a polecat. -func (m *Manager) stateFile(name string) string { - return filepath.Join(m.polecatDir(name), "state.json") -} - // exists checks if a polecat exists. func (m *Manager) exists(name string) bool { _, err := os.Stat(m.polecatDir(name)) return err == nil } -// Add creates a new polecat as a git worktree from the refinery clone. -// This is much faster than a full clone and shares objects with the refinery. +// Add creates a new polecat as a git worktree from the mayor's clone. +// This is much faster than a full clone and shares objects with the mayor. +// Polecat state is derived from beads assignee field, not state.json. func (m *Manager) Add(name string) (*Polecat, error) { if m.exists(name) { return nil, ErrPolecatExists @@ -94,25 +100,19 @@ func (m *Manager) Add(name string) (*Polecat, error) { } } - // Create polecat state - ephemeral polecats start in working state + // Return polecat with derived state (no issue assigned yet = idle) + // State is derived from beads, not stored in state.json now := time.Now() polecat := &Polecat{ Name: name, Rig: m.rig.Name, - State: StateWorking, + State: StateIdle, // No issue assigned yet ClonePath: polecatPath, Branch: branchName, CreatedAt: now, UpdatedAt: now, } - // Save state - if err := m.saveState(polecat); err != nil { - // Clean up worktree on failure - _ = mayorGit.WorktreeRemove(polecatPath, true) - return nil, fmt.Errorf("saving state: %w", err) - } - return polecat, nil } @@ -182,54 +182,115 @@ func (m *Manager) List() ([]*Polecat, error) { } // Get returns a specific polecat by name. +// State is derived from beads assignee field: +// - If an issue is assigned to this polecat and is open/in_progress: StateWorking +// - If an issue is assigned but closed: StateDone +// - If no issue assigned: StateIdle func (m *Manager) Get(name string) (*Polecat, error) { if !m.exists(name) { return nil, ErrPolecatNotFound } - return m.loadState(name) + return m.loadFromBeads(name) } // SetState updates a polecat's state. +// In the beads model, state is derived from issue status: +// - StateWorking/StateActive: issue status set to in_progress +// - StateDone/StateIdle: assignee cleared from issue +// - StateStuck: issue status set to blocked (if supported) +// If beads is not available, this is a no-op. func (m *Manager) SetState(name string, state State) error { - polecat, err := m.Get(name) - if err != nil { - return err + if !m.exists(name) { + return ErrPolecatNotFound } - polecat.State = state - polecat.UpdatedAt = time.Now() + // Find the issue assigned to this polecat + assignee := m.assigneeID(name) + issue, err := m.beads.GetAssignedIssue(assignee) + if err != nil { + // If beads is not available, treat as no-op (state can't be changed) + return nil + } - return m.saveState(polecat) + switch state { + case StateWorking, StateActive: + // Set issue to in_progress if there is one + if issue != nil { + status := "in_progress" + if err := m.beads.Update(issue.ID, beads.UpdateOptions{Status: &status}); err != nil { + return fmt.Errorf("setting issue status: %w", err) + } + } + case StateDone, StateIdle: + // Clear assignment when done/idle + if issue != nil { + empty := "" + if err := m.beads.Update(issue.ID, beads.UpdateOptions{Assignee: &empty}); err != nil { + return fmt.Errorf("clearing assignee: %w", err) + } + } + case StateStuck: + // Mark issue as blocked if supported, otherwise just note in issue + if issue != nil { + // For now, just keep the assignment - the issue's blocked_by would indicate stuck + // We could add a status="blocked" here if beads supports it + } + } + + return nil } -// AssignIssue assigns an issue to a polecat. +// AssignIssue assigns an issue to a polecat by setting the issue's assignee in beads. func (m *Manager) AssignIssue(name, issue string) error { - polecat, err := m.Get(name) - if err != nil { - return err + if !m.exists(name) { + return ErrPolecatNotFound } - polecat.Issue = issue - polecat.State = StateWorking - polecat.UpdatedAt = time.Now() + // Set the issue's assignee to this polecat + assignee := m.assigneeID(name) + status := "in_progress" + if err := m.beads.Update(issue, beads.UpdateOptions{ + Assignee: &assignee, + Status: &status, + }); err != nil { + return fmt.Errorf("setting issue assignee: %w", err) + } - return m.saveState(polecat) + return nil } // ClearIssue removes the issue assignment from a polecat. // In the ephemeral model, this transitions to Done state for cleanup. +// This clears the assignee from the currently assigned issue in beads. +// If beads is not available, this is a no-op. func (m *Manager) ClearIssue(name string) error { - polecat, err := m.Get(name) - if err != nil { - return err + if !m.exists(name) { + return ErrPolecatNotFound } - polecat.Issue = "" - polecat.State = StateDone - polecat.UpdatedAt = time.Now() + // Find the issue assigned to this polecat + assignee := m.assigneeID(name) + issue, err := m.beads.GetAssignedIssue(assignee) + if err != nil { + // If beads is not available, treat as no-op + return nil + } - return m.saveState(polecat) + if issue == nil { + // No issue assigned, nothing to clear + return nil + } + + // Clear the assignee from the issue + empty := "" + if err := m.beads.Update(issue.ID, beads.UpdateOptions{ + Assignee: &empty, + }); err != nil { + return fmt.Errorf("clearing issue assignee: %w", err) + } + + return nil } // Wake transitions a polecat from idle to active. @@ -267,6 +328,7 @@ func (m *Manager) Sleep(name string) error { } // Finish transitions a polecat from working/done/stuck to idle and clears the issue. +// This clears the assignee from any assigned issue. func (m *Manager) Finish(name string) error { polecat, err := m.Get(name) if err != nil { @@ -281,65 +343,66 @@ func (m *Manager) Finish(name string) error { return fmt.Errorf("polecat is not in a finishing state (state: %s)", polecat.State) } - polecat.Issue = "" - polecat.State = StateIdle - polecat.UpdatedAt = time.Now() - - return m.saveState(polecat) + // Clear the issue assignment + return m.ClearIssue(name) } // Reset forces a polecat to idle state regardless of current state. +// This clears the assignee from any assigned issue. func (m *Manager) Reset(name string) error { - polecat, err := m.Get(name) - if err != nil { - return err + if !m.exists(name) { + return ErrPolecatNotFound } - polecat.Issue = "" - polecat.State = StateIdle - polecat.UpdatedAt = time.Now() - - return m.saveState(polecat) + // Clear the issue assignment + return m.ClearIssue(name) } -// saveState persists polecat state to disk. -func (m *Manager) saveState(polecat *Polecat) error { - data, err := json.MarshalIndent(polecat, "", " ") +// loadFromBeads derives polecat state from beads assignee field. +// State is derived as follows: +// - If an issue is assigned to this polecat and is open/in_progress: StateWorking +// - If no issue assigned: StateIdle +func (m *Manager) loadFromBeads(name string) (*Polecat, error) { + polecatPath := m.polecatDir(name) + branchName := fmt.Sprintf("polecat/%s", name) + + // Query beads for assigned issue + assignee := m.assigneeID(name) + issue, err := m.beads.GetAssignedIssue(assignee) if err != nil { - return fmt.Errorf("marshaling state: %w", err) + // If beads query fails, return basic polecat info + // This allows the system to work even if beads is not available + return &Polecat{ + Name: name, + Rig: m.rig.Name, + State: StateIdle, + ClonePath: polecatPath, + Branch: branchName, + }, nil } - stateFile := m.stateFile(polecat.Name) - if err := os.WriteFile(stateFile, data, 0644); err != nil { - return fmt.Errorf("writing state: %w", err) - } - - return nil -} - -// loadState reads polecat state from disk. -func (m *Manager) loadState(name string) (*Polecat, error) { - stateFile := m.stateFile(name) - - data, err := os.ReadFile(stateFile) - if err != nil { - if os.IsNotExist(err) { - // Return minimal polecat if state file missing - // Use StateWorking since ephemeral polecats are always working - return &Polecat{ - Name: name, - Rig: m.rig.Name, - State: StateWorking, - ClonePath: m.polecatDir(name), - }, nil + // Derive state from issue + state := StateIdle + issueID := "" + if issue != nil { + issueID = issue.ID + switch issue.Status { + case "open", "in_progress": + state = StateWorking + case "closed": + state = StateDone + default: + // Unknown status, assume working if assigned + state = StateWorking } - return nil, fmt.Errorf("reading state: %w", err) } - var polecat Polecat - if err := json.Unmarshal(data, &polecat); err != nil { - return nil, fmt.Errorf("parsing state: %w", err) - } - - return &polecat, nil + return &Polecat{ + Name: name, + Rig: m.rig.Name, + State: state, + ClonePath: polecatPath, + Branch: branchName, + Issue: issueID, + }, nil } diff --git a/internal/polecat/manager_test.go b/internal/polecat/manager_test.go index 73500628..df96c912 100644 --- a/internal/polecat/manager_test.go +++ b/internal/polecat/manager_test.go @@ -126,72 +126,72 @@ func TestPolecatDir(t *testing.T) { } } -func TestStateFile(t *testing.T) { +func TestAssigneeID(t *testing.T) { r := &rig.Rig{ Name: "test-rig", Path: "/home/user/ai/test-rig", } m := NewManager(r, git.NewGit(r.Path)) - file := m.stateFile("Toast") - expected := "/home/user/ai/test-rig/polecats/Toast/state.json" - if file != expected { - t.Errorf("stateFile = %q, want %q", file, expected) + id := m.assigneeID("Toast") + expected := "test-rig/Toast" + if id != expected { + t.Errorf("assigneeID = %q, want %q", id, expected) } } -func TestStatePersistence(t *testing.T) { +// Note: State persistence tests removed - state is now derived from beads assignee field. +// Integration tests should verify beads-based state management. + +func TestGetReturnsIdleWithoutBeads(t *testing.T) { + // When beads is not available, Get should return StateIdle root := t.TempDir() polecatDir := filepath.Join(root, "polecats", "Test") if err := os.MkdirAll(polecatDir, 0755); err != nil { t.Fatalf("mkdir: %v", err) } + // Create mayor/rig directory for beads (but no actual beads) + mayorRigDir := filepath.Join(root, "mayor", "rig") + if err := os.MkdirAll(mayorRigDir, 0755); err != nil { + t.Fatalf("mkdir mayor/rig: %v", err) + } + r := &rig.Rig{ Name: "test-rig", Path: root, } m := NewManager(r, git.NewGit(root)) - // Save state - polecat := &Polecat{ - Name: "Test", - Rig: "test-rig", - State: StateWorking, - ClonePath: polecatDir, - Issue: "gt-xyz", - } - if err := m.saveState(polecat); err != nil { - t.Fatalf("saveState: %v", err) - } - - // Load state - loaded, err := m.loadState("Test") + // Get should return polecat with StateIdle (no beads = no assignment) + polecat, err := m.Get("Test") if err != nil { - t.Fatalf("loadState: %v", err) + t.Fatalf("Get: %v", err) } - if loaded.Name != "Test" { - t.Errorf("Name = %q, want Test", loaded.Name) + if polecat.Name != "Test" { + t.Errorf("Name = %q, want Test", polecat.Name) } - if loaded.State != StateWorking { - t.Errorf("State = %v, want StateWorking", loaded.State) - } - if loaded.Issue != "gt-xyz" { - t.Errorf("Issue = %q, want gt-xyz", loaded.Issue) + if polecat.State != StateIdle { + t.Errorf("State = %v, want StateIdle (beads not available)", polecat.State) } } func TestListWithPolecats(t *testing.T) { root := t.TempDir() - // Create some polecat directories with state files + // Create some polecat directories (state is now derived from beads, not state files) for _, name := range []string{"Toast", "Cheedo"} { polecatDir := filepath.Join(root, "polecats", name) if err := os.MkdirAll(polecatDir, 0755); err != nil { t.Fatalf("mkdir: %v", err) } } + // Create mayor/rig for beads path + mayorRig := filepath.Join(root, "mayor", "rig") + if err := os.MkdirAll(mayorRig, 0755); err != nil { + t.Fatalf("mkdir mayor/rig: %v", err) + } r := &rig.Rig{ Name: "test-rig", @@ -208,12 +208,23 @@ func TestListWithPolecats(t *testing.T) { } } -func TestSetState(t *testing.T) { +// Note: TestSetState, TestAssignIssue, and TestClearIssue were removed. +// These operations now require a running beads instance and are tested +// via integration tests. The unit tests here focus on testing the basic +// polecat lifecycle operations that don't require beads. + +func TestSetStateWithoutBeads(t *testing.T) { + // SetState should not error when beads is not available root := t.TempDir() polecatDir := filepath.Join(root, "polecats", "Test") if err := os.MkdirAll(polecatDir, 0755); err != nil { t.Fatalf("mkdir: %v", err) } + // Create mayor/rig for beads path + mayorRig := filepath.Join(root, "mayor", "rig") + if err := os.MkdirAll(mayorRig, 0755); err != nil { + t.Fatalf("mkdir mayor/rig: %v", err) + } r := &rig.Rig{ Name: "test-rig", @@ -221,32 +232,25 @@ func TestSetState(t *testing.T) { } m := NewManager(r, git.NewGit(root)) - // Initial state - if err := m.saveState(&Polecat{Name: "Test", State: StateIdle}); err != nil { - t.Fatalf("saveState: %v", err) - } - - // Update state - if err := m.SetState("Test", StateActive); err != nil { - t.Fatalf("SetState: %v", err) - } - - // Verify - polecat, err := m.Get("Test") + // SetState should succeed (no-op when no issue assigned) + err := m.SetState("Test", StateActive) if err != nil { - t.Fatalf("Get: %v", err) - } - if polecat.State != StateActive { - t.Errorf("State = %v, want StateActive", polecat.State) + t.Errorf("SetState: %v (expected no error when no beads/issue)", err) } } -func TestAssignIssue(t *testing.T) { +func TestClearIssueWithoutAssignment(t *testing.T) { + // ClearIssue should not error when no issue is assigned root := t.TempDir() polecatDir := filepath.Join(root, "polecats", "Test") if err := os.MkdirAll(polecatDir, 0755); err != nil { t.Fatalf("mkdir: %v", err) } + // Create mayor/rig for beads path + mayorRig := filepath.Join(root, "mayor", "rig") + if err := os.MkdirAll(mayorRig, 0755); err != nil { + t.Fatalf("mkdir mayor/rig: %v", err) + } r := &rig.Rig{ Name: "test-rig", @@ -254,61 +258,9 @@ func TestAssignIssue(t *testing.T) { } m := NewManager(r, git.NewGit(root)) - // Initial state - if err := m.saveState(&Polecat{Name: "Test", State: StateIdle}); err != nil { - t.Fatalf("saveState: %v", err) - } - - // Assign issue - if err := m.AssignIssue("Test", "gt-abc"); err != nil { - t.Fatalf("AssignIssue: %v", err) - } - - // Verify - polecat, err := m.Get("Test") + // ClearIssue should succeed even when no issue assigned + err := m.ClearIssue("Test") if err != nil { - t.Fatalf("Get: %v", err) - } - if polecat.Issue != "gt-abc" { - t.Errorf("Issue = %q, want gt-abc", polecat.Issue) - } - if polecat.State != StateWorking { - t.Errorf("State = %v, want StateWorking", polecat.State) - } -} - -func TestClearIssue(t *testing.T) { - root := t.TempDir() - polecatDir := filepath.Join(root, "polecats", "Test") - if err := os.MkdirAll(polecatDir, 0755); err != nil { - t.Fatalf("mkdir: %v", err) - } - - r := &rig.Rig{ - Name: "test-rig", - Path: root, - } - m := NewManager(r, git.NewGit(root)) - - // Initial state with issue - if err := m.saveState(&Polecat{Name: "Test", State: StateWorking, Issue: "gt-abc"}); err != nil { - t.Fatalf("saveState: %v", err) - } - - // Clear issue - if err := m.ClearIssue("Test"); err != nil { - t.Fatalf("ClearIssue: %v", err) - } - - // Verify - in ephemeral model, ClearIssue transitions to Done - polecat, err := m.Get("Test") - if err != nil { - t.Fatalf("Get: %v", err) - } - if polecat.Issue != "" { - t.Errorf("Issue = %q, want empty", polecat.Issue) - } - if polecat.State != StateDone { - t.Errorf("State = %v, want StateDone", polecat.State) + t.Errorf("ClearIssue: %v (expected no error when no assignment)", err) } }