pi_wake_agent: --pane targeting + crontab backend fallback + cronicle HCL fix + hermetic tests
- --pane/--pane-id: write-chars AND write(13) hit terminal_1 (bottom pane) - cronicle_available()=shutil.which + daemon-live check; crontab/crnd auto-fallback (cronicle daemon down here -> live scheduler is system crnd) - fix cronicle_install invalid HCL (command=[...] array, was bare comma-list) - rewrite TestCrontabEntry hermetic (subprocess mocked; real crontab never touched) - add --pane-id / no-pane omission / HCL-array mutation-litmus tests; .sh --pane - cronicle.hcl: corrected pi_wake_pi_test_20m (msg + --pane terminal_1), valid HCL - live 20m crontab doorbell installed for pi_test/terminal_1, bell verified delivered - SIGQUIT rescued a locked pi (final_test @1318s); steer queued + bus-posted
This commit is contained in:
24
cronicle.hcl
Normal file
24
cronicle.hcl
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
# pi_wake_agent schedules (cronicle backend).
|
||||||
|
#
|
||||||
|
# IMPORTANT (see AGENTS.md): on this host the cronicle daemon is NOT running
|
||||||
|
# (not supervised), so these schedules are INERT. The LIVE scheduler is system
|
||||||
|
# crnd via crontab (see `crontab -l`). This HCL is only served when
|
||||||
|
# `cronicle run --path <this-file>` is supervised. The pi_test 20m wake is
|
||||||
|
# installed into crontab as well, so it actually fires regardless.
|
||||||
|
|
||||||
|
schedule "pi_wake_pi_test_20m" {
|
||||||
|
cron = "@every 20m"
|
||||||
|
|
||||||
|
task "wake" {
|
||||||
|
command = ["/mnt/dolphinng5_predict/pi_wake_agent.py", "--run", "--interval", "20m", "--sessions", "pi_test", "--pane", "terminal_1", "--msg", "Please carefully continue your work"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Sample multi-session schedule (kept from the original config).
|
||||||
|
schedule "pi_wake_s1_s2_1h" {
|
||||||
|
cron = "@every 1h"
|
||||||
|
|
||||||
|
task "wake" {
|
||||||
|
command = ["/mnt/dolphinng5_predict/pi_wake_agent.py", "--run", "--interval", "1h", "--sessions", "s1,s2", "--msg", "test"]
|
||||||
|
}
|
||||||
|
}
|
||||||
332
pi_wake_agent.py
332
pi_wake_agent.py
@@ -17,6 +17,7 @@ import argparse
|
|||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
|
import shutil
|
||||||
import shlex
|
import shlex
|
||||||
import signal
|
import signal
|
||||||
import subprocess
|
import subprocess
|
||||||
@@ -36,8 +37,20 @@ AGENT_NICK = "pi_nvnemo"
|
|||||||
H5I_AGENT = "pi_nvnemo"
|
H5I_AGENT = "pi_nvnemo"
|
||||||
H5I_BUS_ROOT = Path("/mnt/dolphinng5_predict")
|
H5I_BUS_ROOT = Path("/mnt/dolphinng5_predict")
|
||||||
DEFAULT_INTERVAL = "1h"
|
DEFAULT_INTERVAL = "1h"
|
||||||
|
CRONICLE_CONFIG = Path(os.environ.get("PI_WAKE_CRONICLE_CONFIG", "/mnt/dolphinng5_predict/cronicle.hcl"))
|
||||||
|
BACKEND_ENV = "PI_WAKE_BACKEND"
|
||||||
|
CRON_PATH = "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"
|
||||||
|
PYTHON_BIN = "/usr/bin/python3"
|
||||||
|
|
||||||
|
# Bootstrap PATH at import: crnd fires this script under a minimal env, so
|
||||||
|
# make zjet/h5i/cronicle discoverable for the subprocess calls below. (The
|
||||||
|
# shebang resolves python3 via the caller PATH; the script is stdlib-only.)
|
||||||
|
os.environ["PATH"] = CRON_PATH + os.environ.get("PATH", "")
|
||||||
|
|
||||||
# ─── Logging Setup ───────────────────────────────────────────────────────
|
# ─── Logging Setup ───────────────────────────────────────────────────────
|
||||||
|
ZELIG_BIN = 'zellij'
|
||||||
|
|
||||||
|
|
||||||
def setup_logging(debug: bool = False) -> logging.Logger:
|
def setup_logging(debug: bool = False) -> logging.Logger:
|
||||||
log_rotate()
|
log_rotate()
|
||||||
logger = logging.getLogger("pi_wake_agent")
|
logger = logging.getLogger("pi_wake_agent")
|
||||||
@@ -115,50 +128,234 @@ def interval_to_human(interval: str) -> str:
|
|||||||
return interval
|
return interval
|
||||||
|
|
||||||
|
|
||||||
# ─── Cron Management ────────────────────────────────────────────────────
|
# ─── Cronicle Support ────────────────────────────────────────────────────
|
||||||
|
def cronicle_available() -> bool:
|
||||||
|
"""True if the cronicle binary is installed (backend present)."""
|
||||||
|
return shutil.which("cronicle") is not None
|
||||||
|
|
||||||
|
|
||||||
|
def cronicle_daemon_running() -> bool:
|
||||||
|
"""True if a cronicle daemon is actually serving (binary alone is not enough).
|
||||||
|
|
||||||
|
On this host cronicle has been flaky (the walker never ran) and its daemon
|
||||||
|
is NOT supervised (see AGENTS.md). Schedules written to cronicle.hcl only
|
||||||
|
fire while a daemon is serving it.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
result = subprocess.run(["pgrep", "-f", "cronicle run --path"],
|
||||||
|
capture_output=True, text=True, timeout=5)
|
||||||
|
return result.returncode == 0
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def use_cronicle() -> bool:
|
||||||
|
"""Decide the live scheduler backend.
|
||||||
|
|
||||||
|
PI_WAKE_BACKEND override (cronicle|crontab) wins for tests/ops. Otherwise
|
||||||
|
cronicle is used only when the binary is installed AND its daemon is
|
||||||
|
running; otherwise crontab (system crnd is the live scheduler here).
|
||||||
|
"""
|
||||||
|
backend = os.environ.get(BACKEND_ENV, "").strip().lower()
|
||||||
|
if backend == "cronicle":
|
||||||
|
return True
|
||||||
|
if backend == "crontab":
|
||||||
|
return False
|
||||||
|
return cronicle_available() and cronicle_daemon_running()
|
||||||
|
|
||||||
|
|
||||||
def cron_comment(sessions: List[str], interval: str) -> str:
|
def cron_comment(sessions: List[str], interval: str) -> str:
|
||||||
sessions_str = ",".join(sessions)
|
sessions_str = ",".join(sessions)
|
||||||
return f"{CRON_COMMENT_PREFIX}:{sessions_str}:{interval}"
|
return f"{CRON_COMMENT_PREFIX}:{sessions_str}:{interval}"
|
||||||
|
|
||||||
|
|
||||||
def install_cron(sessions: List[str], interval: str, message: str, logger: logging.Logger) -> None:
|
def cronicle_config_path() -> Path:
|
||||||
cron_sched = interval_to_cron(interval)
|
"""Return path to cronicle config file."""
|
||||||
comment = cron_comment(sessions, interval)
|
return CRONICLE_CONFIG
|
||||||
sessions_arg = f"'{','.join(sessions)}'"
|
|
||||||
cmd = f"cd {H5I_BUS_ROOT} && export H5I_AGENT={H5I_AGENT} && {SCRIPT_PATH} --run --sessions {sessions_arg} --msg {shlex.quote(message)}"
|
|
||||||
|
def cronicle_schedule_name(sessions: List[str], interval: str) -> str:
|
||||||
|
"""Generate unique schedule name for cronicle."""
|
||||||
|
sessions_str = "_".join(sessions)
|
||||||
|
return f"pi_wake_{sessions_str}_{interval}"
|
||||||
|
|
||||||
|
|
||||||
|
def wake_command_args(sessions: List[str], interval: str, message: str, pane: Optional[str] = None) -> List[str]:
|
||||||
|
"""Build the argv list for the --run wake command (shared by both backends).
|
||||||
|
|
||||||
|
Always passes --interval so the cronicle/crnd entry is self-describing.
|
||||||
|
--pane targets a specific zjet pane (e.g. the bottom Soundcheck pane).
|
||||||
|
"""
|
||||||
|
args = [str(SCRIPT_PATH), "--run", "--interval", interval, "--sessions", ",".join(sessions)]
|
||||||
|
if pane:
|
||||||
|
args += ["--pane", pane]
|
||||||
|
args += ["--msg", message]
|
||||||
|
return args
|
||||||
|
|
||||||
|
|
||||||
|
def cronicle_install(sessions: List[str], interval: str, message: str, logger: logging.Logger, pane: Optional[str] = None) -> None:
|
||||||
|
"""Install wake schedule using cronicle HCL config."""
|
||||||
|
schedule_name = cronicle_schedule_name(sessions, interval)
|
||||||
|
|
||||||
result = subprocess.run(["crontab", "-l"], capture_output=True, text=True)
|
# Build task command
|
||||||
existing = result.stdout if result.returncode == 0 else ""
|
sessions_arg = f"'{','.join(sessions)}'"
|
||||||
lines = [line for line in existing.splitlines() if comment not in line]
|
cmd = f"cd {H5I_BUS_ROOT} && export H5I_AGENT={H5I_AGENT} && {SCRIPT_PATH} --run --interval {interval} --sessions {sessions_arg} --msg {shlex.quote(message)}"
|
||||||
lines.append(f"{cron_sched} {cmd} # {comment}")
|
|
||||||
new_cron = "\n".join(lines) + "\n"
|
# Read existing config
|
||||||
subprocess.run(["crontab", "-"], input=new_cron, text=True, check=True)
|
config_path = cronicle_config_path()
|
||||||
logger.info(f"Installed cron: {cron_sched} -> {sessions} every {interval}")
|
existing = ""
|
||||||
|
if config_path.exists():
|
||||||
|
existing = config_path.read_text()
|
||||||
|
|
||||||
|
# Remove existing schedule with same name
|
||||||
|
lines = existing.splitlines()
|
||||||
|
new_lines = []
|
||||||
|
i = 0
|
||||||
|
while i < len(lines):
|
||||||
|
if f'schedule "{schedule_name}"' in lines[i]:
|
||||||
|
# Skip this schedule block (find closing brace)
|
||||||
|
brace_count = 0
|
||||||
|
while i < len(lines):
|
||||||
|
brace_count += lines[i].count('{')
|
||||||
|
brace_count -= lines[i].count('}')
|
||||||
|
i += 1
|
||||||
|
if brace_count == 0:
|
||||||
|
break
|
||||||
|
continue
|
||||||
|
new_lines.append(lines[i])
|
||||||
|
i += 1
|
||||||
|
|
||||||
|
# Add new schedule
|
||||||
|
cron_sched = interval_to_cron(interval)
|
||||||
|
cron_sched = cron_sched.replace("*/", "@every ").replace("0 */", "@every ").replace(" * * *", "")
|
||||||
|
# Convert cron to cronicle format
|
||||||
|
match = re.match(r"^(\d+)([hm])$", interval)
|
||||||
|
if match:
|
||||||
|
value, unit = int(match.group(1)), match.group(2)
|
||||||
|
if unit == "h":
|
||||||
|
cronicle_cron = f"@every {value}h"
|
||||||
|
else:
|
||||||
|
cronicle_cron = f"@every {value}m"
|
||||||
|
else:
|
||||||
|
cronicle_cron = "@every 1h"
|
||||||
|
|
||||||
|
new_schedule = f'''
|
||||||
|
schedule "{schedule_name}" {{
|
||||||
|
cron = "{cronicle_cron}"
|
||||||
|
|
||||||
|
task "wake" {{
|
||||||
|
command = [{", ".join(chr(34) + a + chr(34) for a in wake_command_args(sessions, interval, message, pane))}]
|
||||||
|
}}
|
||||||
|
}}'''
|
||||||
|
|
||||||
|
new_config = "\n".join(new_lines) + "\n\n" + new_schedule + "\n"
|
||||||
|
config_path.write_text(new_config)
|
||||||
|
logger.info(f"Installed cronicle schedule: {schedule_name} -> {sessions} every {interval}")
|
||||||
|
|
||||||
|
|
||||||
def remove_cron(sessions: List[str], interval: str, logger: logging.Logger) -> None:
|
def cronicle_remove(sessions: List[str], interval: str, logger: logging.Logger) -> None:
|
||||||
comment = cron_comment(sessions, interval)
|
"""Remove wake schedule from cronicle HCL config."""
|
||||||
result = subprocess.run(["crontab", "-l"], capture_output=True, text=True)
|
schedule_name = cronicle_schedule_name(sessions, interval)
|
||||||
existing = result.stdout if result.returncode == 0 else ""
|
config_path = cronicle_config_path()
|
||||||
lines = [line for line in existing.splitlines() if comment not in line]
|
|
||||||
new_cron = "\n".join(lines) + ("\n" if lines else "")
|
if not config_path.exists():
|
||||||
subprocess.run(["crontab", "-"], input=new_cron, text=True, check=True)
|
return
|
||||||
logger.info(f"Removed cron for {sessions} ({interval})")
|
|
||||||
|
existing = config_path.read_text()
|
||||||
|
lines = existing.splitlines()
|
||||||
|
new_lines = []
|
||||||
|
i = 0
|
||||||
|
while i < len(lines):
|
||||||
|
if f'schedule "{schedule_name}"' in lines[i]:
|
||||||
|
brace_count = 0
|
||||||
|
while i < len(lines):
|
||||||
|
brace_count += lines[i].count('{')
|
||||||
|
brace_count -= lines[i].count('}')
|
||||||
|
i += 1
|
||||||
|
if brace_count == 0:
|
||||||
|
break
|
||||||
|
continue
|
||||||
|
new_lines.append(lines[i])
|
||||||
|
i += 1
|
||||||
|
|
||||||
|
config_path.write_text("\n".join(new_lines) + "\n")
|
||||||
|
logger.info(f"Removed cronicle schedule: {schedule_name}")
|
||||||
|
|
||||||
|
|
||||||
def list_cron(logger: logging.Logger) -> None:
|
def cronicle_list(logger: logging.Logger) -> None:
|
||||||
print("=== pi_wake_agent cron entries ===")
|
"""List cronicle wake schedules."""
|
||||||
result = subprocess.run(["crontab", "-l"], capture_output=True, text=True)
|
config_path = cronicle_config_path()
|
||||||
existing = result.stdout if result.returncode == 0 else ""
|
if not config_path.exists():
|
||||||
|
print(" (none)")
|
||||||
|
return
|
||||||
|
|
||||||
|
existing = config_path.read_text()
|
||||||
found = False
|
found = False
|
||||||
for line in existing.splitlines():
|
for line in existing.splitlines():
|
||||||
if CRON_COMMENT_PREFIX in line:
|
if 'schedule "pi_wake_' in line:
|
||||||
print(f" {line}")
|
print(f" {line.strip()}")
|
||||||
found = True
|
found = True
|
||||||
if not found:
|
if not found:
|
||||||
print(" (none)")
|
print(" (none)")
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def install_cron(sessions: List[str], interval: str, message: str, logger: logging.Logger, pane: Optional[str] = None) -> None:
|
||||||
|
if use_cronicle():
|
||||||
|
if not cronicle_daemon_running():
|
||||||
|
logger.warning(
|
||||||
|
"cronicle binary present but its daemon is NOT running "
|
||||||
|
"schedules written to cronicle.hcl will not fire; set "
|
||||||
|
"PI_WAKE_BACKEND=crontab to use system crnd."
|
||||||
|
)
|
||||||
|
cronicle_install(sessions, interval, message, logger, pane)
|
||||||
|
else:
|
||||||
|
cron_sched = interval_to_cron(interval)
|
||||||
|
comment = cron_comment(sessions, interval)
|
||||||
|
sessions_arg = f"'{','.join(sessions)}'"
|
||||||
|
run_cmd = f"{PYTHON_BIN} {SCRIPT_PATH} --run --interval {interval} --sessions {sessions_arg} --msg {shlex.quote(message)}"
|
||||||
|
if pane:
|
||||||
|
run_cmd += f" --pane {shlex.quote(pane)}"
|
||||||
|
cmd = f"cd {H5I_BUS_ROOT} && export PATH={CRON_PATH} && export H5I_AGENT={H5I_AGENT} && {run_cmd}"
|
||||||
|
|
||||||
|
result = subprocess.run(["crontab", "-l"], capture_output=True, text=True)
|
||||||
|
existing = result.stdout if result.returncode == 0 else ""
|
||||||
|
lines = [line for line in existing.splitlines() if comment not in line]
|
||||||
|
lines.append(f"{cron_sched} {cmd} # {comment}")
|
||||||
|
new_cron = "\n".join(lines) + "\n"
|
||||||
|
subprocess.run(["crontab", "-"], input=new_cron, text=True, check=True)
|
||||||
|
logger.info(f"Installed cron: {cron_sched} -> {sessions} every {interval}")
|
||||||
|
|
||||||
|
|
||||||
|
def remove_cron(sessions: List[str], interval: str, logger: logging.Logger) -> None:
|
||||||
|
if use_cronicle():
|
||||||
|
cronicle_remove(sessions, interval, logger)
|
||||||
|
else:
|
||||||
|
comment = cron_comment(sessions, interval)
|
||||||
|
result = subprocess.run(["crontab", "-l"], capture_output=True, text=True)
|
||||||
|
existing = result.stdout if result.returncode == 0 else ""
|
||||||
|
lines = [line for line in existing.splitlines() if comment not in line]
|
||||||
|
new_cron = "\n".join(lines) + ("\n" if lines else "")
|
||||||
|
subprocess.run(["crontab", "-"], input=new_cron, text=True, check=True)
|
||||||
|
logger.info(f"Removed cron for {sessions} ({interval})")
|
||||||
|
|
||||||
|
|
||||||
|
def list_cron(logger: logging.Logger) -> None:
|
||||||
|
if use_cronicle():
|
||||||
|
cronicle_list(logger)
|
||||||
|
else:
|
||||||
|
print("=== pi_wake_agent cron entries ===")
|
||||||
|
result = subprocess.run(["crontab", "-l"], capture_output=True, text=True)
|
||||||
|
existing = result.stdout if result.returncode == 0 else ""
|
||||||
|
found = False
|
||||||
|
for line in existing.splitlines():
|
||||||
|
if CRON_COMMENT_PREFIX in line:
|
||||||
|
print(f" {line}")
|
||||||
|
found = True
|
||||||
|
if not found:
|
||||||
|
print(" (none)")
|
||||||
|
|
||||||
|
|
||||||
# ─── Zellij Operations ──────────────────────────────────────────────────
|
# ─── Zellij Operations ──────────────────────────────────────────────────
|
||||||
def zellij_session_exists(session: str) -> bool:
|
def zellij_session_exists(session: str) -> bool:
|
||||||
try:
|
try:
|
||||||
@@ -172,19 +369,26 @@ def zellij_session_exists(session: str) -> bool:
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
def zellij_write_chars(session: str, text: str) -> bool:
|
def zellij_write_chars(session: str, text: str, pane: Optional[str] = None) -> bool:
|
||||||
|
"""Write text into a zjet pane (focused, or --pane-id when given)."""
|
||||||
|
cmd = [ZELIG_BIN, "--session", session, "action", "write-chars"]
|
||||||
|
if pane:
|
||||||
|
cmd += ["--pane-id", pane]
|
||||||
|
cmd.append(text)
|
||||||
try:
|
try:
|
||||||
subprocess.run(["zellij", "--session", session, "action", "write-chars", text],
|
subprocess.run(cmd, capture_output=True, timeout=5)
|
||||||
capture_output=True, timeout=5)
|
|
||||||
return True
|
return True
|
||||||
except Exception:
|
except Exception:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
def zellij_write_enter(session: str) -> bool:
|
def zellij_write_enter(session: str, pane: Optional[str] = None) -> bool:
|
||||||
|
"""Press ENTER (CR) in a zjet pane (focused, or --pane-id when given)."""
|
||||||
|
cmd = [ZELIG_BIN, "--session", session, "action", "write", "13"]
|
||||||
|
if pane:
|
||||||
|
cmd += ["--pane-id", pane]
|
||||||
try:
|
try:
|
||||||
subprocess.run(["zellij", "--session", session, "action", "write", "13"],
|
subprocess.run(cmd, capture_output=True, timeout=5)
|
||||||
capture_output=True, timeout=5)
|
|
||||||
return True
|
return True
|
||||||
except Exception:
|
except Exception:
|
||||||
return False
|
return False
|
||||||
@@ -195,7 +399,7 @@ def zellij_write_enter(session: str) -> bool:
|
|||||||
|
|
||||||
def _wake():
|
def _wake():
|
||||||
time.sleep(interval_seconds)
|
time.sleep(interval_seconds)
|
||||||
run_wake(sessions, message, logger)
|
run_wake(sessions, message, logger, pane)
|
||||||
|
|
||||||
thread = threading.Thread(target=_wake, daemon=True)
|
thread = threading.Thread(target=_wake, daemon=True)
|
||||||
thread.start()
|
thread.start()
|
||||||
@@ -204,15 +408,21 @@ def zellij_write_enter(session: str) -> bool:
|
|||||||
pid_file.write_text(str(os.getpid()))
|
pid_file.write_text(str(os.getpid()))
|
||||||
|
|
||||||
|
|
||||||
def run_wake(sessions: List[str], message: str, logger: logging.Logger) -> None:
|
def run_wake(sessions: List[str], message: str, logger: logging.Logger, pane: Optional[str] = None) -> None:
|
||||||
sessions_str = ",".join(sessions)
|
sessions_str = ",".join(sessions)
|
||||||
logger.info(f"Waking {sessions_str} with message: {message}")
|
logger.info(f"Waking {sessions_str} with message: {message}" + (f" (pane={pane})" if pane else ""))
|
||||||
|
|
||||||
for session in sessions:
|
for session in sessions:
|
||||||
zellij_write_chars(session, f"[{AGENT_NICK} via zellij] {message} Run: h5i-bus msg inbox")
|
# Verify session exists before attempting to wake
|
||||||
for _ in range(5):
|
if not zellij_session_exists(session):
|
||||||
zellij_write_enter(session)
|
logger.warning(f"Session {session} does not exist, skipping")
|
||||||
time.sleep(1)
|
continue
|
||||||
|
|
||||||
|
zellij_write_chars(session, f"[{AGENT_NICK} via zellij] {message} Run: h5i-bus msg inbox", pane)
|
||||||
|
# Send more ENTER keypresses with longer delay for reliability
|
||||||
|
for _ in range(10):
|
||||||
|
zellij_write_enter(session, pane)
|
||||||
|
time.sleep(0.5)
|
||||||
|
|
||||||
# Fire-and-forget h5i bus message (non-blocking)
|
# Fire-and-forget h5i bus message (non-blocking)
|
||||||
def _send_bus():
|
def _send_bus():
|
||||||
@@ -231,13 +441,13 @@ def run_wake(sessions: List[str], message: str, logger: logging.Logger) -> None:
|
|||||||
|
|
||||||
logger.info(f"Wake sent to {sessions_str}")
|
logger.info(f"Wake sent to {sessions_str}")
|
||||||
|
|
||||||
def run_once(sessions: List[str], interval: str, message: str, logger: logging.Logger) -> None:
|
def run_once(sessions: List[str], interval: str, message: str, logger: logging.Logger, pane: Optional[str] = None) -> None:
|
||||||
interval_seconds = parse_interval(interval)
|
interval_seconds = parse_interval(interval)
|
||||||
logger.info(f"One-shot timer set for {interval} ({interval_to_human(interval)})")
|
logger.info(f"One-shot timer set for {interval} ({interval_to_human(interval)})")
|
||||||
|
|
||||||
def _wake():
|
def _wake():
|
||||||
time.sleep(interval_seconds)
|
time.sleep(interval_seconds)
|
||||||
run_wake(sessions, message, logger)
|
run_wake(sessions, message, logger, pane)
|
||||||
|
|
||||||
thread = threading.Thread(target=_wake, daemon=True)
|
thread = threading.Thread(target=_wake, daemon=True)
|
||||||
thread.start()
|
thread.start()
|
||||||
@@ -247,22 +457,22 @@ def run_once(sessions: List[str], interval: str, message: str, logger: logging.L
|
|||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
|
||||||
def run_daemon(sessions: List[str], interval: str, message: str, logger: logging.Logger) -> None:
|
def run_daemon(sessions: List[str], interval: str, message: str, logger: logging.Logger, pane: Optional[str] = None) -> None:
|
||||||
interval_seconds = parse_interval(interval)
|
interval_seconds = parse_interval(interval)
|
||||||
logger.info("=== DAEMON START ===")
|
logger.info("=== DAEMON START ===")
|
||||||
logger.info(f"Interval: {interval} ({interval_to_human(interval)})")
|
logger.info(f"Interval: {interval} ({interval_to_human(interval)})")
|
||||||
logger.info(f"Sessions: {sessions}")
|
logger.info(f"Sessions: {sessions}")
|
||||||
logger.info(f"Message: {message}")
|
logger.info(f"Message: {message}")
|
||||||
|
|
||||||
run_wake(sessions, message, logger)
|
run_wake(sessions, message, logger, pane)
|
||||||
|
|
||||||
while True:
|
while True:
|
||||||
logger.debug(f"Sleeping for {interval_seconds}s...")
|
logger.debug(f"Sleeping for {interval_seconds}s...")
|
||||||
time.sleep(interval_seconds)
|
time.sleep(interval_seconds)
|
||||||
run_wake(sessions, message, logger)
|
run_wake(sessions, message, logger, pane)
|
||||||
|
|
||||||
|
|
||||||
def run_succession(sessions: List[str], interval: str, count: int, message: str, logger: logging.Logger) -> None:
|
def run_succession(sessions: List[str], interval: str, count: int, message: str, logger: logging.Logger, pane: Optional[str] = None) -> None:
|
||||||
"""Run wake N times at interval, then self-clean (remove cron if installed)."""
|
"""Run wake N times at interval, then self-clean (remove cron if installed)."""
|
||||||
interval_seconds = parse_interval(interval)
|
interval_seconds = parse_interval(interval)
|
||||||
logger.info(f"=== SUCCESSION START === Count: {count}, Interval: {interval} ({interval_to_human(interval)})")
|
logger.info(f"=== SUCCESSION START === Count: {count}, Interval: {interval} ({interval_to_human(interval)})")
|
||||||
@@ -271,7 +481,7 @@ def run_succession(sessions: List[str], interval: str, count: int, message: str,
|
|||||||
|
|
||||||
for i in range(1, count + 1):
|
for i in range(1, count + 1):
|
||||||
logger.info(f"Succession {i}/{count}")
|
logger.info(f"Succession {i}/{count}")
|
||||||
run_wake(sessions, message, logger)
|
run_wake(sessions, message, logger, pane)
|
||||||
if i < count:
|
if i < count:
|
||||||
logger.debug(f"Sleeping for {interval_seconds}s until next succession...")
|
logger.debug(f"Sleeping for {interval_seconds}s until next succession...")
|
||||||
time.sleep(interval_seconds)
|
time.sleep(interval_seconds)
|
||||||
@@ -292,6 +502,17 @@ def status(logger: logging.Logger) -> None:
|
|||||||
print(f"Log: {LOG_FILE}")
|
print(f"Log: {LOG_FILE}")
|
||||||
print(f"Log size: {LOG_FILE.stat().st_size if LOG_FILE.exists() else 'N/A'} bytes")
|
print(f"Log size: {LOG_FILE.stat().st_size if LOG_FILE.exists() else 'N/A'} bytes")
|
||||||
print(f"Agent: {AGENT_NICK}")
|
print(f"Agent: {AGENT_NICK}")
|
||||||
|
backend = os.environ.get(BACKEND_ENV, "").strip().lower()
|
||||||
|
if backend == "cronicle":
|
||||||
|
backend_label = "cronicle (forced by PI_WAKE_BACKEND)"
|
||||||
|
elif backend == "crontab":
|
||||||
|
backend_label = "crontab (forced by PI_WAKE_BACKEND)"
|
||||||
|
elif use_cronicle():
|
||||||
|
backend_label = "cronicle"
|
||||||
|
else:
|
||||||
|
backend_label = "crontab (system crnd)"
|
||||||
|
print(f"Backend: {backend_label}")
|
||||||
|
print(f"cronicle binary={cronicle_available()} daemon_running={cronicle_daemon_running()}")
|
||||||
print()
|
print()
|
||||||
list_cron(logger)
|
list_cron(logger)
|
||||||
print()
|
print()
|
||||||
@@ -363,10 +584,6 @@ EXAMPLES:
|
|||||||
pi_wake_agent.py --validate --session cc_UV_dev0_Fb
|
pi_wake_agent.py --validate --session cc_UV_dev0_Fb
|
||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
parser = argparse.ArgumentParser(
|
|
||||||
description="pi_wake_agent.py — Reusable multi-agent wake-up timer with self-cron/daemon/succession",
|
|
||||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
||||||
)
|
|
||||||
parser.add_argument("--install", action="store_const", const="install", dest="mode", help="Install recurring cron timer")
|
parser.add_argument("--install", action="store_const", const="install", dest="mode", help="Install recurring cron timer")
|
||||||
parser.add_argument("--once", action="store_const", const="once", dest="mode", help="One-shot wake (no cron)")
|
parser.add_argument("--once", action="store_const", const="once", dest="mode", help="One-shot wake (no cron)")
|
||||||
parser.add_argument("--daemon", action="store_const", const="daemon", dest="mode", help="Run as long-lived daemon (no cron)")
|
parser.add_argument("--daemon", action="store_const", const="daemon", dest="mode", help="Run as long-lived daemon (no cron)")
|
||||||
@@ -380,6 +597,7 @@ EXAMPLES:
|
|||||||
parser.add_argument("--interval", default=DEFAULT_INTERVAL, help="Interval (default: 1h). Formats: 30m, 1h, 90m, 2h, etc.")
|
parser.add_argument("--interval", default=DEFAULT_INTERVAL, help="Interval (default: 1h). Formats: 30m, 1h, 90m, 2h, etc.")
|
||||||
parser.add_argument("--session", action="append", dest="session_list", help="Zellij session name (can repeat)")
|
parser.add_argument("--session", action="append", dest="session_list", help="Zellij session name (can repeat)")
|
||||||
parser.add_argument("--sessions", help="Comma-separated list of sessions")
|
parser.add_argument("--sessions", help="Comma-separated list of sessions")
|
||||||
|
parser.add_argument("--pane", dest="pane", default=None, help="Target a specific zjet pane (focused pane if unset), e.g. terminal_1")
|
||||||
parser.add_argument("--msg", default=f"Operator says CONTINUE. {AGENT_NICK} here, saying hi!", help="Wake message")
|
parser.add_argument("--msg", default=f"Operator says CONTINUE. {AGENT_NICK} here, saying hi!", help="Wake message")
|
||||||
parser.add_argument("--debug", action="store_true", help="Enable debug logging")
|
parser.add_argument("--debug", action="store_true", help="Enable debug logging")
|
||||||
parser.add_argument("--dry-run", action="store_true", help="Show what would be done without executing")
|
parser.add_argument("--dry-run", action="store_true", help="Show what would be done without executing")
|
||||||
@@ -415,6 +633,8 @@ def main() -> int:
|
|||||||
logger.info(f"=== pi_wake_agent {args.mode} ===")
|
logger.info(f"=== pi_wake_agent {args.mode} ===")
|
||||||
logger.info(f"Interval: {args.interval} ({interval_to_human(args.interval)})")
|
logger.info(f"Interval: {args.interval} ({interval_to_human(args.interval)})")
|
||||||
logger.info(f"Sessions: {sessions}")
|
logger.info(f"Sessions: {sessions}")
|
||||||
|
if args.pane:
|
||||||
|
logger.info(f"Pane target: {args.pane}")
|
||||||
logger.info(f"Message: {args.msg}")
|
logger.info(f"Message: {args.msg}")
|
||||||
if args.mode == "succession":
|
if args.mode == "succession":
|
||||||
logger.info(f"Count: {args.count}")
|
logger.info(f"Count: {args.count}")
|
||||||
@@ -427,15 +647,15 @@ def main() -> int:
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
if args.mode == "install":
|
if args.mode == "install":
|
||||||
install_cron(sessions, args.interval, args.msg, logger)
|
install_cron(sessions, args.interval, args.msg, logger, args.pane)
|
||||||
elif args.mode == "once":
|
elif args.mode == "once":
|
||||||
run_once(sessions, args.interval, args.msg, logger)
|
run_once(sessions, args.interval, args.msg, logger, args.pane)
|
||||||
elif args.mode == "daemon":
|
elif args.mode == "daemon":
|
||||||
run_daemon(sessions, args.interval, args.msg, logger)
|
run_daemon(sessions, args.interval, args.msg, logger, args.pane)
|
||||||
elif args.mode == "succession":
|
elif args.mode == "succession":
|
||||||
run_succession(sessions, args.interval, args.count, args.msg, logger)
|
run_succession(sessions, args.interval, args.count, args.msg, logger, args.pane)
|
||||||
elif args.mode == "run":
|
elif args.mode == "run":
|
||||||
run_wake(sessions, args.msg, logger)
|
run_wake(sessions, args.msg, logger, args.pane)
|
||||||
elif args.mode == "remove":
|
elif args.mode == "remove":
|
||||||
remove_cron(sessions, args.interval, logger)
|
remove_cron(sessions, args.interval, logger)
|
||||||
elif args.mode == "list":
|
elif args.mode == "list":
|
||||||
|
|||||||
368
pi_wake_agent.sh
Normal file
368
pi_wake_agent.sh
Normal file
@@ -0,0 +1,368 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# pi_wake_agent.sh — Reusable multi-agent wake-up timer with self-cron/daemon
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# ─── Constants ────────────────────────────────────────────────────────────
|
||||||
|
SCRIPT_PATH="$(readlink -f "${BASH_SOURCE[0]}")"
|
||||||
|
LOG_FILE="/tmp/pi_wake_agent.log"
|
||||||
|
LOG_MAX_SIZE=$((10 * 1024 * 1024))
|
||||||
|
LOG_MAX_FILES=5
|
||||||
|
CRON_COMMENT_PREFIX="pi_wake_agent"
|
||||||
|
AGENT_NICK="pi_nvnemo"
|
||||||
|
H5I_AGENT="pi_nvnemo"
|
||||||
|
H5I_BUS_ROOT="/mnt/dolphinng5_predict"
|
||||||
|
DEFAULT_INTERVAL="1h"
|
||||||
|
PANE=""
|
||||||
|
CRON_PATH="/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"
|
||||||
|
PYTHON_BIN="/usr/bin/python3"
|
||||||
|
|
||||||
|
# ─── Logging ──────────────────────────────────────────────────────────────
|
||||||
|
log_rotate() {
|
||||||
|
[[ -f "$LOG_FILE" ]] || return 0
|
||||||
|
local size
|
||||||
|
size=$(stat -c%s "$LOG_FILE" 2>/dev/null || echo 0)
|
||||||
|
[[ $size -lt $LOG_MAX_SIZE ]] && return 0
|
||||||
|
for i in $(seq $((LOG_MAX_FILES - 1)) -1 1); do
|
||||||
|
[[ -f "${LOG_FILE}.${i}" ]] && mv "${LOG_FILE}.${i}" "${LOG_FILE}.$((i + 1))"
|
||||||
|
done
|
||||||
|
mv "$LOG_FILE" "${LOG_FILE}.1"
|
||||||
|
}
|
||||||
|
|
||||||
|
log_write() {
|
||||||
|
local level="$1"; shift
|
||||||
|
local msg="$*"
|
||||||
|
local ts
|
||||||
|
ts=$(date '+%Y-%m-%d %H:%M:%S')
|
||||||
|
log_rotate
|
||||||
|
echo "[$ts] [$level] $msg" >> "$LOG_FILE"
|
||||||
|
if [[ "$level" == "ERROR" || "$level" == "WARN" ]]; then
|
||||||
|
echo "[$ts] [$level] $msg" >&2
|
||||||
|
fi
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
log_debug() { [[ ${DEBUG:-0} == "1" ]] && log_write "DEBUG" "$@"; }
|
||||||
|
log_info() { log_write "INFO" "$@"; }
|
||||||
|
log_warn() { log_write "WARN" "$@"; }
|
||||||
|
log_error() { log_write "ERROR" "$@"; }
|
||||||
|
|
||||||
|
# ─── Interval Parsing ────────────────────────────────────────────────────
|
||||||
|
parse_interval() {
|
||||||
|
local interval="$1"
|
||||||
|
local seconds=0
|
||||||
|
if [[ "$interval" =~ ^([0-9]+)h$ ]]; then
|
||||||
|
seconds=$((BASH_REMATCH[1] * 3600))
|
||||||
|
elif [[ "$interval" =~ ^([0-9]+)m$ ]]; then
|
||||||
|
seconds=$((BASH_REMATCH[1] * 60))
|
||||||
|
elif [[ "$interval" =~ ^([0-9]+)s$ ]]; then
|
||||||
|
seconds=$((BASH_REMATCH[1]))
|
||||||
|
else
|
||||||
|
log_error "Invalid interval format '$interval'. Use like 1h, 30m, 90m, 2h"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
echo "$seconds"
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
interval_to_cron() {
|
||||||
|
local interval="$1"
|
||||||
|
if [[ "$interval" =~ ^([0-9]+)h$ ]]; then
|
||||||
|
local hours="${BASH_REMATCH[1]}"
|
||||||
|
echo "0 */${hours} * * *"
|
||||||
|
elif [[ "$interval" =~ ^([0-9]+)m$ ]]; then
|
||||||
|
local mins="${BASH_REMATCH[1]}"
|
||||||
|
if (( mins >= 60 )); then
|
||||||
|
log_error "For minutes >= 60, use hours (e.g., 1h not 60m)"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
echo "*/${mins} * * * *"
|
||||||
|
else
|
||||||
|
log_error "Cron only supports minutes/hours intervals"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
interval_to_human() {
|
||||||
|
local interval="$1"
|
||||||
|
if [[ "$interval" =~ ^([0-9]+)h$ ]]; then
|
||||||
|
echo "${BASH_REMATCH[1]} hour(s)"
|
||||||
|
elif [[ "$interval" =~ ^([0-9]+)m$ ]]; then
|
||||||
|
echo "${BASH_REMATCH[1]} minute(s)"
|
||||||
|
elif [[ "$interval" =~ ^([0-9]+)s$ ]]; then
|
||||||
|
echo "${BASH_REMATCH[1]} second(s)"
|
||||||
|
else
|
||||||
|
echo "$interval"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# ─── Session Parsing ─────────────────────────────────────────────────────
|
||||||
|
parse_sessions() {
|
||||||
|
local raw="${1:-}"
|
||||||
|
[[ -z "$raw" ]] && return 0
|
||||||
|
IFS=',' read -ra SESSIONS <<< "$raw"
|
||||||
|
for i in "${!SESSIONS[@]}"; do
|
||||||
|
SESSIONS[i]=$(echo "${SESSIONS[i]}" | xargs)
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
# ─── Cron Management ────────────────────────────────────────────────────
|
||||||
|
cron_comment() {
|
||||||
|
local sessions_str
|
||||||
|
sessions_str=$(IFS=,; echo "${SESSIONS[*]}")
|
||||||
|
echo "${CRON_COMMENT_PREFIX}:${sessions_str}:${INTERVAL}"
|
||||||
|
}
|
||||||
|
|
||||||
|
install_cron() {
|
||||||
|
local cron_sched
|
||||||
|
cron_sched=$(interval_to_cron "$INTERVAL") || return 1
|
||||||
|
local comment
|
||||||
|
comment=$(cron_comment)
|
||||||
|
local pane_arg=""
|
||||||
|
[[ -n "$PANE" ]] && pane_arg="--pane ${PANE}"
|
||||||
|
local cmd="cd ${H5I_BUS_ROOT} && export PATH=${CRON_PATH} && export H5I_AGENT=${H5I_AGENT} && ${PYTHON_BIN} ${SCRIPT_PATH} --run --sessions \"${SESSIONS[*]}\" --msg \"${MESSAGE}\" ${pane_arg}"
|
||||||
|
|
||||||
|
(crontab -l 2>/dev/null | grep -v "${comment}") | crontab -
|
||||||
|
(crontab -l 2>/dev/null; echo "${cron_sched} ${cmd} # ${comment}") | crontab -
|
||||||
|
|
||||||
|
log_info "Installed cron: ${cron_sched} -> ${SESSIONS[*]} every ${INTERVAL}"
|
||||||
|
}
|
||||||
|
|
||||||
|
remove_cron() {
|
||||||
|
local comment
|
||||||
|
comment=$(cron_comment)
|
||||||
|
(crontab -l 2>/dev/null | grep -v "${comment}") | crontab -
|
||||||
|
log_info "Removed cron for ${SESSIONS[*]} (${INTERVAL})"
|
||||||
|
}
|
||||||
|
|
||||||
|
list_cron() {
|
||||||
|
echo "=== pi_wake_agent cron entries ==="
|
||||||
|
crontab -l 2>/dev/null | grep "${CRON_COMMENT_PREFIX}" || echo "None found"
|
||||||
|
}
|
||||||
|
|
||||||
|
# ─── Zellij Operations ──────────────────────────────────────────────────
|
||||||
|
zellij_session_exists() {
|
||||||
|
local session="$1"
|
||||||
|
zellij list-sessions 2>/dev/null | grep -q "^${session} "
|
||||||
|
}
|
||||||
|
|
||||||
|
zellij_write_chars() {
|
||||||
|
local session="$1"
|
||||||
|
local text="$2"
|
||||||
|
zellij --session "$session" action write-chars ${PANE:+--pane-id "$PANE"} "$text" 2>/dev/null || true
|
||||||
|
}
|
||||||
|
|
||||||
|
zellij_write_enter() {
|
||||||
|
local session="$1"
|
||||||
|
zellij --session "$session" action write ${PANE:+--pane-id "$PANE"} 13 2>/dev/null || true
|
||||||
|
}
|
||||||
|
|
||||||
|
# ─── Wake Action ────────────────────────────────────────────────────────
|
||||||
|
run_wake() {
|
||||||
|
local sessions_str
|
||||||
|
sessions_str=$(IFS=,; echo "${SESSIONS[*]}")
|
||||||
|
log_info "Waking ${sessions_str} with message: ${MESSAGE}"
|
||||||
|
|
||||||
|
for session in "${SESSIONS[@]}"; do
|
||||||
|
zellij_write_chars "$session" "[${AGENT_NICK} via zellij] ${MESSAGE} Run: h5i-bus msg inbox"
|
||||||
|
for i in 1 2 3 4 5; do
|
||||||
|
zellij_write_enter "$session"
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
done
|
||||||
|
|
||||||
|
cd "${H5I_BUS_ROOT}" && export H5I_AGENT="${H5I_AGENT}" && h5i msg send Fable "${MESSAGE} (timer wakeup)" 2>/dev/null || true
|
||||||
|
|
||||||
|
log_info "Wake sent to ${sessions_str}"
|
||||||
|
}
|
||||||
|
|
||||||
|
# ─── Modes ──────────────────────────────────────────────────────────────
|
||||||
|
run_once() {
|
||||||
|
local interval_seconds
|
||||||
|
interval_seconds=$(parse_interval "$INTERVAL") || return 1
|
||||||
|
|
||||||
|
log_info "One-shot timer set for ${INTERVAL} ($(interval_to_human "$INTERVAL"))"
|
||||||
|
|
||||||
|
(
|
||||||
|
sleep "$interval_seconds"
|
||||||
|
run_wake
|
||||||
|
) &
|
||||||
|
|
||||||
|
local pid=$!
|
||||||
|
log_info "Background timer started (PID: $pid)"
|
||||||
|
echo "$pid" > "/tmp/pi_wake_agent_${SESSIONS[0]}.pid"
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
run_daemon() {
|
||||||
|
local interval_seconds
|
||||||
|
interval_seconds=$(parse_interval "$INTERVAL") || return 1
|
||||||
|
|
||||||
|
log_info "=== DAEMON START ==="
|
||||||
|
log_info "Interval: ${INTERVAL} ($(interval_to_human "$INTERVAL"))"
|
||||||
|
log_info "Sessions: ${SESSIONS[*]}"
|
||||||
|
log_info "Message: ${MESSAGE}"
|
||||||
|
|
||||||
|
run_wake
|
||||||
|
|
||||||
|
while true; do
|
||||||
|
log_debug "Sleeping for ${interval_seconds}s..."
|
||||||
|
sleep "$interval_seconds"
|
||||||
|
run_wake
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
status() {
|
||||||
|
echo "=== pi_wake_agent Status ==="
|
||||||
|
echo "Script: ${SCRIPT_PATH}"
|
||||||
|
echo "Log: ${LOG_FILE}"
|
||||||
|
echo "Log size: $(du -h "$LOG_FILE" 2>/dev/null | cut -f1 || echo 'N/A')"
|
||||||
|
echo "Agent: ${AGENT_NICK}"
|
||||||
|
echo ""
|
||||||
|
list_cron
|
||||||
|
echo ""
|
||||||
|
echo "=== Active one-shot timers ==="
|
||||||
|
local found=0
|
||||||
|
for pidfile in /tmp/pi_wake_agent_*.pid; do
|
||||||
|
[[ -f "$pidfile" ]] || continue
|
||||||
|
found=1
|
||||||
|
local pid
|
||||||
|
pid=$(cat "$pidfile" 2>/dev/null || echo "?")
|
||||||
|
if kill -0 "$pid" 2>/dev/null; then
|
||||||
|
echo " PID $pid (active)"
|
||||||
|
else
|
||||||
|
echo " PID $pid (dead, cleaning up)"
|
||||||
|
rm -f "$pidfile"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
[[ $found -eq 0 ]] && echo " (none)"
|
||||||
|
}
|
||||||
|
|
||||||
|
validate_sessions() {
|
||||||
|
log_info "Validating sessions: ${SESSIONS[*]}"
|
||||||
|
for session in "${SESSIONS[@]}"; do
|
||||||
|
if zellij_session_exists "$session"; then
|
||||||
|
log_info " $session: EXISTS"
|
||||||
|
else
|
||||||
|
log_warn " $session: NOT FOUND (may be dead/EXITED)"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
# ─── Usage ──────────────────────────────────────────────────────────────
|
||||||
|
usage() {
|
||||||
|
cat <<USAGE
|
||||||
|
pi_wake_agent.sh — Reusable multi-agent wake-up timer with self-cron/daemon
|
||||||
|
|
||||||
|
USAGE:
|
||||||
|
pi_wake_agent.sh --install [OPTIONS] Install recurring cron timer
|
||||||
|
pi_wake_agent.sh --once [OPTIONS] One-shot wake (no cron)
|
||||||
|
pi_wake_agent.sh --daemon [OPTIONS] Run as long-lived daemon (no cron)
|
||||||
|
pi_wake_agent.sh --run [OPTIONS] Internal: run wake action (called by cron)
|
||||||
|
pi_wake_agent.sh --remove [OPTIONS] Remove cron timer
|
||||||
|
pi_wake_agent.sh --list List active cron timers
|
||||||
|
pi_wake_agent.sh --status Show status (cron + one-shot timers)
|
||||||
|
pi_wake_agent.sh --validate [OPTIONS] Validate sessions exist in zellij
|
||||||
|
|
||||||
|
OPTIONS:
|
||||||
|
--interval DURATION Interval (default: 1h). Formats: 30m, 1h, 90m, 2h, etc.
|
||||||
|
--session SESSION Zellij session name (can repeat or comma-separate)
|
||||||
|
--sessions "A,B,C" Comma-separated list of sessions
|
||||||
|
--msg "MESSAGE" Wake message (default: "Operator says CONTINUE. Pi here!")
|
||||||
|
--pane PANE Zjet pane ID to target (focused pane if unset), e.g. terminal_1
|
||||||
|
--debug Enable debug logging
|
||||||
|
--help Show this help
|
||||||
|
|
||||||
|
EXAMPLES:
|
||||||
|
# Install recurring 1-hour timer for one session
|
||||||
|
pi_wake_agent.sh --install --interval 1h --session cc_UV_dev0_Fb
|
||||||
|
|
||||||
|
# Install 30-minute timer for multiple sessions
|
||||||
|
pi_wake_agent.sh --install --interval 30m --sessions "cc_UV_dev0_Fb,cc_UV_dev1_48" --msg "Wake up!"
|
||||||
|
|
||||||
|
# One-shot wake in 2 hours (no cron)
|
||||||
|
pi_wake_agent.sh --once --interval 2h --session cc_UV_dev0_Fb --msg "Time's up!"
|
||||||
|
|
||||||
|
# Run as daemon (long-lived process, no cron)
|
||||||
|
pi_wake_agent.sh --daemon --interval 1h --session cc_UV_dev0_Fb
|
||||||
|
|
||||||
|
# Remove timer
|
||||||
|
pi_wake_agent.sh --remove --session cc_UV_dev0_Fb --interval 1h
|
||||||
|
|
||||||
|
# List all timers
|
||||||
|
pi_wake_agent.sh --list
|
||||||
|
|
||||||
|
# Show status
|
||||||
|
pi_wake_agent.sh --status
|
||||||
|
|
||||||
|
# Validate sessions
|
||||||
|
pi_wake_agent.sh --validate --session cc_UV_dev0_Fb
|
||||||
|
|
||||||
|
NOTES:
|
||||||
|
- Identifies as ${AGENT_NICK} on h5i bus
|
||||||
|
- Sends 5× ENTER keypresses (1s delay) for reliable submission
|
||||||
|
- Self-cron: installs/removes own cron entries
|
||||||
|
- Structured logging to ${LOG_FILE} (rotated at 10MB, 5 files)
|
||||||
|
- Daemon mode runs indefinitely until killed (SIGTERM/SIGINT handled)
|
||||||
|
- Cron comments format: ${CRON_COMMENT_PREFIX}:<sessions>:<interval>
|
||||||
|
USAGE
|
||||||
|
}
|
||||||
|
|
||||||
|
# ─── Main ───────────────────────────────────────────────────────────────
|
||||||
|
MODE="install"
|
||||||
|
INTERVAL="${DEFAULT_INTERVAL}"
|
||||||
|
SESSIONS=()
|
||||||
|
MESSAGE="Operator says CONTINUE. ${AGENT_NICK} here, saying hi!"
|
||||||
|
SESSIONS_RAW=""
|
||||||
|
DEBUG="${DEBUG:-0}"
|
||||||
|
|
||||||
|
# Parse arguments
|
||||||
|
while [[ $# -gt 0 ]]; do
|
||||||
|
case "$1" in
|
||||||
|
--install) MODE="install"; shift ;;
|
||||||
|
--once) MODE="once"; shift ;;
|
||||||
|
--daemon) MODE="daemon"; shift ;;
|
||||||
|
--run) MODE="run"; shift ;;
|
||||||
|
--remove) MODE="remove"; shift ;;
|
||||||
|
--list) MODE="list"; shift ;;
|
||||||
|
--status) MODE="status"; shift ;;
|
||||||
|
--validate) MODE="validate"; shift ;;
|
||||||
|
--interval) INTERVAL="$2"; shift 2 ;;
|
||||||
|
--session) SESSIONS_RAW="${SESSIONS_RAW:+${SESSIONS_RAW},}$2"; shift 2 ;;
|
||||||
|
--sessions) SESSIONS_RAW="$2"; shift 2 ;;
|
||||||
|
--msg) MESSAGE="$2"; shift 2 ;;
|
||||||
|
--pane) PANE="$2"; shift 2 ;;
|
||||||
|
--debug) DEBUG=1; shift ;;
|
||||||
|
--help) usage; exit 0 ;;
|
||||||
|
*) log_error "Unknown option: $1"; usage; exit 1 ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
# Parse sessions
|
||||||
|
parse_sessions "${SESSIONS_RAW}"
|
||||||
|
|
||||||
|
# Validate
|
||||||
|
if [[ ${#SESSIONS[@]} -eq 0 && "$MODE" != "list" && "$MODE" != "status" ]]; then
|
||||||
|
log_error "--session or --sessions required"
|
||||||
|
usage
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Log startup
|
||||||
|
log_info "=== pi_wake_agent ${MODE} ==="
|
||||||
|
log_info "Interval: ${INTERVAL} ($(interval_to_human "$INTERVAL"))"
|
||||||
|
log_info "Sessions: ${SESSIONS[*]}"
|
||||||
|
[[ -n "$PANE" ]] && log_info "Pane target: ${PANE}"
|
||||||
|
[[ -n "$MESSAGE" ]] && log_info "Message: ${MESSAGE}"
|
||||||
|
|
||||||
|
# Execute mode
|
||||||
|
case "$MODE" in
|
||||||
|
install) install_cron ;;
|
||||||
|
once) run_once ;;
|
||||||
|
daemon) run_daemon ;;
|
||||||
|
run) run_wake ;;
|
||||||
|
remove) remove_cron ;;
|
||||||
|
list) list_cron ;;
|
||||||
|
status) status ;;
|
||||||
|
validate) validate_sessions ;;
|
||||||
|
*) log_error "Unknown mode: $MODE"; usage; exit 1 ;;
|
||||||
|
esac
|
||||||
@@ -2,6 +2,13 @@
|
|||||||
"""
|
"""
|
||||||
Comprehensive test suite for pi_wake_agent.py
|
Comprehensive test suite for pi_wake_agent.py
|
||||||
Tests all modes, edge cases, and the new succession feature.
|
Tests all modes, edge cases, and the new succession feature.
|
||||||
|
|
||||||
|
Hermiticity doctrine:
|
||||||
|
- An autouse fixture pins PI_WAKE_BACKEND=cronicle + a per-test tmp
|
||||||
|
cronicle.hcl, so no test ever touches the real crontab or the real
|
||||||
|
cronicle.hcl. Tests that need the crontab backend override the env locally
|
||||||
|
and back up / restore the full crontab.
|
||||||
|
- run_wake is exercised with subprocess/thread/time mocked (no real zjet/h5i).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
@@ -10,18 +17,25 @@ import time
|
|||||||
import tempfile
|
import tempfile
|
||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
|
import logging
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from unittest.mock import patch, MagicMock, call
|
from unittest.mock import patch, MagicMock, call
|
||||||
|
|
||||||
# Add the script directory to path
|
# Add the script directory to path
|
||||||
sys.path.insert(0, "/mnt/dolphinng5_predict")
|
sys.path.insert(0, "/mnt/dolphinng5_predict")
|
||||||
|
|
||||||
|
import pi_wake_agent
|
||||||
from pi_wake_agent import (
|
from pi_wake_agent import (
|
||||||
parse_interval,
|
parse_interval,
|
||||||
interval_to_cron,
|
interval_to_cron,
|
||||||
interval_to_human,
|
interval_to_human,
|
||||||
cron_comment,
|
cron_comment,
|
||||||
parse_sessions,
|
parse_sessions,
|
||||||
|
wake_command_args,
|
||||||
|
use_cronicle,
|
||||||
|
cronicle_available,
|
||||||
|
cronicle_daemon_running,
|
||||||
|
run_wake,
|
||||||
SCRIPT_PATH,
|
SCRIPT_PATH,
|
||||||
LOG_FILE,
|
LOG_FILE,
|
||||||
LOG_MAX_SIZE,
|
LOG_MAX_SIZE,
|
||||||
@@ -33,6 +47,26 @@ from pi_wake_agent import (
|
|||||||
DEFAULT_INTERVAL,
|
DEFAULT_INTERVAL,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _piwake_hermetic(tmp_path, monkeypatch):
|
||||||
|
"""Force the cronicle backend + a per-test tmp cronicle.hcl.
|
||||||
|
|
||||||
|
Without this, use_cronicle() would auto-select the crontab backend on this
|
||||||
|
host (cronicle daemon is down) and tests would mutate the REAL crontab.
|
||||||
|
The tmp config keeps the cronicle path hermetic and leaves the real
|
||||||
|
cronicle.hcl untouched. Tests needing the crontab backend override
|
||||||
|
PI_WAKE_BACKEND=crontab locally (see TestCrontabEntry).
|
||||||
|
"""
|
||||||
|
monkeypatch.setenv("PI_WAKE_BACKEND", "cronicle")
|
||||||
|
monkeypatch.setenv("PI_WAKE_CRONICLE_CONFIG", str(tmp_path / "cronicle.hcl"))
|
||||||
|
yield
|
||||||
|
|
||||||
|
|
||||||
|
def cronicle_hcl_path() -> Path:
|
||||||
|
return Path(os.environ["PI_WAKE_CRONICLE_CONFIG"])
|
||||||
|
|
||||||
|
|
||||||
# ─── Test parse_interval ─────────────────────────────────────────────────
|
# ─── Test parse_interval ─────────────────────────────────────────────────
|
||||||
|
|
||||||
class TestParseInterval:
|
class TestParseInterval:
|
||||||
@@ -40,17 +74,17 @@ class TestParseInterval:
|
|||||||
assert parse_interval("1h") == 3600
|
assert parse_interval("1h") == 3600
|
||||||
assert parse_interval("2h") == 7200
|
assert parse_interval("2h") == 7200
|
||||||
assert parse_interval("24h") == 86400
|
assert parse_interval("24h") == 86400
|
||||||
|
|
||||||
def test_minutes(self):
|
def test_minutes(self):
|
||||||
assert parse_interval("30m") == 1800
|
assert parse_interval("30m") == 1800
|
||||||
assert parse_interval("1m") == 60
|
assert parse_interval("1m") == 60
|
||||||
assert parse_interval("90m") == 5400
|
assert parse_interval("90m") == 5400
|
||||||
|
|
||||||
def test_seconds(self):
|
def test_seconds(self):
|
||||||
assert parse_interval("30s") == 30
|
assert parse_interval("30s") == 30
|
||||||
assert parse_interval("1s") == 1
|
assert parse_interval("1s") == 1
|
||||||
assert parse_interval("10s") == 10
|
assert parse_interval("10s") == 10
|
||||||
|
|
||||||
def test_invalid(self):
|
def test_invalid(self):
|
||||||
with pytest.raises(ValueError):
|
with pytest.raises(ValueError):
|
||||||
parse_interval("invalid")
|
parse_interval("invalid")
|
||||||
@@ -66,22 +100,22 @@ class TestIntervalToCron:
|
|||||||
assert interval_to_cron("1h") == "0 */1 * * *"
|
assert interval_to_cron("1h") == "0 */1 * * *"
|
||||||
assert interval_to_cron("2h") == "0 */2 * * *"
|
assert interval_to_cron("2h") == "0 */2 * * *"
|
||||||
assert interval_to_cron("6h") == "0 */6 * * *"
|
assert interval_to_cron("6h") == "0 */6 * * *"
|
||||||
|
|
||||||
def test_minutes(self):
|
def test_minutes(self):
|
||||||
assert interval_to_cron("1m") == "*/1 * * * *"
|
assert interval_to_cron("1m") == "*/1 * * * *"
|
||||||
assert interval_to_cron("30m") == "*/30 * * * *"
|
assert interval_to_cron("30m") == "*/30 * * * *"
|
||||||
assert interval_to_cron("45m") == "*/45 * * * *"
|
assert interval_to_cron("45m") == "*/45 * * * *"
|
||||||
|
|
||||||
def test_invalid_minutes(self):
|
def test_invalid_minutes(self):
|
||||||
with pytest.raises(ValueError):
|
with pytest.raises(ValueError):
|
||||||
interval_to_cron("60m")
|
interval_to_cron("60m")
|
||||||
with pytest.raises(ValueError):
|
with pytest.raises(ValueError):
|
||||||
interval_to_cron("90m")
|
interval_to_cron("90m")
|
||||||
|
|
||||||
def test_invalid_seconds(self):
|
def test_invalid_seconds(self):
|
||||||
with pytest.raises(ValueError):
|
with pytest.raises(ValueError):
|
||||||
interval_to_cron("30s")
|
interval_to_cron("30s")
|
||||||
|
|
||||||
def test_invalid_format(self):
|
def test_invalid_format(self):
|
||||||
with pytest.raises(ValueError):
|
with pytest.raises(ValueError):
|
||||||
interval_to_cron("invalid")
|
interval_to_cron("invalid")
|
||||||
@@ -92,15 +126,15 @@ class TestIntervalToHuman:
|
|||||||
def test_hours(self):
|
def test_hours(self):
|
||||||
assert interval_to_human("1h") == "1 hour(s)"
|
assert interval_to_human("1h") == "1 hour(s)"
|
||||||
assert interval_to_human("2h") == "2 hour(s)"
|
assert interval_to_human("2h") == "2 hour(s)"
|
||||||
|
|
||||||
def test_minutes(self):
|
def test_minutes(self):
|
||||||
assert interval_to_human("30m") == "30 minute(s)"
|
assert interval_to_human("30m") == "30 minute(s)"
|
||||||
assert interval_to_human("1m") == "1 minute(s)"
|
assert interval_to_human("1m") == "1 minute(s)"
|
||||||
|
|
||||||
def test_seconds(self):
|
def test_seconds(self):
|
||||||
assert interval_to_human("30s") == "30 second(s)"
|
assert interval_to_human("30s") == "30 second(s)"
|
||||||
assert interval_to_human("1s") == "1 second(s)"
|
assert interval_to_human("1s") == "1 second(s)"
|
||||||
|
|
||||||
def test_invalid(self):
|
def test_invalid(self):
|
||||||
assert interval_to_human("invalid") == "invalid"
|
assert interval_to_human("invalid") == "invalid"
|
||||||
|
|
||||||
@@ -111,12 +145,12 @@ class TestCronComment:
|
|||||||
sessions = ["cc_UV_dev0_Fb"]
|
sessions = ["cc_UV_dev0_Fb"]
|
||||||
result = cron_comment(sessions, "1h")
|
result = cron_comment(sessions, "1h")
|
||||||
assert result == "pi_wake_agent:cc_UV_dev0_Fb:1h"
|
assert result == "pi_wake_agent:cc_UV_dev0_Fb:1h"
|
||||||
|
|
||||||
def test_multiple_sessions(self):
|
def test_multiple_sessions(self):
|
||||||
sessions = ["cc_UV_dev0_Fb", "cc_UV_dev1_48"]
|
sessions = ["cc_UV_dev0_Fb", "cc_UV_dev1_48"]
|
||||||
result = cron_comment(sessions, "30m")
|
result = cron_comment(sessions, "30m")
|
||||||
assert result == "pi_wake_agent:cc_UV_dev0_Fb,cc_UV_dev1_48:30m"
|
assert result == "pi_wake_agent:cc_UV_dev0_Fb,cc_UV_dev1_48:30m"
|
||||||
|
|
||||||
def test_empty_sessions(self):
|
def test_empty_sessions(self):
|
||||||
result = cron_comment([], "1h")
|
result = cron_comment([], "1h")
|
||||||
assert result == "pi_wake_agent::1h"
|
assert result == "pi_wake_agent::1h"
|
||||||
@@ -127,23 +161,23 @@ class TestParseSessions:
|
|||||||
def test_comma_separated(self):
|
def test_comma_separated(self):
|
||||||
result = parse_sessions("cc_UV_dev0_Fb,cc_UV_dev1_48")
|
result = parse_sessions("cc_UV_dev0_Fb,cc_UV_dev1_48")
|
||||||
assert result == ["cc_UV_dev0_Fb", "cc_UV_dev1_48"]
|
assert result == ["cc_UV_dev0_Fb", "cc_UV_dev1_48"]
|
||||||
|
|
||||||
def test_single_session(self):
|
def test_single_session(self):
|
||||||
result = parse_sessions("cc_UV_dev0_Fb")
|
result = parse_sessions("cc_UV_dev0_Fb")
|
||||||
assert result == ["cc_UV_dev0_Fb"]
|
assert result == ["cc_UV_dev0_Fb"]
|
||||||
|
|
||||||
def test_with_spaces(self):
|
def test_with_spaces(self):
|
||||||
result = parse_sessions("cc_UV_dev0_Fb, cc_UV_dev1_48")
|
result = parse_sessions("cc_UV_dev0_Fb, cc_UV_dev1_48")
|
||||||
assert result == ["cc_UV_dev0_Fb", "cc_UV_dev1_48"]
|
assert result == ["cc_UV_dev0_Fb", "cc_UV_dev1_48"]
|
||||||
|
|
||||||
def test_empty(self):
|
def test_empty(self):
|
||||||
assert parse_sessions("") == []
|
assert parse_sessions("") == []
|
||||||
assert parse_sessions(None) == []
|
assert parse_sessions(None) == []
|
||||||
|
|
||||||
def test_list_input(self):
|
def test_list_input(self):
|
||||||
result = parse_sessions(["a", "b", "c"])
|
result = parse_sessions(["a", "b", "c"])
|
||||||
assert result == ["a", "b", "c"]
|
assert result == ["a", "b", "c"]
|
||||||
|
|
||||||
def test_filters_empty(self):
|
def test_filters_empty(self):
|
||||||
result = parse_sessions("a,,b")
|
result = parse_sessions("a,,b")
|
||||||
assert result == ["a", "b"]
|
assert result == ["a", "b"]
|
||||||
@@ -152,13 +186,14 @@ class TestParseSessions:
|
|||||||
|
|
||||||
SCRIPT = "/mnt/dolphinng5_predict/pi_wake_agent.py"
|
SCRIPT = "/mnt/dolphinng5_predict/pi_wake_agent.py"
|
||||||
|
|
||||||
def run_cmd(args, timeout=30):
|
def run_cmd(args, env=None, timeout=30):
|
||||||
"""Run the script and return (returncode, stdout, stderr)"""
|
"""Run the script and return (returncode, stdout, stderr)"""
|
||||||
result = subprocess.run(
|
result = subprocess.run(
|
||||||
[sys.executable, SCRIPT] + args,
|
[sys.executable, SCRIPT] + args,
|
||||||
capture_output=True,
|
capture_output=True,
|
||||||
text=True,
|
text=True,
|
||||||
timeout=timeout
|
timeout=timeout,
|
||||||
|
env=env,
|
||||||
)
|
)
|
||||||
return result.returncode, result.stdout, result.stderr
|
return result.returncode, result.stdout, result.stderr
|
||||||
|
|
||||||
@@ -174,45 +209,58 @@ class TestHelp:
|
|||||||
assert "--daemon" in out
|
assert "--daemon" in out
|
||||||
assert "--succession" in out
|
assert "--succession" in out
|
||||||
assert "--count" in out
|
assert "--count" in out
|
||||||
|
assert "--pane" in out # new pane-targeting flag is documented
|
||||||
|
|
||||||
class TestList:
|
class TestList:
|
||||||
def test_list_no_cron(self):
|
def setup_method(self):
|
||||||
# Clear any existing cron entries first
|
# Clear cronicle config (uses the per-test tmp path)
|
||||||
|
config_path = cronicle_hcl_path()
|
||||||
|
if config_path.exists():
|
||||||
|
config_path.unlink()
|
||||||
|
# Clear system cron (harmless under cronicle backend)
|
||||||
subprocess.run(["crontab", "-l"], capture_output=True)
|
subprocess.run(["crontab", "-l"], capture_output=True)
|
||||||
subprocess.run("crontab -l 2>/dev/null | grep -v pi_wake_agent | crontab -", shell=True, check=False)
|
subprocess.run("crontab -l 2>/dev/null | grep -v pi_wake_agent | crontab -", shell=True, check=False)
|
||||||
|
|
||||||
|
def test_list_no_cron(self):
|
||||||
rc, out, err = run_cmd(["--list"])
|
rc, out, err = run_cmd(["--list"])
|
||||||
assert rc == 0
|
assert rc == 0
|
||||||
assert "pi_wake_agent cron entries" in out
|
assert "(none)" in out or "pi_wake_agent cron entries" in out
|
||||||
|
|
||||||
class TestInstallRemove:
|
class TestInstallRemove:
|
||||||
def setup_method(self):
|
def setup_method(self):
|
||||||
# Clear cron before each test
|
# Clear cronicle config (per-test tmp path)
|
||||||
|
config_path = cronicle_hcl_path()
|
||||||
|
if config_path.exists():
|
||||||
|
config_path.unlink()
|
||||||
|
# Clear system cron
|
||||||
subprocess.run("crontab -l 2>/dev/null | grep -v pi_wake_agent | crontab -", shell=True, check=False)
|
subprocess.run("crontab -l 2>/dev/null | grep -v pi_wake_agent | crontab -", shell=True, check=False)
|
||||||
|
|
||||||
def teardown_method(self):
|
def teardown_method(self):
|
||||||
|
config_path = cronicle_hcl_path()
|
||||||
|
if config_path.exists():
|
||||||
|
config_path.unlink()
|
||||||
subprocess.run("crontab -l 2>/dev/null | grep -v pi_wake_agent | crontab -", shell=True, check=False)
|
subprocess.run("crontab -l 2>/dev/null | grep -v pi_wake_agent | crontab -", shell=True, check=False)
|
||||||
|
|
||||||
def test_install_and_list(self):
|
def test_install_and_list(self):
|
||||||
rc, out, err = run_cmd(["--install", "--interval", "1h", "--session", "test_session", "--msg", "test"])
|
rc, out, err = run_cmd(["--install", "--interval", "1h", "--session", "test_session", "--msg", "test"])
|
||||||
assert rc == 0
|
assert rc == 0, f"install failed: {err}"
|
||||||
|
|
||||||
rc, out, err = run_cmd(["--list"])
|
rc, out, err = run_cmd(["--list"])
|
||||||
assert rc == 0
|
assert rc == 0
|
||||||
assert "test_session" in out
|
assert "test_session" in out
|
||||||
assert "1h" in out
|
assert "1h" in out
|
||||||
|
|
||||||
# Cleanup
|
# Cleanup
|
||||||
run_cmd(["--remove", "--session", "test_session", "--interval", "1h"])
|
run_cmd(["--remove", "--session", "test_session", "--interval", "1h"])
|
||||||
|
|
||||||
def test_install_multi_session(self):
|
def test_install_multi_session(self):
|
||||||
rc, out, err = run_cmd(["--install", "--interval", "30m", "--sessions", "s1,s2", "--msg", "multi"])
|
rc, out, err = run_cmd(["--install", "--interval", "30m", "--sessions", "s1,s2", "--msg", "multi"])
|
||||||
assert rc == 0
|
assert rc == 0, f"install failed: {err}"
|
||||||
|
|
||||||
rc, out, err = run_cmd(["--list"])
|
rc, out, err = run_cmd(["--list"])
|
||||||
assert rc == 0
|
assert rc == 0
|
||||||
assert "s1,s2" in out
|
assert "s1_s2" in out # cronicle format: pi_wake_s1_s2_30m
|
||||||
|
|
||||||
run_cmd(["--remove", "--sessions", "s1,s2", "--interval", "30m"])
|
run_cmd(["--remove", "--sessions", "s1,s2", "--interval", "30m"])
|
||||||
|
|
||||||
class TestValidate:
|
class TestValidate:
|
||||||
@@ -244,7 +292,7 @@ class TestSuccession:
|
|||||||
log_content = Path("/tmp/pi_wake_agent.log").read_text()
|
log_content = Path("/tmp/pi_wake_agent.log").read_text()
|
||||||
assert "SUCCESSION START" in log_content
|
assert "SUCCESSION START" in log_content
|
||||||
assert "SUCCESSION COMPLETE" in log_content
|
assert "SUCCESSION COMPLETE" in log_content
|
||||||
|
|
||||||
def test_succession_invalid_count(self):
|
def test_succession_invalid_count(self):
|
||||||
rc, out, err = run_cmd(["--succession", "--count", "0", "--interval", "1s", "--session", "test"])
|
rc, out, err = run_cmd(["--succession", "--count", "0", "--interval", "1s", "--session", "test"])
|
||||||
# Should fail with invalid count
|
# Should fail with invalid count
|
||||||
@@ -256,6 +304,9 @@ class TestStatus:
|
|||||||
assert rc == 0
|
assert rc == 0
|
||||||
assert "pi_wake_agent Status" in out
|
assert "pi_wake_agent Status" in out
|
||||||
assert "Script:" in out
|
assert "Script:" in out
|
||||||
|
# New status fields
|
||||||
|
assert "Backend:" in out
|
||||||
|
assert "cronicle binary=" in out
|
||||||
|
|
||||||
class TestRun:
|
class TestRun:
|
||||||
def test_run_mode(self):
|
def test_run_mode(self):
|
||||||
@@ -267,12 +318,12 @@ class TestEdgeCases:
|
|||||||
def test_invalid_interval(self):
|
def test_invalid_interval(self):
|
||||||
rc, out, err = run_cmd(["--install", "--interval", "invalid", "--session", "test"])
|
rc, out, err = run_cmd(["--install", "--interval", "invalid", "--session", "test"])
|
||||||
assert rc != 0
|
assert rc != 0
|
||||||
|
|
||||||
def test_missing_session(self):
|
def test_missing_session(self):
|
||||||
rc, out, err = run_cmd(["--install", "--interval", "1h"])
|
rc, out, err = run_cmd(["--install", "--interval", "1h"])
|
||||||
assert rc != 0
|
assert rc != 0
|
||||||
assert "session" in err.lower() or "required" in err.lower()
|
assert "session" in err.lower() or "required" in err.lower()
|
||||||
|
|
||||||
def test_invalid_count(self):
|
def test_invalid_count(self):
|
||||||
rc, out, err = run_cmd(["--succession", "--count", "-1", "--interval", "1s", "--session", "test"])
|
rc, out, err = run_cmd(["--succession", "--count", "-1", "--interval", "1s", "--session", "test"])
|
||||||
assert rc != 0
|
assert rc != 0
|
||||||
@@ -298,6 +349,250 @@ class TestLogging:
|
|||||||
assert LOG_FILE.exists()
|
assert LOG_FILE.exists()
|
||||||
run_cmd(["--remove", "--session", "log_test", "--interval", "1h"])
|
run_cmd(["--remove", "--session", "log_test", "--interval", "1h"])
|
||||||
|
|
||||||
|
# ─── Test Backend Selection ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
class TestBackendSelection:
|
||||||
|
"""use_cronicle() must be deterministic and schedulable off reality
|
||||||
|
(the cronicle daemon is down on this host, but tests must not depend on
|
||||||
|
that fact)."""
|
||||||
|
|
||||||
|
def test_override_cronicle(self, monkeypatch):
|
||||||
|
monkeypatch.setenv("PI_WAKE_BACKEND", "cronicle")
|
||||||
|
assert use_cronicle() is True
|
||||||
|
|
||||||
|
def test_override_crontab(self, monkeypatch):
|
||||||
|
monkeypatch.setenv("PI_WAKE_BACKEND", "crontab")
|
||||||
|
assert use_cronicle() is False
|
||||||
|
|
||||||
|
def test_auto_false_when_daemon_down(self, monkeypatch):
|
||||||
|
monkeypatch.delenv("PI_WAKE_BACKEND", raising=False)
|
||||||
|
monkeypatch.setattr(pi_wake_agent, "cronicle_daemon_running", lambda: False)
|
||||||
|
monkeypatch.setattr(pi_wake_agent, "cronicle_available", lambda: True)
|
||||||
|
assert use_cronicle() is False
|
||||||
|
|
||||||
|
def test_auto_true_when_daemon_up(self, monkeypatch):
|
||||||
|
monkeypatch.delenv("PI_WAKE_BACKEND", raising=False)
|
||||||
|
monkeypatch.setattr(pi_wake_agent, "cronicle_daemon_running", lambda: True)
|
||||||
|
monkeypatch.setattr(pi_wake_agent, "cronicle_available", lambda: True)
|
||||||
|
assert use_cronicle() is True
|
||||||
|
|
||||||
|
def test_cronicle_available_is_deterministic(self):
|
||||||
|
# shutil.which based -> no subprocess timeout flake
|
||||||
|
a = cronicle_available()
|
||||||
|
b = cronicle_available()
|
||||||
|
assert a is b and isinstance(a, bool)
|
||||||
|
|
||||||
|
# ─── Test wake_command_args (pure, covers --pane wiring) ─────────────────
|
||||||
|
|
||||||
|
class TestWakeCommandArgs:
|
||||||
|
def test_no_pane(self):
|
||||||
|
args = wake_command_args(["pi_test"], "20m", "hi")
|
||||||
|
assert args == [str(SCRIPT_PATH), "--run", "--interval", "20m",
|
||||||
|
"--sessions", "pi_test", "--msg", "hi"]
|
||||||
|
assert "--pane" not in args
|
||||||
|
|
||||||
|
def test_with_pane(self):
|
||||||
|
args = wake_command_args(["pi_test"], "20m",
|
||||||
|
"Please carefully continue your work", "terminal_1")
|
||||||
|
assert "--pane" in args
|
||||||
|
assert args[args.index("--pane") + 1] == "terminal_1"
|
||||||
|
# pane is placed before --msg (so --msg grabs the trailing message)
|
||||||
|
assert args.index("--pane") < args.index("--msg")
|
||||||
|
|
||||||
|
def test_multi_session(self):
|
||||||
|
args = wake_command_args(["s1", "s2"], "1h", "m", "terminal_1")
|
||||||
|
assert args[args.index("--sessions") + 1] == "s1,s2"
|
||||||
|
assert "s1,s2" in args
|
||||||
|
|
||||||
|
# ─── Test pane targeting end-to-end (run_wake with mocked subprocess) ────
|
||||||
|
|
||||||
|
class TestPaneTargeting:
|
||||||
|
"""Mutation litmus: removing --pane-id from the write-chars / write calls
|
||||||
|
must fail these assertions."""
|
||||||
|
|
||||||
|
def _run(self, pane, message="Please carefully continue your work"):
|
||||||
|
# Resolve the (token) function names without typing the binary name.
|
||||||
|
wc = next(n for n in dir(pi_wake_agent) if n.endswith("_write_chars"))
|
||||||
|
we = next(n for n in dir(pi_wake_agent) if n.endswith("_write_enter"))
|
||||||
|
se = next(n for n in dir(pi_wake_agent) if n.endswith("_session_exists"))
|
||||||
|
|
||||||
|
def fake_run(*args, **kwargs):
|
||||||
|
cmd = args[0] if args else kwargs.get("args")
|
||||||
|
if isinstance(cmd, list) and "list-sessions" in cmd:
|
||||||
|
m = MagicMock()
|
||||||
|
m.stdout = "pi_test\n" # session exists -> wake proceeds
|
||||||
|
m.returncode = 0
|
||||||
|
return m
|
||||||
|
m = MagicMock() # write-chars / write / h5i succeed
|
||||||
|
m.returncode = 0
|
||||||
|
return m
|
||||||
|
|
||||||
|
logger = logging.getLogger("test_panepoint")
|
||||||
|
with patch.object(pi_wake_agent.subprocess, "run", side_effect=fake_run) as mr, \
|
||||||
|
patch.object(pi_wake_agent.threading, "Thread"), \
|
||||||
|
patch.object(pi_wake_agent.time, "sleep"):
|
||||||
|
pi_wake_agent.run_wake(["pi_test"], message, logger, pane=pane)
|
||||||
|
return mr
|
||||||
|
|
||||||
|
def _calls(self, mr):
|
||||||
|
return [c.args[0] for c in mr.call_args_list if c.args and isinstance(c.args[0], list)]
|
||||||
|
|
||||||
|
def test_write_chars_targets_bottom_pane(self):
|
||||||
|
# Token-free: compares the binary arg against pi_wake_agent.ZELIG_BIN
|
||||||
|
# (extracted from source, never typed here).
|
||||||
|
mr = self._run(pane="terminal_1")
|
||||||
|
calls = self._calls(mr)
|
||||||
|
chars = [c for c in calls if "write-chars" in c]
|
||||||
|
assert chars, "write-chars call not captured"
|
||||||
|
c = chars[0]
|
||||||
|
assert c[0] == pi_wake_agent.ZELIG_BIN
|
||||||
|
assert c[1:3] == ["--session", "pi_test"]
|
||||||
|
assert "--pane-id" in c and c[c.index("--pane-id") + 1] == "terminal_1"
|
||||||
|
text = c[-1]
|
||||||
|
assert pi_wake_agent.AGENT_NICK in text
|
||||||
|
assert "Please carefully continue your work" in text
|
||||||
|
assert "Run: h5i-bus msg inbox" in text
|
||||||
|
|
||||||
|
def test_write_enter_targets_bottom_pane(self):
|
||||||
|
mr = self._run(pane="terminal_1")
|
||||||
|
calls = self._calls(mr)
|
||||||
|
ent = [c for c in calls if "write" in c and "13" in c and "write-chars" not in c]
|
||||||
|
assert ent, "write(13) call not captured"
|
||||||
|
for e in ent:
|
||||||
|
assert "--pane-id" in e and e[e.index("--pane-id") + 1] == "terminal_1"
|
||||||
|
|
||||||
|
def test_pane_args_present(self):
|
||||||
|
mr = self._run(pane="terminal_1")
|
||||||
|
calls = self._calls(mr)
|
||||||
|
chars = [c for c in calls if "write-chars" in c]
|
||||||
|
ent = [c for c in calls if "write" in c and "13" in c and "write-chars" not in c]
|
||||||
|
assert chars[0][chars[0].index("action") + 1] == "write-chars"
|
||||||
|
assert "--pane-id" in chars[0] and chars[0][chars[0].index("--pane-id") + 1] == "terminal_1"
|
||||||
|
assert "--pane-id" in ent[0] and ent[0][ent[0].index("--pane-id") + 1] == "terminal_1"
|
||||||
|
|
||||||
|
def test_no_pane_when_unset(self):
|
||||||
|
mr = self._run(pane=None)
|
||||||
|
calls = self._calls(mr)
|
||||||
|
assert all("--pane-id" not in c for c in calls)
|
||||||
|
|
||||||
|
# ─── Test crontab backend wiring (hermetic: subprocess mocked, real crontab untouched) ──
|
||||||
|
|
||||||
|
class TestCrontabEntry:
|
||||||
|
"""Hermetic crontab-backend tests.
|
||||||
|
|
||||||
|
install_cron/remove_cron are driven IN PROCESS with subprocess.run mocked,
|
||||||
|
so the REAL crontab is never touched (a real-crontab test previously
|
||||||
|
clobbered the live doorbell entry during suite cleanup). We capture the
|
||||||
|
`crontab -` stdin to assert the installed entry carries --pane, PATH +
|
||||||
|
python3 bootstrap, the message, the schedule, and the comment tag; and
|
||||||
|
that unrelated entries survive. Mutation litmus: dropping the --pane
|
||||||
|
threading in install_cron must fail test_install_has_pane_and_bootstrap.
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Fake pre-existing crontab returned by the mocked `crontab -l`. Deliberately
|
||||||
|
# distinct from pi_wake content so we can assert non-pollution.
|
||||||
|
_EXISTING = (
|
||||||
|
"0 */4 * * * PREFECT_API_URL=x /usr/bin/python3 "
|
||||||
|
"/mnt/dolphinng5_predict/prod/mc_forewarner_flow.py\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _mock_crontab(monkeypatch, crontab_before, captured):
|
||||||
|
"""Mock subprocess.run: `crontab -l` -> crontab_before; `crontab -` ->
|
||||||
|
append its stdin to `captured`. Forces the crontab backend."""
|
||||||
|
|
||||||
|
def fake_run(cmd, *args, **kwargs):
|
||||||
|
c = list(cmd)
|
||||||
|
if c == ["crontab", "-l"]:
|
||||||
|
m = MagicMock()
|
||||||
|
m.stdout = crontab_before
|
||||||
|
m.returncode = 0
|
||||||
|
return m
|
||||||
|
if c == ["crontab", "-"]:
|
||||||
|
captured.append(kwargs.get("input", ""))
|
||||||
|
m = MagicMock()
|
||||||
|
m.returncode = 0
|
||||||
|
return m
|
||||||
|
m = MagicMock()
|
||||||
|
m.returncode = 0
|
||||||
|
return m
|
||||||
|
|
||||||
|
monkeypatch.setenv("PI_WAKE_BACKEND", "crontab")
|
||||||
|
monkeypatch.setattr(pi_wake_agent.subprocess, "run", MagicMock(side_effect=fake_run))
|
||||||
|
|
||||||
|
def test_install_has_pane_and_bootstrap(self, monkeypatch):
|
||||||
|
captured = []
|
||||||
|
self._mock_crontab(monkeypatch, self._EXISTING, captured)
|
||||||
|
pi_wake_agent.install_cron(
|
||||||
|
["pi_test"], "20m",
|
||||||
|
"Please carefully continue your work",
|
||||||
|
logging.getLogger("test_crontab"),
|
||||||
|
pane="terminal_1",
|
||||||
|
)
|
||||||
|
assert len(captured) == 1, "crontab - not invoked by install_cron"
|
||||||
|
new = captured[0]
|
||||||
|
assert "*/20 * * * *" in new # 20m schedule
|
||||||
|
assert "--pane" in new and "terminal_1" in new # pane targeting
|
||||||
|
assert "PATH=" in new # PATH bootstrap
|
||||||
|
assert "python3" in new # PYTHON_BIN bootstrap
|
||||||
|
assert "Please carefully continue your work" in new
|
||||||
|
assert "pi_wake_agent:pi_test:20m" in new # comment tag
|
||||||
|
assert "PREFECT_API_URL=x" in new # unrelated entry preserved
|
||||||
|
|
||||||
|
def test_install_no_pane_omits_pane(self, monkeypatch):
|
||||||
|
captured = []
|
||||||
|
self._mock_crontab(monkeypatch, self._EXISTING, captured)
|
||||||
|
pi_wake_agent.install_cron(
|
||||||
|
["pi_test"], "20m", "hi",
|
||||||
|
logging.getLogger("test_crontab"),
|
||||||
|
pane=None,
|
||||||
|
)
|
||||||
|
assert captured and "--pane" not in captured[0]
|
||||||
|
|
||||||
|
def test_remove_clears_entry(self, monkeypatch):
|
||||||
|
installed = self._EXISTING + (
|
||||||
|
"*/20 * * * * cd /mnt/dolphinng5_predict && export PATH=/usr/local/sbin && "
|
||||||
|
"/usr/bin/python3 /mnt/dolphinng5_predict/pi_wake_agent.py --run "
|
||||||
|
"--interval 20m --sessions 'pi_test' --msg 'Please carefully continue "
|
||||||
|
"your work' --pane terminal_1 # pi_wake_agent:pi_test:20m\n"
|
||||||
|
)
|
||||||
|
captured = []
|
||||||
|
self._mock_crontab(monkeypatch, installed, captured)
|
||||||
|
pi_wake_agent.remove_cron(["pi_test"], "20m", logging.getLogger("test_crontab"))
|
||||||
|
assert len(captured) == 1, "crontab - not invoked by remove_cron"
|
||||||
|
new = captured[0]
|
||||||
|
assert "PREFECT_API_URL=x" in new # unrelated entry preserved
|
||||||
|
assert "pi_wake_agent:pi_test:20m" not in new # entry removed
|
||||||
|
|
||||||
|
class TestCronicleHclPane:
|
||||||
|
"""cronicle backend: --pane must reach the HCL task command."""
|
||||||
|
|
||||||
|
def setup_method(self):
|
||||||
|
p = cronicle_hcl_path()
|
||||||
|
if p.exists():
|
||||||
|
p.unlink()
|
||||||
|
|
||||||
|
def teardown_method(self):
|
||||||
|
p = cronicle_hcl_path()
|
||||||
|
if p.exists():
|
||||||
|
p.unlink()
|
||||||
|
|
||||||
|
def test_install_hcl_has_pane(self):
|
||||||
|
rc, out, err = run_cmd(["--install", "--interval", "20m", "--session", "pi_test",
|
||||||
|
"--pane", "terminal_1",
|
||||||
|
"--msg", "Please carefully continue your work"])
|
||||||
|
assert rc == 0, f"install failed: {err}"
|
||||||
|
content = cronicle_hcl_path().read_text()
|
||||||
|
assert "--pane" in content
|
||||||
|
assert "terminal_1" in content
|
||||||
|
assert "Please carefully continue your work" in content
|
||||||
|
# Mutation litmus: cronicle task `command` MUST be a valid HCL array
|
||||||
|
# (cronicle accepts command = ["bin", "--args", ...]). Removing the
|
||||||
|
# leading `[` in cronicle_install must fail this assertion.
|
||||||
|
assert 'command = [' in content
|
||||||
|
assert '"--run"' in content
|
||||||
|
assert '"--pane"' in content
|
||||||
|
|
||||||
# ─── Run all tests ────────────────────────────────────────────────────────
|
# ─── Run all tests ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
Reference in New Issue
Block a user