fix(heartbeat): make start idempotent and check exact OK token

This commit is contained in:
yzchen
2026-02-23 13:56:37 +08:00
parent bc32e85c25
commit bfdae1b177
2 changed files with 54 additions and 1 deletions

View File

@@ -1,6 +1,7 @@
"""Heartbeat service - periodic agent wake-up to check for tasks.""" """Heartbeat service - periodic agent wake-up to check for tasks."""
import asyncio import asyncio
import re
from pathlib import Path from pathlib import Path
from typing import Any, Callable, Coroutine from typing import Any, Callable, Coroutine
@@ -35,6 +36,15 @@ def _is_heartbeat_empty(content: str | None) -> bool:
return True return True
def _is_heartbeat_ok_response(response: str | None) -> bool:
"""Return True only for an exact HEARTBEAT_OK token (with simple markdown wrappers)."""
if not response:
return False
normalized = re.sub(r"[\s`*_>\-]", "", response).upper()
return normalized == HEARTBEAT_OK_TOKEN.replace("_", "")
class HeartbeatService: class HeartbeatService:
""" """
Periodic heartbeat service that wakes the agent to check for tasks. Periodic heartbeat service that wakes the agent to check for tasks.
@@ -75,6 +85,9 @@ class HeartbeatService:
if not self.enabled: if not self.enabled:
logger.info("Heartbeat disabled") logger.info("Heartbeat disabled")
return return
if self._running:
logger.warning("Heartbeat already running")
return
self._running = True self._running = True
self._task = asyncio.create_task(self._run_loop()) self._task = asyncio.create_task(self._run_loop())
@@ -115,7 +128,7 @@ class HeartbeatService:
response = await self.on_heartbeat(HEARTBEAT_PROMPT) response = await self.on_heartbeat(HEARTBEAT_PROMPT)
# Check if agent said "nothing to do" # Check if agent said "nothing to do"
if HEARTBEAT_OK_TOKEN.replace("_", "") in response.upper().replace("_", ""): if _is_heartbeat_ok_response(response):
logger.info("Heartbeat: OK (no action needed)") logger.info("Heartbeat: OK (no action needed)")
else: else:
logger.info("Heartbeat: completed task") logger.info("Heartbeat: completed task")

View File

@@ -0,0 +1,40 @@
import asyncio
import pytest
from nanobot.heartbeat.service import (
HeartbeatService,
_is_heartbeat_ok_response,
)
def test_heartbeat_ok_response_requires_exact_token() -> None:
assert _is_heartbeat_ok_response("HEARTBEAT_OK")
assert _is_heartbeat_ok_response("`HEARTBEAT_OK`")
assert _is_heartbeat_ok_response("**HEARTBEAT_OK**")
assert not _is_heartbeat_ok_response("HEARTBEAT_OK, done")
assert not _is_heartbeat_ok_response("done HEARTBEAT_OK")
assert not _is_heartbeat_ok_response("HEARTBEAT_NOT_OK")
@pytest.mark.asyncio
async def test_start_is_idempotent(tmp_path) -> None:
async def _on_heartbeat(_: str) -> str:
return "HEARTBEAT_OK"
service = HeartbeatService(
workspace=tmp_path,
on_heartbeat=_on_heartbeat,
interval_s=9999,
enabled=True,
)
await service.start()
first_task = service._task
await service.start()
assert service._task is first_task
service.stop()
await asyncio.sleep(0)