diff --git a/internal/daemon/daemon.go b/internal/daemon/daemon.go index df977e6b..bdf7c1ab 100644 --- a/internal/daemon/daemon.go +++ b/internal/daemon/daemon.go @@ -239,6 +239,20 @@ func (d *Daemon) nextMOTD() string { // If the session doesn't exist, it creates it and starts Claude. // The Deacon is the system's heartbeat - it must always be running. func (d *Daemon) ensureDeaconRunning() { + // Check agent bead state (ZFC: trust what agent reports) + // This is the preferred state source per gt-39ttg + beadState, beadErr := d.getAgentBeadState("gt-deacon") + if beadErr == nil { + // Agent bead exists - check its state + if beadState == "running" || beadState == "working" { + // Agent reports it's running - trust it + // (Future: gt-2hzl4 will add timeout fallback for stale state) + return + } + // Agent reports not running - fall through to tmux check + } + // If agent bead not found, fall through to legacy tmux detection + sessionExists, err := d.tmux.HasSession(DeaconSessionName) if err != nil { d.logger.Printf("Error checking Deacon session: %v", err) diff --git a/internal/daemon/lifecycle.go b/internal/daemon/lifecycle.go index b99a177a..2803fb49 100644 --- a/internal/daemon/lifecycle.go +++ b/internal/daemon/lifecycle.go @@ -161,7 +161,15 @@ func (d *Daemon) executeLifecycleAction(request *LifecycleRequest) error { return fmt.Errorf("state verification failed: %w", err) } - // Check if session exists + // Check agent bead state (ZFC: trust what agent reports) - gt-39ttg + agentBeadID := d.identityToAgentBeadID(request.From) + if agentBeadID != "" { + if beadState, err := d.getAgentBeadState(agentBeadID); err == nil { + d.logger.Printf("Agent bead %s reports state: %s", agentBeadID, beadState) + } + } + + // Check if session exists (legacy tmux detection - to be removed per gt-psuw7) running, err := d.tmux.HasSession(sessionName) if err != nil { return fmt.Errorf("checking session: %w", err) @@ -459,6 +467,153 @@ func (d *Daemon) identityToStateFile(identity string) string { } } +// AgentBeadInfo represents the parsed fields from an agent bead. +type AgentBeadInfo struct { + ID string `json:"id"` + Type string `json:"issue_type"` + State string // Parsed from description: agent_state + HookBead string // Parsed from description: hook_bead + RoleBead string // Parsed from description: role_bead + RoleType string // Parsed from description: role_type + Rig string // Parsed from description: rig + LastUpdate string `json:"updated_at"` +} + +// getAgentBeadState reads agent state from an agent bead. +// This is the ZFC-compliant way to get agent state: trust what agents report. +// Returns the agent_state field value (idle|running|stuck|stopped) or empty string if not found. +func (d *Daemon) getAgentBeadState(agentBeadID string) (string, error) { + info, err := d.getAgentBeadInfo(agentBeadID) + if err != nil { + return "", err + } + return info.State, nil +} + +// getAgentBeadInfo fetches and parses an agent bead by ID. +func (d *Daemon) getAgentBeadInfo(agentBeadID string) (*AgentBeadInfo, error) { + cmd := exec.Command("bd", "show", agentBeadID, "--json") + cmd.Dir = d.config.TownRoot + + output, err := cmd.Output() + if err != nil { + return nil, fmt.Errorf("bd show %s: %w", agentBeadID, err) + } + + // bd show --json returns an array with one element + var beads []struct { + ID string `json:"id"` + Type string `json:"issue_type"` + Description string `json:"description"` + UpdatedAt string `json:"updated_at"` + } + + if err := json.Unmarshal(output, &beads); err != nil { + return nil, fmt.Errorf("parsing bd show output: %w", err) + } + + if len(beads) == 0 { + return nil, fmt.Errorf("agent bead not found: %s", agentBeadID) + } + + bead := beads[0] + if bead.Type != "agent" { + return nil, fmt.Errorf("bead %s is not an agent bead (type=%s)", agentBeadID, bead.Type) + } + + // Parse agent fields from description (YAML-like format) + info := &AgentBeadInfo{ + ID: bead.ID, + Type: bead.Type, + LastUpdate: bead.UpdatedAt, + } + + for _, line := range strings.Split(bead.Description, "\n") { + line = strings.TrimSpace(line) + if line == "" { + continue + } + colonIdx := strings.Index(line, ":") + if colonIdx == -1 { + continue + } + key := strings.TrimSpace(line[:colonIdx]) + value := strings.TrimSpace(line[colonIdx+1:]) + if value == "" || value == "null" { + continue + } + + switch strings.ToLower(key) { + case "agent_state": + info.State = value + case "hook_bead": + info.HookBead = value + case "role_bead": + info.RoleBead = value + case "role_type": + info.RoleType = value + case "rig": + info.Rig = value + } + } + + return info, nil +} + +// identityToAgentBeadID maps a daemon identity to an agent bead ID. +// Examples: +// - "deacon" → "gt-deacon" +// - "mayor" → "gt-mayor" +// - "gastown-witness" → "gt-witness-gastown" +// - "gastown-refinery" → "gt-refinery-gastown" +func (d *Daemon) identityToAgentBeadID(identity string) string { + switch identity { + case "deacon": + return "gt-deacon" + case "mayor": + return "gt-mayor" + default: + // Pattern: -witness → gt-witness- + if strings.HasSuffix(identity, "-witness") { + rigName := strings.TrimSuffix(identity, "-witness") + return "gt-witness-" + rigName + } + // Pattern: -refinery → gt-refinery- + if strings.HasSuffix(identity, "-refinery") { + rigName := strings.TrimSuffix(identity, "-refinery") + return "gt-refinery-" + rigName + } + // Pattern: -crew- → gt-crew-- + if strings.Contains(identity, "-crew-") { + parts := strings.SplitN(identity, "-crew-", 2) + if len(parts) == 2 { + return "gt-crew-" + parts[0] + "-" + parts[1] + } + } + // Unknown format + return "" + } +} + +// isAgentRunningByBead checks if an agent reports itself as running via its agent bead. +// Returns (running, found) where found indicates if the agent bead exists. +func (d *Daemon) isAgentRunningByBead(identity string) (bool, bool) { + agentBeadID := d.identityToAgentBeadID(identity) + if agentBeadID == "" { + return false, false + } + + state, err := d.getAgentBeadState(agentBeadID) + if err != nil { + // Agent bead not found or not readable + return false, false + } + + // Consider "running" or "working" as running states + running := state == "running" || state == "working" + return running, true +} + // identityToBDActor converts a daemon identity (with dashes) to BD_ACTOR format (with slashes). // Examples: // - "mayor" → "mayor"