diff --git a/cmd/bd/agent.go b/cmd/bd/agent.go new file mode 100644 index 00000000..2f33d83e --- /dev/null +++ b/cmd/bd/agent.go @@ -0,0 +1,404 @@ +package main + +import ( + "encoding/json" + "fmt" + "os" + "strings" + "time" + + "github.com/spf13/cobra" + "github.com/steveyegge/beads/internal/rpc" + "github.com/steveyegge/beads/internal/types" + "github.com/steveyegge/beads/internal/ui" + "github.com/steveyegge/beads/internal/utils" +) + +// Valid agent states for state command (bd-uxlb) +var validAgentStates = map[string]bool{ + "idle": true, // Agent is waiting for work + "spawning": true, // Agent is starting up + "running": true, // Agent is executing (general) + "working": true, // Agent is actively working on a task + "stuck": true, // Agent is blocked and needs help + "done": true, // Agent completed its current work + "stopped": true, // Agent has cleanly shut down + "dead": true, // Agent died without clean shutdown +} + +var agentCmd = &cobra.Command{ + Use: "agent", + Short: "Manage agent bead state", + Long: `Manage state on agent beads for ZFC-compliant state reporting. + +Agent beads (type=agent) can self-report their state using these commands. +This enables the Witness and other monitoring systems to track agent health. + +States: + idle - Agent is waiting for work + spawning - Agent is starting up + running - Agent is executing (general) + working - Agent is actively working on a task + stuck - Agent is blocked and needs help + done - Agent completed its current work + stopped - Agent has cleanly shut down + dead - Agent died without clean shutdown (set by Witness via timeout) + +Examples: + bd agent state gt-emma running # Set emma's state to running + bd agent heartbeat gt-emma # Update emma's last_activity timestamp + bd agent show gt-emma # Show emma's agent details`, +} + +var agentStateCmd = &cobra.Command{ + Use: "state ", + Short: "Set agent state", + Long: `Set the state of an agent bead. + +This updates both the agent_state field and the last_activity timestamp. +Use this for ZFC-compliant state reporting. + +Valid states: idle, spawning, running, working, stuck, done, stopped, dead + +Examples: + bd agent state gt-emma running # Set state to running + bd agent state gt-mayor idle # Set state to idle`, + Args: cobra.ExactArgs(2), + RunE: runAgentState, +} + +var agentHeartbeatCmd = &cobra.Command{ + Use: "heartbeat ", + Short: "Update agent last_activity timestamp", + Long: `Update the last_activity timestamp of an agent bead without changing state. + +Use this for periodic heartbeats to indicate the agent is still alive. +The Witness can use this to detect dead agents via timeout. + +Examples: + bd agent heartbeat gt-emma # Update emma's last_activity + bd agent heartbeat gt-mayor # Update mayor's last_activity`, + Args: cobra.ExactArgs(1), + RunE: runAgentHeartbeat, +} + +var agentShowCmd = &cobra.Command{ + Use: "show ", + Short: "Show agent bead details", + Long: `Show detailed information about an agent bead. + +Displays agent-specific fields including state, last_activity, hook, and role. + +Examples: + bd agent show gt-emma # Show emma's agent details + bd agent show gt-mayor # Show mayor's agent details`, + Args: cobra.ExactArgs(1), + RunE: runAgentShow, +} + +func init() { + agentCmd.AddCommand(agentStateCmd) + agentCmd.AddCommand(agentHeartbeatCmd) + agentCmd.AddCommand(agentShowCmd) + rootCmd.AddCommand(agentCmd) +} + +func runAgentState(cmd *cobra.Command, args []string) error { + CheckReadonly("agent state") + + agentArg := args[0] + state := strings.ToLower(args[1]) + + // Validate state + if !validAgentStates[state] { + validList := []string{} + for s := range validAgentStates { + validList = append(validList, s) + } + return fmt.Errorf("invalid state %q; valid states: %s", state, strings.Join(validList, ", ")) + } + + ctx := rootCtx + + // Resolve agent ID + var agentID string + if daemonClient != nil { + resp, err := daemonClient.ResolveID(&rpc.ResolveIDArgs{ID: agentArg}) + if err != nil { + return fmt.Errorf("failed to resolve agent %s: %w", agentArg, err) + } + if err := json.Unmarshal(resp.Data, &agentID); err != nil { + return fmt.Errorf("parsing response: %w", err) + } + } else { + var err error + agentID, err = utils.ResolvePartialID(ctx, store, agentArg) + if err != nil { + return fmt.Errorf("failed to resolve agent %s: %w", agentArg, err) + } + } + + // Get agent bead to verify it's an agent + var agent *types.Issue + if daemonClient != nil { + resp, err := daemonClient.Show(&rpc.ShowArgs{ID: agentID}) + if err != nil { + return fmt.Errorf("agent bead not found: %s", agentID) + } + if err := json.Unmarshal(resp.Data, &agent); err != nil { + return fmt.Errorf("parsing response: %w", err) + } + } else { + var err error + agent, err = store.GetIssue(ctx, agentID) + if err != nil || agent == nil { + return fmt.Errorf("agent bead not found: %s", agentID) + } + } + + // Verify agent bead is actually an agent + if agent.IssueType != "agent" { + return fmt.Errorf("%s is not an agent bead (type=%s)", agentID, agent.IssueType) + } + + // Update state and last_activity + updateLastActivity := true + if daemonClient != nil { + _, err := daemonClient.Update(&rpc.UpdateArgs{ + ID: agentID, + AgentState: &state, + LastActivity: &updateLastActivity, + }) + if err != nil { + return fmt.Errorf("failed to update agent state: %w", err) + } + } else { + updates := map[string]interface{}{ + "agent_state": state, + "last_activity": time.Now(), + } + if err := store.UpdateIssue(ctx, agentID, updates, actor); err != nil { + return fmt.Errorf("failed to update agent state: %w", err) + } + } + + // Trigger auto-flush + if flushManager != nil { + flushManager.MarkDirty(false) + } + + if jsonOutput { + result := map[string]interface{}{ + "agent": agentID, + "agent_state": state, + "last_activity": time.Now().Format(time.RFC3339), + } + encoder := json.NewEncoder(os.Stdout) + encoder.SetIndent("", " ") + return encoder.Encode(result) + } + + fmt.Printf("%s %s state=%s\n", ui.RenderPass("✓"), agentID, state) + return nil +} + +func runAgentHeartbeat(cmd *cobra.Command, args []string) error { + CheckReadonly("agent heartbeat") + + agentArg := args[0] + + ctx := rootCtx + + // Resolve agent ID + var agentID string + if daemonClient != nil { + resp, err := daemonClient.ResolveID(&rpc.ResolveIDArgs{ID: agentArg}) + if err != nil { + return fmt.Errorf("failed to resolve agent %s: %w", agentArg, err) + } + if err := json.Unmarshal(resp.Data, &agentID); err != nil { + return fmt.Errorf("parsing response: %w", err) + } + } else { + var err error + agentID, err = utils.ResolvePartialID(ctx, store, agentArg) + if err != nil { + return fmt.Errorf("failed to resolve agent %s: %w", agentArg, err) + } + } + + // Get agent bead to verify it's an agent + var agent *types.Issue + if daemonClient != nil { + resp, err := daemonClient.Show(&rpc.ShowArgs{ID: agentID}) + if err != nil { + return fmt.Errorf("agent bead not found: %s", agentID) + } + if err := json.Unmarshal(resp.Data, &agent); err != nil { + return fmt.Errorf("parsing response: %w", err) + } + } else { + var err error + agent, err = store.GetIssue(ctx, agentID) + if err != nil || agent == nil { + return fmt.Errorf("agent bead not found: %s", agentID) + } + } + + // Verify agent bead is actually an agent + if agent.IssueType != "agent" { + return fmt.Errorf("%s is not an agent bead (type=%s)", agentID, agent.IssueType) + } + + // Update only last_activity + updateLastActivity := true + if daemonClient != nil { + _, err := daemonClient.Update(&rpc.UpdateArgs{ + ID: agentID, + LastActivity: &updateLastActivity, + }) + if err != nil { + return fmt.Errorf("failed to update agent heartbeat: %w", err) + } + } else { + updates := map[string]interface{}{ + "last_activity": time.Now(), + } + if err := store.UpdateIssue(ctx, agentID, updates, actor); err != nil { + return fmt.Errorf("failed to update agent heartbeat: %w", err) + } + } + + // Trigger auto-flush + if flushManager != nil { + flushManager.MarkDirty(false) + } + + if jsonOutput { + result := map[string]interface{}{ + "agent": agentID, + "last_activity": time.Now().Format(time.RFC3339), + } + encoder := json.NewEncoder(os.Stdout) + encoder.SetIndent("", " ") + return encoder.Encode(result) + } + + fmt.Printf("%s %s heartbeat\n", ui.RenderPass("✓"), agentID) + return nil +} + +func runAgentShow(cmd *cobra.Command, args []string) error { + agentArg := args[0] + + ctx := rootCtx + + // Resolve agent ID + var agentID string + if daemonClient != nil { + resp, err := daemonClient.ResolveID(&rpc.ResolveIDArgs{ID: agentArg}) + if err != nil { + return fmt.Errorf("failed to resolve agent %s: %w", agentArg, err) + } + if err := json.Unmarshal(resp.Data, &agentID); err != nil { + return fmt.Errorf("parsing response: %w", err) + } + } else { + var err error + agentID, err = utils.ResolvePartialID(ctx, store, agentArg) + if err != nil { + return fmt.Errorf("failed to resolve agent %s: %w", agentArg, err) + } + } + + // Get agent bead + var agent *types.Issue + if daemonClient != nil { + resp, err := daemonClient.Show(&rpc.ShowArgs{ID: agentID}) + if err != nil { + return fmt.Errorf("agent bead not found: %s", agentID) + } + if err := json.Unmarshal(resp.Data, &agent); err != nil { + return fmt.Errorf("parsing response: %w", err) + } + } else { + var err error + agent, err = store.GetIssue(ctx, agentID) + if err != nil || agent == nil { + return fmt.Errorf("agent bead not found: %s", agentID) + } + } + + // Verify agent bead is actually an agent + if agent.IssueType != "agent" { + return fmt.Errorf("%s is not an agent bead (type=%s)", agentID, agent.IssueType) + } + + if jsonOutput { + result := map[string]interface{}{ + "id": agentID, + "title": agent.Title, + "agent_state": emptyToNil(string(agent.AgentState)), + "last_activity": formatTimeOrNil(agent.LastActivity), + "hook_bead": emptyToNil(agent.HookBead), + "role_bead": emptyToNil(agent.RoleBead), + "role_type": emptyToNil(agent.RoleType), + "rig": emptyToNil(agent.Rig), + } + encoder := json.NewEncoder(os.Stdout) + encoder.SetIndent("", " ") + return encoder.Encode(result) + } + + // Human-readable output + fmt.Printf("Agent: %s\n", agentID) + fmt.Printf("Title: %s\n", agent.Title) + fmt.Println() + fmt.Println("State:") + if agent.AgentState != "" { + fmt.Printf(" agent_state: %s\n", agent.AgentState) + } else { + fmt.Println(" agent_state: (not set)") + } + if agent.LastActivity != nil { + fmt.Printf(" last_activity: %s (%s ago)\n", + agent.LastActivity.Format(time.RFC3339), + time.Since(*agent.LastActivity).Round(time.Second)) + } else { + fmt.Println(" last_activity: (not set)") + } + fmt.Println() + fmt.Println("Identity:") + if agent.RoleType != "" { + fmt.Printf(" role_type: %s\n", agent.RoleType) + } else { + fmt.Println(" role_type: (not set)") + } + if agent.Rig != "" { + fmt.Printf(" rig: %s\n", agent.Rig) + } else { + fmt.Println(" rig: (not set)") + } + fmt.Println() + fmt.Println("Slots:") + if agent.HookBead != "" { + fmt.Printf(" hook: %s\n", agent.HookBead) + } else { + fmt.Println(" hook: (empty)") + } + if agent.RoleBead != "" { + fmt.Printf(" role: %s\n", agent.RoleBead) + } else { + fmt.Println(" role: (empty)") + } + + return nil +} + +// formatTimeOrNil returns the time formatted as RFC3339 or nil if nil +func formatTimeOrNil(t *time.Time) interface{} { + if t == nil { + return nil + } + return t.Format(time.RFC3339) +} diff --git a/cmd/bd/agent_test.go b/cmd/bd/agent_test.go new file mode 100644 index 00000000..f9b7eb93 --- /dev/null +++ b/cmd/bd/agent_test.go @@ -0,0 +1,50 @@ +package main + +import ( + "testing" +) + +func TestValidAgentStates(t *testing.T) { + // Test that all expected states are valid + expectedStates := []string{ + "idle", "spawning", "running", "working", + "stuck", "done", "stopped", "dead", + } + + for _, state := range expectedStates { + if !validAgentStates[state] { + t.Errorf("expected state %q to be valid, but it's not", state) + } + } +} + +func TestInvalidAgentStates(t *testing.T) { + // Test that invalid states are rejected + invalidStates := []string{ + "starting", "waiting", "active", "inactive", + "unknown", "error", "RUNNING", "Idle", + } + + for _, state := range invalidStates { + if validAgentStates[state] { + t.Errorf("expected state %q to be invalid, but it's valid", state) + } + } +} + +func TestAgentStateCount(t *testing.T) { + // Verify we have exactly 8 valid states + expectedCount := 8 + actualCount := len(validAgentStates) + if actualCount != expectedCount { + t.Errorf("expected %d valid states, got %d", expectedCount, actualCount) + } +} + +func TestFormatTimeOrNil(t *testing.T) { + // Test nil case + result := formatTimeOrNil(nil) + if result != nil { + t.Errorf("expected nil for nil time, got %v", result) + } +} diff --git a/internal/rpc/protocol.go b/internal/rpc/protocol.go index c262f860..c0243011 100644 --- a/internal/rpc/protocol.go +++ b/internal/rpc/protocol.go @@ -129,6 +129,9 @@ type UpdateArgs struct { // Agent slot fields (gt-h5sza) HookBead *string `json:"hook_bead,omitempty"` // Current work on agent's hook (0..1) RoleBead *string `json:"role_bead,omitempty"` // Role definition bead for agent + // Agent state fields (bd-uxlb) + AgentState *string `json:"agent_state,omitempty"` // Agent state (idle|running|stuck|stopped|dead) + LastActivity *bool `json:"last_activity,omitempty"` // If true, update last_activity to now } // CloseArgs represents arguments for the close operation diff --git a/internal/rpc/server_issues_epics.go b/internal/rpc/server_issues_epics.go index 806cf27e..1436e4ec 100644 --- a/internal/rpc/server_issues_epics.go +++ b/internal/rpc/server_issues_epics.go @@ -108,6 +108,13 @@ func updatesFromArgs(a UpdateArgs) map[string]interface{} { if a.RoleBead != nil { u["role_bead"] = *a.RoleBead } + // Agent state fields (bd-uxlb) + if a.AgentState != nil { + u["agent_state"] = *a.AgentState + } + if a.LastActivity != nil && *a.LastActivity { + u["last_activity"] = time.Now() + } return u }