Files
fnos-agent-checkin/AgentCheckin/app/backend/daemon.py
T
cbb a3b84d5ce2 feat: AgentCheckin v1.2.2 — fnOS 应用:TRAE/WorkBuddy/Qoder 三平台每日积分自动签到
- 原生 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 前端回归 + 真实时钟调度测试
2026-10-13 19:09:48 +08:00

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())