fix: skip heartbeat when agent has an active turn

- Check activeTurnStates before processing heartbeat
- Return HEARTBEAT_OK immediately when agent is busy
- Log the skipped heartbeat with the active session key
- Add 4 tests covering busy, idle, multi-session, and clear scenarios
This commit is contained in:
Kristjan Kruus 2026-03-24 20:12:36 +02:00
parent f2f6987f00
commit 932e907f3c
2 changed files with 111 additions and 0 deletions

View file

@ -1225,10 +1225,20 @@ func (al *AgentLoop) ProcessDirectWithChannel(
// ProcessHeartbeat processes a heartbeat request without session history. // ProcessHeartbeat processes a heartbeat request without session history.
// Each heartbeat is independent and doesn't accumulate context. // Each heartbeat is independent and doesn't accumulate context.
// If the agent is already processing another turn, the heartbeat is skipped.
func (al *AgentLoop) ProcessHeartbeat( func (al *AgentLoop) ProcessHeartbeat(
ctx context.Context, ctx context.Context,
content, channel, chatID string, content, channel, chatID string,
) (string, error) { ) (string, error) {
// Skip heartbeat if the agent is busy with another turn
if ts := al.getAnyActiveTurnState(); ts != nil {
logger.InfoCF("heartbeat", "Skipping heartbeat — agent is busy",
map[string]any{
"active_session": ts.sessionKey,
})
return "HEARTBEAT_OK", nil
}
if err := al.ensureHooksInitialized(ctx); err != nil { if err := al.ensureHooksInitialized(ctx); err != nil {
return "", err return "", err
} }

View file

@ -2132,3 +2132,104 @@ func TestFilterClientWebSearch_EmptyInput(t *testing.T) {
t.Fatalf("len(result) = %d, want 0", len(result)) t.Fatalf("len(result) = %d, want 0", len(result))
} }
} }
func TestProcessHeartbeat_SkipsWhenAgentBusy(t *testing.T) {
al, _, _, _, cleanup := newTestAgentLoop(t)
defer cleanup()
// Simulate an active turn by storing a turnState directly
busyTS := &turnState{
sessionKey: "user-chat",
agentID: "main",
phase: TurnPhaseRunning,
startedAt: time.Now(),
}
al.activeTurnStates.Store(busyTS.sessionKey, busyTS)
defer al.activeTurnStates.Delete(busyTS.sessionKey)
resp, err := al.ProcessHeartbeat(context.Background(), "heartbeat prompt", "telegram", "chat123")
if err != nil {
t.Fatalf("ProcessHeartbeat returned error: %v", err)
}
if resp != "HEARTBEAT_OK" {
t.Fatalf("expected HEARTBEAT_OK when busy, got %q", resp)
}
}
func TestProcessHeartbeat_RunsWhenIdle(t *testing.T) {
al, _, _, _, cleanup := newTestAgentLoop(t)
defer cleanup()
// Verify no active turns exist
if ts := al.getAnyActiveTurnState(); ts != nil {
t.Fatal("expected no active turns in fresh AgentLoop")
}
// ProcessHeartbeat should proceed (not return early with HEARTBEAT_OK skip)
// It will either succeed or fail depending on agent setup, but it should NOT
// return the skip sentinel.
resp, err := al.ProcessHeartbeat(context.Background(), "heartbeat prompt", "cli", "direct")
// With no default agent registered, it returns an error — that's fine,
// the point is it didn't skip due to busy check.
if err != nil && resp == "HEARTBEAT_OK" {
t.Fatal("ProcessHeartbeat skipped despite no active turns")
}
}
func TestProcessHeartbeat_SkipsForAnyActiveSession(t *testing.T) {
al, _, _, _, cleanup := newTestAgentLoop(t)
defer cleanup()
// Register multiple active turns to cover the sync.Map iteration
for _, key := range []string{"session-a", "session-b"} {
ts := &turnState{
sessionKey: key,
agentID: "main",
phase: TurnPhaseRunning,
startedAt: time.Now(),
}
al.activeTurnStates.Store(key, ts)
defer al.activeTurnStates.Delete(key)
}
resp, err := al.ProcessHeartbeat(context.Background(), "heartbeat prompt", "telegram", "chat123")
if err != nil {
t.Fatalf("ProcessHeartbeat returned error: %v", err)
}
if resp != "HEARTBEAT_OK" {
t.Fatalf("expected HEARTBEAT_OK when agent has active turns, got %q", resp)
}
}
func TestProcessHeartbeat_ProceedsAfterTurnClears(t *testing.T) {
al, _, _, _, cleanup := newTestAgentLoop(t)
defer cleanup()
// Simulate a turn that starts and then completes
busyTS := &turnState{
sessionKey: "user-chat",
agentID: "main",
phase: TurnPhaseRunning,
startedAt: time.Now(),
}
al.activeTurnStates.Store(busyTS.sessionKey, busyTS)
// First call — should skip
resp, err := al.ProcessHeartbeat(context.Background(), "heartbeat prompt", "cli", "direct")
if err != nil {
t.Fatalf("first ProcessHeartbeat returned error: %v", err)
}
if resp != "HEARTBEAT_OK" {
t.Fatalf("expected skip on first call, got %q", resp)
}
// Clear the turn (simulating user conversation ending)
al.activeTurnStates.Delete(busyTS.sessionKey)
// Second call — should proceed (not skip)
resp2, err2 := al.ProcessHeartbeat(context.Background(), "heartbeat prompt", "cli", "direct")
if err2 != nil && resp2 == "HEARTBEAT_OK" {
t.Fatal("ProcessHeartbeat skipped after turn was cleared")
}
}