feat(daemon): systemd user timer auto-runner — decentralized scheduler fires hands-off (FPLAN-0287)

This commit is contained in:
AIOSAI
2026-06-25 06:31:05 -07:00
parent a777251ab7
commit f832a558cd
11 changed files with 454 additions and 4 deletions
+14
View File
@@ -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
+18
View File
@@ -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."
}
]
}
+18
View File
@@ -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."
}
]
}
+3 -2
View File
@@ -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."
}
+18
View File
@@ -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": {
+4
View File
@@ -94,6 +94,8 @@ drone @daemon actions list # Action registry
drone @daemon actions <id> 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 <command> --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 |
---
+4 -2
View File
@@ -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:
@@ -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
+22
View File
@@ -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
+20
View File
@@ -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
@@ -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()