Files
gastown/internal/beads/fields.go
gastown/crew/gus e0858096f6 fix(zfc): move stuck detection thresholds to agent-controlled config
Per ZFC principle: 'Let agents decide thresholds. Stuck is a judgment call.'

Changes:
- Add health check threshold fields to RoleConfig (ping_timeout,
  consecutive_failures, kill_cooldown, stuck_threshold)
- Add LoadStuckConfig() to read thresholds from hq-deacon-role bead
- Update patrol_check.go to use configurable stuck threshold
- Defaults remain as fallbacks when no role bead config exists

Agents can now configure their stuck detection by adding fields to their
role bead, e.g.:
  ping_timeout: 45s
  consecutive_failures: 5
  kill_cooldown: 10m
  stuck_threshold: 2h

Fixes: hq-2355b
2026-01-09 22:07:35 -08:00

667 lines
20 KiB
Go

// Package beads provides field parsing utilities for structured issue descriptions.
package beads
import (
"fmt"
"strings"
)
// Note: AgentFields, ParseAgentFields, FormatAgentDescription, and CreateAgentBead are in beads.go
// ParseAgentFieldsFromDescription is an alias for ParseAgentFields.
// Used by daemon for compatibility.
func ParseAgentFieldsFromDescription(description string) *AgentFields {
return ParseAgentFields(description)
}
// AttachmentFields holds the attachment info for pinned beads.
// These fields track which molecule is attached to a handoff/pinned bead.
type AttachmentFields struct {
AttachedMolecule string // Root issue ID of the attached molecule
AttachedAt string // ISO 8601 timestamp when attached
AttachedArgs string // Natural language args passed via gt sling --args (no-tmux mode)
DispatchedBy string // Agent ID that dispatched this work (for completion notification)
}
// ParseAttachmentFields extracts attachment fields from an issue's description.
// Fields are expected as "key: value" lines. Returns nil if no attachment fields found.
func ParseAttachmentFields(issue *Issue) *AttachmentFields {
if issue == nil || issue.Description == "" {
return nil
}
fields := &AttachmentFields{}
hasFields := false
for _, line := range strings.Split(issue.Description, "\n") {
line = strings.TrimSpace(line)
if line == "" {
continue
}
// Look for "key: value" pattern
colonIdx := strings.Index(line, ":")
if colonIdx == -1 {
continue
}
key := strings.TrimSpace(line[:colonIdx])
value := strings.TrimSpace(line[colonIdx+1:])
if value == "" {
continue
}
// Map keys to fields (case-insensitive)
switch strings.ToLower(key) {
case "attached_molecule", "attached-molecule", "attachedmolecule":
fields.AttachedMolecule = value
hasFields = true
case "attached_at", "attached-at", "attachedat":
fields.AttachedAt = value
hasFields = true
case "attached_args", "attached-args", "attachedargs":
fields.AttachedArgs = value
hasFields = true
case "dispatched_by", "dispatched-by", "dispatchedby":
fields.DispatchedBy = value
hasFields = true
}
}
if !hasFields {
return nil
}
return fields
}
// FormatAttachmentFields formats AttachmentFields as a string suitable for an issue description.
// Only non-empty fields are included.
func FormatAttachmentFields(fields *AttachmentFields) string {
if fields == nil {
return ""
}
var lines []string
if fields.AttachedMolecule != "" {
lines = append(lines, "attached_molecule: "+fields.AttachedMolecule)
}
if fields.AttachedAt != "" {
lines = append(lines, "attached_at: "+fields.AttachedAt)
}
if fields.AttachedArgs != "" {
lines = append(lines, "attached_args: "+fields.AttachedArgs)
}
if fields.DispatchedBy != "" {
lines = append(lines, "dispatched_by: "+fields.DispatchedBy)
}
return strings.Join(lines, "\n")
}
// SetAttachmentFields updates an issue's description with the given attachment fields.
// Existing attachment field lines are replaced; other content is preserved.
// Returns the new description string.
func SetAttachmentFields(issue *Issue, fields *AttachmentFields) string {
// Known attachment field keys (lowercase)
attachmentKeys := map[string]bool{
"attached_molecule": true,
"attached-molecule": true,
"attachedmolecule": true,
"attached_at": true,
"attached-at": true,
"attachedat": true,
"attached_args": true,
"attached-args": true,
"attachedargs": true,
"dispatched_by": true,
"dispatched-by": true,
"dispatchedby": true,
}
// Collect non-attachment lines from existing description
var otherLines []string
if issue != nil && issue.Description != "" {
for _, line := range strings.Split(issue.Description, "\n") {
trimmed := strings.TrimSpace(line)
if trimmed == "" {
// Preserve blank lines in content
otherLines = append(otherLines, line)
continue
}
// Check if this is an attachment field line
colonIdx := strings.Index(trimmed, ":")
if colonIdx == -1 {
otherLines = append(otherLines, line)
continue
}
key := strings.ToLower(strings.TrimSpace(trimmed[:colonIdx]))
if !attachmentKeys[key] {
otherLines = append(otherLines, line)
}
// Skip attachment field lines - they'll be replaced
}
}
// Build new description: attachment fields first, then other content
formatted := FormatAttachmentFields(fields)
// Trim trailing blank lines from other content
for len(otherLines) > 0 && strings.TrimSpace(otherLines[len(otherLines)-1]) == "" {
otherLines = otherLines[:len(otherLines)-1]
}
// Trim leading blank lines from other content
for len(otherLines) > 0 && strings.TrimSpace(otherLines[0]) == "" {
otherLines = otherLines[1:]
}
if formatted == "" {
return strings.Join(otherLines, "\n")
}
if len(otherLines) == 0 {
return formatted
}
return formatted + "\n\n" + strings.Join(otherLines, "\n")
}
// MRFields holds the structured fields for a merge-request issue.
// These fields are stored as key: value lines in the issue description.
type MRFields struct {
Branch string // Source branch name (e.g., "polecat/Nux/gt-xyz")
Target string // Target branch (e.g., "main" or "integration/gt-epic")
SourceIssue string // The work item being merged (e.g., "gt-xyz")
Worker string // Who did the work
Rig string // Which rig
MergeCommit string // SHA of merge commit (set on close)
CloseReason string // Reason for closing: merged, rejected, conflict, superseded
AgentBead string // Agent bead ID that created this MR (for traceability)
// Conflict resolution fields (for priority scoring)
RetryCount int // Number of conflict-resolution cycles
LastConflictSHA string // SHA of main when conflict occurred
ConflictTaskID string // Link to conflict-resolution task (if any)
// Convoy tracking (for priority scoring - convoy starvation prevention)
ConvoyID string // Parent convoy ID if part of a convoy
ConvoyCreatedAt string // Convoy creation time (ISO 8601) for starvation prevention
}
// ParseMRFields extracts structured merge-request fields from an issue's description.
// Fields are expected as "key: value" lines, with optional prose text mixed in.
// Returns nil if no MR fields are found.
func ParseMRFields(issue *Issue) *MRFields {
if issue == nil || issue.Description == "" {
return nil
}
fields := &MRFields{}
hasFields := false
for _, line := range strings.Split(issue.Description, "\n") {
line = strings.TrimSpace(line)
if line == "" {
continue
}
// Look for "key: value" pattern
colonIdx := strings.Index(line, ":")
if colonIdx == -1 {
continue
}
key := strings.TrimSpace(line[:colonIdx])
value := strings.TrimSpace(line[colonIdx+1:])
if value == "" {
continue
}
// Map keys to fields (case-insensitive)
switch strings.ToLower(key) {
case "branch":
fields.Branch = value
hasFields = true
case "target":
fields.Target = value
hasFields = true
case "source_issue", "source-issue", "sourceissue":
fields.SourceIssue = value
hasFields = true
case "worker":
fields.Worker = value
hasFields = true
case "rig":
fields.Rig = value
hasFields = true
case "merge_commit", "merge-commit", "mergecommit":
fields.MergeCommit = value
hasFields = true
case "close_reason", "close-reason", "closereason":
fields.CloseReason = value
hasFields = true
case "agent_bead", "agent-bead", "agentbead":
fields.AgentBead = value
hasFields = true
case "retry_count", "retry-count", "retrycount":
if n, err := parseIntField(value); err == nil {
fields.RetryCount = n
hasFields = true
}
case "last_conflict_sha", "last-conflict-sha", "lastconflictsha":
fields.LastConflictSHA = value
hasFields = true
case "conflict_task_id", "conflict-task-id", "conflicttaskid":
fields.ConflictTaskID = value
hasFields = true
case "convoy_id", "convoy-id", "convoyid", "convoy":
fields.ConvoyID = value
hasFields = true
case "convoy_created_at", "convoy-created-at", "convoycreatedat":
fields.ConvoyCreatedAt = value
hasFields = true
}
}
if !hasFields {
return nil
}
return fields
}
// parseIntField parses an integer from a string, returning 0 on error.
func parseIntField(s string) (int, error) {
var n int
_, err := fmt.Sscanf(s, "%d", &n)
return n, err
}
// FormatMRFields formats MRFields as a string suitable for an issue description.
// Only non-empty fields are included.
func FormatMRFields(fields *MRFields) string {
if fields == nil {
return ""
}
var lines []string
if fields.Branch != "" {
lines = append(lines, "branch: "+fields.Branch)
}
if fields.Target != "" {
lines = append(lines, "target: "+fields.Target)
}
if fields.SourceIssue != "" {
lines = append(lines, "source_issue: "+fields.SourceIssue)
}
if fields.Worker != "" {
lines = append(lines, "worker: "+fields.Worker)
}
if fields.Rig != "" {
lines = append(lines, "rig: "+fields.Rig)
}
if fields.MergeCommit != "" {
lines = append(lines, "merge_commit: "+fields.MergeCommit)
}
if fields.CloseReason != "" {
lines = append(lines, "close_reason: "+fields.CloseReason)
}
if fields.AgentBead != "" {
lines = append(lines, "agent_bead: "+fields.AgentBead)
}
if fields.RetryCount > 0 {
lines = append(lines, fmt.Sprintf("retry_count: %d", fields.RetryCount))
}
if fields.LastConflictSHA != "" {
lines = append(lines, "last_conflict_sha: "+fields.LastConflictSHA)
}
if fields.ConflictTaskID != "" {
lines = append(lines, "conflict_task_id: "+fields.ConflictTaskID)
}
if fields.ConvoyID != "" {
lines = append(lines, "convoy_id: "+fields.ConvoyID)
}
if fields.ConvoyCreatedAt != "" {
lines = append(lines, "convoy_created_at: "+fields.ConvoyCreatedAt)
}
return strings.Join(lines, "\n")
}
// SetMRFields updates an issue's description with the given MR fields.
// Existing MR field lines are replaced; other content is preserved.
// Returns the new description string.
func SetMRFields(issue *Issue, fields *MRFields) string {
if issue == nil {
return FormatMRFields(fields)
}
// Known MR field keys (lowercase)
mrKeys := map[string]bool{
"branch": true,
"target": true,
"source_issue": true,
"source-issue": true,
"sourceissue": true,
"worker": true,
"rig": true,
"merge_commit": true,
"merge-commit": true,
"mergecommit": true,
"close_reason": true,
"close-reason": true,
"closereason": true,
"agent_bead": true,
"agent-bead": true,
"agentbead": true,
"retry_count": true,
"retry-count": true,
"retrycount": true,
"last_conflict_sha": true,
"last-conflict-sha": true,
"lastconflictsha": true,
"conflict_task_id": true,
"conflict-task-id": true,
"conflicttaskid": true,
"convoy_id": true,
"convoy-id": true,
"convoyid": true,
"convoy": true,
"convoy_created_at": true,
"convoy-created-at": true,
"convoycreatedat": true,
}
// Collect non-MR lines from existing description
var otherLines []string
if issue.Description != "" {
for _, line := range strings.Split(issue.Description, "\n") {
trimmed := strings.TrimSpace(line)
if trimmed == "" {
// Preserve blank lines in content
otherLines = append(otherLines, line)
continue
}
// Check if this is an MR field line
colonIdx := strings.Index(trimmed, ":")
if colonIdx == -1 {
otherLines = append(otherLines, line)
continue
}
key := strings.ToLower(strings.TrimSpace(trimmed[:colonIdx]))
if !mrKeys[key] {
otherLines = append(otherLines, line)
}
// Skip MR field lines - they'll be replaced
}
}
// Build new description: MR fields first, then other content
formatted := FormatMRFields(fields)
// Trim trailing blank lines from other content
for len(otherLines) > 0 && strings.TrimSpace(otherLines[len(otherLines)-1]) == "" {
otherLines = otherLines[:len(otherLines)-1]
}
// Trim leading blank lines from other content
for len(otherLines) > 0 && strings.TrimSpace(otherLines[0]) == "" {
otherLines = otherLines[1:]
}
if formatted == "" {
return strings.Join(otherLines, "\n")
}
if len(otherLines) == 0 {
return formatted
}
return formatted + "\n\n" + strings.Join(otherLines, "\n")
}
// SynthesisFields holds structured fields for synthesis beads.
// These fields track the synthesis step in a convoy workflow.
type SynthesisFields struct {
ConvoyID string `json:"convoy_id"` // Parent convoy ID
ReviewID string `json:"review_id"` // Review ID for output paths
OutputPath string `json:"output_path"` // Path to synthesis output file
Formula string `json:"formula"` // Formula name (if from formula)
}
// ParseSynthesisFields extracts synthesis fields from an issue's description.
// Fields are expected as "key: value" lines. Returns nil if no fields found.
func ParseSynthesisFields(issue *Issue) *SynthesisFields {
if issue == nil || issue.Description == "" {
return nil
}
fields := &SynthesisFields{}
hasFields := false
for _, line := range strings.Split(issue.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 == "" {
continue
}
switch strings.ToLower(key) {
case "convoy", "convoy_id", "convoy-id":
fields.ConvoyID = value
hasFields = true
case "review_id", "review-id", "reviewid":
fields.ReviewID = value
hasFields = true
case "output_path", "output-path", "outputpath":
fields.OutputPath = value
hasFields = true
case "formula":
fields.Formula = value
hasFields = true
}
}
if !hasFields {
return nil
}
return fields
}
// FormatSynthesisFields formats SynthesisFields as a string for issue description.
func FormatSynthesisFields(fields *SynthesisFields) string {
if fields == nil {
return ""
}
var lines []string
if fields.ConvoyID != "" {
lines = append(lines, "convoy: "+fields.ConvoyID)
}
if fields.ReviewID != "" {
lines = append(lines, "review_id: "+fields.ReviewID)
}
if fields.OutputPath != "" {
lines = append(lines, "output_path: "+fields.OutputPath)
}
if fields.Formula != "" {
lines = append(lines, "formula: "+fields.Formula)
}
return strings.Join(lines, "\n")
}
// RoleConfig holds structured lifecycle configuration for role beads.
// These fields are stored as "key: value" lines in the role bead description.
// This enables agents to self-register their lifecycle configuration,
// replacing hardcoded identity string parsing in the daemon.
type RoleConfig struct {
// SessionPattern defines how to derive tmux session name.
// Supports placeholders: {rig}, {name}, {role}
// Examples: "hq-mayor", "hq-deacon", "gt-{rig}-{role}", "gt-{rig}-{name}"
SessionPattern string
// WorkDirPattern defines the working directory relative to town root.
// Supports placeholders: {town}, {rig}, {name}, {role}
// Examples: "{town}", "{town}/{rig}", "{town}/{rig}/polecats/{name}"
WorkDirPattern string
// NeedsPreSync indicates whether workspace needs git sync before starting.
// True for agents with persistent clones (refinery, crew, polecat).
NeedsPreSync bool
// StartCommand is the command to run after creating the session.
// Default: "exec claude --dangerously-skip-permissions"
StartCommand string
// EnvVars are additional environment variables to set in the session.
// Stored as "key=value" pairs.
EnvVars map[string]string
// Health check thresholds - per ZFC, agents control their own stuck detection.
// These allow the Deacon's patrol config to be agent-defined rather than hardcoded.
// PingTimeout is how long to wait for a health check response.
// Format: duration string (e.g., "30s", "1m"). Default: 30s.
PingTimeout string
// ConsecutiveFailures is how many failed health checks before force-kill.
// Default: 3.
ConsecutiveFailures int
// KillCooldown is the minimum time between force-kills of the same agent.
// Format: duration string (e.g., "5m", "10m"). Default: 5m.
KillCooldown string
// StuckThreshold is how long a wisp can be in_progress before considered stuck.
// Format: duration string (e.g., "1h", "30m"). Default: 1h.
StuckThreshold string
}
// ParseRoleConfig extracts RoleConfig from a role bead's description.
// Fields are expected as "key: value" lines. Returns nil if no config found.
func ParseRoleConfig(description string) *RoleConfig {
config := &RoleConfig{
EnvVars: make(map[string]string),
}
hasFields := false
for _, line := range strings.Split(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 "session_pattern", "session-pattern", "sessionpattern":
config.SessionPattern = value
hasFields = true
case "work_dir_pattern", "work-dir-pattern", "workdirpattern", "workdir_pattern":
config.WorkDirPattern = value
hasFields = true
case "needs_pre_sync", "needs-pre-sync", "needspresync":
config.NeedsPreSync = strings.ToLower(value) == "true"
hasFields = true
case "start_command", "start-command", "startcommand":
config.StartCommand = value
hasFields = true
case "env_var", "env-var", "envvar":
// Format: "env_var: KEY=VALUE"
if eqIdx := strings.Index(value, "="); eqIdx != -1 {
envKey := strings.TrimSpace(value[:eqIdx])
envVal := strings.TrimSpace(value[eqIdx+1:])
config.EnvVars[envKey] = envVal
hasFields = true
}
// Health check threshold fields (ZFC: agent-controlled)
case "ping_timeout", "ping-timeout", "pingtimeout":
config.PingTimeout = value
hasFields = true
case "consecutive_failures", "consecutive-failures", "consecutivefailures":
if n, err := parseIntValue(value); err == nil {
config.ConsecutiveFailures = n
hasFields = true
}
case "kill_cooldown", "kill-cooldown", "killcooldown":
config.KillCooldown = value
hasFields = true
case "stuck_threshold", "stuck-threshold", "stuckthreshold":
config.StuckThreshold = value
hasFields = true
}
}
if !hasFields {
return nil
}
return config
}
// parseIntValue parses an integer from a string value.
func parseIntValue(s string) (int, error) {
var n int
_, err := fmt.Sscanf(s, "%d", &n)
return n, err
}
// FormatRoleConfig formats RoleConfig as a string suitable for a role bead description.
// Only non-empty/non-default fields are included.
func FormatRoleConfig(config *RoleConfig) string {
if config == nil {
return ""
}
var lines []string
if config.SessionPattern != "" {
lines = append(lines, "session_pattern: "+config.SessionPattern)
}
if config.WorkDirPattern != "" {
lines = append(lines, "work_dir_pattern: "+config.WorkDirPattern)
}
if config.NeedsPreSync {
lines = append(lines, "needs_pre_sync: true")
}
if config.StartCommand != "" {
lines = append(lines, "start_command: "+config.StartCommand)
}
for k, v := range config.EnvVars {
lines = append(lines, "env_var: "+k+"="+v)
}
return strings.Join(lines, "\n")
}
// ExpandRolePattern expands placeholders in a pattern string.
// Supported placeholders: {town}, {rig}, {name}, {role}
func ExpandRolePattern(pattern, townRoot, rig, name, role string) string {
result := pattern
result = strings.ReplaceAll(result, "{town}", townRoot)
result = strings.ReplaceAll(result, "{rig}", rig)
result = strings.ReplaceAll(result, "{name}", name)
result = strings.ReplaceAll(result, "{role}", role)
return result
}