#!/usr/bin/env python3 """AgentCheckin scheduler daemon. Responsibilities: * run check-ins at the configured daily times (default 00:05/09:00/12:30/19:00), each slot at most once per day; idempotent so missed slots self-heal * honour manual trigger files written by the CGI API ("run now") * keep state.json fresh (daemon liveness, next run time) * survive reboots: on start, if today has no run yet, run once (catch-up) The daemon writes its own pidfile so cmd/main and the web UI can check it. """ import os import signal import sys import time sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) from common import (DATA_DIR, PID_FILE, TRIGGER_FILE, load_settings, load_state, save_state, log, today_str, fmt_now, now_ts, fmt_ts, ensure_dirs) import engine TICK_SECONDS = 15 _running = True def _handle_signal(signum, frame): global _running _running = False def write_pidfile(): ensure_dirs() with open(PID_FILE, "w") as f: f.write(str(os.getpid())) def remove_pidfile(): try: if os.path.exists(PID_FILE): with open(PID_FILE) as f: if f.read().strip() == str(os.getpid()): os.unlink(PID_FILE) except OSError: pass def slot_today_ts(hhmm): """Unix ts of today's HH:MM local time.""" now = time.localtime() hh, mm = int(hhmm[:2]), int(hhmm[3:5]) return int(time.mktime((now.tm_year, now.tm_mon, now.tm_mday, hh, mm, 0, 0, 0, -1))) def compute_next_run(settings): """Next scheduled slot ts (today or tomorrow), or None.""" times = settings.get("schedule_times") or [] now = now_ts() candidates = [] for hhmm in times: ts = slot_today_ts(hhmm) candidates.append(ts if ts > now else ts + 86400) return min(candidates) if candidates else None def tick(): settings = load_settings() state = load_state() today = today_str() times = settings.get("schedule_times") or [] slot_runs = state.get("slot_runs") or {} now = now_ts() # 1) manual trigger if os.path.exists(TRIGGER_FILE): try: os.unlink(TRIGGER_FILE) except OSError: pass log("trigger file detected -> running check-in") engine.run_all(reason="manual") return # 2) catch-up: today has no run recorded at all (fresh boot / first install). # Mark every slot whose time has already passed as covered by this run. # NOTE: check key existence — an empty result dict is still "a run". if today not in (state.get("today") or {}): if times: log("no run recorded today -> startup catch-up") engine.run_all(reason="startup-catchup") state = load_state() slot_runs = state.setdefault("slot_runs", {}) changed = False for hhmm in times: if now >= slot_today_ts(hhmm) and slot_runs.get(hhmm) != today: slot_runs[hhmm] = today changed = True if changed: save_state(state) return # 3) scheduled slots (each at most once per day) for hhmm in times: ts = slot_today_ts(hhmm) if now >= ts and slot_runs.get(hhmm) != today: log("schedule slot %s due" % hhmm) engine.run_all(reason="schedule:%s" % hhmm) mark_slot_ran(hhmm) return def mark_slot_ran(hhmm): state = load_state() state.setdefault("slot_runs", {})[hhmm] = today_str() save_state(state) def update_liveness(): state = load_state() settings = load_settings() prev = state.get("daemon") or {} # reset started_at when the pid changed (fresh daemon process) started_at = prev.get("started_at") if prev.get("pid") == os.getpid() else fmt_now() state["daemon"] = { "pid": os.getpid(), "started_at": started_at, "last_tick": fmt_now(), } nxt = compute_next_run(settings) state["next_run_at"] = fmt_ts(nxt) if nxt else None state["next_run_ts"] = nxt save_state(state) def main(): signal.signal(signal.SIGTERM, _handle_signal) signal.signal(signal.SIGINT, _handle_signal) ensure_dirs() write_pidfile() log("daemon started (pid %d), data dir %s" % (os.getpid(), DATA_DIR)) try: while _running: try: tick() except Exception as e: log("tick error: %s" % e, "ERROR") try: update_liveness() except Exception as e: log("liveness update failed: %s" % e, "ERROR") # sleep in small slices so SIGTERM is responsive for _ in range(TICK_SECONDS): if not _running: break time.sleep(1) finally: remove_pidfile() log("daemon stopped") return 0 if __name__ == "__main__": sys.exit(main())