From 666f56d2eeefc4d7c7a48ba314328547ea64d3c6 Mon Sep 17 00:00:00 2001 From: dj-oyu <68707227+dj-oyu@users.noreply.github.com> Date: Tue, 24 Feb 2026 07:38:32 +0900 Subject: [PATCH] fix: suppress duplicate heartbeat notifications with 24h TTL Prevent heartbeat from spamming the user with repeated interview/review messages. After sending a non-silent result, further notifications are suppressed for 24 hours. Suppression resets when a real user message arrives via OnUserMessage callback. Co-Authored-By: Claude Opus 4.6 --- cmd/picoclaw/cmd_gateway.go | 3 +++ pkg/agent/loop.go | 6 ++++++ pkg/heartbeat/service.go | 40 +++++++++++++++++++++++++++++-------- 3 files changed, 41 insertions(+), 8 deletions(-) diff --git a/cmd/picoclaw/cmd_gateway.go b/cmd/picoclaw/cmd_gateway.go index 315b4e4a0..8757e667c 100644 --- a/cmd/picoclaw/cmd_gateway.go +++ b/cmd/picoclaw/cmd_gateway.go @@ -123,6 +123,9 @@ func gatewayCmd() { return tools.SilentResult(response) }) + // Reset heartbeat suppression when a real user message arrives + agentLoop.OnUserMessage = heartbeatService.ResetSuppression + channelManager, err := channels.NewManager(cfg, msgBus) if err != nil { fmt.Printf("Error creating channel manager: %v\n", err) diff --git a/pkg/agent/loop.go b/pkg/agent/loop.go index e3194869f..a4181a491 100644 --- a/pkg/agent/loop.go +++ b/pkg/agent/loop.go @@ -91,6 +91,7 @@ type AgentLoop struct { activeTasks sync.Map // sessionKey → *activeTask sessions *SessionTracker OnStateChange func() // called on plan/session/skills mutations + OnUserMessage func() // called when a real user message is processed } // processOptions configures how a message is processed @@ -533,6 +534,11 @@ func (al *AgentLoop) processMessage(ctx context.Context, msg bus.InboundMessage) return al.processSystemMessage(ctx, msg) } + // Notify listeners that a real user message arrived (e.g. reset heartbeat suppression) + if al.OnUserMessage != nil { + al.OnUserMessage() + } + // Expand /skill command: inject SKILL.md content into message, then continue to LLM var expansionCompact string if expanded, compact, ok := al.expandSkillCommand(msg); ok { diff --git a/pkg/heartbeat/service.go b/pkg/heartbeat/service.go index 75d6248b9..e1d08a908 100644 --- a/pkg/heartbeat/service.go +++ b/pkg/heartbeat/service.go @@ -24,6 +24,7 @@ import ( const ( minIntervalMinutes = 5 defaultIntervalMinutes = 30 + suppressionTTL = 24 * time.Hour ) // HeartbeatHandler is the function type for handling heartbeat. @@ -33,14 +34,15 @@ type HeartbeatHandler func(prompt, channel, chatID string) *tools.ToolResult // HeartbeatService manages periodic heartbeat checks type HeartbeatService struct { - workspace string - bus *bus.MessageBus - state *state.Manager - handler HeartbeatHandler - interval time.Duration - enabled bool - mu sync.RWMutex - stopChan chan struct{} + workspace string + bus *bus.MessageBus + state *state.Manager + handler HeartbeatHandler + interval time.Duration + enabled bool + mu sync.RWMutex + stopChan chan struct{} + lastNotifiedAt time.Time // when a non-silent result was last sent to user } // NewHeartbeatService creates a new heartbeat service @@ -76,6 +78,15 @@ func (hs *HeartbeatService) SetHandler(handler HeartbeatHandler) { hs.handler = handler } +// ResetSuppression clears the notification suppression so the next +// non-silent heartbeat result will be delivered to the user again. +// Typically called when a user message arrives. +func (hs *HeartbeatService) ResetSuppression() { + hs.mu.Lock() + defer hs.mu.Unlock() + hs.lastNotifiedAt = time.Time{} +} + // Start begins the heartbeat service func (hs *HeartbeatService) Start() error { hs.mu.Lock() @@ -205,6 +216,15 @@ func (hs *HeartbeatService) executeHeartbeat() { return } + // Suppress duplicate notifications within the TTL window + hs.mu.RLock() + suppressed := !hs.lastNotifiedAt.IsZero() && time.Since(hs.lastNotifiedAt) < suppressionTTL + hs.mu.RUnlock() + if suppressed { + hs.logInfo("Heartbeat suppressed (already notified user recently)") + return + } + // Send result to user if result.ForUser != "" { hs.sendResponse(result.ForUser) @@ -212,6 +232,10 @@ func (hs *HeartbeatService) executeHeartbeat() { hs.sendResponse(result.ForLLM) } + hs.mu.Lock() + hs.lastNotifiedAt = time.Now() + hs.mu.Unlock() + hs.logInfo("Heartbeat completed: %s", result.ForLLM) }