From 3b9d1a113ce39ea4e2a615a39fcfd0b159765f65 Mon Sep 17 00:00:00 2001 From: nux Date: Sun, 4 Jan 2026 16:28:57 -0800 Subject: [PATCH 1/2] fix(sling): Set hook slot when creating agent beads MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sync with mayor/rig fix: Set hook slot in CreateAgentBead and pass beadID to UpdateAgentState. Fixes: mi-619 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- internal/beads/beads.go | 10 ++++++++++ internal/cmd/sling.go | 16 +++++++++------- 2 files changed, 19 insertions(+), 7 deletions(-) diff --git a/internal/beads/beads.go b/internal/beads/beads.go index 70deca0e..e5375a0b 100644 --- a/internal/beads/beads.go +++ b/internal/beads/beads.go @@ -975,6 +975,16 @@ func (b *Beads) CreateAgentBead(id, title string, fields *AgentFields) (*Issue, } } + // Set the hook slot if specified (this is the authoritative storage) + // This fixes the slot inconsistency bug where bead status is 'hooked' but + // agent's hook slot is empty. See mi-619. + if fields != nil && fields.HookBead != "" { + if _, err := b.run("slot", "set", id, "hook", fields.HookBead); err != nil { + // Non-fatal: warn but continue - description text has the backup + fmt.Printf("Warning: could not set hook slot: %v\n", err) + } + } + return &issue, nil } diff --git a/internal/cmd/sling.go b/internal/cmd/sling.go index 39da5e46..17fbac6c 100644 --- a/internal/cmd/sling.go +++ b/internal/cmd/sling.go @@ -895,17 +895,17 @@ func runSlingFormula(args []string) error { return nil } -// updateAgentHookBead updates the agent bead's state when work is slung. +// updateAgentHookBead updates the agent bead's state and hook when work is slung. // This enables the witness to see that each agent is working. // // We run from the polecat's workDir (which redirects to the rig's beads database) // WITHOUT setting BEADS_DIR, so the redirect mechanism works for gt-* agent beads. // -// Note: We only update the agent_state field, not hook_bead. The hook_bead field -// requires cross-database access (agent in rig db, hook bead in town db), but -// bd slot set has a bug where it doesn't support this. See BD_BUG_AGENT_STATE_ROUTING.md. +// For rig-level beads (same database), we set the hook_bead slot directly. +// For cross-database scenarios (agent in rig db, hook bead in town db), +// the slot set may fail - this is handled gracefully with a warning. // The work is still correctly attached via `bd update --assignee=`. -func updateAgentHookBead(agentID, _, workDir, townBeadsDir string) { // beadID unused due to BD_BUG_AGENT_STATE_ROUTING +func updateAgentHookBead(agentID, beadID, workDir, townBeadsDir string) { _ = townBeadsDir // Not used - BEADS_DIR breaks redirect mechanism // Convert agent ID to agent bead ID @@ -934,9 +934,11 @@ func updateAgentHookBead(agentID, _, workDir, townBeadsDir string) { // beadID u } // Run from workDir WITHOUT BEADS_DIR to enable redirect-based routing. - // Only update agent_state (not hook_bead) due to bd cross-database bug. + // Update agent_state to "running" and set hook_bead to the slung work. + // For same-database beads, the hook slot is set via `bd slot set`. + // For cross-database scenarios, slot set may fail gracefully (warning only). bd := beads.New(bdWorkDir) - if err := bd.UpdateAgentState(agentBeadID, "running", nil); err != nil { + if err := bd.UpdateAgentState(agentBeadID, "running", &beadID); err != nil { // Log warning instead of silent ignore - helps debug cross-beads issues fmt.Fprintf(os.Stderr, "Warning: couldn't update agent %s state: %v\n", agentBeadID, err) return From 2d56b6c02b9df7fa07d536e6d8893e2d0bc17305 Mon Sep 17 00:00:00 2001 From: slit Date: Sun, 4 Jan 2026 19:46:27 -0800 Subject: [PATCH 2/2] test: Add E2E integration tests for hook slot verification (mi-3zc) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add comprehensive integration tests covering hook slot operations: - BasicHook: Verify bead can be hooked to an agent - Singleton: Document that bd allows multiple hooks (gt enforces singleton) - Unhook: Verify hook removal via status change - DifferentAgents: Verify independent hooks per agent - HookPersistence: Verify hooks survive beads instance recreation - StatusTransitions: Test open -> hooked -> open -> hooked -> closed Also fix missing json import in install_integration_test.go. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- internal/cmd/hook_slot_integration_test.go | 488 +++++++++++++++++++++ internal/cmd/install_integration_test.go | 1 + 2 files changed, 489 insertions(+) create mode 100644 internal/cmd/hook_slot_integration_test.go diff --git a/internal/cmd/hook_slot_integration_test.go b/internal/cmd/hook_slot_integration_test.go new file mode 100644 index 00000000..16521387 --- /dev/null +++ b/internal/cmd/hook_slot_integration_test.go @@ -0,0 +1,488 @@ +//go:build integration + +// Package cmd contains integration tests for hook slot verification. +// +// Run with: go test -tags=integration ./internal/cmd -run TestHookSlot -v +package cmd + +import ( + "os" + "os/exec" + "path/filepath" + "testing" + + "github.com/steveyegge/gastown/internal/beads" +) + +// setupHookTestTown creates a minimal Gas Town with a polecat for testing hooks. +// Returns townRoot and the path to the polecat's worktree. +func setupHookTestTown(t *testing.T) (townRoot, polecatDir string) { + t.Helper() + + townRoot = t.TempDir() + + // Create town-level .beads directory + townBeadsDir := filepath.Join(townRoot, ".beads") + if err := os.MkdirAll(townBeadsDir, 0755); err != nil { + t.Fatalf("mkdir town .beads: %v", err) + } + + // Create routes.jsonl + routes := []beads.Route{ + {Prefix: "hq-", Path: "."}, // Town-level beads + {Prefix: "gt-", Path: "gastown/mayor/rig"}, // Gastown rig + } + if err := beads.WriteRoutes(townBeadsDir, routes); err != nil { + t.Fatalf("write routes: %v", err) + } + + // Create gastown rig structure + gasRigPath := filepath.Join(townRoot, "gastown", "mayor", "rig") + if err := os.MkdirAll(gasRigPath, 0755); err != nil { + t.Fatalf("mkdir gastown: %v", err) + } + + // Create gastown .beads directory with its own config + gasBeadsDir := filepath.Join(gasRigPath, ".beads") + if err := os.MkdirAll(gasBeadsDir, 0755); err != nil { + t.Fatalf("mkdir gastown .beads: %v", err) + } + if err := os.WriteFile(filepath.Join(gasBeadsDir, "config.yaml"), []byte("prefix: gt\n"), 0644); err != nil { + t.Fatalf("write gastown config: %v", err) + } + + // Create polecat worktree with redirect + polecatDir = filepath.Join(townRoot, "gastown", "polecats", "toast") + if err := os.MkdirAll(polecatDir, 0755); err != nil { + t.Fatalf("mkdir polecats: %v", err) + } + + // Create redirect file for polecat -> mayor/rig/.beads + polecatBeadsDir := filepath.Join(polecatDir, ".beads") + if err := os.MkdirAll(polecatBeadsDir, 0755); err != nil { + t.Fatalf("mkdir polecat .beads: %v", err) + } + redirectContent := "../../mayor/rig/.beads" + if err := os.WriteFile(filepath.Join(polecatBeadsDir, "redirect"), []byte(redirectContent), 0644); err != nil { + t.Fatalf("write redirect: %v", err) + } + + return townRoot, polecatDir +} + +// initBeadsDB initializes the beads database by running bd init. +func initBeadsDB(t *testing.T, dir string) { + t.Helper() + + cmd := exec.Command("bd", "--no-daemon", "init") + cmd.Dir = dir + if output, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("bd init failed: %v\n%s", err, output) + } +} + +// TestHookSlot_BasicHook verifies that a bead can be hooked to an agent. +func TestHookSlot_BasicHook(t *testing.T) { + // Skip if bd is not available + if _, err := exec.LookPath("bd"); err != nil { + t.Skip("bd not installed, skipping test") + } + + townRoot, polecatDir := setupHookTestTown(t) + _ = townRoot // Not used directly but shows test context + + // Initialize beads in the rig + rigDir := filepath.Join(polecatDir, "..", "..", "mayor", "rig") + initBeadsDB(t, rigDir) + + b := beads.New(rigDir) + + // Create a test bead + issue, err := b.Create(beads.CreateOptions{ + Title: "Test task for hooking", + Type: "task", + Priority: 2, + }) + if err != nil { + t.Fatalf("create bead: %v", err) + } + t.Logf("Created bead: %s", issue.ID) + + // Hook the bead to the polecat + agentID := "gastown/polecats/toast" + status := beads.StatusHooked + if err := b.Update(issue.ID, beads.UpdateOptions{ + Status: &status, + Assignee: &agentID, + }); err != nil { + t.Fatalf("hook bead: %v", err) + } + + // Verify the bead is hooked + hookedBeads, err := b.List(beads.ListOptions{ + Status: beads.StatusHooked, + Assignee: agentID, + Priority: -1, + }) + if err != nil { + t.Fatalf("list hooked beads: %v", err) + } + + if len(hookedBeads) != 1 { + t.Errorf("expected 1 hooked bead, got %d", len(hookedBeads)) + } + + if len(hookedBeads) > 0 && hookedBeads[0].ID != issue.ID { + t.Errorf("hooked bead ID = %s, want %s", hookedBeads[0].ID, issue.ID) + } +} + +// TestHookSlot_Singleton verifies that only one bead can be hooked per agent. +func TestHookSlot_Singleton(t *testing.T) { + if _, err := exec.LookPath("bd"); err != nil { + t.Skip("bd not installed, skipping test") + } + + townRoot, polecatDir := setupHookTestTown(t) + _ = townRoot + + rigDir := filepath.Join(polecatDir, "..", "..", "mayor", "rig") + initBeadsDB(t, rigDir) + + b := beads.New(rigDir) + agentID := "gastown/polecats/toast" + status := beads.StatusHooked + + // Create and hook first bead + issue1, err := b.Create(beads.CreateOptions{ + Title: "First task", + Type: "task", + Priority: 2, + }) + if err != nil { + t.Fatalf("create first bead: %v", err) + } + + if err := b.Update(issue1.ID, beads.UpdateOptions{ + Status: &status, + Assignee: &agentID, + }); err != nil { + t.Fatalf("hook first bead: %v", err) + } + + // Create second bead + issue2, err := b.Create(beads.CreateOptions{ + Title: "Second task", + Type: "task", + Priority: 2, + }) + if err != nil { + t.Fatalf("create second bead: %v", err) + } + + // Hook second bead to same agent + if err := b.Update(issue2.ID, beads.UpdateOptions{ + Status: &status, + Assignee: &agentID, + }); err != nil { + t.Fatalf("hook second bead: %v", err) + } + + // Query hooked beads - both should be hooked (bd allows multiple) + // The singleton constraint is enforced by gt hook, not bd itself + hookedBeads, err := b.List(beads.ListOptions{ + Status: beads.StatusHooked, + Assignee: agentID, + Priority: -1, + }) + if err != nil { + t.Fatalf("list hooked beads: %v", err) + } + + t.Logf("Found %d hooked beads for agent %s", len(hookedBeads), agentID) + for _, h := range hookedBeads { + t.Logf(" - %s: %s", h.ID, h.Title) + } + + // The test documents actual behavior: bd allows multiple hooked beads + // The gt hook command enforces singleton behavior + if len(hookedBeads) != 2 { + t.Errorf("expected 2 hooked beads (bd allows multiple), got %d", len(hookedBeads)) + } +} + +// TestHookSlot_Unhook verifies that a bead can be unhooked by changing status. +func TestHookSlot_Unhook(t *testing.T) { + if _, err := exec.LookPath("bd"); err != nil { + t.Skip("bd not installed, skipping test") + } + + townRoot, polecatDir := setupHookTestTown(t) + _ = townRoot + + rigDir := filepath.Join(polecatDir, "..", "..", "mayor", "rig") + initBeadsDB(t, rigDir) + + b := beads.New(rigDir) + agentID := "gastown/polecats/toast" + + // Create and hook a bead + issue, err := b.Create(beads.CreateOptions{ + Title: "Task to unhook", + Type: "task", + Priority: 2, + }) + if err != nil { + t.Fatalf("create bead: %v", err) + } + + status := beads.StatusHooked + if err := b.Update(issue.ID, beads.UpdateOptions{ + Status: &status, + Assignee: &agentID, + }); err != nil { + t.Fatalf("hook bead: %v", err) + } + + // Unhook by setting status back to open + openStatus := "open" + if err := b.Update(issue.ID, beads.UpdateOptions{ + Status: &openStatus, + }); err != nil { + t.Fatalf("unhook bead: %v", err) + } + + // Verify no hooked beads remain + hookedBeads, err := b.List(beads.ListOptions{ + Status: beads.StatusHooked, + Assignee: agentID, + Priority: -1, + }) + if err != nil { + t.Fatalf("list hooked beads: %v", err) + } + + if len(hookedBeads) != 0 { + t.Errorf("expected 0 hooked beads after unhook, got %d", len(hookedBeads)) + } +} + +// TestHookSlot_DifferentAgents verifies that different agents can have different hooks. +func TestHookSlot_DifferentAgents(t *testing.T) { + if _, err := exec.LookPath("bd"); err != nil { + t.Skip("bd not installed, skipping test") + } + + townRoot, polecatDir := setupHookTestTown(t) + + // Create second polecat directory + polecat2Dir := filepath.Join(townRoot, "gastown", "polecats", "nux") + if err := os.MkdirAll(polecat2Dir, 0755); err != nil { + t.Fatalf("mkdir polecat2: %v", err) + } + + rigDir := filepath.Join(polecatDir, "..", "..", "mayor", "rig") + initBeadsDB(t, rigDir) + + b := beads.New(rigDir) + agent1 := "gastown/polecats/toast" + agent2 := "gastown/polecats/nux" + status := beads.StatusHooked + + // Create and hook bead to first agent + issue1, err := b.Create(beads.CreateOptions{ + Title: "Toast's task", + Type: "task", + Priority: 2, + }) + if err != nil { + t.Fatalf("create bead 1: %v", err) + } + + if err := b.Update(issue1.ID, beads.UpdateOptions{ + Status: &status, + Assignee: &agent1, + }); err != nil { + t.Fatalf("hook bead to agent1: %v", err) + } + + // Create and hook bead to second agent + issue2, err := b.Create(beads.CreateOptions{ + Title: "Nux's task", + Type: "task", + Priority: 2, + }) + if err != nil { + t.Fatalf("create bead 2: %v", err) + } + + if err := b.Update(issue2.ID, beads.UpdateOptions{ + Status: &status, + Assignee: &agent2, + }); err != nil { + t.Fatalf("hook bead to agent2: %v", err) + } + + // Verify each agent has exactly one hook + agent1Hooks, err := b.List(beads.ListOptions{ + Status: beads.StatusHooked, + Assignee: agent1, + Priority: -1, + }) + if err != nil { + t.Fatalf("list agent1 hooks: %v", err) + } + + agent2Hooks, err := b.List(beads.ListOptions{ + Status: beads.StatusHooked, + Assignee: agent2, + Priority: -1, + }) + if err != nil { + t.Fatalf("list agent2 hooks: %v", err) + } + + if len(agent1Hooks) != 1 { + t.Errorf("agent1 should have 1 hook, got %d", len(agent1Hooks)) + } + if len(agent2Hooks) != 1 { + t.Errorf("agent2 should have 1 hook, got %d", len(agent2Hooks)) + } + + // Verify correct assignment + if len(agent1Hooks) > 0 && agent1Hooks[0].ID != issue1.ID { + t.Errorf("agent1 hook ID = %s, want %s", agent1Hooks[0].ID, issue1.ID) + } + if len(agent2Hooks) > 0 && agent2Hooks[0].ID != issue2.ID { + t.Errorf("agent2 hook ID = %s, want %s", agent2Hooks[0].ID, issue2.ID) + } +} + +// TestHookSlot_HookPersistence verifies that hooks persist across beads object recreation. +func TestHookSlot_HookPersistence(t *testing.T) { + if _, err := exec.LookPath("bd"); err != nil { + t.Skip("bd not installed, skipping test") + } + + townRoot, polecatDir := setupHookTestTown(t) + _ = townRoot + + rigDir := filepath.Join(polecatDir, "..", "..", "mayor", "rig") + initBeadsDB(t, rigDir) + + agentID := "gastown/polecats/toast" + status := beads.StatusHooked + + // Create first beads instance and hook a bead + b1 := beads.New(rigDir) + issue, err := b1.Create(beads.CreateOptions{ + Title: "Persistent task", + Type: "task", + Priority: 2, + }) + if err != nil { + t.Fatalf("create bead: %v", err) + } + + if err := b1.Update(issue.ID, beads.UpdateOptions{ + Status: &status, + Assignee: &agentID, + }); err != nil { + t.Fatalf("hook bead: %v", err) + } + + // Create new beads instance (simulates session restart) + b2 := beads.New(rigDir) + + // Verify hook persists + hookedBeads, err := b2.List(beads.ListOptions{ + Status: beads.StatusHooked, + Assignee: agentID, + Priority: -1, + }) + if err != nil { + t.Fatalf("list hooked beads with new instance: %v", err) + } + + if len(hookedBeads) != 1 { + t.Errorf("expected hook to persist, got %d hooked beads", len(hookedBeads)) + } + + if len(hookedBeads) > 0 && hookedBeads[0].ID != issue.ID { + t.Errorf("persisted hook ID = %s, want %s", hookedBeads[0].ID, issue.ID) + } +} + +// TestHookSlot_StatusTransitions tests valid status transitions for hooked beads. +func TestHookSlot_StatusTransitions(t *testing.T) { + if _, err := exec.LookPath("bd"); err != nil { + t.Skip("bd not installed, skipping test") + } + + townRoot, polecatDir := setupHookTestTown(t) + _ = townRoot + + rigDir := filepath.Join(polecatDir, "..", "..", "mayor", "rig") + initBeadsDB(t, rigDir) + + b := beads.New(rigDir) + agentID := "gastown/polecats/toast" + + // Create a bead + issue, err := b.Create(beads.CreateOptions{ + Title: "Status transition test", + Type: "task", + Priority: 2, + }) + if err != nil { + t.Fatalf("create bead: %v", err) + } + + // Test transitions: open -> hooked -> open -> hooked -> closed + transitions := []struct { + name string + status string + }{ + {"hook", beads.StatusHooked}, + {"unhook", "open"}, + {"rehook", beads.StatusHooked}, + } + + for _, trans := range transitions { + t.Run(trans.name, func(t *testing.T) { + status := trans.status + opts := beads.UpdateOptions{Status: &status} + if trans.status == beads.StatusHooked { + opts.Assignee = &agentID + } + + if err := b.Update(issue.ID, opts); err != nil { + t.Errorf("transition to %s failed: %v", trans.status, err) + } + + // Verify status + updated, err := b.Show(issue.ID) + if err != nil { + t.Errorf("show after %s: %v", trans.name, err) + return + } + if updated.Status != trans.status { + t.Errorf("status after %s = %s, want %s", trans.name, updated.Status, trans.status) + } + }) + } + + // Finally close the bead + if err := b.Close(issue.ID); err != nil { + t.Errorf("close hooked bead: %v", err) + } + + // Verify it's closed + closed, err := b.Show(issue.ID) + if err != nil { + t.Fatalf("show closed bead: %v", err) + } + if closed.Status != "closed" { + t.Errorf("final status = %s, want closed", closed.Status) + } +} diff --git a/internal/cmd/install_integration_test.go b/internal/cmd/install_integration_test.go index 65d8b68f..41583335 100644 --- a/internal/cmd/install_integration_test.go +++ b/internal/cmd/install_integration_test.go @@ -3,6 +3,7 @@ package cmd import ( + "encoding/json" "os" "os/exec" "path/filepath"