Daemon reads agent bead state (gt-39ttg)
- Add getAgentBeadState() and getAgentBeadInfo() to read agent state from beads - Add identityToAgentBeadID() to map daemon identities to agent bead IDs - Update ensureDeaconRunning() to check agent bead state first (ZFC compliant) - Add agent bead state logging in executeLifecycleAction() This is the first step toward ZFC-compliant state detection. Dependent tasks: - gt-psuw7: Remove PID/tmux state inference - gt-2hzl4: Add timeout fallback for dead agents 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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: <rig>-witness → gt-witness-<rig>
|
||||
if strings.HasSuffix(identity, "-witness") {
|
||||
rigName := strings.TrimSuffix(identity, "-witness")
|
||||
return "gt-witness-" + rigName
|
||||
}
|
||||
// Pattern: <rig>-refinery → gt-refinery-<rig>
|
||||
if strings.HasSuffix(identity, "-refinery") {
|
||||
rigName := strings.TrimSuffix(identity, "-refinery")
|
||||
return "gt-refinery-" + rigName
|
||||
}
|
||||
// Pattern: <rig>-crew-<name> → gt-crew-<rig>-<name>
|
||||
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"
|
||||
|
||||
Reference in New Issue
Block a user