feat(pi_wake_agent): --at HHMM one-off self-cleaning wake mode

This commit is contained in:
Codex
2026-09-15 11:17:36 +02:00
parent ad4fcc538a
commit 616a23c0b3
2 changed files with 590 additions and 8 deletions

View File

@@ -25,7 +25,7 @@ import sys
import threading
import time
from pathlib import Path
from typing import List, Optional
from typing import List, Optional, Tuple
# ─── Constants ────────────────────────────────────────────────────────────
SCRIPT_PATH = Path(__file__).resolve()
@@ -187,13 +187,18 @@ def cronicle_schedule_name(sessions: List[str], interval: str) -> str:
return f"pi_wake_{sessions_str}_{interval}"
def wake_command_args(sessions: List[str], interval: str, message: str, pane: Optional[str] = None) -> List[str]:
def wake_command_args(sessions: List[str], interval: Optional[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).
``interval`` is the recurring cadence used by cron/crontab entries; pass
``None`` for a one-off --run invocation (e.g. --at, which is time-of-day
triggered, not interval driven) so --interval is omitted. --pane targets a
specific zjet pane (e.g. the bottom Soundcheck pane).
"""
args = [str(SCRIPT_PATH), "--run", "--interval", interval, "--sessions", ",".join(sessions)]
args = [str(SCRIPT_PATH), "--run"]
if interval is not None:
args += ["--interval", interval]
args += ["--sessions", ",".join(sessions)]
if pane:
args += ["--pane", pane]
args += ["--msg", message]
@@ -347,9 +352,199 @@ def remove_cron(sessions: List[str], interval: str, logger: logging.Logger) -> N
logger.info(f"Removed cron for {sessions} ({interval})")
# ─── One-off Time-of-Day Scheduling (--at) ───────────────────────────────
AT_TIME_RE = re.compile(r"^(\d{1,2}):(\d{2})$|^(\d{4})$")
def parse_at_time(spec: str) -> Tuple[str, str, str]:
"""Parse a one-off wake time-of-day.
Accepts 'HH:MM' (e.g. '01:50', '1:05') or 'HHMM' (e.g. '0150'). Returns
(hhmm, minute, hour), each zero-padded, e.g. ('0150', '50', '01'). Raises
ValueError on an unparseable or out-of-range time.
"""
m = re.match(r"^(\d{1,2}):(\d{2})$", spec)
if m:
hour, minute = int(m.group(1)), int(m.group(2))
else:
m = re.match(r"^(\d{4})$", spec)
if not m:
raise ValueError(f"--at expects HH:MM or HHMM (e.g. 01:50 or 0150), got {spec!r}")
hour, minute = int(spec[:2]), int(spec[2:])
if not (0 <= hour <= 23) or not (0 <= minute <= 59):
raise ValueError(f"--at time out of range: {spec!r} (hour 0-23, minute 0-59)")
return f"{hour:02d}{minute:02d}", f"{minute:02d}", f"{hour:02d}"
def cron_at_comment(sessions: List[str], hhmm: str) -> str:
"""Crontab/crnd tag for a one-off --at entry (distinct from recurring)."""
sessions_str = ",".join(sessions)
return f"{CRON_COMMENT_PREFIX}:{sessions_str}:at:{hhmm}"
def cron_at_schedule_name(sessions: List[str], hhmm: str) -> str:
"""Cronicle schedule name for a one-off --at entry (starts with 'pi_wake_')."""
return f"pi_wake_{'_'.join(sessions)}_at_{hhmm}"
def _at_wake_command(sessions: List[str], message: str, pane: Optional[str] = None) -> str:
"""Shell command string for the --run wake invoked by a one-off --at entry.
Mirrors install_cron's command construction but omits --interval: a one-off
is time-of-day triggered, not interval driven (and --run ignores --interval
anyway, so omitting it is both cleaner and accurate).
"""
sessions_arg = f"'{','.join(sessions)}'"
cmd = f"{PYTHON_BIN} {SCRIPT_PATH} --run --sessions {sessions_arg} --msg {shlex.quote(message)}"
if pane:
cmd += f" --pane {shlex.quote(pane)}"
return cmd
def _strip_lines_by_tag(lines: List[str], tag: str) -> List[str]:
"""Drop crontab lines whose comment contains `tag` (idempotent install/remove)."""
return [line for line in lines if tag not in line]
def build_at_cron_entry(sessions: List[str], hhmm: str, message: str, pane: Optional[str] = None) -> str:
"""Build the full crontab line for a one-off, self-cleaning --at wake.
Layout:
'min hour * * * cd <root> && export PATH=... && export H5I_AGENT=... && <wake_cmd> ; (crontab -l | grep -v '<tag>' ; echo) | crontab - # <tag>'
The self-strip suffix removes THIS line (matched by its unique 'at:<HHMM>'
tag) after it fires, so the entry is strictly one-off and never clobbers
unrelated entries (the tag is distinct from the recurring
'pi_wake_agent:<s>:<interval>' tag).
"""
_, minute, hour = parse_at_time(hhmm)
comment = cron_at_comment(sessions, hhmm)
wake_cmd = _at_wake_command(sessions, message, pane)
self_strip = f"crontab -l 2>/dev/null | grep -v '{comment}'"
cmd = (f"cd {H5I_BUS_ROOT} && export PATH={CRON_PATH} && export H5I_AGENT={H5I_AGENT} && "
f"{wake_cmd} ; ({self_strip} ; echo) | crontab - # {comment}")
return f"{minute} {hour} * * * {cmd}"
def crontab_install_at(sessions: List[str], hhmm: str, message: str, logger: logging.Logger, pane: Optional[str] = None) -> None:
"""Install a one-off, self-cleaning crontab entry at the given time-of-day (crnd backend).
Idempotent: a pre-existing entry with the same 'at:<HHMM>' tag is replaced
rather than duplicated. Unrelated crontab entries are preserved verbatim.
"""
comment = cron_at_comment(sessions, hhmm)
entry = build_at_cron_entry(sessions, hhmm, message, pane)
result = subprocess.run(["crontab", "-l"], capture_output=True, text=True)
existing = result.stdout if result.returncode == 0 else ""
lines = _strip_lines_by_tag(existing.splitlines(), comment)
lines.append(entry)
subprocess.run(["crontab", "-"], input="\n".join(lines) + "\n", text=True, check=True)
logger.info(f"Installed one-off at {hhmm} -> {sessions} (self-cleaning)")
def crontab_remove_at(sessions: List[str], hhmm: str, logger: logging.Logger) -> None:
"""Remove a pending one-off --at entry from the crontab."""
comment = cron_at_comment(sessions, hhmm)
result = subprocess.run(["crontab", "-l"], capture_output=True, text=True)
existing = result.stdout if result.returncode == 0 else ""
lines = _strip_lines_by_tag(existing.splitlines(), comment)
new_cron = "\n".join(lines) + ("\n" if lines else "")
subprocess.run(["crontab", "-"], input=new_cron, text=True, check=True)
logger.info(f"Removed one-off at {hhmm} for {sessions}")
def cronicle_install_at(sessions: List[str], hhmm: str, message: str, logger: logging.Logger, pane: Optional[str] = None) -> None:
"""Install a one-off wake schedule into the cronicle HCL config (tmp-hcl in tests).
Cronicle's `cron` field accepts the standard 5-field expression, so a
time-of-day one-off is written as 'min hour * * *'. On this host the cronicle
daemon is NOT running (see AGENTS.md §38.10), so these schedules are INERT
and this path is only exercised by hermetic tests asserting the HCL string;
the LIVE one-off scheduler is crontab (crontab_install_at).
"""
_, minute, hour = parse_at_time(hhmm)
schedule_name = cron_at_schedule_name(sessions, hhmm)
config_path = cronicle_config_path()
existing = config_path.read_text() if config_path.exists() else ""
lines = existing.splitlines()
new_lines: List[str] = []
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
new_schedule = f'''\nschedule "{schedule_name}" {{
cron = "{minute} {hour} * * *"
task "wake" {{
command = [{", ".join(chr(34) + a + chr(34) for a in wake_command_args(sessions, None, message, pane))}]
}}
}}'''
config_path.write_text("\n".join(new_lines) + "\n\n" + new_schedule + "\n")
logger.info(f"Installed cronicle one-off: {schedule_name} -> {sessions} at {hhmm}")
def cronicle_remove_at(sessions: List[str], hhmm: str, logger: logging.Logger) -> None:
"""Remove a one-off wake schedule from the cronicle HCL config."""
schedule_name = cron_at_schedule_name(sessions, hhmm)
config_path = cronicle_config_path()
if not config_path.exists():
return
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 one-off: {schedule_name}")
def install_at(sessions: List[str], hhmm: str, message: str, logger: logging.Logger, pane: Optional[str] = None) -> None:
"""Install a one-off wake at HH:MM, routing through the configured backend.
Cronicle (tmp-hcl) when selected (keeps tests hermetic) and the system
crontab (crnd) otherwise. The crontab path is strictly one-off: the entry
self-strips its tag after firing. ``hhmm`` is re-parsed here to validate.
"""
parse_at_time(hhmm)
if use_cronicle():
cronicle_install_at(sessions, hhmm, message, logger, pane)
else:
crontab_install_at(sessions, hhmm, message, logger, pane)
logger.info(f"Scheduled one-off wake at {hhmm} for {sessions}")
def remove_at(sessions: List[str], hhmm: str, logger: logging.Logger) -> None:
"""Remove a pending one-off --at wake (routes through the configured backend)."""
parse_at_time(hhmm)
if use_cronicle():
cronicle_remove_at(sessions, hhmm, logger)
else:
crontab_remove_at(sessions, hhmm, logger)
def list_cron(logger: logging.Logger) -> None:
if use_cronicle():
cronicle_list(logger)
cronicle_list(logger)
else:
print("=== pi_wake_agent cron entries ===")
result = subprocess.run(["crontab", "-l"], capture_output=True, text=True)
@@ -609,13 +804,16 @@ def parse_sessions(raw) -> List[str]:
# ─── Usage ──────────────────────────────────────────────────────────────
def create_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
description="pi_wake_agent.py — Reusable multi-agent wake-up timer with self-cron/daemon/succession",
description="pi_wake_agent.py — Reusable multi-agent wake-up timer with self-cron/daemon/succession/one-off --at",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
EXAMPLES:
# Install recurring 1-hour timer for one session
pi_wake_agent.py --install --interval 1h --session cc_UV_dev0_Fb
# One-off wake at 01:50 AM — fires once, then self-removes from crontab
pi_wake_agent.py --at 0150 --session cc_UV_dev0_Fb --msg "continue"
# Install 30-minute timer for multiple sessions
pi_wake_agent.py --install --interval 30m --sessions "cc_UV_dev0_Fb,cc_UV_dev1_48" --msg "Wake up!"
@@ -639,6 +837,12 @@ EXAMPLES:
# Validate sessions
pi_wake_agent.py --validate --session cc_UV_dev0_Fb
# One-off wake at 01:50 AM (fires once, then self-removes from crontab)
pi_wake_agent.py --at 0150 --session cc_UV_dev0_Fb --msg "continue"
# Cancel a pending one-off
pi_wake_agent.py --remove --at 0150 --session cc_UV_dev0_Fb
"""
)
parser.add_argument("--install", action="store_const", const="install", dest="mode", help="Install recurring cron timer")
@@ -658,6 +862,13 @@ EXAMPLES:
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("--dry-run", action="store_true", help="Show what would be done without executing")
parser.add_argument("--at", dest="at_time", default=None, metavar="HHMM",
help=("Schedule a ONE-OFF wake at HH:MM or HHMM (e.g. "
"0150 or 01:50). Fires once at that time-of-day then "
"self-removes from the crontab (crnd backend). Combine "
"with --remove to cancel, or --dry-run to preview. "
"Cannot combine with --once/--daemon/--succession/"
"--run/--list/--status/--validate."))
parser.set_defaults(mode="install")
return parser
@@ -677,6 +888,22 @@ def main() -> int:
# Setup logging
logger = setup_logging(args.debug)
# --at HHMM: one-off time-of-day wake (self-cleaning). Mutually exclusive
# with --once/--daemon/--succession/--run which own their own timing.
if args.at_time is not None:
if args.mode not in ("install", "remove"):
logger.error("--at HHMM may only be used alone (one-off install) or with "
"--remove; it cannot combine with --once/--daemon/"
"--succession/--run/--list/--status/--validate")
return 1
try:
parse_at_time(args.at_time) # fail-fast validation before install/remove
except ValueError as e:
logger.error(f"Invalid --at time {args.at_time!r}: {e}")
return 1
if args.mode == "install":
args.mode = "at"
# Validate
if len(sessions) == 0 and args.mode not in ("list", "status"):
logger.error("--session or --sessions required")
@@ -703,7 +930,9 @@ def main() -> int:
return 0
try:
if args.mode == "install":
if args.mode == "at":
install_at(sessions, args.at_time, args.msg, logger, args.pane)
elif args.mode == "install":
install_cron(sessions, args.interval, args.msg, logger, args.pane)
elif args.mode == "once":
run_once(sessions, args.interval, args.msg, logger, args.pane)
@@ -714,7 +943,10 @@ def main() -> int:
elif args.mode == "run":
run_wake(sessions, args.msg, logger, args.pane)
elif args.mode == "remove":
remove_cron(sessions, args.interval, logger)
if args.at_time is not None:
remove_at(sessions, args.at_time, logger)
else:
remove_cron(sessions, args.interval, logger)
elif args.mode == "list":
list_cron(logger)
elif args.mode == "status":