fix(hook): Read hook_bead from database column, not description (gt-7m33w)

The hook discovery code was reading hook_bead from the agent bead's
description field (parsed via ParseAgentFieldsFromDescription), but
the slot update code writes to the hook_bead database column via
'bd slot set'. This mismatch caused polecats to see stale hook values
from the description instead of the current value from the database.

Fixed in:
- molecule_status.go: Use agentBead.HookBead instead of parsing description
- status.go: Use issue.HookBead directly
- lifecycle.go: Update all GUPP and orphan detection to read from
  database columns instead of parsing description

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
gastown/polecats/furiosa
2026-01-01 17:38:39 -08:00
committed by Steve Yegge
parent 65a5c7888f
commit 278abf15d6
3 changed files with 44 additions and 41 deletions

View File

@@ -322,11 +322,13 @@ func runMoleculeStatus(cmd *cobra.Command, args []string) error {
if err == nil && agentBead != nil && agentBead.Type == "agent" { if err == nil && agentBead != nil && agentBead.Type == "agent" {
status.AgentBeadID = agentBeadID status.AgentBeadID = agentBeadID
// Parse hook_bead from the agent bead's description // Read hook_bead from the agent bead's database field (not description!)
agentFields := beads.ParseAgentFieldsFromDescription(agentBead.Description) // The hook_bead column is updated by `bd slot set` in UpdateAgentState.
if agentFields != nil && agentFields.HookBead != "" { // IMPORTANT: Don't use ParseAgentFieldsFromDescription - the description
// field may contain stale data, causing the wrong issue to be hooked.
if agentBead.HookBead != "" {
// Fetch the bead on the hook // Fetch the bead on the hook
hookBead, err = b.Show(agentFields.HookBead) hookBead, err = b.Show(agentBead.HookBead)
if err != nil { if err != nil {
// Hook bead referenced but not found - report error but continue // Hook bead referenced but not found - report error but continue
hookBead = nil hookBead = nil

View File

@@ -174,11 +174,11 @@ func runStatus(cmd *cobra.Command, args []string) error {
} }
// Pre-fetch all hook beads (referenced in agent beads) in a single query // Pre-fetch all hook beads (referenced in agent beads) in a single query
// Use the HookBead field from the database column, not parsed from description.
var allHookIDs []string var allHookIDs []string
for _, issue := range allAgentBeads { for _, issue := range allAgentBeads {
fields := beads.ParseAgentFields(issue.Description) if issue.HookBead != "" {
if fields != nil && fields.HookBead != "" { allHookIDs = append(allHookIDs, issue.HookBead)
allHookIDs = append(allHookIDs, fields.HookBead)
} }
} }
allHookBeads, _ := agentBeads.ShowMultiple(allHookIDs) allHookBeads, _ := agentBeads.ShowMultiple(allHookIDs)

View File

@@ -654,6 +654,8 @@ func (d *Daemon) getAgentBeadInfo(agentBeadID string) (*AgentBeadInfo, error) {
Type string `json:"issue_type"` Type string `json:"issue_type"`
Description string `json:"description"` Description string `json:"description"`
UpdatedAt string `json:"updated_at"` UpdatedAt string `json:"updated_at"`
HookBead string `json:"hook_bead"` // Read from database column
AgentState string `json:"agent_state"` // Read from database column
} }
if err := json.Unmarshal(output, &issues); err != nil { if err := json.Unmarshal(output, &issues); err != nil {
@@ -669,7 +671,7 @@ func (d *Daemon) getAgentBeadInfo(agentBeadID string) (*AgentBeadInfo, error) {
return nil, fmt.Errorf("bead %s is not an agent bead (type=%s)", agentBeadID, issue.Type) return nil, fmt.Errorf("bead %s is not an agent bead (type=%s)", agentBeadID, issue.Type)
} }
// Use shared parsing from beads package // Parse agent fields from description for role/state info
fields := beads.ParseAgentFieldsFromDescription(issue.Description) fields := beads.ParseAgentFieldsFromDescription(issue.Description)
info := &AgentBeadInfo{ info := &AgentBeadInfo{
@@ -680,12 +682,15 @@ func (d *Daemon) getAgentBeadInfo(agentBeadID string) (*AgentBeadInfo, error) {
if fields != nil { if fields != nil {
info.State = fields.AgentState info.State = fields.AgentState
info.HookBead = fields.HookBead
info.RoleBead = fields.RoleBead info.RoleBead = fields.RoleBead
info.RoleType = fields.RoleType info.RoleType = fields.RoleType
info.Rig = fields.Rig info.Rig = fields.Rig
} }
// Use HookBead from database column directly (not from description)
// The description may contain stale data - the slot is the source of truth.
info.HookBead = issue.HookBead
return info, nil return info, nil
} }
@@ -872,6 +877,8 @@ func (d *Daemon) checkRigGUPPViolations(rigName string) {
Type string `json:"issue_type"` Type string `json:"issue_type"`
Description string `json:"description"` Description string `json:"description"`
UpdatedAt string `json:"updated_at"` UpdatedAt string `json:"updated_at"`
HookBead string `json:"hook_bead"` // Read from database column, not description
AgentState string `json:"agent_state"`
} }
if err := json.Unmarshal(output, &agents); err != nil { if err := json.Unmarshal(output, &agents); err != nil {
@@ -885,19 +892,14 @@ func (d *Daemon) checkRigGUPPViolations(rigName string) {
continue continue
} }
// Parse agent fields
fields := beads.ParseAgentFieldsFromDescription(agent.Description)
if fields == nil {
continue
}
// Check if agent has work on hook // Check if agent has work on hook
if fields.HookBead == "" { // Use HookBead from database column directly (not parsed from description)
if agent.HookBead == "" {
continue // No hooked work - no GUPP violation possible continue // No hooked work - no GUPP violation possible
} }
// Check if agent is actively working // Check if agent is actively working
if fields.AgentState == "working" || fields.AgentState == "running" { if agent.AgentState == "working" || agent.AgentState == "running" {
// Check when the agent bead was last updated // Check when the agent bead was last updated
updatedAt, err := time.Parse(time.RFC3339, agent.UpdatedAt) updatedAt, err := time.Parse(time.RFC3339, agent.UpdatedAt)
if err != nil { if err != nil {
@@ -907,10 +909,10 @@ func (d *Daemon) checkRigGUPPViolations(rigName string) {
age := time.Since(updatedAt) age := time.Since(updatedAt)
if age > GUPPViolationTimeout { if age > GUPPViolationTimeout {
d.logger.Printf("GUPP violation: agent %s has hook_bead=%s but hasn't updated in %v (timeout: %v)", d.logger.Printf("GUPP violation: agent %s has hook_bead=%s but hasn't updated in %v (timeout: %v)",
agent.ID, fields.HookBead, age.Round(time.Minute), GUPPViolationTimeout) agent.ID, agent.HookBead, age.Round(time.Minute), GUPPViolationTimeout)
// Notify the witness for this rig // Notify the witness for this rig
d.notifyWitnessOfGUPP(rigName, agent.ID, fields.HookBead, age) d.notifyWitnessOfGUPP(rigName, agent.ID, agent.HookBead, age)
} }
} }
} }
@@ -948,28 +950,31 @@ func (d *Daemon) checkOrphanedWork() {
} }
// For each dead agent, check if they have hooked work // For each dead agent, check if they have hooked work
// Use HookBead from database column directly (not parsed from description)
for _, agent := range deadAgents { for _, agent := range deadAgents {
fields := beads.ParseAgentFieldsFromDescription(agent.Description) if agent.HookBead == "" {
if fields == nil || fields.HookBead == "" {
continue // No hooked work to orphan continue // No hooked work to orphan
} }
d.logger.Printf("Orphaned work detected: agent %s is dead but has hook_bead=%s", d.logger.Printf("Orphaned work detected: agent %s is dead but has hook_bead=%s",
agent.ID, fields.HookBead) agent.ID, agent.HookBead)
// Determine the rig from the agent ID (gt-polecat-<rig>-<name>) // Determine the rig from the agent ID (gt-polecat-<rig>-<name>)
rigName := d.extractRigFromAgentID(agent.ID) rigName := d.extractRigFromAgentID(agent.ID)
if rigName != "" { if rigName != "" {
d.notifyWitnessOfOrphanedWork(rigName, agent.ID, fields.HookBead) d.notifyWitnessOfOrphanedWork(rigName, agent.ID, agent.HookBead)
} }
} }
} }
// deadAgentInfo holds info about a dead agent for orphaned work detection.
type deadAgentInfo struct {
ID string
HookBead string // Read from database column, not description
}
// getDeadAgents returns all agent beads with state=dead. // getDeadAgents returns all agent beads with state=dead.
func (d *Daemon) getDeadAgents() []struct { func (d *Daemon) getDeadAgents() []deadAgentInfo {
ID string
Description string
} {
cmd := exec.Command("bd", "list", "--type=agent", "--json") cmd := exec.Command("bd", "list", "--type=agent", "--json")
cmd.Dir = d.config.TownRoot cmd.Dir = d.config.TownRoot
@@ -979,27 +984,23 @@ func (d *Daemon) getDeadAgents() []struct {
} }
var agents []struct { var agents []struct {
ID string `json:"id"` ID string `json:"id"`
Type string `json:"issue_type"` Type string `json:"issue_type"`
Description string `json:"description"` HookBead string `json:"hook_bead"` // Read from database column
AgentState string `json:"agent_state"` // Read from database column
} }
if err := json.Unmarshal(output, &agents); err != nil { if err := json.Unmarshal(output, &agents); err != nil {
return nil return nil
} }
var dead []struct { var dead []deadAgentInfo
ID string
Description string
}
for _, agent := range agents { for _, agent := range agents {
fields := beads.ParseAgentFieldsFromDescription(agent.Description) if agent.AgentState == "dead" {
if fields != nil && fields.AgentState == "dead" { dead = append(dead, deadAgentInfo{
dead = append(dead, struct { ID: agent.ID,
ID string HookBead: agent.HookBead,
Description string })
}{agent.ID, agent.Description})
} }
} }