personal memory agent

Add heartbeat system test coverage

Tests for heartbeat CLI/PID/log behavior, dream.daily_complete emission,
supervisor trigger handler, and scheduler fallback registration.

+367
+193
tests/test_heartbeat.py
··· 1 + # SPDX-License-Identifier: AGPL-3.0-only 2 + # Copyright (c) 2026 sol pbc 3 + 4 + import argparse 5 + import os 6 + 7 + import pytest 8 + 9 + 10 + @pytest.fixture 11 + def journal_path(tmp_path, monkeypatch): 12 + monkeypatch.setenv("_SOLSTONE_JOURNAL_OVERRIDE", str(tmp_path)) 13 + (tmp_path / "health").mkdir() 14 + return tmp_path 15 + 16 + 17 + @pytest.fixture 18 + def heartbeat_mocks(monkeypatch): 19 + monkeypatch.setattr("think.heartbeat.setup_cli", lambda parser: argparse.Namespace()) 20 + monkeypatch.setattr("think.heartbeat.ensure_sol_directory", lambda *args, **kwargs: None) 21 + monkeypatch.setattr("think.heartbeat.cortex_request", lambda *args, **kwargs: "agent-123") 22 + monkeypatch.setattr( 23 + "think.heartbeat.wait_for_agents", 24 + lambda *args, **kwargs: ({"agent-123": "finish"}, []), 25 + ) 26 + 27 + 28 + def test_heartbeat_command_mapping(): 29 + """heartbeat key in COMMANDS maps to think.heartbeat module.""" 30 + from sol import COMMANDS 31 + 32 + assert COMMANDS["heartbeat"] == "think.heartbeat" 33 + 34 + 35 + def test_heartbeat_main_is_callable(): 36 + """think.heartbeat.main is a callable function.""" 37 + from think.heartbeat import main 38 + 39 + assert callable(main) 40 + 41 + 42 + def test_pid_guard_live_process_exits_zero(journal_path, heartbeat_mocks): 43 + """When PID file contains current process PID, main() exits 0 without cortex.""" 44 + import think.heartbeat as mod 45 + 46 + pid_file = journal_path / "health" / "heartbeat.pid" 47 + pid_file.write_text(str(os.getpid())) 48 + 49 + mod.cortex_request = lambda *a, **kw: pytest.fail("cortex_request should not be called") 50 + 51 + with pytest.raises(SystemExit) as exc_info: 52 + mod.main() 53 + assert exc_info.value.code == 0 54 + 55 + 56 + def test_pid_guard_dead_process_removes_stale_pid(journal_path, heartbeat_mocks): 57 + """When PID file contains a dead PID, main() removes it and proceeds to cortex.""" 58 + import think.heartbeat as mod 59 + 60 + pid_file = journal_path / "health" / "heartbeat.pid" 61 + dead_pid = 99999999 62 + try: 63 + os.kill(dead_pid, 0) 64 + pytest.skip("PID 99999999 is unexpectedly alive") 65 + except ProcessLookupError: 66 + pass 67 + 68 + pid_file.write_text(str(dead_pid)) 69 + 70 + cortex_called = [] 71 + 72 + def fake_cortex(*args, **kwargs): 73 + cortex_called.append(True) 74 + return "agent-123" 75 + 76 + mod.cortex_request = fake_cortex 77 + 78 + with pytest.raises(SystemExit) as exc_info: 79 + mod.main() 80 + assert exc_info.value.code == 0 81 + assert len(cortex_called) == 1 82 + 83 + 84 + def test_pid_file_created_and_removed_on_success(journal_path, heartbeat_mocks): 85 + """PID file exists during execution and is removed after main() completes.""" 86 + import think.heartbeat as mod 87 + 88 + pid_file = journal_path / "health" / "heartbeat.pid" 89 + pid_during_run = [] 90 + 91 + def capture_pid_cortex(*args, **kwargs): 92 + pid_during_run.append(pid_file.exists()) 93 + if pid_file.exists(): 94 + pid_during_run.append(pid_file.read_text().strip()) 95 + return "agent-123" 96 + 97 + mod.cortex_request = capture_pid_cortex 98 + 99 + with pytest.raises(SystemExit): 100 + mod.main() 101 + 102 + assert pid_during_run[0] is True 103 + assert pid_during_run[1] == str(os.getpid()) 104 + assert not pid_file.exists() 105 + 106 + 107 + def test_pid_file_removed_on_error(journal_path, heartbeat_mocks): 108 + """PID file is removed even when cortex_request returns None (error path).""" 109 + import think.heartbeat as mod 110 + 111 + pid_file = journal_path / "health" / "heartbeat.pid" 112 + mod.cortex_request = lambda *a, **kw: None 113 + 114 + with pytest.raises(SystemExit) as exc_info: 115 + mod.main() 116 + assert exc_info.value.code == 1 117 + assert not pid_file.exists() 118 + 119 + 120 + def test_pid_file_removed_on_timeout(journal_path, heartbeat_mocks): 121 + """PID file is removed on timeout path.""" 122 + import think.heartbeat as mod 123 + 124 + pid_file = journal_path / "health" / "heartbeat.pid" 125 + mod.wait_for_agents = lambda *a, **kw: ({}, ["agent-123"]) 126 + 127 + with pytest.raises(SystemExit) as exc_info: 128 + mod.main() 129 + assert exc_info.value.code == 2 130 + assert not pid_file.exists() 131 + 132 + 133 + def test_log_run_appends_line(journal_path): 134 + """_log_run appends a correctly formatted line to heartbeat.log.""" 135 + import time 136 + 137 + from think.heartbeat import _log_run 138 + 139 + health_dir = journal_path / "health" 140 + start_time = time.monotonic() - 5 141 + 142 + _log_run(health_dir, start_time, "success") 143 + 144 + log_file = health_dir / "heartbeat.log" 145 + assert log_file.exists() 146 + content = log_file.read_text() 147 + assert content.endswith("\n") 148 + line = content.strip() 149 + assert "duration=" in line 150 + assert "outcome=success" in line 151 + 152 + 153 + def test_log_written_after_successful_run(journal_path, heartbeat_mocks): 154 + """After a successful main() run, heartbeat.log has a success entry.""" 155 + import think.heartbeat as mod 156 + 157 + with pytest.raises(SystemExit) as exc_info: 158 + mod.main() 159 + assert exc_info.value.code == 0 160 + 161 + log_file = journal_path / "health" / "heartbeat.log" 162 + assert log_file.exists() 163 + content = log_file.read_text() 164 + assert "outcome=success" in content 165 + 166 + 167 + def test_dream_emit_daily_complete_shape(monkeypatch): 168 + """dream.emit('daily_complete', ...) calls _callosum.emit with correct tract and fields.""" 169 + from unittest.mock import Mock 170 + 171 + import think.dream as dream_mod 172 + 173 + mock_conn = Mock() 174 + monkeypatch.setattr(dream_mod, "_callosum", mock_conn) 175 + 176 + dream_mod.emit("daily_complete", day="20260318", success=3, failed=0, duration_ms=5000) 177 + 178 + mock_conn.emit.assert_called_once_with( 179 + "dream", 180 + "daily_complete", 181 + day="20260318", 182 + success=3, 183 + failed=0, 184 + duration_ms=5000, 185 + ) 186 + 187 + 188 + def test_dream_emit_noop_without_callosum(monkeypatch): 189 + """dream.emit() does nothing when _callosum is None.""" 190 + import think.dream as dream_mod 191 + 192 + monkeypatch.setattr(dream_mod, "_callosum", None) 193 + dream_mod.emit("daily_complete", day="20260318")
+62
tests/test_scheduler.py
··· 680 680 assert "due" in status[0] 681 681 682 682 683 + class TestHeartbeatSchedule: 684 + """Tests for heartbeat schedule registration and daily firing.""" 685 + 686 + def test_register_defaults_creates_heartbeat(self, journal_path): 687 + """register_defaults() creates a heartbeat entry in the config file.""" 688 + import think.scheduler as mod 689 + 690 + mock_cal = Mock() 691 + mod.init(mock_cal) 692 + mod.register_defaults() 693 + 694 + assert "heartbeat" in mod._entries 695 + assert mod._entries["heartbeat"]["cmd"] == ["sol", "heartbeat"] 696 + assert mod._entries["heartbeat"]["every"] == "daily" 697 + 698 + config_path = journal_path / "config" / "schedules.json" 699 + assert config_path.exists() 700 + with open(config_path) as f: 701 + raw = json.load(f) 702 + assert "heartbeat" in raw 703 + assert raw["heartbeat"]["cmd"] == ["sol", "heartbeat"] 704 + 705 + def test_register_defaults_idempotent(self, journal_path): 706 + """register_defaults() does not overwrite existing heartbeat config.""" 707 + import think.scheduler as mod 708 + 709 + _write_config( 710 + journal_path, 711 + { 712 + "heartbeat": { 713 + "cmd": ["sol", "heartbeat", "--custom"], 714 + "every": "daily", 715 + "enabled": True, 716 + } 717 + }, 718 + ) 719 + 720 + mock_cal = Mock() 721 + mod.init(mock_cal) 722 + mod.register_defaults() 723 + 724 + assert mod._entries["heartbeat"]["cmd"] == ["sol", "heartbeat", "--custom"] 725 + 726 + def test_heartbeat_is_due_when_never_run(self, journal_path): 727 + """_is_due returns True for heartbeat entry with no prior run.""" 728 + import think.scheduler as mod 729 + 730 + entry = {"cmd": ["sol", "heartbeat"], "every": "daily", "enabled": True} 731 + now = datetime(2026, 3, 19, 10, 0, 0) 732 + assert mod._is_due(entry, None, now) is True 733 + 734 + def test_heartbeat_not_due_when_recently_run(self, journal_path): 735 + """_is_due returns False for heartbeat entry that ran after the daily mark.""" 736 + import think.scheduler as mod 737 + 738 + entry = {"cmd": ["sol", "heartbeat"], "every": "daily", "enabled": True} 739 + now = datetime(2026, 3, 19, 10, 0, 0) 740 + last_run_ts = datetime(2026, 3, 19, 1, 0, 0).timestamp() 741 + state_entry = {"last_run": last_run_ts} 742 + assert mod._is_due(entry, state_entry, now) is False 743 + 744 + 683 745 # --------------------------------------------------------------------------- 684 746 # CLI main() 685 747 # ---------------------------------------------------------------------------
+112
tests/test_supervisor_schedule.py
··· 3 3 4 4 """Test supervisor daily scheduling functionality.""" 5 5 6 + import os 6 7 from datetime import date 7 8 from unittest.mock import patch 8 9 ··· 232 233 handle_daily_tasks() 233 234 234 235 assert captured_exclude["value"] == {"20250102"} 236 + 237 + 238 + def test_handle_dream_daily_complete_submits_heartbeat( 239 + mock_callosum, tmp_path, monkeypatch 240 + ): 241 + """_handle_dream_daily_complete submits heartbeat when no PID file exists.""" 242 + import think.supervisor as mod 243 + 244 + monkeypatch.setenv("_SOLSTONE_JOURNAL_OVERRIDE", str(tmp_path)) 245 + (tmp_path / "health").mkdir(exist_ok=True) 246 + 247 + submitted = [] 248 + original_submit = mod._task_queue.submit 249 + 250 + def capture_submit(cmd, *args, **kwargs): 251 + submitted.append(cmd) 252 + return original_submit(cmd, *args, **kwargs) 253 + 254 + mod._task_queue.submit = capture_submit 255 + 256 + message = { 257 + "tract": "dream", 258 + "event": "daily_complete", 259 + "day": "20260318", 260 + "success": 3, 261 + "failed": 0, 262 + "duration_ms": 5000, 263 + } 264 + mod._handle_dream_daily_complete(message) 265 + 266 + assert len(submitted) == 1 267 + assert submitted[0] == ["sol", "heartbeat"] 268 + 269 + 270 + def test_handle_dream_daily_complete_ignores_wrong_event( 271 + mock_callosum, tmp_path, monkeypatch 272 + ): 273 + """_handle_dream_daily_complete ignores messages with wrong tract or event.""" 274 + import think.supervisor as mod 275 + 276 + monkeypatch.setenv("_SOLSTONE_JOURNAL_OVERRIDE", str(tmp_path)) 277 + (tmp_path / "health").mkdir(exist_ok=True) 278 + 279 + submitted = [] 280 + original_submit = mod._task_queue.submit 281 + 282 + def capture_submit(cmd, *args, **kwargs): 283 + submitted.append(cmd) 284 + return original_submit(cmd, *args, **kwargs) 285 + 286 + mod._task_queue.submit = capture_submit 287 + 288 + mod._handle_dream_daily_complete({"tract": "supervisor", "event": "daily_complete"}) 289 + mod._handle_dream_daily_complete({"tract": "dream", "event": "started"}) 290 + mod._handle_dream_daily_complete({}) 291 + 292 + assert len(submitted) == 0 293 + 294 + 295 + def test_handle_dream_daily_complete_skips_when_pid_alive( 296 + mock_callosum, tmp_path, monkeypatch 297 + ): 298 + """_handle_dream_daily_complete does not submit when PID file shows running process.""" 299 + import think.supervisor as mod 300 + 301 + monkeypatch.setenv("_SOLSTONE_JOURNAL_OVERRIDE", str(tmp_path)) 302 + health = tmp_path / "health" 303 + health.mkdir(exist_ok=True) 304 + 305 + (health / "heartbeat.pid").write_text(str(os.getpid())) 306 + 307 + submitted = [] 308 + original_submit = mod._task_queue.submit 309 + 310 + def capture_submit(cmd, *args, **kwargs): 311 + submitted.append(cmd) 312 + return original_submit(cmd, *args, **kwargs) 313 + 314 + mod._task_queue.submit = capture_submit 315 + 316 + message = {"tract": "dream", "event": "daily_complete", "day": "20260318"} 317 + mod._handle_dream_daily_complete(message) 318 + 319 + assert len(submitted) == 0 320 + 321 + 322 + def test_handle_dream_daily_complete_proceeds_on_dead_pid( 323 + mock_callosum, tmp_path, monkeypatch 324 + ): 325 + """_handle_dream_daily_complete submits heartbeat when PID file has dead process.""" 326 + import think.supervisor as mod 327 + 328 + monkeypatch.setenv("_SOLSTONE_JOURNAL_OVERRIDE", str(tmp_path)) 329 + health = tmp_path / "health" 330 + health.mkdir(exist_ok=True) 331 + (health / "heartbeat.pid").write_text("99999999") 332 + 333 + submitted = [] 334 + original_submit = mod._task_queue.submit 335 + 336 + def capture_submit(cmd, *args, **kwargs): 337 + submitted.append(cmd) 338 + return original_submit(cmd, *args, **kwargs) 339 + 340 + mod._task_queue.submit = capture_submit 341 + 342 + message = {"tract": "dream", "event": "daily_complete", "day": "20260318"} 343 + mod._handle_dream_daily_complete(message) 344 + 345 + assert len(submitted) == 1 346 + assert submitted[0] == ["sol", "heartbeat"]