From f832a558cd17d18848eee60ecc54f94b1810cecd Mon Sep 17 00:00:00 2001 From: AIOSAI Date: Thu, 25 Jun 2026 06:31:05 -0700 Subject: [PATCH] =?UTF-8?q?feat(daemon):=20systemd=20user=20timer=20auto-r?= =?UTF-8?q?unner=20=E2=80=94=20decentralized=20scheduler=20fires=20hands-o?= =?UTF-8?q?ff=20(FPLAN-0287)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 14 ++ src/aipass/backup/.daemon/schedule.json | 18 ++ src/aipass/cli/.daemon/schedule.json | 18 ++ src/aipass/commons/.daemon/schedule.json | 5 +- src/aipass/daemon/.seedgo/bypass.json | 18 ++ src/aipass/daemon/README.md | 4 + src/aipass/daemon/apps/daemon.py | 6 +- .../daemon/apps/modules/timer_install.py | 183 ++++++++++++++++++ src/aipass/daemon/daemon-tick.service | 22 +++ src/aipass/daemon/daemon-tick.timer | 20 ++ src/aipass/daemon/tests/test_timer_install.py | 150 ++++++++++++++ 11 files changed, 454 insertions(+), 4 deletions(-) create mode 100644 src/aipass/backup/.daemon/schedule.json create mode 100644 src/aipass/cli/.daemon/schedule.json create mode 100644 src/aipass/daemon/apps/modules/timer_install.py create mode 100644 src/aipass/daemon/daemon-tick.service create mode 100644 src/aipass/daemon/daemon-tick.timer create mode 100644 src/aipass/daemon/tests/test_timer_install.py diff --git a/CHANGELOG.md b/CHANGELOG.md index b237fef7..a66d9b9a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,20 @@ PyPI version — not the changelog header. ### Added +- **Daemon auto-runner — systemd user timer (the deferred last mile of the + decentralized scheduler)** — `.daemon/schedule.json` jobs now fire **hands-off**. + A oneshot `daemon-tick.service` + `daemon-tick.timer` (every ~2 min, mirroring + the `prax-monitor.service` pattern: user-scope `~/.config/systemd/user/`, `%h` + not hardcoded paths, venv-python ExecStart `-m aipass.daemon.apps.daemon run`, + logs to `~/.aipass/daemon-tick.log` outside any tailed dir) reuses the existing + fcntl-locked `run.py` tick unchanged — the timer is the ticker. New + `apps/modules/timer_install.py` installs/enables it idempotently. Live-proven: + @devpulse received a `DAEMON TEST` ping from a branch woken purely by the timer, + no human tick. Tick profile: ~1.7s (import overhead only); the earlier CPU spike + was `wake_branch` spawning opus agents concurrently, **not** the tick — so + scheduled wakes want light models + staggering. Closes the piece DPLAN-0204 / + FPLAN-0282 deferred. 461 daemon tests green, seedgo 100%. (FPLAN-0287) + - **Prax monitor → Telegram relay (`prax_monitor` bot)** — the live `drone @prax monitor run` Mission-Control feed now mirrors to a dedicated Telegram bot, so the whole-system monitor is watchable from a phone ("same diff --git a/src/aipass/backup/.daemon/schedule.json b/src/aipass/backup/.daemon/schedule.json new file mode 100644 index 00000000..212d84b8 --- /dev/null +++ b/src/aipass/backup/.daemon/schedule.json @@ -0,0 +1,18 @@ +{ + "version": 1, + "branch": "@backup", + "jobs": [ + { + "id": "wake-test", + "enabled": false, + "schedule": { + "type": "interval", + "interval_minutes": 5 + }, + "wake": { + "fresh": true + }, + "prompt": "AUTOMATED DAEMON TEST. Do ONLY this: run drone @ai_mail email @devpulse \"DAEMON TEST \u2014 backup\" \"Woke via @daemon systemd timer. No memory touched.\" then STOP. Do NOT run startup, do NOT update memory, do NOT do anything else." + } + ] +} diff --git a/src/aipass/cli/.daemon/schedule.json b/src/aipass/cli/.daemon/schedule.json new file mode 100644 index 00000000..4a960c65 --- /dev/null +++ b/src/aipass/cli/.daemon/schedule.json @@ -0,0 +1,18 @@ +{ + "version": 1, + "branch": "@cli", + "jobs": [ + { + "id": "wake-test", + "enabled": false, + "schedule": { + "type": "interval", + "interval_minutes": 5 + }, + "wake": { + "fresh": true + }, + "prompt": "AUTOMATED DAEMON TEST. Do ONLY this: run drone @ai_mail email @devpulse \"DAEMON TEST \u2014 cli\" \"Woke via @daemon systemd timer. No memory touched.\" then STOP. Do NOT run startup, do NOT update memory, do NOT do anything else." + } + ] +} diff --git a/src/aipass/commons/.daemon/schedule.json b/src/aipass/commons/.daemon/schedule.json index 431c2f02..723521bf 100644 --- a/src/aipass/commons/.daemon/schedule.json +++ b/src/aipass/commons/.daemon/schedule.json @@ -7,10 +7,11 @@ "enabled": false, "schedule": { "type": "interval", - "interval_minutes": 1 + "interval_minutes": 5 }, "wake": { - "fresh": true + "fresh": true, + "model": "haiku" }, "prompt": "AUTOMATED DAEMON TEST. Do ONLY this: run drone @ai_mail email @devpulse \"DAEMON TEST FIRED\" \"Wake received from @daemon scheduler. No memory updated.\" then STOP. Do NOT run startup, do NOT update memory, do NOT do anything else." } diff --git a/src/aipass/daemon/.seedgo/bypass.json b/src/aipass/daemon/.seedgo/bypass.json index e2973693..5e6468e1 100644 --- a/src/aipass/daemon/.seedgo/bypass.json +++ b/src/aipass/daemon/.seedgo/bypass.json @@ -142,6 +142,24 @@ "standard": "architecture", "reason": "Scheduler plugin — autodiscovered by plugins/__init__.py discover_plugins(). Lives in apps/plugins/ by design, not a module or handler.", "pattern": "File not in standard 3-layer structure" + }, + { + "file": "tests/test_timer_install.py", + "standard": "architecture", + "reason": "Test file — lives in tests/ by convention, not in 3-layer apps/ structure", + "pattern": "File not in standard 3-layer structure" + }, + { + "file": "apps/modules/timer_install.py", + "standard": "introspection", + "reason": "install-timer/uninstall-timer are action commands — no-args IS the action (install). Introspection gate would break primary use case (same pattern as run.py).", + "pattern": "no-args gate" + }, + { + "file": "apps/modules/timer_install.py", + "standard": "modules", + "reason": "Installer module — mkdir for ~/.config/systemd/user/ is the core purpose. Direct file ops are inherent to the install function.", + "pattern": "direct file operations" } ], "notes": { diff --git a/src/aipass/daemon/README.md b/src/aipass/daemon/README.md index 6c1a6395..a8059043 100644 --- a/src/aipass/daemon/README.md +++ b/src/aipass/daemon/README.md @@ -94,6 +94,8 @@ drone @daemon actions list # Action registry drone @daemon actions on/off # Toggle action drone @daemon actions set reminder 7d "msg" --to @branch drone @daemon actions set schedule @branch "prompt" daily 04:00 +drone @daemon install-timer # Install + enable systemd user timer +drone @daemon uninstall-timer # Stop + remove systemd user timer ``` Each module accepts `--help` for module-specific usage: @@ -113,6 +115,8 @@ drone @daemon --help | `actions` | Action registry CLI — list, toggle, info, set reminder, set schedule, migrate | Operational | | `scheduler_ops` | Scheduler cron operations facade for scheduler_cron.py | Operational | | `wakeup_ops` | Wake-up cron operations facade for daemon_wakeup.py | Operational | +| `timer_install` | Idempotent systemd user timer installer for daemon scheduler | Operational | +| `run` | Decentralized scheduler tick: discover .daemon/ jobs, fire due ones | Operational | --- diff --git a/src/aipass/daemon/apps/daemon.py b/src/aipass/daemon/apps/daemon.py index 347a4072..a921048c 100644 --- a/src/aipass/daemon/apps/daemon.py +++ b/src/aipass/daemon/apps/daemon.py @@ -24,7 +24,7 @@ from aipass.prax.apps.modules.logger import system_logger as logger # Console from aipass.cli.apps.modules import console, error from aipass.daemon.apps.handlers.json import json_handler -from aipass.daemon.apps.modules import update, schedule, activity_report, actions, run +from aipass.daemon.apps.modules import update, schedule, activity_report, actions, run, timer_install def _header(text): @@ -46,7 +46,7 @@ def get_modules() -> List[Any]: List of module objects with handle_command function """ modules = [] - for mod in [update, schedule, activity_report, actions, run]: + for mod in [update, schedule, activity_report, actions, run, timer_install]: if hasattr(mod, "handle_command"): modules.append(mod) return modules @@ -142,6 +142,8 @@ def print_help(modules: List[Any]): ("branch-health", "Single branch deep dive (e.g., branch-health DAEMON)."), ("actions", "CLI interface for the numbered action registry."), ("run", "One scheduler tick: discover .daemon/ jobs, fire due ones."), + ("install-timer", "Install + enable daemon-tick systemd user timer (~2 min)."), + ("uninstall-timer", "Stop + remove daemon-tick systemd user timer."), ] for cmd_name, desc in _COMMAND_HELP: diff --git a/src/aipass/daemon/apps/modules/timer_install.py b/src/aipass/daemon/apps/modules/timer_install.py new file mode 100644 index 00000000..fa4d8195 --- /dev/null +++ b/src/aipass/daemon/apps/modules/timer_install.py @@ -0,0 +1,183 @@ +# =================== AIPass ==================== +# Name: timer_install.py +# Description: Idempotent systemd user timer installer for daemon scheduler +# Version: 1.0.0 +# Created: 2026-06-25 +# Modified: 2026-06-25 +# ============================================= + +""" +Timer installer — idempotent install/uninstall of daemon-tick systemd user units. + +Handles 'drone @daemon install-timer' and 'drone @daemon uninstall-timer'. +Copies daemon-tick.service + daemon-tick.timer to ~/.config/systemd/user/, +reloads systemd, and enables/starts the timer. +""" + +import shutil +import subprocess +import sys +from pathlib import Path +from typing import List + +from aipass.prax import logger +from aipass.cli.apps.modules import console, error +from aipass.daemon.apps.handlers.json import json_handler + +_DAEMON_ROOT = Path(__file__).resolve().parents[2] +_UNIT_DIR = Path.home() / ".config" / "systemd" / "user" +_SERVICE_NAME = "daemon-tick.service" +_TIMER_NAME = "daemon-tick.timer" + +HANDLED_COMMANDS = {"install-timer", "uninstall-timer"} + + +def print_introspection(): + """Display module introspection info.""" + console.print() + console.print("[bold cyan]timer_install Module[/bold cyan]") + console.print() + console.print("[dim]Idempotent systemd user timer installer for daemon scheduler[/dim]") + console.print() + console.print("[yellow]Unit files:[/yellow]") + console.print(f" [cyan]*[/cyan] {_DAEMON_ROOT / _SERVICE_NAME}") + console.print(f" [cyan]*[/cyan] {_DAEMON_ROOT / _TIMER_NAME}") + console.print(f" [cyan]*[/cyan] Installs to: {_UNIT_DIR}/") + console.print() + + +def print_help(): + """Display usage information.""" + console.print("\n[bold cyan]install-timer / uninstall-timer — Daemon Scheduler Timer[/bold cyan]") + console.print("\n[yellow]USAGE:[/yellow]") + console.print(" drone @daemon install-timer Install + enable daemon-tick timer") + console.print(" drone @daemon uninstall-timer Stop + remove daemon-tick timer") + console.print(" drone @daemon install-timer --help") + console.print("\n[yellow]DESCRIPTION:[/yellow]") + console.print(" Copies daemon-tick.service and daemon-tick.timer to") + console.print(f" {_UNIT_DIR}/") + console.print(" Then reloads systemd and enables+starts the timer.") + console.print(" Idempotent — safe to run multiple times.") + console.print() + + +def _run_systemctl(*args: str) -> bool: + """Run a systemctl --user command. Returns True on success.""" + cmd = ["systemctl", "--user", *args] + try: + result = subprocess.run(cmd, capture_output=True, text=True, timeout=15) + if result.returncode != 0: + logger.warning("[timer_install] systemctl --user %s failed: %s", " ".join(args), result.stderr.strip()) + console.print(f" [red]FAIL:[/red] systemctl --user {' '.join(args)}") + if result.stderr.strip(): + console.print(f" [dim]{result.stderr.strip()}[/dim]") + return False + return True + except FileNotFoundError: + logger.error("[timer_install] systemctl not found — systemd not available") + error("systemctl not found — systemd not available") + return False + except subprocess.TimeoutExpired: + logger.error("[timer_install] systemctl --user %s timed out", " ".join(args)) + console.print(" [red]systemctl timed out[/red]") + return False + + +def _install() -> int: + """Install and enable the daemon-tick timer.""" + service_src = _DAEMON_ROOT / _SERVICE_NAME + timer_src = _DAEMON_ROOT / _TIMER_NAME + + for src in (service_src, timer_src): + if not src.exists(): + logger.error("[timer_install] Missing unit file: %s", src) + return 1 + + _UNIT_DIR.mkdir(parents=True, exist_ok=True) + + json_handler.log_operation("install_timer", {"target": str(_UNIT_DIR)}) + + console.print("[bold cyan]Installing daemon-tick units...[/bold cyan]") + console.print() + + for src in (service_src, timer_src): + dst = _UNIT_DIR / src.name + shutil.copy2(src, dst) + console.print(f" [green]Copied:[/green] {src.name} -> {dst}") + + console.print() + + console.print(" Reloading systemd user daemon...") + if not _run_systemctl("daemon-reload"): + return 1 + + console.print(" Enabling daemon-tick.timer...") + if not _run_systemctl("enable", _TIMER_NAME): + return 1 + + console.print(" Starting daemon-tick.timer...") + if not _run_systemctl("start", _TIMER_NAME): + return 1 + + console.print() + console.print("[bold green]daemon-tick.timer installed and active.[/bold green]") + console.print("[dim]Verify: systemctl --user list-timers | grep daemon[/dim]") + console.print() + + Path.home().joinpath(".aipass").mkdir(parents=True, exist_ok=True) + + logger.info("[timer_install] daemon-tick timer installed and started") + return 0 + + +def _uninstall() -> int: + """Stop, disable, and remove the daemon-tick timer.""" + console.print("[bold cyan]Uninstalling daemon-tick units...[/bold cyan]") + console.print() + + _run_systemctl("stop", _TIMER_NAME) + _run_systemctl("disable", _TIMER_NAME) + + for name in (_SERVICE_NAME, _TIMER_NAME): + dst = _UNIT_DIR / name + if dst.exists(): + dst.unlink() + try: + from aipass.trigger.apps.modules.core import trigger + + trigger.fire("file_deleted", path=str(dst), source="timer_install") + except ImportError: + logger.info("[timer_install] Trigger module not available, skipping event fire") + except Exception as e: + logger.warning("[timer_install] Trigger fire failed (non-critical): %s", e) + console.print(f" [yellow]Removed:[/yellow] {dst}") + else: + console.print(f" [dim]Not found:[/dim] {dst}") + + _run_systemctl("daemon-reload") + + console.print() + console.print("[bold green]daemon-tick units removed.[/bold green]") + console.print() + + logger.info("[timer_install] daemon-tick timer uninstalled") + return 0 + + +def handle_command(command: str, args: List[str]) -> bool: + """Handle install-timer / uninstall-timer commands.""" + if command not in HANDLED_COMMANDS: + return False + + if args and args[0] in ("--help", "-h"): + print_help() + return True + + if command == "install-timer": + exit_code = _install() + else: + exit_code = _uninstall() + + if exit_code != 0: + sys.exit(exit_code) + return True diff --git a/src/aipass/daemon/daemon-tick.service b/src/aipass/daemon/daemon-tick.service new file mode 100644 index 00000000..ded38baf --- /dev/null +++ b/src/aipass/daemon/daemon-tick.service @@ -0,0 +1,22 @@ +# Systemd user service for the daemon scheduler tick (oneshot). +# +# Install: +# cp daemon-tick.service daemon-tick.timer ~/.config/systemd/user/ +# systemctl --user daemon-reload +# systemctl --user enable --now daemon-tick.timer +# +# Usage: +# systemctl --user start daemon-tick # manual single tick +# systemctl --user list-timers # check timer schedule +# systemctl --user status daemon-tick # last tick result +# journalctl --user -u daemon-tick # journal entries + +[Unit] +Description=AIPass Daemon Scheduler Tick — discover and fire due .daemon/ jobs + +[Service] +Type=oneshot +ExecStart=%h/Projects/AIPass/.venv/bin/python3 -m aipass.daemon.apps.daemon run +WorkingDirectory=%h/Projects/AIPass +StandardOutput=append:%h/.aipass/daemon-tick.log +StandardError=append:%h/.aipass/daemon-tick.log diff --git a/src/aipass/daemon/daemon-tick.timer b/src/aipass/daemon/daemon-tick.timer new file mode 100644 index 00000000..30aad404 --- /dev/null +++ b/src/aipass/daemon/daemon-tick.timer @@ -0,0 +1,20 @@ +# Systemd user timer for the daemon scheduler tick (~2 min cadence). +# +# Install: +# cp daemon-tick.service daemon-tick.timer ~/.config/systemd/user/ +# systemctl --user daemon-reload +# systemctl --user enable --now daemon-tick.timer +# +# Disable: +# systemctl --user disable --now daemon-tick.timer + +[Unit] +Description=AIPass Daemon Scheduler Timer — fires daemon-tick.service every ~2 min + +[Timer] +OnActiveSec=30s +OnUnitActiveSec=2min +Persistent=true + +[Install] +WantedBy=timers.target diff --git a/src/aipass/daemon/tests/test_timer_install.py b/src/aipass/daemon/tests/test_timer_install.py new file mode 100644 index 00000000..3fe7cbc3 --- /dev/null +++ b/src/aipass/daemon/tests/test_timer_install.py @@ -0,0 +1,150 @@ +# =================== AIPass ==================== +# Name: test_timer_install.py +# Description: Tests for the timer_install module (systemd user timer installer) +# Version: 1.0.0 +# Created: 2026-06-25 +# Modified: 2026-06-25 +# ============================================= + +"""Tests for the timer_install module (systemd user timer installer).""" + +import subprocess +from unittest.mock import patch, MagicMock +from pathlib import Path + +from aipass.daemon.apps.modules.timer_install import ( + handle_command, + HANDLED_COMMANDS, + _run_systemctl, + _install, + _uninstall, +) + + +class TestHandleCommand: + """Tests for command routing.""" + + def test_handles_install_timer(self): + """Verify install-timer is in handled commands.""" + assert "install-timer" in HANDLED_COMMANDS + + def test_handles_uninstall_timer(self): + """Verify uninstall-timer is in handled commands.""" + assert "uninstall-timer" in HANDLED_COMMANDS + + def test_rejects_unknown(self): + """Unknown commands return False.""" + assert handle_command("unknown", []) is False + + def test_help_flag(self, capsys): + """Help flag prints usage and returns True.""" + result = handle_command("install-timer", ["--help"]) + assert result is True + + +class TestRunSystemctl: + """Tests for the systemctl wrapper.""" + + @patch("subprocess.run") + def test_success(self, mock_run): + """Successful systemctl returns True.""" + mock_run.return_value = MagicMock(returncode=0) + assert _run_systemctl("status", "daemon-tick.timer") is True + + @patch("subprocess.run") + def test_failure_returncode(self, mock_run): + """Non-zero returncode returns False.""" + mock_run.return_value = MagicMock(returncode=1, stderr="unit not found") + assert _run_systemctl("start", "daemon-tick.timer") is False + + @patch("subprocess.run", side_effect=FileNotFoundError) + def test_systemctl_not_found(self, mock_run): + """Missing systemctl returns False.""" + assert _run_systemctl("status", "daemon-tick.timer") is False + + @patch( + "subprocess.run", + side_effect=subprocess.TimeoutExpired(cmd="systemctl", timeout=15), + ) + def test_timeout(self, mock_run): + """Timed-out systemctl returns False.""" + assert _run_systemctl("status", "daemon-tick.timer") is False + + +class TestInstall: + """Tests for the install flow.""" + + def test_install_missing_unit_file(self): + """Returns 1 when unit files are missing.""" + with patch( + "aipass.daemon.apps.modules.timer_install._DAEMON_ROOT", + Path("/nonexistent"), + ): + result = _install() + assert result == 1 + + @patch("aipass.daemon.apps.modules.timer_install._run_systemctl", return_value=True) + @patch("shutil.copy2") + def test_install_success(self, mock_copy, mock_systemctl, tmp_path): + """Successful install copies files and calls systemctl 3 times.""" + service = tmp_path / "daemon-tick.service" + timer = tmp_path / "daemon-tick.timer" + service.write_text("[Unit]\n") + timer.write_text("[Unit]\n") + + install_dir = tmp_path / "systemd" + install_dir.mkdir() + + with ( + patch("aipass.daemon.apps.modules.timer_install._DAEMON_ROOT", tmp_path), + patch("aipass.daemon.apps.modules.timer_install._UNIT_DIR", install_dir), + ): + result = _install() + assert result == 0 + assert mock_systemctl.call_count == 3 + + @patch("aipass.daemon.apps.modules.timer_install._run_systemctl") + @patch("shutil.copy2") + def test_install_systemctl_fails(self, mock_copy, mock_systemctl, tmp_path): + """Returns 1 when systemctl fails.""" + service = tmp_path / "daemon-tick.service" + timer = tmp_path / "daemon-tick.timer" + service.write_text("[Unit]\n") + timer.write_text("[Unit]\n") + + install_dir = tmp_path / "systemd" + install_dir.mkdir() + + mock_systemctl.return_value = False + + with ( + patch("aipass.daemon.apps.modules.timer_install._DAEMON_ROOT", tmp_path), + patch("aipass.daemon.apps.modules.timer_install._UNIT_DIR", install_dir), + ): + result = _install() + assert result == 1 + + +class TestUninstall: + """Tests for the uninstall flow.""" + + @patch("aipass.daemon.apps.modules.timer_install._run_systemctl", return_value=True) + def test_uninstall_files_not_present(self, mock_systemctl, tmp_path): + """Returns 0 even when unit files are already absent.""" + with patch("aipass.daemon.apps.modules.timer_install._UNIT_DIR", tmp_path): + result = _uninstall() + assert result == 0 + + @patch("aipass.daemon.apps.modules.timer_install._run_systemctl", return_value=True) + def test_uninstall_removes_files(self, mock_systemctl, tmp_path): + """Removes unit files from the target directory.""" + service = tmp_path / "daemon-tick.service" + timer = tmp_path / "daemon-tick.timer" + service.write_text("[Unit]\n") + timer.write_text("[Unit]\n") + + with patch("aipass.daemon.apps.modules.timer_install._UNIT_DIR", tmp_path): + result = _uninstall() + assert result == 0 + assert not service.exists() + assert not timer.exists()