From bdbba025f5a07e68cdc3355700c023d66c834247 Mon Sep 17 00:00:00 2001 From: Steve Yegge Date: Sun, 21 Dec 2025 01:14:05 -0800 Subject: [PATCH] fix(daemon): clear requesting_cycle after successful restart MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- internal/daemon/lifecycle.go | 86 +++++++++++++++++++++++++++--------- 1 file changed, 64 insertions(+), 22 deletions(-) diff --git a/internal/daemon/lifecycle.go b/internal/daemon/lifecycle.go index 2d2f880f..f72f1f04 100644 --- a/internal/daemon/lifecycle.go +++ b/internal/daemon/lifecycle.go @@ -12,15 +12,16 @@ import ( "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 { - ID string `json:"id"` - Title string `json:"title"` - Description string `json:"description"` - Sender string `json:"sender"` - Assignee string `json:"assignee"` - Priority int `json:"priority"` - Status string `json:"status"` + ID string `json:"id"` + From string `json:"from"` + To string `json:"to"` + Subject string `json:"subject"` + Body string `json:"body"` + Read bool `json:"read"` + Priority string `json:"priority"` + Type string `json:"type"` } // ProcessLifecycleRequests checks for and processes lifecycle requests from the deacon inbox. @@ -46,7 +47,7 @@ func (d *Daemon) ProcessLifecycleRequests() { } for _, msg := range messages { - if msg.Status == "closed" { + if msg.Read { continue // Already processed } @@ -71,37 +72,37 @@ func (d *Daemon) ProcessLifecycleRequests() { // parseLifecycleRequest extracts a lifecycle request from a message. func (d *Daemon) parseLifecycleRequest(msg *BeadsMessage) *LifecycleRequest { - // Look for lifecycle keywords in subject/title + // Look for lifecycle keywords in subject // Expected format: "LIFECYCLE: requesting " - title := strings.ToLower(msg.Title) + subject := strings.ToLower(msg.Subject) - if !strings.HasPrefix(title, "lifecycle:") { + if !strings.HasPrefix(subject, "lifecycle:") { return nil } var action LifecycleAction var from string - if strings.Contains(title, "cycle") || strings.Contains(title, "cycling") { + if strings.Contains(subject, "cycle") || strings.Contains(subject, "cycling") { action = ActionCycle - } else if strings.Contains(title, "restart") { + } else if strings.Contains(subject, "restart") { action = ActionRestart - } else if strings.Contains(title, "shutdown") || strings.Contains(title, "stop") { + } else if strings.Contains(subject, "shutdown") || strings.Contains(subject, "stop") { action = ActionShutdown } else { return nil } - // Extract role from title: "LIFECYCLE: requesting ..." + // Extract role from subject: "LIFECYCLE: requesting ..." // Parse between "lifecycle: " and " requesting" - parts := strings.Split(title, " requesting") + parts := strings.Split(subject, " requesting") if len(parts) >= 1 { rolePart := strings.TrimPrefix(parts[0], "lifecycle:") from = strings.TrimSpace(rolePart) } if from == "" { - from = msg.Sender // fallback + from = msg.From // fallback } return &LifecycleRequest{ @@ -159,6 +160,11 @@ func (d *Daemon) executeLifecycleAction(request *LifecycleRequest) error { return fmt.Errorf("restarting session: %w", err) } 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 default: @@ -259,10 +265,8 @@ func (d *Daemon) restartSession(sessionName, identity string) error { return fmt.Errorf("sending startup command: %w", err) } - // Prime after delay - if err := d.tmux.SendKeysDelayed(sessionName, "gt prime", 2000); err != nil { - d.logger.Printf("Warning: could not send prime: %v", err) - } + // Note: gt prime is handled by Claude's SessionStart hook, not injected here. + // Injecting it via SendKeysDelayed causes rogue text to appear in the terminal. return nil } @@ -342,6 +346,44 @@ func (d *Daemon) verifyAgentRequestingState(identity string, action LifecycleAct return nil } +// clearAgentRequestingState clears the requesting_=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_ 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. func (d *Daemon) identityToStateFile(identity string) string { switch identity {