fix(daemon): clear requesting_cycle after successful restart

Bug: When daemon cycled a session, it verified requesting_cycle=true
but never cleared the flag after restart. This caused infinite
cycle loops on each heartbeat.

Also removed redundant SendKeysDelayed("gt prime") that injected
rogue text into terminal (SessionStart hook already handles priming).

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Steve Yegge
2025-12-21 01:14:05 -08:00
parent c4892938d0
commit bdbba025f5

View File

@@ -12,15 +12,16 @@ import (
"github.com/steveyegge/gastown/internal/tmux" "github.com/steveyegge/gastown/internal/tmux"
) )
// BeadsMessage represents a message from beads mail. // BeadsMessage represents a message from gt mail inbox --json.
type BeadsMessage struct { type BeadsMessage struct {
ID string `json:"id"` ID string `json:"id"`
Title string `json:"title"` From string `json:"from"`
Description string `json:"description"` To string `json:"to"`
Sender string `json:"sender"` Subject string `json:"subject"`
Assignee string `json:"assignee"` Body string `json:"body"`
Priority int `json:"priority"` Read bool `json:"read"`
Status string `json:"status"` Priority string `json:"priority"`
Type string `json:"type"`
} }
// ProcessLifecycleRequests checks for and processes lifecycle requests from the deacon inbox. // ProcessLifecycleRequests checks for and processes lifecycle requests from the deacon inbox.
@@ -46,7 +47,7 @@ func (d *Daemon) ProcessLifecycleRequests() {
} }
for _, msg := range messages { for _, msg := range messages {
if msg.Status == "closed" { if msg.Read {
continue // Already processed continue // Already processed
} }
@@ -71,37 +72,37 @@ func (d *Daemon) ProcessLifecycleRequests() {
// parseLifecycleRequest extracts a lifecycle request from a message. // parseLifecycleRequest extracts a lifecycle request from a message.
func (d *Daemon) parseLifecycleRequest(msg *BeadsMessage) *LifecycleRequest { func (d *Daemon) parseLifecycleRequest(msg *BeadsMessage) *LifecycleRequest {
// Look for lifecycle keywords in subject/title // Look for lifecycle keywords in subject
// Expected format: "LIFECYCLE: <role> requesting <action>" // Expected format: "LIFECYCLE: <role> requesting <action>"
title := strings.ToLower(msg.Title) subject := strings.ToLower(msg.Subject)
if !strings.HasPrefix(title, "lifecycle:") { if !strings.HasPrefix(subject, "lifecycle:") {
return nil return nil
} }
var action LifecycleAction var action LifecycleAction
var from string var from string
if strings.Contains(title, "cycle") || strings.Contains(title, "cycling") { if strings.Contains(subject, "cycle") || strings.Contains(subject, "cycling") {
action = ActionCycle action = ActionCycle
} else if strings.Contains(title, "restart") { } else if strings.Contains(subject, "restart") {
action = ActionRestart action = ActionRestart
} else if strings.Contains(title, "shutdown") || strings.Contains(title, "stop") { } else if strings.Contains(subject, "shutdown") || strings.Contains(subject, "stop") {
action = ActionShutdown action = ActionShutdown
} else { } else {
return nil return nil
} }
// Extract role from title: "LIFECYCLE: <role> requesting ..." // Extract role from subject: "LIFECYCLE: <role> requesting ..."
// Parse between "lifecycle: " and " requesting" // Parse between "lifecycle: " and " requesting"
parts := strings.Split(title, " requesting") parts := strings.Split(subject, " requesting")
if len(parts) >= 1 { if len(parts) >= 1 {
rolePart := strings.TrimPrefix(parts[0], "lifecycle:") rolePart := strings.TrimPrefix(parts[0], "lifecycle:")
from = strings.TrimSpace(rolePart) from = strings.TrimSpace(rolePart)
} }
if from == "" { if from == "" {
from = msg.Sender // fallback from = msg.From // fallback
} }
return &LifecycleRequest{ return &LifecycleRequest{
@@ -159,6 +160,11 @@ func (d *Daemon) executeLifecycleAction(request *LifecycleRequest) error {
return fmt.Errorf("restarting session: %w", err) return fmt.Errorf("restarting session: %w", err)
} }
d.logger.Printf("Restarted session %s", sessionName) d.logger.Printf("Restarted session %s", sessionName)
// Clear the requesting state so we don't cycle again
if err := d.clearAgentRequestingState(request.From, request.Action); err != nil {
d.logger.Printf("Warning: failed to clear agent state: %v", err)
}
return nil return nil
default: default:
@@ -259,10 +265,8 @@ func (d *Daemon) restartSession(sessionName, identity string) error {
return fmt.Errorf("sending startup command: %w", err) return fmt.Errorf("sending startup command: %w", err)
} }
// Prime after delay // Note: gt prime is handled by Claude's SessionStart hook, not injected here.
if err := d.tmux.SendKeysDelayed(sessionName, "gt prime", 2000); err != nil { // Injecting it via SendKeysDelayed causes rogue text to appear in the terminal.
d.logger.Printf("Warning: could not send prime: %v", err)
}
return nil return nil
} }
@@ -342,6 +346,44 @@ func (d *Daemon) verifyAgentRequestingState(identity string, action LifecycleAct
return nil return nil
} }
// clearAgentRequestingState clears the requesting_<action>=true flag after
// successfully completing a lifecycle action. This prevents the daemon from
// repeatedly cycling the same session.
func (d *Daemon) clearAgentRequestingState(identity string, action LifecycleAction) error {
stateFile := d.identityToStateFile(identity)
if stateFile == "" {
return fmt.Errorf("cannot determine state file for %s", identity)
}
data, err := os.ReadFile(stateFile)
if err != nil {
return fmt.Errorf("reading state file: %w", err)
}
var state map[string]interface{}
if err := json.Unmarshal(data, &state); err != nil {
return fmt.Errorf("parsing state: %w", err)
}
// Remove the requesting_<action> key
key := "requesting_" + string(action)
delete(state, key)
delete(state, "requesting_time") // Also clean up the timestamp
// Write back
newData, err := json.MarshalIndent(state, "", " ")
if err != nil {
return fmt.Errorf("marshaling state: %w", err)
}
if err := os.WriteFile(stateFile, newData, 0644); err != nil {
return fmt.Errorf("writing state file: %w", err)
}
d.logger.Printf("Cleared %s from agent %s state", key, identity)
return nil
}
// identityToStateFile maps an agent identity to its state.json file path. // identityToStateFile maps an agent identity to its state.json file path.
func (d *Daemon) identityToStateFile(identity string) string { func (d *Daemon) identityToStateFile(identity string) string {
switch identity { switch identity {