- Add checkStaleAgents() to detect agents reporting "running" but not updating - Add markAgentDead() to update agent bead state to "dead" - Integrate stale agent check into heartbeat cycle - DeadAgentTimeout set to 15 minutes This is a safety mechanism for agents that crash without updating their state. The daemon now marks them as dead so they can be restarted. Also fixes duplicate AgentFields declaration - now uses beads.go version with ParseAgentFieldsFromDescription alias in fields.go. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
336 lines
9.2 KiB
Go
336 lines
9.2 KiB
Go
// Package beads provides field parsing utilities for structured issue descriptions.
|
|
package beads
|
|
|
|
import "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)
|
|
}
|
|
|
|
// 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
|
|
}
|
|
}
|
|
|
|
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)
|
|
}
|
|
|
|
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,
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// 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
|
|
}
|
|
}
|
|
|
|
if !hasFields {
|
|
return nil
|
|
}
|
|
return fields
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
|
|
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,
|
|
}
|
|
|
|
// 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")
|
|
}
|