host: phase2 daemon + health + auto-recovery (restore, recover, double-fork)
This commit is contained in:
1 parent
e9735d1fad
commit
240eaacf50
4 files changed
+434
-1
No files matched your search
@@ -168,6 +168,7 @@ turn 可以真正并发(不是轮流排队)。
|
||||
| 失败通知 | 失败只有 state 里的 status,跑完没人知道 | 已修:失败时写 host-run.log + 跑配置的 notify_command(本地 shell,模板 {task} {status});failures 命令列失败任务,doctor 列失败/缺失 worktree/open 评论 |
|
||||
| 多机器 state 冲突 | 锁只防同一台机器,多机器共享 state 文件会静默覆盖 | 部分修:state 加 version/last_writer/updated_at,保存时磁盘版本比加载时新就记 STATE-CONFLICT 日志并合并保留双方任务,不静默丢;真多机同步仍需中心服务(见下“没对齐”) |
|
||||
| 合并时提交缓存文件 | merge 的 git add -A 会把 __pycache__/*.pyc 也提交进主仓,下次合并被 .pyc 改动挡住 | 已修:merge 前清 worktree 的 __pycache__/*.pyc,主仓的 .pyc 改动先还原;单测覆盖 pyc 不入库 |
|
||||
| 守护进程 + 健康检查 + 自动恢复 | host.py 跑完就退出,不常驻;state 坏了静默当空 state;中断任务一直停在 running | 已修:daemon(双 fork 到 PID 1,循环 recover + run-due)、health(不跑模型,查二进制/配置/state/server/daemon)、restore-state(坏了从 .bak 恢复并留底)、recover(超过阈值的 running 标 interrupted,可用 run 续跑,不自动重跑模型回合) |
|
||||
| 边界:cwd、沙箱、审批、模型 | dispatch / turn 参数里定,见 `config.example.toml` | 已实测 |
|
||||
| 任务登记 | `host-state.json`:task -> thread/worktree/状态/diff;agent 状态本身仍在 CODEX_HOME,不另存第二份 | 已实测 |
|
||||
|
||||
@@ -190,6 +191,11 @@ turn 可以真正并发(不是轮流排队)。
|
||||
任务,但两台机器共享一个 state 文件仍不是 ChatGPT 云端
|
||||
那种中心同步——要真多机,得另起一个中心 state 服务,
|
||||
这不在本地 host 的范围里。
|
||||
- 守护进程的边界:daemon 本身也活不过 VM 平台回收(和
|
||||
微信桥 serve 一样,回收后要重拉),所以 VM 上更稳的定时
|
||||
仍是 Muse cron 定时调 run-due/health,而不是只靠 daemon
|
||||
或 crontab;daemon 适合常驻机器,VM 上是“会话内常驻 +
|
||||
回收后重拉”的定位。
|
||||
|
||||
## 开源没给到的(别指望照搬)
|
||||
|
||||
|
||||
@@ -394,3 +394,47 @@ tests/ 标准库 unittest,验收命令就是 `python3 -m unittest discover -s
|
||||
包括 topo 排序、merge 验测通过/失败回滚/重复加自动解/
|
||||
pyc 不入库、评审评论、重试成功/预算拦截/通知落盘、
|
||||
state 版本冲突、server kill 重拉。
|
||||
|
||||
## 第二阶段:守护进程 + 健康检查 + 自动恢复(2026-10-26 凌晨,用户选“先做第二阶段”)
|
||||
|
||||
- **先堵的洞**:旧 `_read_state_file` 会把 JSON 解析错误
|
||||
吞掉、当成空 state 返回——state 损坏一次就等于全部任务
|
||||
静默丢失。新加 `load_state_strict`(文件存在但解析失败
|
||||
时返回错误,不当空 state),health/recover 都用严格版。
|
||||
- **health(不跑模型)**:查 codex 二进制存在、配置可读、
|
||||
state 可解析、备份存在、daemon pid 存活/过期、server
|
||||
能 initialize + list、缺失 worktree。实测:完好时 ok=true、
|
||||
server_threads=7、exit 0;故意写坏 state 后 ok=false、
|
||||
state_valid=false、带解析错误信息、exit 1。
|
||||
- **restore-state**:state 损坏或丢失时按 .bak、.bak.1、
|
||||
.bak.2 顺序找第一个能解析的备份,先把坏文件改名成
|
||||
.corrupt.<时间戳> 留底,再把备份拷回。实测:坏 state +
|
||||
version=9 的 .bak,恢复后 version=9,health 回到 ok=true。
|
||||
- **recover(只标记,不自动重跑模型)**:status=running
|
||||
且最后回合超过 --older-than(默认 3600 秒)未更新的
|
||||
任务标 interrupted 并记日志,用户随后可用 run 续跑
|
||||
(thread 在 CODEX_HOME 里不丢)。不自动重跑是刻意的
|
||||
安全边界:自动重跑会不经用户同意花中转站额度。
|
||||
实测:2 小时前的 stuck -> interrupted,1 分钟前的
|
||||
fresh 保持 running;单测覆盖只标旧任务。
|
||||
- **daemon(双 fork)**:daemonize 用 fork -> setsid ->
|
||||
再 fork,孙进程 reparent 到 PID 1、写 pidfile、stdout/
|
||||
stderr 重定向到 host-daemon.log,循环每 interval(默认
|
||||
300 秒)跑 recover + run-due,每轮都是独立子进程调
|
||||
host.py CLI,一轮崩了不带走 daemon 本体。实测(interval
|
||||
5 秒):daemon pid 10776、PPID=1(活过 launcher 会话),
|
||||
daemon-status alive=true,日志每 5 秒一个 tick;
|
||||
daemon-stop 后 pid 消失、status alive=false、pidfile
|
||||
删除、日志有 host daemon stopped。
|
||||
- **VM 边界(诚实说)**:daemon 本身活不过平台回收,
|
||||
和微信桥 serve 同理——回收后要重拉,Muse cron 定时调
|
||||
run-due/health 才是 VM 上更稳的定时。本轮没有自动装
|
||||
Muse cron 或 crontab,因为 run-due 到期会跑模型花额度,
|
||||
装定时这一步留给用户显式下令(见下一条待办)。
|
||||
- **回归**:tests 从 15 个涨到 19 个,全部通过,新增
|
||||
corrupt 检测 + 从 .bak 恢复、无备份时不覆盖、recover
|
||||
只标旧任务、health 用假 server 全通过。
|
||||
- **待办(不在本轮自动做)**:Muse cron 定时调
|
||||
`host.py --config <config> health`(无模型成本)和 /
|
||||
或 `run-due`(到期才跑模型),需用户定间隔和是否允许
|
||||
自动跑模型后才装。
|
||||
@@ -381,6 +381,172 @@ def _read_state_file(path):
|
||||
return {"tasks": {}, "automations": []}
|
||||
|
||||
|
||||
def load_state_strict(path):
|
||||
"""严格读 state:文件不存在返回 (空 state, None);
|
||||
文件存在但 JSON 坏了返回 (None, 错误信息),绝不静默
|
||||
当成空 state——否则损坏一次就等于全部任务丢了,
|
||||
这是自动恢复要先堵住的洞(旧 _read_state_file 会吞掉
|
||||
解析错误,health/recover 必须用这个严格版)。"""
|
||||
if not os.path.exists(path):
|
||||
return {"tasks": {}, "automations": []}, None
|
||||
try:
|
||||
with open(path) as f:
|
||||
return json.load(f), None
|
||||
except Exception as e:
|
||||
return None, str(e)
|
||||
|
||||
|
||||
def restore_state(state_path):
|
||||
"""state 损坏或丢失时从备份恢复:按 .bak、.bak.1、.bak.2
|
||||
顺序找第一个能解析的备份,先把坏文件(如果存在)改名
|
||||
成 .corrupt.<时间戳> 留底,再把备份拷回主文件。
|
||||
返回 (ok, message)。主文件完好时不做任何事。"""
|
||||
strict, err = load_state_strict(state_path)
|
||||
if err is None and strict is not None:
|
||||
return True, "state 完好,无需恢复"
|
||||
if os.path.exists(state_path):
|
||||
corrupt = f"{state_path}.corrupt.{int(time.time())}"
|
||||
try:
|
||||
os.replace(state_path, corrupt)
|
||||
except Exception as e:
|
||||
return False, f"坏 state 改名留底失败:{e}"
|
||||
else:
|
||||
corrupt = None
|
||||
for suffix in (".bak", ".bak.1", ".bak.2"):
|
||||
cand = state_path + suffix
|
||||
if not os.path.exists(cand):
|
||||
continue
|
||||
try:
|
||||
with open(cand) as f:
|
||||
data = json.load(f)
|
||||
except Exception:
|
||||
continue
|
||||
import shutil
|
||||
shutil.copy2(cand, state_path)
|
||||
return True, (f"已从 {suffix} 恢复,version="
|
||||
f"{data.get('version')},"
|
||||
f"坏文件留底:{corrupt}")
|
||||
return False, "没有可用的备份(.bak/.bak.1/.bak.2 均缺失或损坏)"
|
||||
|
||||
|
||||
def recover_interrupted(state, state_path, older_than=3600, cfg=None):
|
||||
"""自动恢复(只标记,不自动重跑模型回合):
|
||||
status=running 且最后回合时间超过 older_than 秒的任务,
|
||||
判定为进程被杀/VM 回收导致的中断,标 interrupted 并记日志,
|
||||
用户随后可用 run 续跑(thread 在 CODEX_HOME 里不丢)。
|
||||
不自动重跑是刻意的安全边界:自动重跑会不经用户同意
|
||||
花中转站额度,daemon 只负责标记 + 日志 + 通知。
|
||||
返回被标记的任务名列表。"""
|
||||
now = time.time()
|
||||
recovered = []
|
||||
for name, t in (state.get("tasks") or {}).items():
|
||||
if t.get("status") != "running":
|
||||
continue
|
||||
last = t.get("last_turn_at") or t.get("created_at") or 0
|
||||
if now - last < older_than:
|
||||
continue
|
||||
t["status"] = "interrupted"
|
||||
recovered.append(name)
|
||||
try:
|
||||
log_event(state_path, f"recover {name} -> interrupted "
|
||||
f"(last_turn {int(now - last)}s ago)")
|
||||
except Exception:
|
||||
pass
|
||||
if cfg is not None:
|
||||
notify_failure(state_path, cfg, name,
|
||||
{"status": "interrupted",
|
||||
"text": "process killed / VM reclaimed"})
|
||||
return recovered
|
||||
|
||||
|
||||
def run_health_check(state_path, cfg, codex_bin, codex_home, approval,
|
||||
server_factory=None):
|
||||
"""健康检查(不跑模型):返回 report dict,ok 为全部通过。
|
||||
检查项:二进制存在、配置可读、state 可解析、备份存在、
|
||||
daemon pid 是否存活/过期、server 能 initialize + list、
|
||||
缺失 worktree(doctor 的逻辑)。server_factory 供单测注入
|
||||
假 server,避免健康检查本身要拉真进程。"""
|
||||
checks = {}
|
||||
checks["codex_bin_exists"] = bool(codex_bin) and os.path.exists(codex_bin)
|
||||
checks["config_loads"] = isinstance(cfg, dict)
|
||||
strict, err = load_state_strict(state_path)
|
||||
checks["state_valid"] = err is None
|
||||
checks["state_error"] = err
|
||||
checks["backup_exists"] = os.path.exists(state_path + ".bak")
|
||||
if strict is not None:
|
||||
doc = check_state(state_path, strict)
|
||||
checks["failed_tasks"] = doc["failed_tasks"]
|
||||
checks["missing_worktrees"] = doc["missing_worktrees"]
|
||||
checks["open_review_comments"] = doc["open_review_comments"]
|
||||
else:
|
||||
checks["failed_tasks"] = []
|
||||
checks["missing_worktrees"] = []
|
||||
checks["open_review_comments"] = 0
|
||||
# daemon pid
|
||||
pidfile = os.path.join(os.path.dirname(os.path.abspath(state_path)),
|
||||
"host-daemon.pid")
|
||||
checks["daemon_pidfile"] = pidfile
|
||||
if os.path.exists(pidfile):
|
||||
try:
|
||||
pid = int(open(pidfile).read().strip())
|
||||
os.kill(pid, 0)
|
||||
checks["daemon_alive"] = True
|
||||
checks["daemon_pid"] = pid
|
||||
except Exception:
|
||||
checks["daemon_alive"] = False
|
||||
checks["daemon_pid"] = None
|
||||
else:
|
||||
checks["daemon_alive"] = False
|
||||
checks["daemon_pid"] = None
|
||||
# server(无模型成本,只 initialize + list)
|
||||
try:
|
||||
if server_factory is not None:
|
||||
srv = server_factory()
|
||||
else:
|
||||
srv = Server(codex_bin=codex_bin, codex_home=codex_home,
|
||||
approval=approval)
|
||||
try:
|
||||
srv.initialize()
|
||||
threads = srv.list_threads()
|
||||
checks["server_ok"] = True
|
||||
checks["server_threads"] = len(
|
||||
(threads or {}).get("data") or []) if isinstance(threads, dict) else None
|
||||
finally:
|
||||
try:
|
||||
srv.close()
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as e:
|
||||
checks["server_ok"] = False
|
||||
checks["server_error"] = str(e)[:500]
|
||||
ok = bool(checks["codex_bin_exists"] and checks["state_valid"]
|
||||
and checks["server_ok"])
|
||||
return {"ok": ok, "checks": checks}
|
||||
|
||||
|
||||
def daemonize(pidfile, logfile):
|
||||
"""双 fork 守护进程(WeChat 桥 daemon_serve.py 同款教训:
|
||||
setsid nohup 会在 launcher 会话结束时被 runtime 按进程树
|
||||
回收杀掉,双 fork 到 PID 1 才能活过会话结束)。
|
||||
父进程返回 False(应退出),子进程返回 True(继续跑循环)。"""
|
||||
if os.fork() > 0:
|
||||
return False
|
||||
os.setsid()
|
||||
if os.fork() > 0:
|
||||
os._exit(0)
|
||||
sys.stdout.flush()
|
||||
sys.stderr.flush()
|
||||
log_f = open(logfile, "a", encoding="utf-8")
|
||||
os.dup2(log_f.fileno(), sys.stdout.fileno())
|
||||
os.dup2(log_f.fileno(), sys.stderr.fileno())
|
||||
with open("/dev/null", "r") as devnull:
|
||||
os.dup2(devnull.fileno(), sys.stdin.fileno())
|
||||
with open(pidfile, "w") as f:
|
||||
f.write(str(os.getpid()))
|
||||
return True
|
||||
|
||||
|
||||
|
||||
def load_state(path):
|
||||
with _state_lock(path, exclusive=False):
|
||||
st = _read_state_file(path)
|
||||
@@ -843,10 +1009,14 @@ def run_task_turn(server, state, task, prompt, model, effort,
|
||||
# 否则用调用方显式传的 sandbox;两者都没有时不传 sandboxPolicy,
|
||||
# 保留 thread 原有设置(旧任务没有 sandbox 记录时尤其重要)。
|
||||
use_sandbox = task.get("sandbox") or sandbox
|
||||
started = time.time()
|
||||
result = server.run_turn(task["thread_id"], prompt, model=model,
|
||||
effort=effort, sandbox=use_sandbox)
|
||||
finished = time.time()
|
||||
record_turn_usage(state, failed=(result["status"] != "completed"))
|
||||
task["turns"].append({"prompt": prompt, "result": result})
|
||||
task["turns"].append({"prompt": prompt, "result": result,
|
||||
"started_at": started, "finished_at": finished})
|
||||
task["last_turn_at"] = finished
|
||||
task["status"] = "completed" if result["status"] == "completed" \
|
||||
else "failed"
|
||||
task["last_text"] = result.get("text", "")
|
||||
@@ -939,6 +1109,18 @@ def main():
|
||||
p = sub.add_parser("fix-review"); p.add_argument("--task", required=True)
|
||||
sub.add_parser("doctor")
|
||||
sub.add_parser("failures")
|
||||
sub.add_parser("health")
|
||||
sub.add_parser("restore-state")
|
||||
p = sub.add_parser("recover")
|
||||
p.add_argument("--older-than", type=int, default=3600,
|
||||
help="最后回合超过多少秒未更新才判定中断,默认 3600")
|
||||
p = sub.add_parser("daemon")
|
||||
p.add_argument("--interval", type=int, default=300,
|
||||
help="每轮间隔秒数,默认 300")
|
||||
p.add_argument("--foreground", action="store_true",
|
||||
help="前台跑循环(测试用,不双 fork)")
|
||||
sub.add_parser("daemon-status")
|
||||
sub.add_parser("daemon-stop")
|
||||
p = sub.add_parser("discard"); p.add_argument("--task", required=True)
|
||||
p = sub.add_parser("archive"); p.add_argument("--task", required=True)
|
||||
p = sub.add_parser("fork"); p.add_argument("--task", required=True); p.add_argument("--name", required=True); p.add_argument("--sandbox", default=None)
|
||||
@@ -1053,6 +1235,131 @@ def main():
|
||||
save_state(args.state, state)
|
||||
print(json.dumps(c, indent=2, ensure_ascii=False))
|
||||
return
|
||||
if args.cmd == "health":
|
||||
report = run_health_check(args.state, cfg, args.codex_bin or default_codex_bin(),
|
||||
args.codex_home, args.approval)
|
||||
print(json.dumps(report, indent=2, ensure_ascii=False))
|
||||
if not report["ok"]:
|
||||
raise SystemExit(1)
|
||||
return
|
||||
if args.cmd == "restore-state":
|
||||
ok, msg = restore_state(args.state)
|
||||
print(msg)
|
||||
if not ok:
|
||||
raise SystemExit(1)
|
||||
return
|
||||
if args.cmd == "recover":
|
||||
strict, err = load_state_strict(args.state)
|
||||
if err is not None:
|
||||
print(f"state 已损坏,recover 不会覆盖它,先跑 restore-state:{err}")
|
||||
raise SystemExit(1)
|
||||
recovered = recover_interrupted(state, args.state,
|
||||
older_than=args.older_than, cfg=cfg)
|
||||
if recovered:
|
||||
save_state(args.state, state)
|
||||
print(json.dumps({"recovered": recovered}, indent=2, ensure_ascii=False))
|
||||
return
|
||||
if args.cmd == "daemon-status":
|
||||
pidfile = os.path.join(os.path.dirname(os.path.abspath(args.state)),
|
||||
"host-daemon.pid")
|
||||
logfile = os.path.join(os.path.dirname(os.path.abspath(args.state)),
|
||||
"host-daemon.log")
|
||||
alive = False
|
||||
pid = None
|
||||
if os.path.exists(pidfile):
|
||||
try:
|
||||
pid = int(open(pidfile).read().strip())
|
||||
os.kill(pid, 0)
|
||||
alive = True
|
||||
except Exception:
|
||||
alive = False
|
||||
tail = ""
|
||||
if os.path.exists(logfile):
|
||||
try:
|
||||
tail = "\n".join(open(logfile).read().splitlines()[-5:])
|
||||
except Exception:
|
||||
pass
|
||||
print(json.dumps({"alive": alive, "pid": pid, "pidfile": pidfile,
|
||||
"log_tail": tail}, indent=2, ensure_ascii=False))
|
||||
return
|
||||
if args.cmd == "daemon-stop":
|
||||
pidfile = os.path.join(os.path.dirname(os.path.abspath(args.state)),
|
||||
"host-daemon.pid")
|
||||
if not os.path.exists(pidfile):
|
||||
print("daemon 未运行(无 pidfile)")
|
||||
return
|
||||
try:
|
||||
pid = int(open(pidfile).read().strip())
|
||||
except Exception:
|
||||
print("pidfile 损坏,直接删除")
|
||||
os.remove(pidfile)
|
||||
return
|
||||
try:
|
||||
os.kill(pid, 15)
|
||||
for _ in range(10):
|
||||
time.sleep(0.5)
|
||||
try:
|
||||
os.kill(pid, 0)
|
||||
except Exception:
|
||||
break
|
||||
else:
|
||||
os.kill(pid, 9)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
os.remove(pidfile)
|
||||
except Exception:
|
||||
pass
|
||||
print(f"daemon 已停:pid {pid}")
|
||||
return
|
||||
if args.cmd == "daemon":
|
||||
state_dir = os.path.dirname(os.path.abspath(args.state))
|
||||
pidfile = os.path.join(state_dir, "host-daemon.pid")
|
||||
logfile = os.path.join(state_dir, "host-daemon.log")
|
||||
if not args.foreground:
|
||||
if os.path.exists(pidfile):
|
||||
try:
|
||||
old_pid = int(open(pidfile).read().strip())
|
||||
os.kill(old_pid, 0)
|
||||
print(f"daemon 已在运行:pid {old_pid},先 daemon-stop 再起")
|
||||
return
|
||||
except Exception:
|
||||
pass
|
||||
if not daemonize(pidfile, logfile):
|
||||
print(f"daemon 已在后台启动:pidfile {pidfile},log {logfile}")
|
||||
return
|
||||
# 子进程 / 前台:循环跑 recover + run-due,每轮都是独立
|
||||
# 子进程调 host.py CLI,这样一轮崩了不带走 daemon 本体,
|
||||
# 且 run-due 的模型回合数受自动化本身和预算控制。
|
||||
import signal
|
||||
stop = {"flag": False}
|
||||
|
||||
def _on_term(signum, frame):
|
||||
stop["flag"] = True
|
||||
signal.signal(signal.SIGTERM, _on_term)
|
||||
signal.signal(signal.SIGINT, _on_term)
|
||||
print(f"host daemon started pid={os.getpid()} interval={args.interval}s "
|
||||
f"config={config_path} state={args.state}", flush=True)
|
||||
while not stop["flag"]:
|
||||
ts = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime())
|
||||
print(f"{ts} daemon tick: recover + run-due", flush=True)
|
||||
for subcmd in (["recover", "--older-than", "3600"], ["run-due"]):
|
||||
try:
|
||||
subprocess.run([sys.executable, os.path.join(HERE, "host.py"),
|
||||
"--config", config_path] + subcmd,
|
||||
timeout=3600)
|
||||
except Exception as e:
|
||||
print(f"{ts} daemon subcommand {subcmd} 失败:{e}", flush=True)
|
||||
for _ in range(args.interval):
|
||||
if stop["flag"]:
|
||||
break
|
||||
time.sleep(1)
|
||||
try:
|
||||
os.remove(pidfile)
|
||||
except Exception:
|
||||
pass
|
||||
print("host daemon stopped", flush=True)
|
||||
return
|
||||
srv = Server(codex_bin=args.codex_bin, codex_home=args.codex_home,
|
||||
approval=args.approval)
|
||||
try:
|
||||
|
||||
@@ -297,6 +297,82 @@ class ServerRestartTest(unittest.TestCase):
|
||||
pass
|
||||
|
||||
|
||||
class HealthRestoreRecoverTest(unittest.TestCase):
|
||||
class FakeServer:
|
||||
def initialize(self):
|
||||
return {}
|
||||
|
||||
def list_threads(self):
|
||||
return {"data": []}
|
||||
|
||||
def close(self):
|
||||
pass
|
||||
|
||||
def test_corrupt_state_detected_and_restored_from_bak(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
path = os.path.join(tmp, "host-state.json")
|
||||
good = {"tasks": {"a": {"status": "completed"}},
|
||||
"automations": [], "version": 3}
|
||||
with open(path + ".bak", "w") as f:
|
||||
json.dump(good, f)
|
||||
with open(path, "w") as f:
|
||||
f.write("{not json!!!")
|
||||
strict, err = host.load_state_strict(path)
|
||||
self.assertIsNone(strict)
|
||||
self.assertIsNotNone(err)
|
||||
ok, msg = host.restore_state(path)
|
||||
self.assertTrue(ok, msg)
|
||||
with open(path) as f:
|
||||
restored = json.load(f)
|
||||
self.assertEqual(restored["version"], 3)
|
||||
self.assertTrue(os.path.exists(path + ".corrupt.") or
|
||||
any(fn.startswith("host-state.json.corrupt.")
|
||||
for fn in os.listdir(tmp)))
|
||||
|
||||
def test_restore_without_backup_fails_without_overwriting(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
path = os.path.join(tmp, "host-state.json")
|
||||
with open(path, "w") as f:
|
||||
f.write("{bad")
|
||||
ok, msg = host.restore_state(path)
|
||||
self.assertFalse(ok)
|
||||
# 坏文件被改名留底,主文件已不在(不是被空 state 覆盖)
|
||||
self.assertFalse(os.path.exists(path))
|
||||
|
||||
def test_recover_marks_old_running_only(self):
|
||||
import time as _time
|
||||
now = _time.time()
|
||||
state = {"tasks": {
|
||||
"old": {"status": "running", "created_at": now - 7200,
|
||||
"last_turn_at": now - 7200},
|
||||
"fresh": {"status": "running", "created_at": now - 60,
|
||||
"last_turn_at": now - 60},
|
||||
"done": {"status": "completed", "created_at": now - 7200},
|
||||
}, "automations": []}
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
path = os.path.join(tmp, "host-state.json")
|
||||
recovered = host.recover_interrupted(state, path,
|
||||
older_than=3600, cfg={})
|
||||
self.assertEqual(recovered, ["old"])
|
||||
self.assertEqual(state["tasks"]["old"]["status"], "interrupted")
|
||||
self.assertEqual(state["tasks"]["fresh"]["status"], "running")
|
||||
|
||||
def test_health_with_fake_server(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
path = os.path.join(tmp, "host-state.json")
|
||||
with open(path, "w") as f:
|
||||
json.dump({"tasks": {}, "automations": [], "version": 1}, f)
|
||||
with open(path + ".bak", "w") as f:
|
||||
json.dump({"tasks": {}, "automations": [], "version": 1}, f)
|
||||
report = host.run_health_check(
|
||||
path, {}, "/bin/true", tmp, "deny",
|
||||
server_factory=lambda: self.FakeServer())
|
||||
self.assertTrue(report["ok"], report)
|
||||
self.assertTrue(report["checks"]["state_valid"])
|
||||
self.assertTrue(report["checks"]["backup_exists"])
|
||||
self.assertTrue(report["checks"]["server_ok"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
Reference in new issue
Block a user