/run/chv 是 0755 root:root,chv 身份的 syncd 写不进状态文件 与 socket,_atomic_write 抛错致进程反复退出、systemctl 长期 activating(2026-11-07 真机实测)。socket 与状态文件改到 tmpfiles 预建的 /run/chv/syncd/(0755 chv:chv,与 chv-browser 的 shots 同款);daemon 的 sync 摘要读同一路径;驱动的 syncd socket 路径与 ls 检查同步更新;另修驱动 SSH poller 线程残留(daemon=True + wake 失败也 set 事件)。
320 lines
10 KiB
Python
320 lines
10 KiB
Python
#!/usr/bin/env python3
|
||
"""chv-daemon:客机运行时 daemon(阶段 2 三分之一)。
|
||
|
||
职责(只做这三件,不碰任务执行与同步):
|
||
1. 生命周期/心跳:每 10 秒写 /run/chv/heartbeat.json(atomic),
|
||
含 pid、启动时间、seq、uptime、loadavg、内存、workspace
|
||
余量、execd 任务数、sync 最近状态摘要。seq 跨重启连续
|
||
(启动时读旧文件续号),宿主侧据此判断“心跳丢了还是
|
||
daemon 重启了”。
|
||
2. 资源上报:同上,心跳文件即上报载体(宿主经 SSH 读文件,
|
||
不另开通道;控制通道仍是 SSH,见 executor.py 定稿)。
|
||
3. 快照配合:Unix socket /run/chv/daemon.sock,op
|
||
prepare-snapshot——问 execd 是否有运行中任务;有则
|
||
拒绝({ok:false, reason:task-running}),无则写
|
||
/run/chv/snapshot-ready.json 标记并返回 ok;execd
|
||
联系不上时同样拒绝(fail-closed,不写标记)。
|
||
宿主侧快照流程暂未调它(阶段 2 不改 executor 快照流),
|
||
接口先行、供宿主侧后续接线与本阶段 live 演练。
|
||
|
||
与既有分工的映射(不要第二套 agent):
|
||
- host.py 仍是唯一的任务/ codex 执行引擎(CLI,经 SSH 或
|
||
execd 的 codex op 调);daemon 从不跑 host.py。
|
||
- chv-broker/chv-browser 仍是单元内外 IPC 与浏览器子代理,
|
||
daemon 不碰它们的 socket。
|
||
- sync 仍是 chv-sync.sh + chv-syncd 常驻循环,daemon 只读
|
||
sync-status.json 作摘要,不调 git。
|
||
|
||
只用标准库。以 root 运行(读 /proc 与各服务状态文件无权限
|
||
问题;socket 0600 root:root,只接受本机 root 客户)。
|
||
"""
|
||
import json
|
||
import os
|
||
import signal
|
||
import socket
|
||
import sys
|
||
import threading
|
||
import time
|
||
|
||
HEARTBEAT_PATH = "/run/chv/heartbeat.json"
|
||
DAEMON_SOCK = "/run/chv/daemon.sock"
|
||
SNAPSHOT_READY_PATH = "/run/chv/snapshot-ready.json"
|
||
EXECD_SOCK = "/run/chv/execd.sock"
|
||
EXECD_TASKS = "/run/chv/execd-tasks.json"
|
||
# syncd 的状态文件在它自己的 chv 所有子目录里(见 build.sh
|
||
# tmpfiles:/run/chv 本身 0755 root:root,chv 身份的 syncd
|
||
# 写不进——2026-11-07 真机实测:状态文件写失败致 syncd
|
||
# 反复退出、systemctl 长期 activating)。
|
||
SYNC_STATUS = "/run/chv/syncd/sync-status.json"
|
||
WORKSPACE = "/home/chv/workspace"
|
||
HEARTBEAT_INTERVAL = 10.0
|
||
MAX_REQUEST_BYTES = 65536
|
||
|
||
|
||
def _atomic_write(path, data: bytes):
|
||
tmp = "%s.tmp.%d" % (path, os.getpid())
|
||
with open(tmp, "wb") as fh:
|
||
fh.write(data)
|
||
fh.flush()
|
||
os.fsync(fh.fileno())
|
||
os.rename(tmp, path)
|
||
|
||
|
||
def _read_json(path):
|
||
try:
|
||
with open(path) as fh:
|
||
return json.load(fh)
|
||
except (OSError, ValueError):
|
||
return None
|
||
|
||
|
||
def _loadavg():
|
||
try:
|
||
with open("/proc/loadavg") as fh:
|
||
parts = fh.read().split()
|
||
return [float(parts[0]), float(parts[1]), float(parts[2])]
|
||
except (OSError, ValueError, IndexError):
|
||
return None
|
||
|
||
|
||
def _meminfo():
|
||
mem = {}
|
||
try:
|
||
with open("/proc/meminfo") as fh:
|
||
for line in fh:
|
||
if ":" not in line:
|
||
continue
|
||
key, val = line.split(":", 1)
|
||
val = val.strip().split()
|
||
if val and val[0].isdigit():
|
||
mem[key.strip()] = int(val[0])
|
||
except OSError:
|
||
return None
|
||
if not mem:
|
||
return None
|
||
return {"total_kb": mem.get("MemTotal"),
|
||
"available_kb": mem.get("MemAvailable")}
|
||
|
||
|
||
def _workspace_free():
|
||
try:
|
||
st = os.statvfs(WORKSPACE)
|
||
return st.f_bavail * st.f_frsize
|
||
except OSError:
|
||
return None
|
||
|
||
|
||
def _execd_task_count():
|
||
data = _read_json(EXECD_TASKS)
|
||
if not isinstance(data, dict):
|
||
return None
|
||
tasks = data.get("tasks")
|
||
if not isinstance(tasks, list):
|
||
return None
|
||
running = [t for t in tasks
|
||
if isinstance(t, dict) and t.get("state") == "running"]
|
||
return {"total": len(tasks), "running": len(running)}
|
||
|
||
|
||
def _sync_summary():
|
||
data = _read_json(SYNC_STATUS)
|
||
if not isinstance(data, dict):
|
||
return None
|
||
return {"last": data.get("last"), "at": data.get("at"),
|
||
"head": data.get("head"),
|
||
"error": data.get("error")}
|
||
|
||
|
||
def build_heartbeat(pid, started_at, seq):
|
||
"""组装一次心跳 dict(纯函数,便于单测与 --once)。"""
|
||
now = time.time()
|
||
return {"service": "chv-daemon", "pid": pid,
|
||
"started_at": started_at, "seq": seq,
|
||
"at": now, "uptime_s": round(now - started_at, 1),
|
||
"loadavg": _loadavg(), "mem": _meminfo(),
|
||
"workspace_free_bytes": _workspace_free(),
|
||
"execd_tasks": _execd_task_count(),
|
||
"sync": _sync_summary()}
|
||
|
||
|
||
def _ask_execd_status(timeout=2.0):
|
||
"""问 execd 要任务状态;联系不上返回 (None, reason)。"""
|
||
sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
||
try:
|
||
sock.settimeout(timeout)
|
||
sock.connect(EXECD_SOCK)
|
||
sock.sendall(json.dumps({"op": "status"}).encode() + b"\n")
|
||
buf = b""
|
||
while len(buf) < MAX_REQUEST_BYTES:
|
||
chunk = sock.recv(4096)
|
||
if not chunk:
|
||
break
|
||
buf += chunk
|
||
if buf.endswith(b"\n"):
|
||
break
|
||
data = json.loads(buf.decode())
|
||
if not data.get("ok"):
|
||
return None, "execd-status-not-ok"
|
||
return data, None
|
||
except (OSError, ValueError) as exc:
|
||
return None, "execd-unreachable: %s" % exc
|
||
finally:
|
||
sock.close()
|
||
|
||
|
||
def prepare_snapshot():
|
||
"""快照配合:execd 有运行中任务或联系不上即拒绝。"""
|
||
data, reason = _ask_execd_status()
|
||
if data is None:
|
||
return {"ok": False, "reason": reason}
|
||
tasks = data.get("tasks") or []
|
||
running = [t for t in tasks
|
||
if isinstance(t, dict) and t.get("state") == "running"]
|
||
if running:
|
||
return {"ok": False, "reason": "task-running",
|
||
"running": [t.get("id") for t in running]}
|
||
marker = {"at": time.time(), "pid": os.getpid(),
|
||
"note": "no running execd task"}
|
||
_atomic_write(SNAPSHOT_READY_PATH,
|
||
(json.dumps(marker) + "\n").encode())
|
||
return {"ok": True, "marker": SNAPSHOT_READY_PATH}
|
||
|
||
|
||
def handle_request(req):
|
||
"""socket 请求分发(纯函数,便于单测)。"""
|
||
if not isinstance(req, dict):
|
||
return {"ok": False, "error": "request must be an object"}
|
||
op = req.get("op")
|
||
if op == "ping":
|
||
return {"ok": True, "service": "chv-daemon",
|
||
"pid": os.getpid()}
|
||
if op == "status":
|
||
hb = _read_json(HEARTBEAT_PATH)
|
||
return {"ok": True, "heartbeat": hb}
|
||
if op == "prepare-snapshot":
|
||
try:
|
||
return prepare_snapshot()
|
||
except OSError as exc:
|
||
return {"ok": False, "reason": "marker-write-failed: %s"
|
||
% exc}
|
||
return {"ok": False, "error": "unknown op: %s" % (op,)}
|
||
|
||
|
||
class Daemon:
|
||
def __init__(self, interval=HEARTBEAT_INTERVAL):
|
||
self.interval = interval
|
||
self.pid = os.getpid()
|
||
self.started_at = time.time()
|
||
self.seq = 0
|
||
old = _read_json(HEARTBEAT_PATH)
|
||
if isinstance(old, dict) and isinstance(old.get("seq"), int):
|
||
# 续号:宿主据 seq 连续判断心跳丢包还是 daemon 重启。
|
||
self.seq = old["seq"]
|
||
self._stop = threading.Event()
|
||
self._server = None
|
||
|
||
def write_heartbeat(self):
|
||
self.seq += 1
|
||
hb = build_heartbeat(self.pid, self.started_at, self.seq)
|
||
_atomic_write(HEARTBEAT_PATH,
|
||
(json.dumps(hb) + "\n").encode())
|
||
return hb
|
||
|
||
def _serve(self):
|
||
if os.path.exists(DAEMON_SOCK):
|
||
os.remove(DAEMON_SOCK)
|
||
srv = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
||
srv.bind(DAEMON_SOCK)
|
||
os.chmod(DAEMON_SOCK, 0o600)
|
||
srv.listen(8)
|
||
srv.settimeout(0.5)
|
||
self._server = srv
|
||
while not self._stop.is_set():
|
||
try:
|
||
conn, _ = srv.accept()
|
||
except socket.timeout:
|
||
continue
|
||
except OSError:
|
||
break
|
||
with conn:
|
||
try:
|
||
conn.settimeout(5.0)
|
||
buf = b""
|
||
while len(buf) < MAX_REQUEST_BYTES:
|
||
chunk = conn.recv(4096)
|
||
if not chunk:
|
||
break
|
||
buf += chunk
|
||
if buf.endswith(b"\n"):
|
||
break
|
||
req = json.loads(buf.decode())
|
||
resp = handle_request(req)
|
||
except (OSError, ValueError) as exc:
|
||
resp = {"ok": False, "error": str(exc)}
|
||
try:
|
||
conn.sendall((json.dumps(resp) + "\n").encode())
|
||
except OSError:
|
||
pass
|
||
try:
|
||
srv.close()
|
||
except OSError:
|
||
pass
|
||
try:
|
||
os.remove(DAEMON_SOCK)
|
||
except OSError:
|
||
pass
|
||
|
||
def run(self):
|
||
thread = threading.Thread(target=self._serve, daemon=True)
|
||
thread.start()
|
||
try:
|
||
while not self._stop.is_set():
|
||
self.write_heartbeat()
|
||
self._stop.wait(self.interval)
|
||
finally:
|
||
self._stop.set()
|
||
thread.join(timeout=5)
|
||
|
||
def stop(self):
|
||
self._stop.set()
|
||
|
||
|
||
def main(argv=None):
|
||
argv = list(sys.argv[1:] if argv is None else argv)
|
||
interval = HEARTBEAT_INTERVAL
|
||
once = False
|
||
i = 0
|
||
while i < len(argv):
|
||
if argv[i] == "--interval" and i + 1 < len(argv):
|
||
interval = float(argv[i + 1])
|
||
i += 2
|
||
elif argv[i] == "--once":
|
||
once = True
|
||
i += 1
|
||
else:
|
||
print("用法:chv-daemon [--interval 秒] [--once]",
|
||
file=sys.stderr)
|
||
return 2
|
||
daemon = Daemon(interval=interval)
|
||
if once:
|
||
daemon.write_heartbeat()
|
||
print(json.dumps({"ok": True,
|
||
"heartbeat": HEARTBEAT_PATH,
|
||
"seq": daemon.seq}))
|
||
return 0
|
||
print("chv-daemon pid=%d heartbeat=%s" % (daemon.pid,
|
||
HEARTBEAT_PATH),
|
||
flush=True)
|
||
|
||
def _on_signal(signum, _frame):
|
||
daemon.stop()
|
||
|
||
signal.signal(signal.SIGTERM, _on_signal)
|
||
signal.signal(signal.SIGINT, _on_signal)
|
||
daemon.run()
|
||
return 0
|
||
|
||
|
||
if __name__ == "__main__":
|
||
sys.exit(main())
|