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 <noreply@anthropic.com>
This commit is contained in:
dj-oyu 2026-02-24 07:38:32 +09:00
parent dcbb88066b
commit 666f56d2ee
3 changed files with 41 additions and 8 deletions

View file

@ -123,6 +123,9 @@ func gatewayCmd() {
return tools.SilentResult(response) return tools.SilentResult(response)
}) })
// Reset heartbeat suppression when a real user message arrives
agentLoop.OnUserMessage = heartbeatService.ResetSuppression
channelManager, err := channels.NewManager(cfg, msgBus) channelManager, err := channels.NewManager(cfg, msgBus)
if err != nil { if err != nil {
fmt.Printf("Error creating channel manager: %v\n", err) fmt.Printf("Error creating channel manager: %v\n", err)

View file

@ -91,6 +91,7 @@ type AgentLoop struct {
activeTasks sync.Map // sessionKey → *activeTask activeTasks sync.Map // sessionKey → *activeTask
sessions *SessionTracker sessions *SessionTracker
OnStateChange func() // called on plan/session/skills mutations 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 // 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) 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 // Expand /skill command: inject SKILL.md content into message, then continue to LLM
var expansionCompact string var expansionCompact string
if expanded, compact, ok := al.expandSkillCommand(msg); ok { if expanded, compact, ok := al.expandSkillCommand(msg); ok {

View file

@ -24,6 +24,7 @@ import (
const ( const (
minIntervalMinutes = 5 minIntervalMinutes = 5
defaultIntervalMinutes = 30 defaultIntervalMinutes = 30
suppressionTTL = 24 * time.Hour
) )
// HeartbeatHandler is the function type for handling heartbeat. // 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 // HeartbeatService manages periodic heartbeat checks
type HeartbeatService struct { type HeartbeatService struct {
workspace string workspace string
bus *bus.MessageBus bus *bus.MessageBus
state *state.Manager state *state.Manager
handler HeartbeatHandler handler HeartbeatHandler
interval time.Duration interval time.Duration
enabled bool enabled bool
mu sync.RWMutex mu sync.RWMutex
stopChan chan struct{} stopChan chan struct{}
lastNotifiedAt time.Time // when a non-silent result was last sent to user
} }
// NewHeartbeatService creates a new heartbeat service // NewHeartbeatService creates a new heartbeat service
@ -76,6 +78,15 @@ func (hs *HeartbeatService) SetHandler(handler HeartbeatHandler) {
hs.handler = handler 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 // Start begins the heartbeat service
func (hs *HeartbeatService) Start() error { func (hs *HeartbeatService) Start() error {
hs.mu.Lock() hs.mu.Lock()
@ -205,6 +216,15 @@ func (hs *HeartbeatService) executeHeartbeat() {
return 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 // Send result to user
if result.ForUser != "" { if result.ForUser != "" {
hs.sendResponse(result.ForUser) hs.sendResponse(result.ForUser)
@ -212,6 +232,10 @@ func (hs *HeartbeatService) executeHeartbeat() {
hs.sendResponse(result.ForLLM) hs.sendResponse(result.ForLLM)
} }
hs.mu.Lock()
hs.lastNotifiedAt = time.Now()
hs.mu.Unlock()
hs.logInfo("Heartbeat completed: %s", result.ForLLM) hs.logInfo("Heartbeat completed: %s", result.ForLLM)
} }