- 原生 fnOS 应用(CGI 入口 + Python 守护进程),依赖 python312 运行时 - 三平台签到:TRAE 每日 200 积分、WorkBuddy 7 天循环、Qoder 每日 100 Credits - WorkBuddy 网页授权(OAuth 设备流)、TRAE icubeAuth 解密 Token、Qoder Cosy 设备头 - 多时段调度(默认 00:05/09:00/12:30/19:00)、启动补跑、幂等签到+回查确认 - 多平台 Webhook 通知(企业微信/钉钉/飞书/Bark/Server酱/PushPlus 自动识别) - Qoder 双站点 Token 隔离自动探测与修复 - 前端:仪表盘/账号管理/签到记录/设置四个页签,30s 轮询+变化检测+隐藏暂停 - 测试:34 后端 + 25 E2E + 9 前端回归 + 真实时钟调度测试
166 lines
4.9 KiB
Python
166 lines
4.9 KiB
Python
#!/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())
|