diff --git a/muse/heartbeat.md b/muse/heartbeat.md new file mode 100644 index 000000000..a0e43342f --- /dev/null +++ b/muse/heartbeat.md @@ -0,0 +1,21 @@ +{ + "type": "cogitate", + + "title": "Heartbeat", + "description": "Periodic self-check — verifies system health, journal quality, and tends agency.md", + "instructions": {"system": "journal", "facets": true, "now": true} + +} + +## Constraints +- Do NOT produce user-facing output +- Do NOT execute destructive changes (entity merges, facet changes) — add them as suggestions to `sol/agency.md` +- All findings and recommendations go to `sol/agency.md` + +## Steps +1. **System health**: Run `sol call health status` to check service health. Review recent health logs for anomalies. +2. **Journal quality**: Run `sol call health journal-layout` and `sol call health agent-runs` to verify journal structure and recent agent execution over the last 3 days. +3. **Tend agency.md**: Read `sol/agency.md`. Mark resolved items, prune stale suggestions, and add any new findings from the health and journal-quality checks. +4. **Curation**: Run `sol call speakers suggest` for speaker suggestions. Run `sol call entities list` and `sol call entities detect` to check for merge candidates or stale entities, but record recommendations in `sol/agency.md` rather than making destructive changes. +5. **Review self.md**: Read `sol/self.md`. Only update it if genuine new patterns are observed. Do not change it just to change it. +6. **Git commit + push**: If any files were modified (`agency.md`, `self.md`), commit and push those changes. diff --git a/sol.py b/sol.py index 87a266af4..6fd0e7973 100644 --- a/sol.py +++ b/sol.py @@ -69,6 +69,7 @@ COMMANDS: dict[str, str] = { "call": "think.call", "help": "think.help_cli", "chat": "think.chat_cli", + "heartbeat": "think.heartbeat", # convey package - web UI "convey": "convey.cli", "restart-convey": "convey.restart", @@ -103,6 +104,7 @@ GROUPS: dict[str, list[str]] = { "health", "callosum", "notify", + "heartbeat", ], "Observe (capture)": [ "transcribe", diff --git a/think/heartbeat.py b/think/heartbeat.py new file mode 100644 index 000000000..81122c2c0 --- /dev/null +++ b/think/heartbeat.py @@ -0,0 +1,101 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright (c) 2026 sol pbc + +"""CLI command for periodic self-check agent.""" + +from __future__ import annotations + +import argparse +import logging +import os +import sys +import time +from datetime import datetime +from pathlib import Path + +from think.cortex_client import cortex_request, wait_for_agents +from think.utils import get_journal, setup_cli + +logger = logging.getLogger(__name__) + + +def main() -> None: + """Entry point for ``sol heartbeat``.""" + parser = argparse.ArgumentParser( + prog="sol heartbeat", + description="Run periodic self-check agent", + ) + args = setup_cli(parser) + + journal = Path(get_journal()) + health_dir = journal / "health" + health_dir.mkdir(parents=True, exist_ok=True) + pid_file = health_dir / "heartbeat.pid" + + try: + # PID file guard + if pid_file.exists(): + try: + existing_pid = int(pid_file.read_text().strip()) + os.kill(existing_pid, 0) + # Process is alive - exit cleanly + logger.info("Heartbeat already running (PID %d)", existing_pid) + sys.exit(0) + except ProcessLookupError: + # Dead process - stale PID file, remove and continue + logger.info("Removing stale PID file (PID %d)", existing_pid) + pid_file.unlink(missing_ok=True) + except PermissionError: + # Process alive but different user + logger.info( + "Heartbeat already running (PID %d, different user)", existing_pid + ) + sys.exit(0) + except ValueError: + # Corrupt PID file + logger.warning("Corrupt PID file, removing") + pid_file.unlink(missing_ok=True) + + # Write our PID + pid_file.write_text(str(os.getpid())) + start_time = time.monotonic() + + # Spawn heartbeat agent + agent_id = cortex_request(prompt="Run heartbeat check", name="heartbeat") + if agent_id is None: + logger.error("Failed to send heartbeat request to cortex") + _log_run(health_dir, start_time, "error") + sys.exit(1) + + logger.info("Heartbeat agent started (ID: %s)", agent_id) + + # Wait for completion + completed, timed_out = wait_for_agents([agent_id], timeout=300) + + # Determine outcome + if agent_id in timed_out: + logger.error("Heartbeat agent timed out") + _log_run(health_dir, start_time, "timeout") + sys.exit(2) + + end_state = completed.get(agent_id, "unknown") + if end_state == "finish": + logger.info("Heartbeat completed successfully") + _log_run(health_dir, start_time, "success") + sys.exit(0) + else: + logger.error("Heartbeat agent failed: %s", end_state) + _log_run(health_dir, start_time, "error") + sys.exit(1) + + finally: + pid_file.unlink(missing_ok=True) + + +def _log_run(health_dir: Path, start_time: float, outcome: str) -> None: + """Append one line to heartbeat.log.""" + duration = int(time.monotonic() - start_time) + timestamp = datetime.now().isoformat(timespec="seconds") + log_file = health_dir / "heartbeat.log" + with open(log_file, "a") as f: + f.write(f"{timestamp} duration={duration}s outcome={outcome}\n")